Configuration Hardening 2

What changed:

  - Added visible runtime override reporting with masking for sensitive values.
  - Expanded effective config for build cleanup, script compiler discovery/env, extract layout/cleanup/
    HAK discovery, wiki deploy defaults, autogen cache policy, and release-source env names.
  - Wired configured values into build, script compiler resolution, extract, autogen cache refresh/max
    age, parts manifest release source, wiki generation/deploy, and TLK output naming.
  - Added config sources override output and validation-rule hints in config explain.
  - Added CONFIGURATION_HARDENING_AUDIT.md.
  - Updated README.md with config commands, schema defaults, and JSON artifact boundaries.
  - Added regression tests for override visibility/masking, compiler/extract/wiki/autogen config,
    configured HAK discovery, and config sources output.
This commit is contained in:
2026-05-07 14:22:47 +02:00
parent 60d2de9f2d
commit b39bdf13e8
17 changed files with 877 additions and 171 deletions
+25 -25
View File
@@ -215,7 +215,7 @@ func planHAKs(p *project.Project, progress ProgressFunc) (BuildResult, error) {
}
func planOrBuildHAKs(p *project.Project, progress ProgressFunc, writeArchives bool, archiveNames []string, sourceManifestPath string) (result BuildResult, err error) {
preserveExistingHAKs := envBool("SOW_BUILD_HAKS_KEEP_EXISTING")
preserveExistingHAKs := p.EffectiveConfig().Build.KeepExistingHAKs || envBool("SOW_BUILD_HAKS_KEEP_EXISTING")
progressf(progress, "Validating project...")
if err := validateForBuild(p); err != nil {
@@ -556,7 +556,7 @@ func compileReferencedScripts(p *project.Project) ([]erf.Resource, error) {
if err != nil {
return nil, err
}
compilerEnv, err := resolveScriptCompilerEnvironment()
compilerEnv, err := resolveScriptCompilerEnvironment(p)
if err != nil {
return nil, err
}
@@ -630,40 +630,40 @@ func referencedScriptResrefs(p *project.Project) ([]string, error) {
}
func resolveScriptCompiler(p *project.Project) (string, error) {
if configured := strings.TrimSpace(os.Getenv("SOW_NWN_SCRIPT_COMPILER")); configured != "" {
if configured := p.ScriptCompilerPath(); configured != "" {
return configured, nil
}
if configured := strings.TrimSpace(os.Getenv(p.ScriptCompilerPathEnv())); configured != "" {
return configured, nil
}
candidates := []string{
filepath.Join(p.Root, "tools", "script-compiler", "nwn_script_comp"),
filepath.Join(p.Root, "tools", "script-compiler", "nwn_script_comp.exe"),
filepath.Join(p.Root, "tools", "nwn_script_comp"),
filepath.Join(p.Root, "tools", "nwn_script_comp.exe"),
}
candidates := p.ScriptCompilerSearchPaths()
if runtime.GOOS == "windows" {
candidates = []string{
filepath.Join(p.Root, "tools", "script-compiler", "nwn_script_comp.exe"),
filepath.Join(p.Root, "tools", "script-compiler", "nwn_script_comp"),
filepath.Join(p.Root, "tools", "nwn_script_comp.exe"),
filepath.Join(p.Root, "tools", "nwn_script_comp"),
}
slices.SortStableFunc(candidates, func(a, b string) int {
aExe := strings.HasSuffix(strings.ToLower(a), ".exe")
bExe := strings.HasSuffix(strings.ToLower(b), ".exe")
if aExe == bExe {
return strings.Compare(a, b)
}
if aExe {
return -1
}
return 1
})
}
for _, candidate := range candidates {
if binary, ok := strings.CutPrefix(candidate, "PATH:"); ok {
if compiler, err := exec.LookPath(binary); err == nil {
return compiler, nil
}
continue
}
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate, nil
}
}
if compiler, err := exec.LookPath("nwn_script_comp"); err == nil {
return compiler, nil
}
if runtime.GOOS == "windows" {
if compiler, err := exec.LookPath("nwn_script_comp.exe"); err == nil {
return compiler, nil
}
}
return "", errors.New("script compiler not found; install nwn_script_comp into ./tools or set SOW_NWN_SCRIPT_COMPILER")
return "", fmt.Errorf("script compiler not found; configure scripts.compiler.path or set %s", p.ScriptCompilerPathEnv())
}
func validatorLoadDocument(path string) (gff.Document, string, string, error) {
+31 -5
View File
@@ -57,7 +57,7 @@ func Extract(p *project.Project, files ...string) (ExtractResult, error) {
failures = append(failures, errs...)
}
hakPaths, err := filepath.Glob(filepath.Join(p.BuildDir(), "*.hak"))
hakPaths, err := extractHAKPaths(p)
if err != nil {
return result, fmt.Errorf("scan hak archives: %w", err)
}
@@ -88,9 +88,11 @@ func Extract(p *project.Project, files ...string) (ExtractResult, error) {
failures = append(failures, errs...)
}
removed, errs := cleanupStaleFiles(p, desired)
result.Removed = removed
failures = append(failures, errs...)
if p.EffectiveConfig().Extract.CleanupStale == nil || *p.EffectiveConfig().Extract.CleanupStale {
removed, errs := cleanupStaleFiles(p, desired)
result.Removed = removed
failures = append(failures, errs...)
}
if len(failures) > 0 {
return result, errors.Join(failures...)
@@ -157,7 +159,7 @@ func extractedFile(p *project.Project, resource erf.Resource, extension string)
switch extension {
case "nss":
return filepath.Join(p.SourceDir(), "scripts", resref+".nss"), resource.Data, nil
return filepath.Join(p.ScriptSourceDir(), resref+".nss"), resource.Data, nil
case "utc", "utd", "ute", "uti", "utm", "utp", "uts", "utt", "utw",
"are", "dlg", "fac", "gic", "git", "ifo", "itp", "jrl":
document, err := gff.Read(bytes.NewReader(resource.Data))
@@ -175,6 +177,30 @@ func extractedFile(p *project.Project, resource erf.Resource, extension string)
}
}
func extractHAKPaths(p *project.Project) ([]string, error) {
switch p.EffectiveConfig().Extract.HAKDiscovery {
case "configured_haks":
paths := make([]string, 0, len(p.Config.HAKs))
for _, hak := range p.Config.HAKs {
path := p.HAKArchivePath(hak.Name)
if _, err := os.Stat(path); err == nil {
paths = append(paths, path)
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
}
slices.Sort(paths)
return paths, nil
default:
hakPaths, err := filepath.Glob(filepath.Join(p.BuildDir(), "*.hak"))
if err != nil {
return nil, err
}
slices.Sort(hakPaths)
return hakPaths, nil
}
}
type writeState int
const (
+71
View File
@@ -175,6 +175,77 @@ func TestExtractReadsHAKAssets(t *testing.T) {
}
}
func TestExtractConfiguredHAKDiscoveryIgnoresUnconfiguredArchives(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, "src"))
mustMkdir(t, filepath.Join(root, "assets"))
mustMkdir(t, filepath.Join(root, "build"))
mustWriteFile(t, filepath.Join(root, "nwn-tool.yaml"), `
module:
name: Test Module
resref: testmod
paths:
source: src
assets: assets
build: build
extract:
hak_discovery: configured_haks
haks:
- name: wanted
priority: 1
include:
- wanted/**
`)
p, err := project.Load(root)
if err != nil {
t.Fatalf("load project: %v", err)
}
if err := p.ValidateLayout(); err != nil {
t.Fatalf("validate layout: %v", err)
}
modFile, err := os.Create(p.ModuleArchivePath())
if err != nil {
t.Fatalf("create mod: %v", err)
}
if err := erf.Write(modFile, erf.New("MOD ", nil)); err != nil {
t.Fatalf("write mod: %v", err)
}
if err := modFile.Close(); err != nil {
t.Fatalf("close mod: %v", err)
}
for _, name := range []string{"wanted", "ignored"} {
hakFile, err := os.Create(filepath.Join(root, "build", name+".hak"))
if err != nil {
t.Fatalf("create hak: %v", err)
}
if err := erf.Write(hakFile, erf.New("HAK ", []erf.Resource{
{Name: name + "_asset", Type: 0x07D2, Data: []byte("mdl-data")},
})); err != nil {
t.Fatalf("write hak: %v", err)
}
if err := hakFile.Close(); err != nil {
t.Fatalf("close hak: %v", err)
}
}
result, err := Extract(p)
if err != nil {
t.Fatalf("extract: %v", err)
}
if len(result.HAKPaths) != 1 || filepath.Base(result.HAKPaths[0]) != "wanted.hak" {
t.Fatalf("expected only configured hak, got %#v", result.HAKPaths)
}
if _, err := os.Stat(filepath.Join(root, "assets", "mdl", "wanted_asset.mdl")); err != nil {
t.Fatalf("expected wanted asset: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "assets", "mdl", "ignored_asset.mdl")); !os.IsNotExist(err) {
t.Fatalf("expected ignored asset to remain absent, err=%v", err)
}
}
func TestExtractDeletesConsumedModuleArchiveAfterSuccessWhenConfigured(t *testing.T) {
root := t.TempDir()
mustMkdir(t, filepath.Join(root, "src", "module"))
+9 -7
View File
@@ -9,19 +9,21 @@ import (
"regexp"
"runtime"
"strings"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
)
func resolveScriptCompilerEnvironment() ([]string, error) {
func resolveScriptCompilerEnvironment(p *project.Project) ([]string, error) {
env := os.Environ()
userDir, err := resolveNWNUserDirectory()
userDir, err := resolveNWNUserDirectory(p.ScriptCompilerNWNUserDirectoryEnvKeys())
if err != nil {
return nil, err
}
env = upsertEnv(env, "NWN_HOME", userDir)
env = upsertEnv(env, "NWN_USER_DIRECTORY", userDir)
root, err := resolveNWNRoot()
root, err := resolveNWNRoot(p.ScriptCompilerNWNRootEnvKeys())
if err != nil {
return nil, err
}
@@ -32,8 +34,8 @@ func resolveScriptCompilerEnvironment() ([]string, error) {
return env, nil
}
func resolveNWNUserDirectory() (string, error) {
for _, key := range []string{"SOW_NWN_USER_DIRECTORY", "NWN_HOME", "NWN_USER_DIRECTORY"} {
func resolveNWNUserDirectory(envKeys []string) (string, error) {
for _, key := range envKeys {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
path := filepath.Clean(expandUserPath(value))
if err := os.MkdirAll(path, 0o755); err != nil {
@@ -71,8 +73,8 @@ func defaultNWNUserDirectory() (string, error) {
}
}
func resolveNWNRoot() (string, error) {
for _, key := range []string{"SOW_NWN_ROOT", "NWN_ROOT"} {
func resolveNWNRoot(envKeys []string) (string, error) {
for _, key := range envKeys {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
path := filepath.Clean(expandUserPath(value))
if !looksLikeNWNInstall(path) {