Configuration Hardening
Key changes:
- Added internal/project/effective.go with normalized effective config, defaults, provenance, and
JSON output.
- Added config subcommands:
- config validate
- config effective [--json|--yaml]
- config inspect [<key>]
- config explain <key>
- config sources
- Added YAML schema fields for configurable outputs, cache roots, script cache, inventory extensions,
topdata output/wiki paths, and autogen cache root.
- Routed existing hardcoded paths through effective config wrappers for module archives, HAK
manifests/archives, script cache, music cache/credits, topdata outputs, and autogen caches.
- Added validation for unsafe generated output/cache paths.
- Updated command descriptions to avoid fixed build/, .cache, sow_top, etc.
- Added regression tests for defaults, provenance, deterministic effective config, YAML-controlled
derivation, config commands, and invalid paths.
This commit is contained in:
+146
-5
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
||||
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/topdata"
|
||||
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/validator"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type command struct {
|
||||
@@ -132,17 +134,17 @@ func isInteractiveTTY(w io.Writer) bool {
|
||||
var commands = []command{
|
||||
{
|
||||
name: "build",
|
||||
description: "Build module and HAK outputs into build/, plus top package output when topdata is configured.",
|
||||
description: "Build module, HAK, and configured top package outputs.",
|
||||
run: runBuild,
|
||||
},
|
||||
{
|
||||
name: "build-module",
|
||||
description: "Build only the module archive from src/ into build/.",
|
||||
description: "Build only the configured module archive.",
|
||||
run: runBuildModule,
|
||||
},
|
||||
{
|
||||
name: "build-haks",
|
||||
description: "Build only HAK archives and manifest from assets/ into build/.",
|
||||
description: "Build only configured HAK archives and manifest.",
|
||||
run: runBuildHAKs,
|
||||
},
|
||||
{
|
||||
@@ -155,6 +157,11 @@ var commands = []command{
|
||||
description: "Validate project layout, config, and source inventory.",
|
||||
run: runValidate,
|
||||
},
|
||||
{
|
||||
name: "config",
|
||||
description: "Inspect, explain, and validate the resolved project configuration.",
|
||||
run: runConfig,
|
||||
},
|
||||
{
|
||||
name: "compare",
|
||||
description: "Compare current source content against the built archives.",
|
||||
@@ -172,12 +179,12 @@ var commands = []command{
|
||||
},
|
||||
{
|
||||
name: "build-topdata",
|
||||
description: "Build canonical topdata outputs into .cache/2da and .cache/wiki, then refresh build/sow_top.hak and build/sow_tlk.tlk.",
|
||||
description: "Build configured topdata outputs, then refresh the configured top package.",
|
||||
run: runBuildTopData,
|
||||
},
|
||||
{
|
||||
name: "build-top-package",
|
||||
description: "Build build/sow_top.hak and build/sow_tlk.tlk from cached native topdata output and authored topdata/assets.",
|
||||
description: "Build the configured top package from cached native topdata output and authored topdata assets.",
|
||||
run: runBuildTopPackage,
|
||||
},
|
||||
{
|
||||
@@ -670,6 +677,140 @@ func runValidate(ctx context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConfig(ctx context) error {
|
||||
if len(ctx.args) < 2 {
|
||||
return errors.New("usage: config <validate|effective|inspect|explain|sources> ...")
|
||||
}
|
||||
root, err := project.FindRoot(ctx.cwd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p, err := project.Load(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch ctx.args[1] {
|
||||
case "validate":
|
||||
if len(ctx.args) > 2 {
|
||||
return fmt.Errorf("usage: config validate")
|
||||
}
|
||||
if err := p.ValidateLayout(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(ctx.stdout, "config: ok\n")
|
||||
fmt.Fprintf(ctx.stdout, "source: %s\n", p.ConfigSource.Path)
|
||||
return nil
|
||||
case "effective":
|
||||
format, err := parseConfigFormatArgs("config effective", ctx.args[2:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return emitConfigValue(ctx.stdout, p.EffectiveConfig(), format)
|
||||
case "inspect":
|
||||
key := ""
|
||||
format := "yaml"
|
||||
for _, arg := range ctx.args[2:] {
|
||||
switch arg {
|
||||
case "--json":
|
||||
format = "json"
|
||||
case "--yaml":
|
||||
format = "yaml"
|
||||
default:
|
||||
if key != "" {
|
||||
return fmt.Errorf("usage: config inspect [<key>] [--json|--yaml]")
|
||||
}
|
||||
key = arg
|
||||
}
|
||||
}
|
||||
if key == "" {
|
||||
return emitConfigValue(ctx.stdout, p.EffectiveConfig(), format)
|
||||
}
|
||||
value, _, ok := p.ExplainConfig(key)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown config key %q", key)
|
||||
}
|
||||
return emitConfigValue(ctx.stdout, value, format)
|
||||
case "explain":
|
||||
if len(ctx.args) != 3 {
|
||||
return fmt.Errorf("usage: config explain <key>")
|
||||
}
|
||||
key := ctx.args[2]
|
||||
value, provenance, ok := p.ExplainConfig(key)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown config key %q", key)
|
||||
}
|
||||
fmt.Fprintf(ctx.stdout, "key: %s\n", key)
|
||||
fmt.Fprintf(ctx.stdout, "value: %s\n", formatConfigScalar(value))
|
||||
fmt.Fprintf(ctx.stdout, "source: %s\n", provenance.Source)
|
||||
if provenance.Detail != "" {
|
||||
fmt.Fprintf(ctx.stdout, "detail: %s\n", provenance.Detail)
|
||||
}
|
||||
if provenance.Deprecated {
|
||||
fmt.Fprintf(ctx.stdout, "deprecated: true\n")
|
||||
}
|
||||
return nil
|
||||
case "sources":
|
||||
if len(ctx.args) > 2 {
|
||||
return fmt.Errorf("usage: config sources")
|
||||
}
|
||||
fmt.Fprintf(ctx.stdout, "loaded: %s\n", p.ConfigSource.Path)
|
||||
fmt.Fprintf(ctx.stdout, "name: %s\n", p.ConfigSource.Name)
|
||||
fmt.Fprintf(ctx.stdout, "format: %s\n", p.ConfigSource.Format)
|
||||
fmt.Fprintf(ctx.stdout, "legacy: %t\n", p.ConfigSource.Legacy)
|
||||
fmt.Fprintf(ctx.stdout, "candidates: %s\n", strings.Join(project.ConfigFileCandidates, ", "))
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown config subcommand %q", ctx.args[1])
|
||||
}
|
||||
}
|
||||
|
||||
func parseConfigFormatArgs(commandName string, args []string) (string, error) {
|
||||
format := "yaml"
|
||||
for _, arg := range args {
|
||||
switch arg {
|
||||
case "--json":
|
||||
format = "json"
|
||||
case "--yaml":
|
||||
format = "yaml"
|
||||
default:
|
||||
return "", fmt.Errorf("usage: %s [--json|--yaml]", commandName)
|
||||
}
|
||||
}
|
||||
return format, nil
|
||||
}
|
||||
|
||||
func emitConfigValue(w io.Writer, value any, format string) error {
|
||||
var (
|
||||
raw []byte
|
||||
err error
|
||||
)
|
||||
switch format {
|
||||
case "json":
|
||||
raw, err = json.MarshalIndent(value, "", " ")
|
||||
case "yaml":
|
||||
raw, err = yaml.Marshal(value)
|
||||
default:
|
||||
return fmt.Errorf("unsupported config output format %q", format)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(raw) == 0 || raw[len(raw)-1] != '\n' {
|
||||
raw = append(raw, '\n')
|
||||
}
|
||||
_, err = w.Write(raw)
|
||||
return err
|
||||
}
|
||||
|
||||
func formatConfigScalar(value any) string {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func emitValidatorReport(w io.Writer, report validator.Report) {
|
||||
lines := report.SummaryLines(5)
|
||||
emitSummaryLines(w, lines, 20, "validation diagnostics")
|
||||
|
||||
@@ -198,6 +198,94 @@ func TestRunBuildHAKsVerboseListsMappings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConfigEffectiveJSONShowsDefaultsAndProvenance(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, filepath.Join(root, "nwn-tool.yaml"), `
|
||||
module:
|
||||
name: Test Module
|
||||
resref: testmod
|
||||
paths:
|
||||
source: src
|
||||
`)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
ctx := context{
|
||||
stdout: &stdout,
|
||||
stderr: &bytes.Buffer{},
|
||||
cwd: root,
|
||||
args: []string{"config", "effective", "--json"},
|
||||
}
|
||||
|
||||
if err := runConfig(ctx); err != nil {
|
||||
t.Fatalf("runConfig failed: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, `"hak_manifest": "haks.json"`) {
|
||||
t.Fatalf("expected HAK manifest default in effective config, got %q", output)
|
||||
}
|
||||
if !strings.Contains(output, `"paths.build":`) || !strings.Contains(output, `"toolkit default"`) {
|
||||
t.Fatalf("expected default provenance in effective config, got %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConfigExplainReportsYAMLSource(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, filepath.Join(root, "nwn-tool.yaml"), `
|
||||
module:
|
||||
name: Test Module
|
||||
resref: testmod
|
||||
paths:
|
||||
build: output
|
||||
`)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
ctx := context{
|
||||
stdout: &stdout,
|
||||
stderr: &bytes.Buffer{},
|
||||
cwd: root,
|
||||
args: []string{"config", "explain", "paths.build"},
|
||||
}
|
||||
|
||||
if err := runConfig(ctx); err != nil {
|
||||
t.Fatalf("runConfig failed: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "value: \"output\"") {
|
||||
t.Fatalf("expected configured build value, got %q", output)
|
||||
}
|
||||
if !strings.Contains(output, "source: yaml") {
|
||||
t.Fatalf("expected YAML source, got %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConfigValidateDoesNotScanSourceInventory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mkdirAll(t, filepath.Join(root, "build"))
|
||||
writeFile(t, filepath.Join(root, "nwn-tool.yaml"), `
|
||||
module:
|
||||
name: Test Module
|
||||
resref: testmod
|
||||
paths:
|
||||
source: missing-src
|
||||
build: build
|
||||
`)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
ctx := context{
|
||||
stdout: &stdout,
|
||||
stderr: &bytes.Buffer{},
|
||||
cwd: root,
|
||||
args: []string{"config", "validate"},
|
||||
}
|
||||
|
||||
if err := runConfig(ctx); err != nil {
|
||||
t.Fatalf("runConfig failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "config: ok") {
|
||||
t.Fatalf("expected config validation output, got %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func mkdirAll(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user