diff --git a/internal/pipeline/extract.go b/internal/pipeline/extract.go index cc0dd0f..e52225d 100644 --- a/internal/pipeline/extract.go +++ b/internal/pipeline/extract.go @@ -156,10 +156,15 @@ func extractArchiveResources(p *project.Project, archive erf.Archive, desired ma func extractedFile(p *project.Project, resource erf.Resource, extension string) (string, []byte, error) { resref := strings.ToLower(resource.Name) + effective := p.EffectiveConfig() switch extension { case "nss": - return filepath.Join(p.ScriptSourceDir(), resref+".nss"), resource.Data, nil + target, err := extractionTarget(p, "paths.source", effective.Paths.Source, p.SourceDir(), filepath.FromSlash(effective.Scripts.SourceDir), resref+".nss") + if err != nil { + return "", nil, err + } + return target, resource.Data, nil case "utc", "utd", "ute", "uti", "utm", "utp", "uts", "utt", "utw", "are", "dlg", "fac", "gic", "git", "ifo", "itp", "jrl": document, err := gff.Read(bytes.NewReader(resource.Data)) @@ -171,12 +176,41 @@ func extractedFile(p *project.Project, resource erf.Resource, extension string) return "", nil, fmt.Errorf("marshal json %s.%s: %w", resource.Name, extension, err) } formatted = append(formatted, '\n') - return filepath.Join(p.SourceDir(), sourceSubdir(extension), resref+"."+extension+".json"), formatted, nil + target, err := extractionTarget(p, "paths.source", effective.Paths.Source, p.SourceDir(), sourceSubdir(extension), resref+"."+extension+".json") + if err != nil { + return "", nil, err + } + return target, formatted, nil default: - return filepath.Join(p.AssetsDir(), extension, resref+"."+extension), resource.Data, nil + target, err := extractionTarget(p, "paths.assets", effective.Paths.Assets, p.AssetsDir(), extension, resref+"."+extension) + if err != nil { + return "", nil, err + } + return target, resource.Data, nil } } +func extractionTarget(p *project.Project, field, configured, root string, parts ...string) (string, error) { + if strings.TrimSpace(configured) == "" { + return "", fmt.Errorf("cannot extract resource: %s is not configured", field) + } + + cleanRoot := filepath.Clean(root) + if cleanRoot == "." || cleanRoot == string(filepath.Separator) || cleanRoot == filepath.Clean(p.Root) { + return "", fmt.Errorf("cannot extract resource: %s resolves to unsafe extraction root %s", field, cleanRoot) + } + + target := filepath.Join(append([]string{cleanRoot}, parts...)...) + rel, err := filepath.Rel(cleanRoot, target) + if err != nil { + return "", fmt.Errorf("resolve extraction target %s: %w", target, err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { + return "", fmt.Errorf("refusing to extract resource outside %s: %s", cleanRoot, target) + } + return target, nil +} + func extractHAKPaths(p *project.Project) ([]string, error) { switch p.EffectiveConfig().Extract.HAKDiscovery { case "configured_haks": @@ -236,14 +270,18 @@ func writeManagedFile(path string, data []byte) (writeState, error) { func cleanupStaleFiles(p *project.Project, desired map[string]struct{}) (int, []error) { candidates := make([]string, 0, len(p.Inventory.SourceFiles)+len(p.Inventory.ScriptFiles)+len(p.Inventory.AssetFiles)) - for _, rel := range p.Inventory.SourceFiles { - candidates = append(candidates, filepath.Join(p.SourceDir(), filepath.FromSlash(rel))) + if safeCleanupRoot(p.SourceDir(), p.Root) { + for _, rel := range p.Inventory.SourceFiles { + candidates = append(candidates, filepath.Join(p.SourceDir(), filepath.FromSlash(rel))) + } + for _, rel := range p.Inventory.ScriptFiles { + candidates = append(candidates, filepath.Join(p.SourceDir(), filepath.FromSlash(rel))) + } } - for _, rel := range p.Inventory.ScriptFiles { - candidates = append(candidates, filepath.Join(p.SourceDir(), filepath.FromSlash(rel))) - } - for _, rel := range p.Inventory.AssetFiles { - candidates = append(candidates, filepath.Join(p.AssetsDir(), filepath.FromSlash(rel))) + if safeCleanupRoot(p.AssetsDir(), p.Root) { + for _, rel := range p.Inventory.AssetFiles { + candidates = append(candidates, filepath.Join(p.AssetsDir(), filepath.FromSlash(rel))) + } } removed := 0 @@ -266,6 +304,11 @@ func cleanupStaleFiles(p *project.Project, desired map[string]struct{}) (int, [] return removed, failures } +func safeCleanupRoot(root, projectRoot string) bool { + cleanRoot := filepath.Clean(root) + return cleanRoot != "." && cleanRoot != string(filepath.Separator) && cleanRoot != filepath.Clean(projectRoot) +} + func cleanupEmptyParents(dir string, roots ...string) { for { if dir == "." || dir == string(filepath.Separator) { diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index b4a55f1..a11286f 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -175,6 +175,173 @@ func TestExtractReadsHAKAssets(t *testing.T) { } } +func TestExtractRefusesAssetsWhenAssetsPathIsUnset(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + mustMkdir(t, filepath.Join(root, "src")) + mustMkdir(t, filepath.Join(root, "build")) + mustWriteFile(t, filepath.Join(root, "root_level_asset.mdl"), "must stay") + + mustWriteFile(t, filepath.Join(root, "nwn-tool.yaml"), ` +module: + name: Test Module + resref: testmod +paths: + source: src + build: build +`) + + p, err := project.Load(root) + if err != nil { + t.Fatalf("load project: %v", err) + } + if err := p.Scan(); err != nil { + t.Fatalf("scan: %v", err) + } + if len(p.Inventory.AssetFiles) != 0 { + t.Fatalf("expected unset assets path not to inventory repository-root assets, got %#v", p.Inventory.AssetFiles) + } + + modFile, err := os.Create(p.ModuleArchivePath()) + if err != nil { + t.Fatalf("create mod: %v", err) + } + if err := erf.Write(modFile, erf.New("MOD ", nil)); err != nil { + t.Fatalf("write mod: %v", err) + } + if err := modFile.Close(); err != nil { + t.Fatalf("close mod: %v", err) + } + + hakFile, err := os.Create(filepath.Join(root, "build", "vfx.hak")) + if err != nil { + t.Fatalf("create hak: %v", err) + } + if err := erf.Write(hakFile, erf.New("HAK ", []erf.Resource{ + {Name: "test_vfx", Type: 0x07D2, Data: []byte("mdl-data")}, + })); err != nil { + t.Fatalf("write hak: %v", err) + } + if err := hakFile.Close(); err != nil { + t.Fatalf("close hak: %v", err) + } + + _, err = Extract(p) + if err == nil { + t.Fatal("expected extract to fail") + } + if !strings.Contains(err.Error(), "paths.assets is not configured") { + t.Fatalf("expected missing assets path error, got %v", err) + } + if _, statErr := os.Stat(filepath.Join(root, "mdl", "test_vfx.mdl")); !os.IsNotExist(statErr) { + t.Fatalf("expected no root-level extracted asset, stat err=%v", statErr) + } + if _, statErr := os.Stat(filepath.Join(root, "root_level_asset.mdl")); statErr != nil { + t.Fatalf("expected pre-existing root-level asset to remain, stat err=%v", statErr) + } +} + +func TestExtractRefusesAssetsWhenAssetsPathIsRepositoryRoot(t *testing.T) { + root := t.TempDir() + mustMkdir(t, filepath.Join(root, "src")) + mustMkdir(t, filepath.Join(root, "build")) + + mustWriteFile(t, filepath.Join(root, "nwn-tool.yaml"), ` +module: + name: Test Module + resref: testmod +paths: + source: src + assets: . + build: build +`) + + p, err := project.Load(root) + if err != nil { + t.Fatalf("load project: %v", err) + } + + modFile, err := os.Create(p.ModuleArchivePath()) + if err != nil { + t.Fatalf("create mod: %v", err) + } + if err := erf.Write(modFile, erf.New("MOD ", nil)); err != nil { + t.Fatalf("write mod: %v", err) + } + if err := modFile.Close(); err != nil { + t.Fatalf("close mod: %v", err) + } + + hakFile, err := os.Create(filepath.Join(root, "build", "vfx.hak")) + if err != nil { + t.Fatalf("create hak: %v", err) + } + if err := erf.Write(hakFile, erf.New("HAK ", []erf.Resource{ + {Name: "test_vfx", Type: 0x07D2, Data: []byte("mdl-data")}, + })); err != nil { + t.Fatalf("write hak: %v", err) + } + if err := hakFile.Close(); err != nil { + t.Fatalf("close hak: %v", err) + } + + _, err = Extract(p) + if err == nil { + t.Fatal("expected extract to fail") + } + if !strings.Contains(err.Error(), "paths.assets resolves to unsafe extraction root") { + t.Fatalf("expected unsafe assets root error, got %v", err) + } + if _, statErr := os.Stat(filepath.Join(root, "mdl", "test_vfx.mdl")); !os.IsNotExist(statErr) { + t.Fatalf("expected no root-level extracted asset, stat err=%v", statErr) + } +} + +func TestExtractRefusesSourceWhenSourcePathIsUnset(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + mustMkdir(t, filepath.Join(root, "assets")) + mustMkdir(t, filepath.Join(root, "build")) + + mustWriteFile(t, filepath.Join(root, "nwn-tool.yaml"), ` +module: + name: Test Module + resref: testmod +paths: + assets: assets + build: build +`) + + p, err := project.Load(root) + if err != nil { + t.Fatalf("load project: %v", err) + } + + modFile, err := os.Create(p.ModuleArchivePath()) + if err != nil { + t.Fatalf("create mod: %v", err) + } + if err := erf.Write(modFile, erf.New("MOD ", []erf.Resource{ + {Name: "test_script", Type: 0x07D9, Data: []byte("void main() {}\n")}, + })); err != nil { + t.Fatalf("write mod: %v", err) + } + if err := modFile.Close(); err != nil { + t.Fatalf("close mod: %v", err) + } + + _, err = Extract(p) + if err == nil { + t.Fatal("expected extract to fail") + } + if !strings.Contains(err.Error(), "paths.source is not configured") { + t.Fatalf("expected missing source path error, got %v", err) + } + if _, statErr := os.Stat(filepath.Join(root, "scripts", "test_script.nss")); !os.IsNotExist(statErr) { + t.Fatalf("expected no root-level extracted script, stat err=%v", statErr) + } +} + func TestExtractConfiguredHAKDiscoveryIgnoresUnconfiguredArchives(t *testing.T) { root := t.TempDir() mustMkdir(t, filepath.Join(root, "src")) diff --git a/internal/project/project.go b/internal/project/project.go index 1f690e1..85c36bc 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -581,7 +581,7 @@ func (p *Project) Scan() error { effective := p.EffectiveConfig() sourceDir := p.SourceDir() - if sourceDir != "" && filepath.Clean(sourceDir) != p.Root { + if sourceDir != "" && filepath.Clean(sourceDir) != filepath.Clean(p.Root) { var err error sourceFiles, sourceExts, err = scanDir(sourceDir, func(path string) bool { ext := strings.ToLower(filepath.Ext(path)) @@ -594,15 +594,20 @@ func (p *Project) Scan() error { } } - assetFiles, _, err := scanDir(p.AssetsDir(), func(path string) bool { - ext := strings.ToLower(filepath.Ext(path)) - return slices.Contains(effective.Inventory.AssetExtensions, ext) - }) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - assetFiles = nil - } else { - return fmt.Errorf("scan assets tree: %w", err) + assetDir := p.AssetsDir() + var assetFiles []string + if assetDir != "" && filepath.Clean(assetDir) != filepath.Clean(p.Root) { + var err error + assetFiles, _, err = scanDir(assetDir, func(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + return slices.Contains(effective.Inventory.AssetExtensions, ext) + }) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + assetFiles = nil + } else { + return fmt.Errorf("scan assets tree: %w", err) + } } } diff --git a/nwn-tool b/nwn-tool index 0da5e11..cec62e1 100755 Binary files a/nwn-tool and b/nwn-tool differ