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 {
+14 -2
View File
@@ -167,6 +167,9 @@ func buildModule(p *project.Project, progress ProgressFunc) (BuildResult, error)
progressf(progress, "Writing module archive...")
outputPath := p.ModuleArchivePath()
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return BuildResult{}, fmt.Errorf("create module output dir: %w", err)
}
output, err := os.Create(outputPath)
if err != nil {
return BuildResult{}, fmt.Errorf("create module archive: %w", err)
@@ -321,6 +324,9 @@ func planOrBuildHAKs(p *project.Project, progress ProgressFunc, writeArchives bo
return BuildResult{}, err
}
progressf(progress, "Writing HAK manifest...")
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
return BuildResult{}, fmt.Errorf("create hak manifest dir: %w", err)
}
if err := os.WriteFile(manifestPath, manifestBytes, 0o644); err != nil {
return BuildResult{}, fmt.Errorf("write hak manifest: %w", err)
}
@@ -345,6 +351,9 @@ func planOrBuildHAKs(p *project.Project, progress ProgressFunc, writeArchives bo
} else {
result.HAKSummary.Written++
progressf(progress, fmt.Sprintf("Writing HAK %d/%d: %s (%d assets)", index+1, len(chunks), chunk.Name, len(chunk.Assets)))
if err := os.MkdirAll(filepath.Dir(hakPath), 0o755); err != nil {
return BuildResult{}, fmt.Errorf("create hak archive dir: %w", err)
}
hakOutput, err := os.Create(hakPath)
if err != nil {
return BuildResult{}, fmt.Errorf("create hak archive: %w", err)
@@ -374,6 +383,9 @@ func planOrBuildHAKs(p *project.Project, progress ProgressFunc, writeArchives bo
}
progressf(progress, "Writing HAK manifest...")
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
return BuildResult{}, fmt.Errorf("create hak manifest dir: %w", err)
}
if err := os.WriteFile(manifestPath, manifestBytes, 0o644); err != nil {
return BuildResult{}, fmt.Errorf("write hak manifest: %w", err)
}
@@ -558,7 +570,7 @@ func compileReferencedScripts(p *project.Project) ([]erf.Resource, error) {
}
resources := make([]erf.Resource, 0, len(referenced))
scriptsDir := filepath.Join(p.SourceDir(), "scripts")
scriptsDir := p.ScriptSourceDir()
for _, resref := range referenced {
sourcePath, ok := scriptSources[resref]
if !ok {
@@ -586,7 +598,7 @@ func compileReferencedScripts(p *project.Project) ([]erf.Resource, error) {
}
func compiledScriptCacheDir(p *project.Project) string {
return filepath.Join(p.Root, ".cache", "ncs")
return p.ScriptCacheDir()
}
func referencedScriptResrefs(p *project.Project) ([]string, error) {
+4 -4
View File
@@ -128,7 +128,7 @@ func prepareMusicAssets(p *project.Project) (*preparedMusicAssets, error) {
sort.Strings(dirs)
generatedByDir := make(map[string][]creditsEntry)
stageRoot := filepath.Join(p.Root, ".cache", "music")
stageRoot := p.MusicStageDir()
result.TempRoots = append(result.TempRoots, stageRoot)
ffmpegPath, err := resolveFFmpegBinary()
@@ -144,7 +144,7 @@ func prepareMusicAssets(p *project.Project) (*preparedMusicAssets, error) {
sources := append([]string(nil), dirSources[dir]...)
sort.Strings(sources)
prefix := musicPrefixForDir(p, dir)
overlayPath := filepath.Join(p.AssetsDir(), filepath.FromSlash(dir), "CREDITS.md")
overlayPath := filepath.Join(p.AssetsDir(), filepath.FromSlash(dir), p.EffectiveConfig().Music.CreditsOverlay)
overlay, err := parseCreditsOverlay(overlayPath)
if err != nil {
return nil, fmt.Errorf("parse credits overlay %s: %w", overlayPath, err)
@@ -239,7 +239,7 @@ type creditsArtifactWriteResult struct {
}
func writeCreditsArtifacts(p *project.Project, generatedByDir map[string][]creditsEntry) (creditsArtifactWriteResult, error) {
creditsRoot := filepath.Join(p.Root, ".cache", "credits")
creditsRoot := p.CreditsDir()
sources := make([]creditsSource, 0)
desiredFiles := make(map[string][]byte)
summary := CreditsRefreshSummary{}
@@ -260,7 +260,7 @@ func writeCreditsArtifacts(p *project.Project, generatedByDir map[string][]credi
}
return strings.ToLower(entries[i].OutputFile) < strings.ToLower(entries[j].OutputFile)
})
outputPath := filepath.Join(creditsRoot, filepath.FromSlash(dir), "CREDITS.md")
outputPath := filepath.Join(creditsRoot, filepath.FromSlash(dir), p.EffectiveConfig().Music.CreditsOverlay)
desiredFiles[outputPath] = []byte(renderCreditsMarkdown(entries))
summary.GeneratedPaths = append(summary.GeneratedPaths, outputPath)
for _, entry := range entries {
+318
View File
@@ -0,0 +1,318 @@
package project
import (
"encoding/json"
"fmt"
"path/filepath"
"slices"
"strings"
"time"
)
const (
DefaultBuildPath = "build"
DefaultCachePath = ".cache"
DefaultToolsPath = "tools"
DefaultModuleArchiveTemplate = "{module.resref}.mod"
DefaultHAKManifest = "haks.json"
DefaultHAKArchiveTemplate = "{hak.name}.hak"
DefaultSourceJSONPattern = "{resref}.{extension}.json"
DefaultScriptsSourceDir = "scripts"
DefaultScriptsCache = "{paths.cache}/ncs"
DefaultMusicStageRoot = "{paths.cache}/music"
DefaultCreditsRoot = "{paths.cache}/credits"
DefaultCreditsOverlayFile = "CREDITS.md"
DefaultTopDataBuild = "{paths.build}/topdata"
DefaultTopDataCompiled2DADir = "2da"
DefaultTopDataCompiledTLK = "sow_tlk.tlk"
DefaultTopDataWikiOutputRoot = "wiki"
DefaultTopDataWikiPagesDir = "pages"
DefaultTopDataWikiStateFile = "state.json"
DefaultTopDataPackageHAK = "sow_top.hak"
DefaultTopDataPackageTLK = "sow_tlk.tlk"
DefaultAutogenCacheRoot = "{paths.cache}"
DefaultAutogenCacheMaxAge = time.Hour
DefaultAutogenRefreshEnv = "SOW_AUTOGEN_MANIFEST_REFRESH"
)
type ConfigProvenance map[string]ConfigValueProvenance
type ConfigValueProvenance struct {
Source string `json:"source" yaml:"source"`
Detail string `json:"detail,omitempty" yaml:"detail,omitempty"`
Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"`
}
type EffectiveConfig struct {
ConfigSource ConfigSource `json:"config_source" yaml:"config_source"`
Module ModuleConfig `json:"module" yaml:"module"`
Paths EffectivePathConfig `json:"paths" yaml:"paths"`
Outputs EffectiveOutputConfig `json:"outputs" yaml:"outputs"`
Inventory InventoryConfig `json:"inventory" yaml:"inventory"`
Scripts EffectiveScriptsConfig `json:"scripts" yaml:"scripts"`
Music EffectiveMusicConfig `json:"music" yaml:"music"`
TopData EffectiveTopDataConfig `json:"topdata" yaml:"topdata"`
Extract ExtractConfig `json:"extract" yaml:"extract"`
Autogen EffectiveAutogenConfig `json:"autogen" yaml:"autogen"`
HAKs []HAKConfig `json:"haks" yaml:"haks"`
Provenance ConfigProvenance `json:"provenance" yaml:"provenance"`
}
type EffectivePathConfig 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 EffectiveOutputConfig 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 EffectiveScriptsConfig struct {
SourceDir string `json:"source_dir" yaml:"source_dir"`
Cache string `json:"cache" yaml:"cache"`
}
type EffectiveMusicConfig struct {
Prefixes map[string]string `json:"prefixes" yaml:"prefixes"`
StageRoot string `json:"stage_root" yaml:"stage_root"`
CreditsRoot string `json:"credits_root" yaml:"credits_root"`
CreditsOverlay string `json:"credits_overlay" yaml:"credits_overlay"`
MaxStemLength int `json:"max_stem_length" yaml:"max_stem_length"`
}
type EffectiveTopDataConfig 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"`
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 EffectiveAutogenConfig struct {
Producers []AutogenProducerConfig `json:"producers" yaml:"producers"`
Consumers []AutogenConsumerConfig `json:"consumers" yaml:"consumers"`
Cache EffectiveAutogenCacheConfig `json:"cache" yaml:"cache"`
}
type EffectiveAutogenCacheConfig struct {
Root string `json:"root" yaml:"root"`
MaxAge string `json:"max_age" yaml:"max_age"`
RefreshEnv string `json:"refresh_env" yaml:"refresh_env"`
}
func (p *Project) EffectiveConfig() EffectiveConfig {
provenance := cloneProvenance(p.Provenance)
markMissingDefaults(provenance)
paths := EffectivePathConfig{
Source: strings.TrimSpace(p.Config.Paths.Source),
Assets: strings.TrimSpace(p.Config.Paths.Assets),
Build: defaultString(p.Config.Paths.Build, DefaultBuildPath),
Cache: defaultString(p.Config.Paths.Cache, DefaultCachePath),
Tools: defaultString(p.Config.Paths.Tools, DefaultToolsPath),
}
outputs := EffectiveOutputConfig{
ModuleArchive: defaultString(p.Config.Outputs.ModuleArchive, DefaultModuleArchiveTemplate),
HAKManifest: defaultString(p.Config.Outputs.HAKManifest, DefaultHAKManifest),
HAKArchive: defaultString(p.Config.Outputs.HAKArchive, DefaultHAKArchiveTemplate),
}
inventory := InventoryConfig{
SourceExtensions: defaultStringSlice(p.Config.Inventory.SourceExtensions, SourceExtensions),
AssetExtensions: defaultStringSlice(p.Config.Inventory.AssetExtensions, AssetExtensions),
SourceJSONPattern: defaultString(p.Config.Inventory.SourceJSONPattern, DefaultSourceJSONPattern),
}
scripts := EffectiveScriptsConfig{
SourceDir: defaultString(p.Config.Scripts.SourceDir, DefaultScriptsSourceDir),
Cache: expandPathTemplate(defaultString(p.Config.Scripts.Cache, DefaultScriptsCache), paths),
}
topBuild := expandPathTemplate(defaultString(p.Config.TopData.Build, DefaultTopDataBuild), paths)
top := EffectiveTopDataConfig{
Source: strings.TrimSpace(p.Config.TopData.Source),
Build: topBuild,
ReferenceBuilder: strings.TrimSpace(p.Config.TopData.ReferenceBuilder),
Assets: strings.TrimSpace(p.Config.TopData.Assets),
Compiled2DADir: defaultString(p.Config.TopData.Compiled2DADir, DefaultTopDataCompiled2DADir),
CompiledTLK: defaultString(p.Config.TopData.CompiledTLK, DefaultTopDataCompiledTLK),
PackageHAK: defaultString(p.Config.TopData.PackageHAK, DefaultTopDataPackageHAK),
PackageTLK: defaultString(p.Config.TopData.PackageTLK, DefaultTopDataPackageTLK),
Wiki: TopDataWikiConfig{
OutputRoot: defaultString(p.Config.TopData.Wiki.OutputRoot, DefaultTopDataWikiOutputRoot),
PagesDir: defaultString(p.Config.TopData.Wiki.PagesDir, DefaultTopDataWikiPagesDir),
StateFile: defaultString(p.Config.TopData.Wiki.StateFile, DefaultTopDataWikiStateFile),
},
}
return EffectiveConfig{
ConfigSource: p.ConfigSource,
Module: p.Config.Module,
Paths: paths,
Outputs: outputs,
Inventory: inventory,
Scripts: scripts,
Music: EffectiveMusicConfig{
Prefixes: cloneStringMap(p.Config.Music.Prefixes),
StageRoot: expandPathTemplate(DefaultMusicStageRoot, paths),
CreditsRoot: expandPathTemplate(DefaultCreditsRoot, paths),
CreditsOverlay: DefaultCreditsOverlayFile,
MaxStemLength: 16,
},
TopData: top,
Extract: p.Config.Extract,
Autogen: EffectiveAutogenConfig{
Producers: slices.Clone(p.Config.Autogen.Producers),
Consumers: slices.Clone(p.Config.Autogen.Consumers),
Cache: EffectiveAutogenCacheConfig{
Root: expandPathTemplate(defaultString(p.Config.Autogen.Cache.Root, DefaultAutogenCacheRoot), paths),
MaxAge: DefaultAutogenCacheMaxAge.String(),
RefreshEnv: DefaultAutogenRefreshEnv,
},
},
HAKs: slices.Clone(p.Config.HAKs),
Provenance: provenance,
}
}
func (p *Project) EffectiveConfigJSON() ([]byte, error) {
return json.MarshalIndent(p.EffectiveConfig(), "", " ")
}
func (p *Project) ExplainConfig(key string) (any, ConfigValueProvenance, bool) {
effective := p.EffectiveConfig()
value, ok := lookupEffectiveValue(effective, key)
if !ok {
return nil, ConfigValueProvenance{}, false
}
prov := effective.Provenance[key]
if prov.Source == "" {
prov = ConfigValueProvenance{Source: "yaml", Detail: p.ConfigSource.Name}
}
return value, prov, true
}
func lookupEffectiveValue(effective EffectiveConfig, key string) (any, bool) {
raw, err := json.Marshal(effective)
if err != nil {
return nil, false
}
var data any
if err := json.Unmarshal(raw, &data); err != nil {
return nil, false
}
current := data
for _, part := range strings.Split(key, ".") {
object, ok := current.(map[string]any)
if !ok {
return nil, false
}
current, ok = object[part]
if !ok {
return nil, false
}
}
return current, true
}
func markMissingDefaults(provenance ConfigProvenance) {
defaults := map[string]string{
"paths.build": "build",
"paths.cache": ".cache",
"paths.tools": "tools",
"outputs.module_archive": DefaultModuleArchiveTemplate,
"outputs.hak_manifest": DefaultHAKManifest,
"outputs.hak_archive": DefaultHAKArchiveTemplate,
"inventory.source_extensions": "NWN source resource extensions",
"inventory.asset_extensions": "NWN asset resource extensions",
"inventory.source_json_pattern": DefaultSourceJSONPattern,
"scripts.source_dir": DefaultScriptsSourceDir,
"scripts.cache": DefaultScriptsCache,
"music.stage_root": DefaultMusicStageRoot,
"music.credits_root": DefaultCreditsRoot,
"music.credits_overlay": DefaultCreditsOverlayFile,
"music.max_stem_length": "16",
"topdata.build": DefaultTopDataBuild,
"topdata.compiled_2da_dir": DefaultTopDataCompiled2DADir,
"topdata.compiled_tlk": DefaultTopDataCompiledTLK,
"topdata.package_hak": DefaultTopDataPackageHAK,
"topdata.package_tlk": DefaultTopDataPackageTLK,
"topdata.wiki.output_root": DefaultTopDataWikiOutputRoot,
"topdata.wiki.pages_dir": DefaultTopDataWikiPagesDir,
"topdata.wiki.state_file": DefaultTopDataWikiStateFile,
"autogen.cache.root": DefaultAutogenCacheRoot,
"autogen.cache.max_age": DefaultAutogenCacheMaxAge.String(),
"autogen.cache.refresh_env": DefaultAutogenRefreshEnv,
}
for key, detail := range defaults {
if _, ok := provenance[key]; !ok {
provenance[key] = ConfigValueProvenance{Source: "toolkit default", Detail: detail}
}
}
}
func cloneProvenance(input ConfigProvenance) ConfigProvenance {
out := ConfigProvenance{}
for key, value := range input {
out[key] = value
}
return out
}
func defaultString(value, fallback string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return fallback
}
return trimmed
}
func defaultStringSlice(value, fallback []string) []string {
if len(value) == 0 {
return slices.Clone(fallback)
}
return slices.Clone(value)
}
func cloneStringMap(input map[string]string) map[string]string {
if len(input) == 0 {
return nil
}
out := make(map[string]string, len(input))
for key, value := range input {
out[key] = value
}
return out
}
func expandPathTemplate(template string, paths EffectivePathConfig) string {
replacer := strings.NewReplacer(
"{paths.source}", paths.Source,
"{paths.assets}", paths.Assets,
"{paths.build}", paths.Build,
"{paths.cache}", paths.Cache,
"{paths.tools}", paths.Tools,
)
return filepath.ToSlash(replacer.Replace(template))
}
func (o EffectiveOutputConfig) ModuleArchiveName(module ModuleConfig) string {
return strings.NewReplacer("{module.resref}", strings.TrimSpace(module.ResRef)).Replace(o.ModuleArchive)
}
func (o EffectiveOutputConfig) HAKArchiveName(name string) string {
return strings.NewReplacer("{hak.name}", strings.TrimSpace(name)).Replace(o.HAKArchive)
}
func (p ConfigValueProvenance) String() string {
if p.Detail == "" {
return p.Source
}
return fmt.Sprintf("%s (%s)", p.Source, p.Detail)
}
+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)
}
+155
View File
@@ -1,6 +1,7 @@
package project
import (
"bytes"
"errors"
"os"
"path/filepath"
@@ -43,6 +44,128 @@ haks:
}
}
func TestEffectiveConfigAppliesVisibleToolkitDefaults(t *testing.T) {
root := t.TempDir()
writeProjectFile(t, filepath.Join(root, ConfigFile), `
module:
name: Test Module
resref: testmod
paths:
source: src
`)
proj, err := Load(root)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
effective := proj.EffectiveConfig()
if got, want := effective.Paths.Build, "build"; got != want {
t.Fatalf("expected default build path %q, got %q", want, got)
}
if got, want := effective.Outputs.HAKManifest, "haks.json"; got != want {
t.Fatalf("expected default HAK manifest %q, got %q", want, got)
}
if got, want := effective.TopData.PackageHAK, "sow_top.hak"; got != want {
t.Fatalf("expected default topdata HAK %q, got %q", want, got)
}
if prov := effective.Provenance["paths.build"]; prov.Source != "toolkit default" {
t.Fatalf("expected paths.build toolkit default provenance, got %#v", prov)
}
if prov := effective.Provenance["module.name"]; prov.Source != "yaml" {
t.Fatalf("expected module.name YAML provenance, got %#v", prov)
}
}
func TestEffectiveConfigHonorsConfiguredOutputAndCacheFields(t *testing.T) {
root := t.TempDir()
writeProjectFile(t, filepath.Join(root, ConfigFile), `
module:
name: Test Module
resref: testmod
paths:
build: output
cache: cache
outputs:
module_archive: modules/{module.resref}.mod
hak_manifest: manifests/haks.json
hak_archive: haks/{hak.name}.hak
scripts:
cache: "{paths.cache}/compiled"
topdata:
source: topdata
build: "{paths.cache}"
compiled_2da_dir: tables
compiled_tlk: dialog.tlk
package_hak: custom_top.hak
package_tlk: custom_dialog.tlk
wiki:
output_root: docs
pages_dir: pages
state_file: wiki-state.json
autogen:
cache:
root: "{paths.cache}/autogen"
`)
proj, err := Load(root)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if got, want := proj.ModuleArchivePath(), filepath.Join(root, "output", "modules", "testmod.mod"); got != want {
t.Fatalf("expected module archive path %s, got %s", want, got)
}
if got, want := proj.HAKManifestPath(), filepath.Join(root, "output", "manifests", "haks.json"); got != want {
t.Fatalf("expected HAK manifest path %s, got %s", want, got)
}
if got, want := proj.HAKArchivePath("core_01"), filepath.Join(root, "output", "haks", "core_01.hak"); got != want {
t.Fatalf("expected HAK archive path %s, got %s", want, got)
}
if got, want := proj.ScriptCacheDir(), filepath.Join(root, "cache", "compiled"); got != want {
t.Fatalf("expected script cache path %s, got %s", want, got)
}
if got, want := proj.TopDataCompiled2DADir(), filepath.Join(root, "cache", "tables"); got != want {
t.Fatalf("expected topdata 2da path %s, got %s", want, got)
}
if got, want := proj.TopDataWikiStatePath(), filepath.Join(root, "cache", "docs", "wiki-state.json"); got != want {
t.Fatalf("expected wiki state path %s, got %s", want, got)
}
if got, want := proj.AutogenCachePath("manifest.json"), filepath.Join(root, "cache", "autogen", "manifest.json"); got != want {
t.Fatalf("expected autogen cache path %s, got %s", want, got)
}
}
func TestEffectiveConfigJSONIsDeterministic(t *testing.T) {
root := t.TempDir()
writeProjectFile(t, filepath.Join(root, ConfigFile), `
module:
name: Test Module
resref: testmod
paths:
source: src
`)
proj, err := Load(root)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
first, err := proj.EffectiveConfigJSON()
if err != nil {
t.Fatalf("EffectiveConfigJSON returned error: %v", err)
}
second, err := proj.EffectiveConfigJSON()
if err != nil {
t.Fatalf("EffectiveConfigJSON returned error: %v", err)
}
if !bytes.Equal(first, second) {
t.Fatalf("effective config JSON is not deterministic")
}
if !bytes.Contains(first, []byte(`"hak_manifest": "haks.json"`)) {
t.Fatalf("effective config JSON missing HAK manifest default: %s", first)
}
}
func TestLoadYMLConfig(t *testing.T) {
root := t.TempDir()
writeProjectFile(t, filepath.Join(root, ConfigFileYML), `
@@ -346,6 +469,38 @@ func TestValidateLayoutRejectsInvalidTopDataPackageOutputNames(t *testing.T) {
}
}
func TestValidateLayoutRejectsUnsafeGeneratedOutputPaths(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "src"))
mkdirAll(t, filepath.Join(root, "build"))
proj := &Project{
Root: root,
Config: Config{
Module: ModuleConfig{Name: "Test", ResRef: "test"},
Paths: PathConfig{Source: "src", Build: "build"},
Outputs: OutputConfig{
HAKManifest: "../haks.json",
},
TopData: TopDataConfig{
Source: "topdata",
Compiled2DADir: "../2da",
},
},
}
err := proj.ValidateLayout()
if err == nil {
t.Fatal("expected unsafe output path validation error")
}
if !strings.Contains(err.Error(), "outputs.hak_manifest") {
t.Fatalf("expected HAK manifest path validation error, got %v", err)
}
if !strings.Contains(err.Error(), "topdata.compiled_2da_dir") {
t.Fatalf("expected topdata path validation error, got %v", err)
}
}
func TestValidateLayoutRejectsInvalidAutogenConsumerConfig(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "src"))
+1 -1
View File
@@ -328,7 +328,7 @@ func deriveAutogenManifestEntry(rel string, derive project.AutogenDeriveConfig)
}
func resolveReleasedAutogenManifestEntries(p *project.Project, consumer project.AutogenConsumerConfig, progress func(string)) ([]autogenManifestEntry, error) {
cachePath := filepath.Join(p.Root, ".cache", consumer.Manifest.CacheName)
cachePath := p.AutogenCachePath(consumer.Manifest.CacheName)
if strings.TrimSpace(os.Getenv("SOW_AUTOGEN_MANIFEST_REFRESH")) == "" {
manifest, fresh, err := readFreshAutogenManifestCache(cachePath, autogenManifestCacheMaxAge)
if err != nil {
+1 -1
View File
@@ -45,7 +45,7 @@ func resolveReleasedPartsManifest(p *project.Project, progress func(string)) (ma
if progress == nil {
progress = func(string) {}
}
cachePath := filepath.Join(p.Root, ".cache", partsManifestCacheFileName)
cachePath := p.AutogenCachePath(partsManifestCacheFileName)
if strings.TrimSpace(os.Getenv("SOW_PARTS_MANIFEST_REFRESH")) == "" {
manifest, fresh, err := readFreshPartsManifestCache(cachePath, partsManifestCacheMaxAge)
if err != nil {
+1 -1
View File
@@ -322,7 +322,7 @@ func newestAutogenInput(p *project.Project, now time.Time) (time.Time, string, e
continue
}
cachePath := filepath.Join(p.Root, ".cache", consumer.Manifest.CacheName)
cachePath := p.AutogenCachePath(consumer.Manifest.CacheName)
candidateTime, candidatePath, err := newestReleasedAutogenManifestInput(p, consumer, now, cachePath)
if err != nil {
return time.Time{}, "", err