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:
2026-05-07 14:09:20 +02:00
parent 000d31ad24
commit 60d2de9f2d
12 changed files with 2481 additions and 62 deletions
+146 -5
View File
@@ -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")
+88
View File
@@ -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 {