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
+233 -48
View File
@@ -36,6 +36,7 @@ type Project struct {
Root string
Config Config
ConfigSource ConfigSource
Provenance ConfigProvenance
Inventory Inventory
}
@@ -47,13 +48,16 @@ type ConfigSource struct {
}
type Config struct {
Module ModuleConfig `json:"module" yaml:"module"`
Paths PathConfig `json:"paths" yaml:"paths"`
HAKs []HAKConfig `json:"haks" yaml:"haks"`
Music MusicConfig `json:"music" yaml:"music"`
TopData TopDataConfig `json:"topdata" yaml:"topdata"`
Extract ExtractConfig `json:"extract" yaml:"extract"`
Autogen AutogenConfig `json:"autogen" yaml:"autogen"`
Module ModuleConfig `json:"module" yaml:"module"`
Paths PathConfig `json:"paths" yaml:"paths"`
Outputs OutputConfig `json:"outputs" yaml:"outputs"`
HAKs []HAKConfig `json:"haks" yaml:"haks"`
Inventory InventoryConfig `json:"inventory" yaml:"inventory"`
Scripts ScriptsConfig `json:"scripts" yaml:"scripts"`
Music MusicConfig `json:"music" yaml:"music"`
TopData TopDataConfig `json:"topdata" yaml:"topdata"`
Extract ExtractConfig `json:"extract" yaml:"extract"`
Autogen AutogenConfig `json:"autogen" yaml:"autogen"`
}
type ModuleConfig struct {
@@ -67,6 +71,25 @@ type PathConfig struct {
Source string `json:"source" yaml:"source"`
Assets string `json:"assets" yaml:"assets"`
Build string `json:"build" yaml:"build"`
Cache string `json:"cache" yaml:"cache"`
Tools string `json:"tools" yaml:"tools"`
}
type OutputConfig struct {
ModuleArchive string `json:"module_archive" yaml:"module_archive"`
HAKManifest string `json:"hak_manifest" yaml:"hak_manifest"`
HAKArchive string `json:"hak_archive" yaml:"hak_archive"`
}
type InventoryConfig struct {
SourceExtensions []string `json:"source_extensions" yaml:"source_extensions"`
AssetExtensions []string `json:"asset_extensions" yaml:"asset_extensions"`
SourceJSONPattern string `json:"source_json_pattern" yaml:"source_json_pattern"`
}
type ScriptsConfig struct {
SourceDir string `json:"source_dir" yaml:"source_dir"`
Cache string `json:"cache" yaml:"cache"`
}
type HAKConfig struct {
@@ -83,12 +106,21 @@ type MusicConfig struct {
}
type TopDataConfig struct {
Source string `json:"source" yaml:"source"`
Build string `json:"build" yaml:"build"`
ReferenceBuilder string `json:"reference_builder" yaml:"reference_builder"`
Assets string `json:"assets" yaml:"assets"`
PackageHAK string `json:"package_hak" yaml:"package_hak"`
PackageTLK string `json:"package_tlk" yaml:"package_tlk"`
Source string `json:"source" yaml:"source"`
Build string `json:"build" yaml:"build"`
ReferenceBuilder string `json:"reference_builder" yaml:"reference_builder"`
Assets string `json:"assets" yaml:"assets"`
Compiled2DADir string `json:"compiled_2da_dir" yaml:"compiled_2da_dir"`
CompiledTLK string `json:"compiled_tlk" yaml:"compiled_tlk"`
PackageHAK string `json:"package_hak" yaml:"package_hak"`
PackageTLK string `json:"package_tlk" yaml:"package_tlk"`
Wiki TopDataWikiConfig `json:"wiki" yaml:"wiki"`
}
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"`
}
type ExtractConfig struct {
@@ -99,6 +131,11 @@ type ExtractConfig struct {
type AutogenConfig struct {
Producers []AutogenProducerConfig `json:"producers" yaml:"producers"`
Consumers []AutogenConsumerConfig `json:"consumers" yaml:"consumers"`
Cache AutogenCacheConfig `json:"cache" yaml:"cache"`
}
type AutogenCacheConfig struct {
Root string `json:"root" yaml:"root"`
}
type AutogenProducerConfig struct {
@@ -175,6 +212,10 @@ func Load(root string) (*Project, error) {
}
cfg := defaultConfig()
provenance, err := configProvenance(raw, source)
if err != nil {
return nil, err
}
if source.Format == "yaml" {
decoder := yaml.NewDecoder(bytes.NewReader(raw))
decoder.KnownFields(true)
@@ -202,6 +243,7 @@ func Load(root string) (*Project, error) {
Root: root,
Config: cfg,
ConfigSource: source,
Provenance: provenance,
}, nil
}
@@ -226,6 +268,51 @@ func findConfigSource(root string) (ConfigSource, error) {
return ConfigSource{}, fmt.Errorf("could not find %s, %s, or legacy %s in %s", ConfigFile, ConfigFileYML, LegacyConfigFile, root)
}
func configProvenance(raw []byte, source ConfigSource) (ConfigProvenance, error) {
var data any
if source.Format == "yaml" {
if err := yaml.Unmarshal(raw, &data); err != nil {
return nil, fmt.Errorf("parse %s for provenance: %w", source.Name, err)
}
} else {
if err := json.Unmarshal(raw, &data); err != nil {
return nil, fmt.Errorf("parse legacy %s for provenance: %w", source.Name, err)
}
}
provenance := ConfigProvenance{}
sourceName := "yaml"
if source.Legacy {
sourceName = "legacy json"
}
recordProvenance(provenance, "", data, ConfigValueProvenance{
Source: sourceName,
Detail: source.Name,
Deprecated: source.Legacy,
})
return provenance, nil
}
func recordProvenance(provenance ConfigProvenance, prefix string, value any, source ConfigValueProvenance) {
switch typed := value.(type) {
case map[string]any:
for key, nested := range typed {
next := key
if prefix != "" {
next = prefix + "." + key
}
recordProvenance(provenance, next, nested, source)
}
case []any:
if prefix != "" {
provenance[prefix] = source
}
default:
if prefix != "" {
provenance[prefix] = source
}
}
}
func (p *Project) ValidateLayout() error {
var failures []error
@@ -245,15 +332,49 @@ func (p *Project) ValidateLayout() error {
"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,
} {
if strings.Contains(value, "\x00") {
failures = append(failures, fmt.Errorf("%s must not contain NUL bytes", field))
}
}
effective := p.EffectiveConfig()
failures = append(failures, validateRelativePath("paths.cache", effective.Paths.Cache)...)
failures = append(failures, validateRelativePath("outputs.module_archive", effective.Outputs.ModuleArchive)...)
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("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("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)
}
if err := validateOutputFileName("outputs.hak_manifest", filepath.Base(effective.Outputs.HAKManifest), ".json"); err != nil {
failures = append(failures, err)
}
if err := validateOutputFileName("outputs.hak_archive", filepath.Base(effective.Outputs.HAKArchive), ".hak"); err != nil {
failures = append(failures, err)
}
if p.HasTopData() {
if err := validateOutputFileName("topdata.package_hak", p.TopDataPackageHAKName(), ".hak"); err != nil {
failures = append(failures, err)
@@ -322,6 +443,7 @@ func (p *Project) ValidateLayout() error {
func (p *Project) Scan() error {
var sourceFiles []string
var sourceExts []string
effective := p.EffectiveConfig()
sourceDir := p.SourceDir()
if sourceDir != "" && filepath.Clean(sourceDir) != p.Root {
@@ -339,7 +461,7 @@ func (p *Project) Scan() error {
assetFiles, _, err := scanDir(p.AssetsDir(), func(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
return slices.Contains(AssetExtensions, ext)
return slices.Contains(effective.Inventory.AssetExtensions, ext)
})
if err != nil {
if errors.Is(err, os.ErrNotExist) {
@@ -381,31 +503,36 @@ func (p *Project) Scan() error {
}
func (p *Project) SourceDir() string {
return filepath.Join(p.Root, p.Config.Paths.Source)
return p.rootPath(p.EffectiveConfig().Paths.Source)
}
func (p *Project) AssetsDir() string {
assets := strings.TrimSpace(p.Config.Paths.Assets)
assets := p.EffectiveConfig().Paths.Assets
if assets == "" {
return ""
}
return filepath.Join(p.Root, assets)
return p.rootPath(assets)
}
func (p *Project) BuildDir() string {
return filepath.Join(p.Root, p.Config.Paths.Build)
return p.rootPath(p.EffectiveConfig().Paths.Build)
}
func (p *Project) CacheDir() string {
return p.rootPath(p.EffectiveConfig().Paths.Cache)
}
func (p *Project) ModuleArchivePath() string {
return filepath.Join(p.BuildDir(), p.Config.Module.ResRef+".mod")
effective := p.EffectiveConfig()
return filepath.Join(p.BuildDir(), effective.Outputs.ModuleArchiveName(effective.Module))
}
func (p *Project) HAKManifestPath() string {
return filepath.Join(p.BuildDir(), "haks.json")
return filepath.Join(p.BuildDir(), filepath.FromSlash(p.EffectiveConfig().Outputs.HAKManifest))
}
func (p *Project) HAKArchivePath(name string) string {
return filepath.Join(p.BuildDir(), strings.TrimSpace(name)+".hak")
return filepath.Join(p.BuildDir(), filepath.FromSlash(p.EffectiveConfig().Outputs.HAKArchiveName(name)))
}
func (p *Project) CloneWithHAKNames(names []string) (*Project, error) {
@@ -469,14 +596,7 @@ func (p *Project) TopDataSourceDir() string {
}
func (p *Project) TopDataBuildDir() string {
buildPath := strings.TrimSpace(p.Config.TopData.Build)
if buildPath == "" {
buildPath = filepath.Join(p.Config.Paths.Build, "topdata")
}
if filepath.IsAbs(buildPath) {
return buildPath
}
return filepath.Join(p.Root, buildPath)
return p.rootPath(p.EffectiveConfig().TopData.Build)
}
func (p *Project) TopDataReferenceBuilderDir() string {
@@ -506,18 +626,19 @@ func (p *Project) TopDataAssetsDir() string {
}
func (p *Project) TopDataUsesRepoCache() bool {
return sameCleanPath(p.TopDataBuildDir(), filepath.Join(p.Root, ".cache"))
return sameCleanPath(p.TopDataBuildDir(), p.CacheDir())
}
func (p *Project) TopDataCompiled2DADir() string {
return filepath.Join(p.TopDataBuildDir(), "2da")
return filepath.Join(p.TopDataBuildDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Compiled2DADir))
}
func (p *Project) TopDataCompiledTLKPath() string {
tlkName := p.EffectiveConfig().TopData.CompiledTLK
if p.TopDataUsesRepoCache() {
return filepath.Join(p.BuildDir(), "sow_tlk.tlk")
return filepath.Join(p.BuildDir(), filepath.FromSlash(tlkName))
}
return filepath.Join(p.TopDataBuildDir(), "sow_tlk.tlk")
return filepath.Join(p.TopDataBuildDir(), filepath.FromSlash(tlkName))
}
func (p *Project) TopDataCompiledTLKDir() string {
@@ -525,34 +646,27 @@ func (p *Project) TopDataCompiledTLKDir() string {
}
func (p *Project) TopDataWikiRootDir() string {
wikiRoot := filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.OutputRoot)
if p.TopDataUsesRepoCache() {
return filepath.Join(p.Root, ".cache", "wiki")
return filepath.Join(p.CacheDir(), wikiRoot)
}
return filepath.Join(p.TopDataBuildDir(), "wiki")
return filepath.Join(p.TopDataBuildDir(), wikiRoot)
}
func (p *Project) TopDataWikiPagesDir() string {
return filepath.Join(p.TopDataWikiRootDir(), "pages")
return filepath.Join(p.TopDataWikiRootDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.PagesDir))
}
func (p *Project) TopDataWikiStatePath() string {
return filepath.Join(p.TopDataWikiRootDir(), "state.json")
return filepath.Join(p.TopDataWikiRootDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.StateFile))
}
func (p *Project) TopDataPackageHAKName() string {
name := strings.TrimSpace(p.Config.TopData.PackageHAK)
if name == "" {
name = "sow_top.hak"
}
return name
return p.EffectiveConfig().TopData.PackageHAK
}
func (p *Project) TopDataPackageTLKName() string {
name := strings.TrimSpace(p.Config.TopData.PackageTLK)
if name == "" {
name = "sow_tlk.tlk"
}
return name
return p.EffectiveConfig().TopData.PackageTLK
}
func (p *Project) TopDataPackageHAKPath() string {
@@ -563,6 +677,40 @@ func (p *Project) TopDataPackageTLKPath() string {
return filepath.Join(p.BuildDir(), p.TopDataPackageTLKName())
}
func (p *Project) ScriptSourceDir() string {
return filepath.Join(p.SourceDir(), filepath.FromSlash(p.EffectiveConfig().Scripts.SourceDir))
}
func (p *Project) ScriptCacheDir() string {
return p.rootPath(p.EffectiveConfig().Scripts.Cache)
}
func (p *Project) MusicStageDir() string {
return p.rootPath(p.EffectiveConfig().Music.StageRoot)
}
func (p *Project) CreditsDir() string {
return p.rootPath(p.EffectiveConfig().Music.CreditsRoot)
}
func (p *Project) AutogenCacheDir() string {
return p.rootPath(p.EffectiveConfig().Autogen.Cache.Root)
}
func (p *Project) AutogenCachePath(name string) string {
return filepath.Join(p.AutogenCacheDir(), filepath.FromSlash(strings.TrimSpace(name)))
}
func (p *Project) rootPath(path string) string {
if strings.TrimSpace(path) == "" {
return p.Root
}
if filepath.IsAbs(path) {
return path
}
return filepath.Join(p.Root, filepath.FromSlash(path))
}
func (i Inventory) Report() InventoryReport {
return InventoryReport{
SourceFiles: len(i.SourceFiles),
@@ -575,12 +723,14 @@ func (i Inventory) Report() InventoryReport {
func defaultConfig() Config {
return Config{
Paths: PathConfig{
Build: "build",
Build: DefaultBuildPath,
},
}
}
func normalizeConfig(cfg *Config) {
cfg.Inventory.SourceExtensions = normalizeExtensionSlice(cfg.Inventory.SourceExtensions)
cfg.Inventory.AssetExtensions = normalizeExtensionSlice(cfg.Inventory.AssetExtensions)
for i := range cfg.HAKs {
cfg.HAKs[i].Include = normalizeStringSlice(cfg.HAKs[i].Include)
}
@@ -604,6 +754,22 @@ func normalizeStringSlice(input []string) []string {
return out
}
func normalizeExtensionSlice(input []string) []string {
if len(input) == 0 {
return input
}
out := make([]string, 0, len(input))
for _, value := range input {
trimmed := strings.ToLower(strings.TrimSpace(value))
if trimmed != "" && !strings.HasPrefix(trimmed, ".") {
trimmed = "." + trimmed
}
out = append(out, trimmed)
}
slices.Sort(out)
return out
}
func validateAutogenConfig(cfg AutogenConfig) []error {
var failures []error
producerIDs := map[string]struct{}{}
@@ -816,6 +982,25 @@ func validateOutputFileName(field, name, expectedExt string) error {
return nil
}
func validateRelativePath(field, path string) []error {
var failures []error
trimmed := strings.TrimSpace(path)
if trimmed == "" {
return failures
}
if filepath.IsAbs(trimmed) {
failures = append(failures, fmt.Errorf("%s must be relative to the repository root: %q", field, path))
}
clean := filepath.Clean(filepath.FromSlash(trimmed))
if clean == "." {
return failures
}
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
failures = append(failures, fmt.Errorf("%s must not escape the repository root: %q", field, path))
}
return failures
}
func sameCleanPath(a, b string) bool {
return filepath.Clean(a) == filepath.Clean(b)
}