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:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user