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
+159 -29
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"slices"
"strings"
"time"
"gopkg.in/yaml.v3"
)
@@ -51,6 +52,7 @@ type Config struct {
Module ModuleConfig `json:"module" yaml:"module"`
Paths PathConfig `json:"paths" yaml:"paths"`
Outputs OutputConfig `json:"outputs" yaml:"outputs"`
Build BuildConfig `json:"build" yaml:"build"`
HAKs []HAKConfig `json:"haks" yaml:"haks"`
Inventory InventoryConfig `json:"inventory" yaml:"inventory"`
Scripts ScriptsConfig `json:"scripts" yaml:"scripts"`
@@ -81,6 +83,10 @@ type OutputConfig struct {
HAKArchive string `json:"hak_archive" yaml:"hak_archive"`
}
type BuildConfig struct {
KeepExistingHAKs bool `json:"keep_existing_haks,omitempty" yaml:"keep_existing_haks,omitempty"`
}
type InventoryConfig struct {
SourceExtensions []string `json:"source_extensions" yaml:"source_extensions"`
AssetExtensions []string `json:"asset_extensions" yaml:"asset_extensions"`
@@ -88,8 +94,21 @@ type InventoryConfig struct {
}
type ScriptsConfig struct {
SourceDir string `json:"source_dir" yaml:"source_dir"`
Cache string `json:"cache" yaml:"cache"`
SourceDir string `json:"source_dir" yaml:"source_dir"`
Cache string `json:"cache" yaml:"cache"`
Compiler ScriptCompilerConfig `json:"compiler" yaml:"compiler"`
}
type ScriptCompilerConfig struct {
Path string `json:"path" yaml:"path"`
Search []string `json:"search" yaml:"search"`
Env ScriptCompilerEnvConfig `json:"env" yaml:"env"`
}
type ScriptCompilerEnvConfig struct {
Path string `json:"path" yaml:"path"`
NWNRoot []string `json:"nwn_root" yaml:"nwn_root"`
NWNUserDirectory []string `json:"nwn_user_directory" yaml:"nwn_user_directory"`
}
type HAKConfig struct {
@@ -118,24 +137,39 @@ type TopDataConfig struct {
}
type TopDataWikiConfig struct {
OutputRoot string `json:"output_root" yaml:"output_root"`
PagesDir string `json:"pages_dir" yaml:"pages_dir"`
StateFile string `json:"state_file" yaml:"state_file"`
OutputRoot string `json:"output_root" yaml:"output_root"`
PagesDir string `json:"pages_dir" yaml:"pages_dir"`
StateFile string `json:"state_file" yaml:"state_file"`
ManagedNamespaces []string `json:"managed_namespaces" yaml:"managed_namespaces"`
DeployManifest string `json:"deploy_manifest" yaml:"deploy_manifest"`
DeployEditSummary string `json:"deploy_edit_summary" yaml:"deploy_edit_summary"`
}
type ExtractConfig struct {
IgnoreExtensions []string `json:"ignore_extensions" yaml:"ignore_extensions"`
Layout string `json:"layout" yaml:"layout"`
HAKDiscovery string `json:"hak_discovery" yaml:"hak_discovery"`
CleanupStale *bool `json:"cleanup_stale,omitempty" yaml:"cleanup_stale,omitempty"`
DeleteModuleArchiveAfterSuccess bool `json:"delete_module_archive_after_success,omitempty" yaml:"delete_module_archive_after_success,omitempty"`
}
type AutogenConfig struct {
Producers []AutogenProducerConfig `json:"producers" yaml:"producers"`
Consumers []AutogenConsumerConfig `json:"consumers" yaml:"consumers"`
Cache AutogenCacheConfig `json:"cache" yaml:"cache"`
Producers []AutogenProducerConfig `json:"producers" yaml:"producers"`
Consumers []AutogenConsumerConfig `json:"consumers" yaml:"consumers"`
Cache AutogenCacheConfig `json:"cache" yaml:"cache"`
ReleaseSource AutogenReleaseSourceConfig `json:"release_source" yaml:"release_source"`
}
type AutogenCacheConfig struct {
Root string `json:"root" yaml:"root"`
Root string `json:"root" yaml:"root"`
MaxAge string `json:"max_age" yaml:"max_age"`
RefreshEnv string `json:"refresh_env" yaml:"refresh_env"`
}
type AutogenReleaseSourceConfig struct {
Provider string `json:"provider" yaml:"provider"`
ServerURLEnv string `json:"server_url_env" yaml:"server_url_env"`
RepoEnv string `json:"repo_env" yaml:"repo_env"`
}
type AutogenProducerConfig struct {
@@ -329,26 +363,37 @@ func (p *Project) ValidateLayout() error {
failures = append(failures, errors.New("at least one of paths.source, paths.assets, or topdata.source is required"))
}
for field, value := range map[string]string{
"paths.source": p.Config.Paths.Source,
"paths.assets": p.Config.Paths.Assets,
"paths.build": p.Config.Paths.Build,
"paths.cache": p.Config.Paths.Cache,
"paths.tools": p.Config.Paths.Tools,
"outputs.module_archive": p.Config.Outputs.ModuleArchive,
"outputs.hak_manifest": p.Config.Outputs.HAKManifest,
"outputs.hak_archive": p.Config.Outputs.HAKArchive,
"scripts.source_dir": p.Config.Scripts.SourceDir,
"scripts.cache": p.Config.Scripts.Cache,
"topdata.source": p.Config.TopData.Source,
"topdata.build": p.Config.TopData.Build,
"topdata.reference_builder": p.Config.TopData.ReferenceBuilder,
"topdata.assets": p.Config.TopData.Assets,
"topdata.compiled_2da_dir": p.Config.TopData.Compiled2DADir,
"topdata.compiled_tlk": p.Config.TopData.CompiledTLK,
"topdata.wiki.output_root": p.Config.TopData.Wiki.OutputRoot,
"topdata.wiki.pages_dir": p.Config.TopData.Wiki.PagesDir,
"topdata.wiki.state_file": p.Config.TopData.Wiki.StateFile,
"autogen.cache.root": p.Config.Autogen.Cache.Root,
"paths.source": p.Config.Paths.Source,
"paths.assets": p.Config.Paths.Assets,
"paths.build": p.Config.Paths.Build,
"paths.cache": p.Config.Paths.Cache,
"paths.tools": p.Config.Paths.Tools,
"outputs.module_archive": p.Config.Outputs.ModuleArchive,
"outputs.hak_manifest": p.Config.Outputs.HAKManifest,
"outputs.hak_archive": p.Config.Outputs.HAKArchive,
"scripts.source_dir": p.Config.Scripts.SourceDir,
"scripts.cache": p.Config.Scripts.Cache,
"scripts.compiler.path": p.Config.Scripts.Compiler.Path,
"scripts.compiler.env.path": p.Config.Scripts.Compiler.Env.Path,
"topdata.source": p.Config.TopData.Source,
"topdata.build": p.Config.TopData.Build,
"topdata.reference_builder": p.Config.TopData.ReferenceBuilder,
"topdata.assets": p.Config.TopData.Assets,
"topdata.compiled_2da_dir": p.Config.TopData.Compiled2DADir,
"topdata.compiled_tlk": p.Config.TopData.CompiledTLK,
"topdata.wiki.output_root": p.Config.TopData.Wiki.OutputRoot,
"topdata.wiki.pages_dir": p.Config.TopData.Wiki.PagesDir,
"topdata.wiki.state_file": p.Config.TopData.Wiki.StateFile,
"topdata.wiki.deploy_manifest": p.Config.TopData.Wiki.DeployManifest,
"topdata.wiki.deploy_edit_summary": p.Config.TopData.Wiki.DeployEditSummary,
"extract.layout": p.Config.Extract.Layout,
"extract.hak_discovery": p.Config.Extract.HAKDiscovery,
"autogen.cache.root": p.Config.Autogen.Cache.Root,
"autogen.cache.max_age": p.Config.Autogen.Cache.MaxAge,
"autogen.cache.refresh_env": p.Config.Autogen.Cache.RefreshEnv,
"autogen.release_source.provider": p.Config.Autogen.ReleaseSource.Provider,
"autogen.release_source.server_url_env": p.Config.Autogen.ReleaseSource.ServerURLEnv,
"autogen.release_source.repo_env": p.Config.Autogen.ReleaseSource.RepoEnv,
} {
if strings.Contains(value, "\x00") {
failures = append(failures, fmt.Errorf("%s must not contain NUL bytes", field))
@@ -360,11 +405,13 @@ func (p *Project) ValidateLayout() error {
failures = append(failures, validateRelativePath("outputs.hak_manifest", effective.Outputs.HAKManifest)...)
failures = append(failures, validateRelativePath("outputs.hak_archive", effective.Outputs.HAKArchive)...)
failures = append(failures, validateRelativePath("scripts.cache", effective.Scripts.Cache)...)
failures = append(failures, validateRelativePath("scripts.source_dir", effective.Scripts.SourceDir)...)
failures = append(failures, validateRelativePath("topdata.compiled_2da_dir", effective.TopData.Compiled2DADir)...)
failures = append(failures, validateRelativePath("topdata.compiled_tlk", effective.TopData.CompiledTLK)...)
failures = append(failures, validateRelativePath("topdata.wiki.output_root", effective.TopData.Wiki.OutputRoot)...)
failures = append(failures, validateRelativePath("topdata.wiki.pages_dir", effective.TopData.Wiki.PagesDir)...)
failures = append(failures, validateRelativePath("topdata.wiki.state_file", effective.TopData.Wiki.StateFile)...)
failures = append(failures, validateRelativePath("topdata.wiki.deploy_manifest", effective.TopData.Wiki.DeployManifest)...)
failures = append(failures, validateRelativePath("autogen.cache.root", effective.Autogen.Cache.Root)...)
if err := validateOutputFileName("outputs.module_archive", filepath.Base(effective.Outputs.ModuleArchive), ".mod"); err != nil {
failures = append(failures, err)
@@ -375,6 +422,25 @@ func (p *Project) ValidateLayout() error {
if err := validateOutputFileName("outputs.hak_archive", filepath.Base(effective.Outputs.HAKArchive), ".hak"); err != nil {
failures = append(failures, err)
}
if err := validateOutputFileName("topdata.wiki.deploy_manifest", filepath.Base(effective.TopData.Wiki.DeployManifest), ".json"); err != nil {
failures = append(failures, err)
}
switch effective.Extract.Layout {
case "nwn_canonical_json":
default:
failures = append(failures, fmt.Errorf("extract.layout %q is not supported", effective.Extract.Layout))
}
switch effective.Extract.HAKDiscovery {
case "build_glob", "configured_haks":
default:
failures = append(failures, fmt.Errorf("extract.hak_discovery %q is not supported", effective.Extract.HAKDiscovery))
}
if _, err := time.ParseDuration(effective.Autogen.Cache.MaxAge); err != nil {
failures = append(failures, fmt.Errorf("autogen.cache.max_age must be a duration: %w", err))
}
if effective.Autogen.ReleaseSource.Provider != "gitea" {
failures = append(failures, fmt.Errorf("autogen.release_source.provider %q is not supported", effective.Autogen.ReleaseSource.Provider))
}
if p.HasTopData() {
if err := validateOutputFileName("topdata.package_hak", p.TopDataPackageHAKName(), ".hak"); err != nil {
failures = append(failures, err)
@@ -685,6 +751,46 @@ func (p *Project) ScriptCacheDir() string {
return p.rootPath(p.EffectiveConfig().Scripts.Cache)
}
func (p *Project) ScriptCompilerPath() string {
path := strings.TrimSpace(p.EffectiveConfig().Scripts.Compiler.Path)
if path == "" {
return ""
}
if filepath.IsAbs(path) {
return path
}
return filepath.Join(p.Root, filepath.FromSlash(path))
}
func (p *Project) ScriptCompilerSearchPaths() []string {
search := p.EffectiveConfig().Scripts.Compiler.Search
out := make([]string, 0, len(search))
for _, candidate := range search {
candidate = strings.TrimSpace(candidate)
if candidate == "" {
continue
}
if strings.HasPrefix(candidate, "PATH:") || filepath.IsAbs(candidate) {
out = append(out, candidate)
continue
}
out = append(out, filepath.Join(p.Root, filepath.FromSlash(candidate)))
}
return out
}
func (p *Project) ScriptCompilerPathEnv() string {
return p.EffectiveConfig().Scripts.Compiler.Env.Path
}
func (p *Project) ScriptCompilerNWNRootEnvKeys() []string {
return slices.Clone(p.EffectiveConfig().Scripts.Compiler.Env.NWNRoot)
}
func (p *Project) ScriptCompilerNWNUserDirectoryEnvKeys() []string {
return slices.Clone(p.EffectiveConfig().Scripts.Compiler.Env.NWNUserDirectory)
}
func (p *Project) MusicStageDir() string {
return p.rootPath(p.EffectiveConfig().Music.StageRoot)
}
@@ -701,6 +807,30 @@ func (p *Project) AutogenCachePath(name string) string {
return filepath.Join(p.AutogenCacheDir(), filepath.FromSlash(strings.TrimSpace(name)))
}
func (p *Project) AutogenCacheMaxAge() time.Duration {
duration, err := time.ParseDuration(p.EffectiveConfig().Autogen.Cache.MaxAge)
if err != nil {
return DefaultAutogenCacheMaxAge
}
return duration
}
func (p *Project) AutogenRefreshEnv() string {
return p.EffectiveConfig().Autogen.Cache.RefreshEnv
}
func (p *Project) PartsManifestRefreshEnv() string {
return p.EffectiveConfig().Autogen.Cache.PartsManifestRefreshEnv
}
func (p *Project) AssetsServerURLEnv() string {
return p.EffectiveConfig().Autogen.ReleaseSource.ServerURLEnv
}
func (p *Project) AssetsRepoEnv() string {
return p.EffectiveConfig().Autogen.ReleaseSource.RepoEnv
}
func (p *Project) rootPath(path string) string {
if strings.TrimSpace(path) == "" {
return p.Root