diff --git a/internal/cmd/dev/cmd_apply.go b/internal/cmd/dev/cmd_apply.go index 25a9646f..7f955f7c 100644 --- a/internal/cmd/dev/cmd_apply.go +++ b/internal/cmd/dev/cmd_apply.go @@ -18,6 +18,7 @@ import ( "github.com/host-uk/core/pkg/errors" "github.com/host-uk/core/pkg/git" "github.com/host-uk/core/pkg/i18n" + "github.com/host-uk/core/pkg/io" "github.com/host-uk/core/pkg/repos" ) @@ -76,8 +77,8 @@ func runApply() error { // Validate script exists if applyScript != "" { - if _, err := os.Stat(applyScript); err != nil { - return errors.E("dev.apply", "script not found: "+applyScript, err) + if !io.Local.Exists(applyScript) { + return errors.E("dev.apply", "script not found: "+applyScript, nil) } } diff --git a/internal/cmd/dev/cmd_workflow.go b/internal/cmd/dev/cmd_workflow.go index 354f9387..4f4ca263 100644 --- a/internal/cmd/dev/cmd_workflow.go +++ b/internal/cmd/dev/cmd_workflow.go @@ -1,13 +1,13 @@ package dev import ( - "os" "path/filepath" "sort" "strings" "github.com/host-uk/core/pkg/cli" "github.com/host-uk/core/pkg/i18n" + "github.com/host-uk/core/pkg/io" ) // Workflow command flags @@ -156,7 +156,7 @@ func runWorkflowSync(registryPath string, workflowFile string, dryRun bool) erro } // Read template content - templateContent, err := os.ReadFile(templatePath) + templateContent, err := io.Local.Read(templatePath) if err != nil { return cli.Wrap(err, i18n.T("cmd.dev.workflow.read_template_error")) } @@ -189,8 +189,8 @@ func runWorkflowSync(registryPath string, workflowFile string, dryRun bool) erro destPath := filepath.Join(destDir, workflowFile) // Check if workflow already exists and is identical - if existingContent, err := os.ReadFile(destPath); err == nil { - if string(existingContent) == string(templateContent) { + if existingContent, err := io.Local.Read(destPath); err == nil { + if existingContent == templateContent { cli.Print(" %s %s %s\n", dimStyle.Render("-"), repoNameStyle.Render(repo.Name), @@ -210,7 +210,7 @@ func runWorkflowSync(registryPath string, workflowFile string, dryRun bool) erro } // Create .github/workflows directory if needed - if err := os.MkdirAll(destDir, 0755); err != nil { + if err := io.Local.EnsureDir(destDir); err != nil { cli.Print(" %s %s %s\n", errorStyle.Render(cli.Glyph(":cross:")), repoNameStyle.Render(repo.Name), @@ -220,7 +220,7 @@ func runWorkflowSync(registryPath string, workflowFile string, dryRun bool) erro } // Write workflow file - if err := os.WriteFile(destPath, templateContent, 0644); err != nil { + if err := io.Local.Write(destPath, templateContent); err != nil { cli.Print(" %s %s %s\n", errorStyle.Render(cli.Glyph(":cross:")), repoNameStyle.Render(repo.Name), @@ -264,7 +264,7 @@ func findWorkflows(dir string) []string { workflowsDir = dir } - entries, err := os.ReadDir(workflowsDir) + entries, err := io.Local.List(workflowsDir) if err != nil { return nil } @@ -298,7 +298,7 @@ func findTemplateWorkflow(registryDir, workflowFile string) string { } for _, candidate := range candidates { - if _, err := os.Stat(candidate); err == nil { + if io.Local.Exists(candidate) { return candidate } } diff --git a/internal/cmd/docs/cmd_scan.go b/internal/cmd/docs/cmd_scan.go index 8257c944..99244072 100644 --- a/internal/cmd/docs/cmd_scan.go +++ b/internal/cmd/docs/cmd_scan.go @@ -9,6 +9,7 @@ import ( "github.com/host-uk/core/internal/cmd/workspace" "github.com/host-uk/core/pkg/cli" "github.com/host-uk/core/pkg/i18n" + "github.com/host-uk/core/pkg/io" "github.com/host-uk/core/pkg/repos" ) @@ -93,28 +94,28 @@ func scanRepoDocs(repo *repos.Repo) RepoDocInfo { // Check for README.md readme := filepath.Join(repo.Path, "README.md") - if _, err := os.Stat(readme); err == nil { + if io.Local.Exists(readme) { info.Readme = readme info.HasDocs = true } // Check for CLAUDE.md claudeMd := filepath.Join(repo.Path, "CLAUDE.md") - if _, err := os.Stat(claudeMd); err == nil { + if io.Local.Exists(claudeMd) { info.ClaudeMd = claudeMd info.HasDocs = true } // Check for CHANGELOG.md changelog := filepath.Join(repo.Path, "CHANGELOG.md") - if _, err := os.Stat(changelog); err == nil { + if io.Local.Exists(changelog) { info.Changelog = changelog info.HasDocs = true } // Recursively scan docs/ directory for .md files docsDir := filepath.Join(repo.Path, "docs") - if _, err := os.Stat(docsDir); err == nil { + if io.Local.IsDir(docsDir) { filepath.WalkDir(docsDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return nil @@ -139,9 +140,5 @@ func scanRepoDocs(repo *repos.Repo) RepoDocInfo { } func copyFile(src, dst string) error { - data, err := os.ReadFile(src) - if err != nil { - return err - } - return os.WriteFile(dst, data, 0644) + return io.Copy(io.Local, src, io.Local, dst) } diff --git a/internal/cmd/docs/cmd_sync.go b/internal/cmd/docs/cmd_sync.go index b4ce4888..79bdac02 100644 --- a/internal/cmd/docs/cmd_sync.go +++ b/internal/cmd/docs/cmd_sync.go @@ -1,12 +1,12 @@ package docs import ( - "os" "path/filepath" "strings" "github.com/host-uk/core/pkg/cli" "github.com/host-uk/core/pkg/i18n" + "github.com/host-uk/core/pkg/io" ) // Flag variables for sync command @@ -127,9 +127,9 @@ func runDocsSync(registryPath string, outputDir string, dryRun bool) error { repoOutDir := filepath.Join(outputDir, outName) // Clear existing directory - os.RemoveAll(repoOutDir) + _ = io.Local.DeleteAll(repoOutDir) - if err := os.MkdirAll(repoOutDir, 0755); err != nil { + if err := io.Local.EnsureDir(repoOutDir); err != nil { cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), info.Name, err) continue } @@ -139,7 +139,7 @@ func runDocsSync(registryPath string, outputDir string, dryRun bool) error { for _, f := range info.DocsFiles { src := filepath.Join(docsDir, f) dst := filepath.Join(repoOutDir, f) - os.MkdirAll(filepath.Dir(dst), 0755) + _ = io.Local.EnsureDir(filepath.Dir(dst)) if err := copyFile(src, dst); err != nil { cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), f, err) } diff --git a/internal/cmd/setup/cmd_bootstrap.go b/internal/cmd/setup/cmd_bootstrap.go index 2e902b4c..8f73331d 100644 --- a/internal/cmd/setup/cmd_bootstrap.go +++ b/internal/cmd/setup/cmd_bootstrap.go @@ -15,6 +15,7 @@ import ( "github.com/host-uk/core/internal/cmd/workspace" "github.com/host-uk/core/pkg/i18n" + "github.com/host-uk/core/pkg/io" "github.com/host-uk/core/pkg/repos" ) @@ -96,7 +97,7 @@ func runBootstrap(ctx context.Context, only string, dryRun, all bool, projectNam fmt.Printf("%s %s: %s\n", dimStyle.Render(">>"), i18n.T("cmd.setup.creating_project_dir"), projectName) if !dryRun { - if err := os.MkdirAll(targetDir, 0755); err != nil { + if err := io.Local.EnsureDir(targetDir); err != nil { return fmt.Errorf("failed to create directory: %w", err) } } @@ -104,7 +105,7 @@ func runBootstrap(ctx context.Context, only string, dryRun, all bool, projectNam // Clone core-devops first devopsPath := filepath.Join(targetDir, devopsRepo) - if _, err := os.Stat(filepath.Join(devopsPath, ".git")); os.IsNotExist(err) { + if !io.Local.IsDir(filepath.Join(devopsPath, ".git")) { fmt.Printf("%s %s %s...\n", dimStyle.Render(">>"), i18n.T("common.status.cloning"), devopsRepo) if !dryRun { @@ -148,13 +149,12 @@ func runBootstrap(ctx context.Context, only string, dryRun, all bool, projectNam // isGitRepoRoot returns true if the directory is a git repository root. func isGitRepoRoot(path string) bool { - _, err := os.Stat(filepath.Join(path, ".git")) - return err == nil + return io.Local.Exists(filepath.Join(path, ".git")) } // isDirEmpty returns true if the directory is empty or contains only hidden files. func isDirEmpty(path string) (bool, error) { - entries, err := os.ReadDir(path) + entries, err := io.Local.List(path) if err != nil { return false, err } diff --git a/internal/cmd/setup/cmd_ci.go b/internal/cmd/setup/cmd_ci.go index cad86332..e19b6393 100644 --- a/internal/cmd/setup/cmd_ci.go +++ b/internal/cmd/setup/cmd_ci.go @@ -7,6 +7,7 @@ import ( "runtime" "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/io" "github.com/spf13/cobra" "gopkg.in/yaml.v3" ) @@ -51,9 +52,9 @@ func LoadCIConfig() *CIConfig { for { configPath := filepath.Join(dir, ".core", "ci.yaml") - data, err := os.ReadFile(configPath) + data, err := io.Local.Read(configPath) if err == nil { - if err := yaml.Unmarshal(data, cfg); err == nil { + if err := yaml.Unmarshal([]byte(data), cfg); err == nil { return cfg } } diff --git a/internal/cmd/setup/cmd_registry.go b/internal/cmd/setup/cmd_registry.go index e68fc2b9..6e193d32 100644 --- a/internal/cmd/setup/cmd_registry.go +++ b/internal/cmd/setup/cmd_registry.go @@ -16,6 +16,7 @@ import ( "github.com/host-uk/core/internal/cmd/workspace" "github.com/host-uk/core/pkg/cli" "github.com/host-uk/core/pkg/i18n" + "github.com/host-uk/core/pkg/io" "github.com/host-uk/core/pkg/repos" ) @@ -80,7 +81,7 @@ func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryP // Ensure base path exists if !dryRun { - if err := os.MkdirAll(basePath, 0755); err != nil { + if err := io.Local.EnsureDir(basePath); err != nil { return fmt.Errorf("failed to create packages directory: %w", err) } } @@ -116,7 +117,7 @@ func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryP // Check if already exists repoPath := filepath.Join(basePath, repo.Name) - if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil { + if io.Local.Exists(filepath.Join(repoPath, ".git")) { exists++ continue } @@ -145,7 +146,7 @@ func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryP // Check if already exists repoPath := filepath.Join(basePath, repo.Name) - if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil { + if io.Local.Exists(filepath.Join(repoPath, ".git")) { exists++ continue } diff --git a/internal/cmd/setup/cmd_repo.go b/internal/cmd/setup/cmd_repo.go index 330313a0..558f4546 100644 --- a/internal/cmd/setup/cmd_repo.go +++ b/internal/cmd/setup/cmd_repo.go @@ -8,12 +8,12 @@ package setup import ( "fmt" - "os" "os/exec" "path/filepath" "strings" "github.com/host-uk/core/pkg/i18n" + "github.com/host-uk/core/pkg/io" ) // runRepoSetup sets up the current repository with .core/ configuration. @@ -27,7 +27,7 @@ func runRepoSetup(repoPath string, dryRun bool) error { // Create .core directory coreDir := filepath.Join(repoPath, ".core") if !dryRun { - if err := os.MkdirAll(coreDir, 0755); err != nil { + if err := io.Local.EnsureDir(coreDir); err != nil { return fmt.Errorf("failed to create .core directory: %w", err) } } @@ -54,7 +54,7 @@ func runRepoSetup(repoPath string, dryRun bool) error { for filename, content := range configs { configPath := filepath.Join(coreDir, filename) - if err := os.WriteFile(configPath, []byte(content), 0644); err != nil { + if err := io.Local.Write(configPath, content); err != nil { return fmt.Errorf("failed to write %s: %w", filename, err) } fmt.Printf("%s %s %s\n", successStyle.Render(">>"), i18n.T("cmd.setup.repo.created"), configPath) @@ -66,16 +66,16 @@ func runRepoSetup(repoPath string, dryRun bool) error { // detectProjectType identifies the project type from files present. func detectProjectType(path string) string { // Check in priority order - if _, err := os.Stat(filepath.Join(path, "wails.json")); err == nil { + if io.Local.IsFile(filepath.Join(path, "wails.json")) { return "wails" } - if _, err := os.Stat(filepath.Join(path, "go.mod")); err == nil { + if io.Local.IsFile(filepath.Join(path, "go.mod")) { return "go" } - if _, err := os.Stat(filepath.Join(path, "composer.json")); err == nil { + if io.Local.IsFile(filepath.Join(path, "composer.json")) { return "php" } - if _, err := os.Stat(filepath.Join(path, "package.json")); err == nil { + if io.Local.IsFile(filepath.Join(path, "package.json")) { return "node" } return "unknown" diff --git a/internal/cmd/setup/github_config.go b/internal/cmd/setup/github_config.go index 3c673450..905e9e1b 100644 --- a/internal/cmd/setup/github_config.go +++ b/internal/cmd/setup/github_config.go @@ -12,6 +12,7 @@ import ( "regexp" "strings" + "github.com/host-uk/core/pkg/io" "gopkg.in/yaml.v3" ) @@ -64,13 +65,13 @@ type SecurityConfig struct { // LoadGitHubConfig reads and parses a GitHub configuration file. func LoadGitHubConfig(path string) (*GitHubConfig, error) { - data, err := os.ReadFile(path) + data, err := io.Local.Read(path) if err != nil { return nil, fmt.Errorf("failed to read config file: %w", err) } // Expand environment variables before parsing - expanded := expandEnvVars(string(data)) + expanded := expandEnvVars(data) var config GitHubConfig if err := yaml.Unmarshal([]byte(expanded), &config); err != nil { @@ -127,7 +128,7 @@ func expandEnvVars(input string) string { // 3. github.yaml (relative to registry) func FindGitHubConfig(registryDir, specifiedPath string) (string, error) { if specifiedPath != "" { - if _, err := os.Stat(specifiedPath); err == nil { + if io.Local.Exists(specifiedPath) { return specifiedPath, nil } return "", fmt.Errorf("config file not found: %s", specifiedPath) @@ -140,7 +141,7 @@ func FindGitHubConfig(registryDir, specifiedPath string) (string, error) { } for _, path := range candidates { - if _, err := os.Stat(path); err == nil { + if io.Local.Exists(path) { return path, nil } } diff --git a/pkg/io/client_test.go b/pkg/io/client_test.go index 1579460b..9d76d518 100644 --- a/pkg/io/client_test.go +++ b/pkg/io/client_test.go @@ -73,6 +73,127 @@ func TestMockMedium_FileSet_Good(t *testing.T) { assert.Equal(t, "content", m.Files["test.txt"]) } +func TestMockMedium_Delete_Good(t *testing.T) { + m := NewMockMedium() + m.Files["test.txt"] = "content" + + err := m.Delete("test.txt") + assert.NoError(t, err) + assert.False(t, m.IsFile("test.txt")) +} + +func TestMockMedium_Delete_Bad_NotFound(t *testing.T) { + m := NewMockMedium() + err := m.Delete("nonexistent.txt") + assert.Error(t, err) +} + +func TestMockMedium_Delete_Bad_DirNotEmpty(t *testing.T) { + m := NewMockMedium() + m.Dirs["mydir"] = true + m.Files["mydir/file.txt"] = "content" + + err := m.Delete("mydir") + assert.Error(t, err) +} + +func TestMockMedium_DeleteAll_Good(t *testing.T) { + m := NewMockMedium() + m.Dirs["mydir"] = true + m.Dirs["mydir/subdir"] = true + m.Files["mydir/file.txt"] = "content" + m.Files["mydir/subdir/nested.txt"] = "nested" + + err := m.DeleteAll("mydir") + assert.NoError(t, err) + assert.Empty(t, m.Dirs) + assert.Empty(t, m.Files) +} + +func TestMockMedium_Rename_Good(t *testing.T) { + m := NewMockMedium() + m.Files["old.txt"] = "content" + + err := m.Rename("old.txt", "new.txt") + assert.NoError(t, err) + assert.False(t, m.IsFile("old.txt")) + assert.True(t, m.IsFile("new.txt")) + assert.Equal(t, "content", m.Files["new.txt"]) +} + +func TestMockMedium_Rename_Good_Dir(t *testing.T) { + m := NewMockMedium() + m.Dirs["olddir"] = true + m.Files["olddir/file.txt"] = "content" + + err := m.Rename("olddir", "newdir") + assert.NoError(t, err) + assert.False(t, m.Dirs["olddir"]) + assert.True(t, m.Dirs["newdir"]) + assert.Equal(t, "content", m.Files["newdir/file.txt"]) +} + +func TestMockMedium_List_Good(t *testing.T) { + m := NewMockMedium() + m.Dirs["mydir"] = true + m.Files["mydir/file1.txt"] = "content1" + m.Files["mydir/file2.txt"] = "content2" + m.Dirs["mydir/subdir"] = true + + entries, err := m.List("mydir") + assert.NoError(t, err) + assert.Len(t, entries, 3) + + names := make(map[string]bool) + for _, e := range entries { + names[e.Name()] = true + } + assert.True(t, names["file1.txt"]) + assert.True(t, names["file2.txt"]) + assert.True(t, names["subdir"]) +} + +func TestMockMedium_Stat_Good(t *testing.T) { + m := NewMockMedium() + m.Files["test.txt"] = "hello world" + + info, err := m.Stat("test.txt") + assert.NoError(t, err) + assert.Equal(t, "test.txt", info.Name()) + assert.Equal(t, int64(11), info.Size()) + assert.False(t, info.IsDir()) +} + +func TestMockMedium_Stat_Good_Dir(t *testing.T) { + m := NewMockMedium() + m.Dirs["mydir"] = true + + info, err := m.Stat("mydir") + assert.NoError(t, err) + assert.Equal(t, "mydir", info.Name()) + assert.True(t, info.IsDir()) +} + +func TestMockMedium_Exists_Good(t *testing.T) { + m := NewMockMedium() + m.Files["file.txt"] = "content" + m.Dirs["mydir"] = true + + assert.True(t, m.Exists("file.txt")) + assert.True(t, m.Exists("mydir")) + assert.False(t, m.Exists("nonexistent")) +} + +func TestMockMedium_IsDir_Good(t *testing.T) { + m := NewMockMedium() + m.Files["file.txt"] = "content" + m.Dirs["mydir"] = true + + assert.False(t, m.IsDir("file.txt")) + assert.True(t, m.IsDir("mydir")) + assert.False(t, m.IsDir("nonexistent")) +} + // --- Wrapper Function Tests --- func TestRead_Good(t *testing.T) { diff --git a/pkg/io/io.go b/pkg/io/io.go index 2b573c4d..e542872c 100644 --- a/pkg/io/io.go +++ b/pkg/io/io.go @@ -1,7 +1,11 @@ package io import ( + "io/fs" "os" + "path/filepath" + "strings" + "time" coreerr "github.com/host-uk/core/pkg/framework/core" "github.com/host-uk/core/pkg/io/local" @@ -28,8 +32,58 @@ type Medium interface { // FileSet is a convenience function that writes a file to the medium. FileSet(path, content string) error + + // Delete removes a file or empty directory. + Delete(path string) error + + // DeleteAll removes a file or directory and all its contents recursively. + DeleteAll(path string) error + + // Rename moves a file or directory from oldPath to newPath. + Rename(oldPath, newPath string) error + + // List returns the directory entries for the given path. + List(path string) ([]fs.DirEntry, error) + + // Stat returns file information for the given path. + Stat(path string) (fs.FileInfo, error) + + // Exists checks if a path exists (file or directory). + Exists(path string) bool + + // IsDir checks if a path exists and is a directory. + IsDir(path string) bool } +// FileInfo provides a simple implementation of fs.FileInfo for mock testing. +type FileInfo struct { + name string + size int64 + mode fs.FileMode + modTime time.Time + isDir bool +} + +func (fi FileInfo) Name() string { return fi.name } +func (fi FileInfo) Size() int64 { return fi.size } +func (fi FileInfo) Mode() fs.FileMode { return fi.mode } +func (fi FileInfo) ModTime() time.Time { return fi.modTime } +func (fi FileInfo) IsDir() bool { return fi.isDir } +func (fi FileInfo) Sys() any { return nil } + +// DirEntry provides a simple implementation of fs.DirEntry for mock testing. +type DirEntry struct { + name string + isDir bool + mode fs.FileMode + info fs.FileInfo +} + +func (de DirEntry) Name() string { return de.name } +func (de DirEntry) IsDir() bool { return de.isDir } +func (de DirEntry) Type() fs.FileMode { return de.mode.Type() } +func (de DirEntry) Info() (fs.FileInfo, error) { return de.info, nil } + // Local is a pre-initialized medium for the local filesystem. // It uses "/" as root, providing unsandboxed access to the filesystem. // For sandboxed access, use NewSandboxed with a specific root path. @@ -136,3 +190,254 @@ func (m *MockMedium) FileGet(path string) (string, error) { func (m *MockMedium) FileSet(path, content string) error { return m.Write(path, content) } + +// Delete removes a file or empty directory from the mock filesystem. +func (m *MockMedium) Delete(path string) error { + if _, ok := m.Files[path]; ok { + delete(m.Files, path) + return nil + } + if _, ok := m.Dirs[path]; ok { + // Check if directory is empty (no files or subdirs with this prefix) + prefix := path + if !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + for f := range m.Files { + if strings.HasPrefix(f, prefix) { + return coreerr.E("io.MockMedium.Delete", "directory not empty: "+path, os.ErrExist) + } + } + for d := range m.Dirs { + if d != path && strings.HasPrefix(d, prefix) { + return coreerr.E("io.MockMedium.Delete", "directory not empty: "+path, os.ErrExist) + } + } + delete(m.Dirs, path) + return nil + } + return coreerr.E("io.MockMedium.Delete", "path not found: "+path, os.ErrNotExist) +} + +// DeleteAll removes a file or directory and all contents from the mock filesystem. +func (m *MockMedium) DeleteAll(path string) error { + found := false + if _, ok := m.Files[path]; ok { + delete(m.Files, path) + found = true + } + if _, ok := m.Dirs[path]; ok { + delete(m.Dirs, path) + found = true + } + + // Delete all entries under this path + prefix := path + if !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + for f := range m.Files { + if strings.HasPrefix(f, prefix) { + delete(m.Files, f) + found = true + } + } + for d := range m.Dirs { + if strings.HasPrefix(d, prefix) { + delete(m.Dirs, d) + found = true + } + } + + if !found { + return coreerr.E("io.MockMedium.DeleteAll", "path not found: "+path, os.ErrNotExist) + } + return nil +} + +// Rename moves a file or directory in the mock filesystem. +func (m *MockMedium) Rename(oldPath, newPath string) error { + if content, ok := m.Files[oldPath]; ok { + m.Files[newPath] = content + delete(m.Files, oldPath) + return nil + } + if _, ok := m.Dirs[oldPath]; ok { + // Move directory and all contents + m.Dirs[newPath] = true + delete(m.Dirs, oldPath) + + oldPrefix := oldPath + if !strings.HasSuffix(oldPrefix, "/") { + oldPrefix += "/" + } + newPrefix := newPath + if !strings.HasSuffix(newPrefix, "/") { + newPrefix += "/" + } + + // Move files under this directory + for f, content := range m.Files { + if strings.HasPrefix(f, oldPrefix) { + newF := newPrefix + strings.TrimPrefix(f, oldPrefix) + m.Files[newF] = content + delete(m.Files, f) + } + } + // Move subdirectories + for d := range m.Dirs { + if strings.HasPrefix(d, oldPrefix) { + newD := newPrefix + strings.TrimPrefix(d, oldPrefix) + m.Dirs[newD] = true + delete(m.Dirs, d) + } + } + return nil + } + return coreerr.E("io.MockMedium.Rename", "path not found: "+oldPath, os.ErrNotExist) +} + +// List returns directory entries for the mock filesystem. +func (m *MockMedium) List(path string) ([]fs.DirEntry, error) { + if _, ok := m.Dirs[path]; !ok { + // Check if it's the root or has children + hasChildren := false + prefix := path + if path != "" && !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + for f := range m.Files { + if strings.HasPrefix(f, prefix) { + hasChildren = true + break + } + } + if !hasChildren { + for d := range m.Dirs { + if strings.HasPrefix(d, prefix) { + hasChildren = true + break + } + } + } + if !hasChildren && path != "" { + return nil, coreerr.E("io.MockMedium.List", "directory not found: "+path, os.ErrNotExist) + } + } + + prefix := path + if path != "" && !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + + seen := make(map[string]bool) + var entries []fs.DirEntry + + // Find immediate children (files) + for f, content := range m.Files { + if !strings.HasPrefix(f, prefix) { + continue + } + rest := strings.TrimPrefix(f, prefix) + if rest == "" || strings.Contains(rest, "/") { + // Skip if it's not an immediate child + if idx := strings.Index(rest, "/"); idx != -1 { + // This is a subdirectory + dirName := rest[:idx] + if !seen[dirName] { + seen[dirName] = true + entries = append(entries, DirEntry{ + name: dirName, + isDir: true, + mode: fs.ModeDir | 0755, + info: FileInfo{ + name: dirName, + isDir: true, + mode: fs.ModeDir | 0755, + }, + }) + } + } + continue + } + if !seen[rest] { + seen[rest] = true + entries = append(entries, DirEntry{ + name: rest, + isDir: false, + mode: 0644, + info: FileInfo{ + name: rest, + size: int64(len(content)), + mode: 0644, + }, + }) + } + } + + // Find immediate subdirectories + for d := range m.Dirs { + if !strings.HasPrefix(d, prefix) { + continue + } + rest := strings.TrimPrefix(d, prefix) + if rest == "" { + continue + } + // Get only immediate child + if idx := strings.Index(rest, "/"); idx != -1 { + rest = rest[:idx] + } + if !seen[rest] { + seen[rest] = true + entries = append(entries, DirEntry{ + name: rest, + isDir: true, + mode: fs.ModeDir | 0755, + info: FileInfo{ + name: rest, + isDir: true, + mode: fs.ModeDir | 0755, + }, + }) + } + } + + return entries, nil +} + +// Stat returns file information for the mock filesystem. +func (m *MockMedium) Stat(path string) (fs.FileInfo, error) { + if content, ok := m.Files[path]; ok { + return FileInfo{ + name: filepath.Base(path), + size: int64(len(content)), + mode: 0644, + }, nil + } + if _, ok := m.Dirs[path]; ok { + return FileInfo{ + name: filepath.Base(path), + isDir: true, + mode: fs.ModeDir | 0755, + }, nil + } + return nil, coreerr.E("io.MockMedium.Stat", "path not found: "+path, os.ErrNotExist) +} + +// Exists checks if a path exists in the mock filesystem. +func (m *MockMedium) Exists(path string) bool { + if _, ok := m.Files[path]; ok { + return true + } + if _, ok := m.Dirs[path]; ok { + return true + } + return false +} + +// IsDir checks if a path is a directory in the mock filesystem. +func (m *MockMedium) IsDir(path string) bool { + _, ok := m.Dirs[path] + return ok +} diff --git a/pkg/io/local/client.go b/pkg/io/local/client.go index afe632ee..ad90e590 100644 --- a/pkg/io/local/client.go +++ b/pkg/io/local/client.go @@ -3,6 +3,7 @@ package local import ( "errors" + "io/fs" "os" "path/filepath" "strings" @@ -42,12 +43,20 @@ func (m *Medium) path(relativePath string) (string, error) { return "", errors.New("path traversal attempt detected") } - // Reject absolute paths - they bypass the sandbox - if filepath.IsAbs(cleanPath) { + // When root is "/" (full filesystem access), allow absolute paths + isRootFS := m.root == "/" || m.root == string(filepath.Separator) + + // Reject absolute paths unless we're the root filesystem + if filepath.IsAbs(cleanPath) && !isRootFS { return "", errors.New("path traversal attempt detected") } - fullPath := filepath.Join(m.root, cleanPath) + var fullPath string + if filepath.IsAbs(cleanPath) { + fullPath = cleanPath + } else { + fullPath = filepath.Join(m.root, cleanPath) + } // Verify the resulting path is still within root (boundary-aware check) // Must use separator to prevent /tmp/root matching /tmp/root2 @@ -167,3 +176,75 @@ func (m *Medium) FileGet(relativePath string) (string, error) { func (m *Medium) FileSet(relativePath, content string) error { return m.Write(relativePath, content) } + +// Delete removes a file or empty directory. +func (m *Medium) Delete(relativePath string) error { + fullPath, err := m.path(relativePath) + if err != nil { + return err + } + return os.Remove(fullPath) +} + +// DeleteAll removes a file or directory and all its contents recursively. +func (m *Medium) DeleteAll(relativePath string) error { + fullPath, err := m.path(relativePath) + if err != nil { + return err + } + return os.RemoveAll(fullPath) +} + +// Rename moves a file or directory from oldPath to newPath. +func (m *Medium) Rename(oldPath, newPath string) error { + fullOldPath, err := m.path(oldPath) + if err != nil { + return err + } + fullNewPath, err := m.path(newPath) + if err != nil { + return err + } + return os.Rename(fullOldPath, fullNewPath) +} + +// List returns the directory entries for the given path. +func (m *Medium) List(relativePath string) ([]fs.DirEntry, error) { + fullPath, err := m.path(relativePath) + if err != nil { + return nil, err + } + return os.ReadDir(fullPath) +} + +// Stat returns file information for the given path. +func (m *Medium) Stat(relativePath string) (fs.FileInfo, error) { + fullPath, err := m.path(relativePath) + if err != nil { + return nil, err + } + return os.Stat(fullPath) +} + +// Exists checks if a path exists (file or directory). +func (m *Medium) Exists(relativePath string) bool { + fullPath, err := m.path(relativePath) + if err != nil { + return false + } + _, err = os.Stat(fullPath) + return err == nil +} + +// IsDir checks if a path exists and is a directory. +func (m *Medium) IsDir(relativePath string) bool { + fullPath, err := m.path(relativePath) + if err != nil { + return false + } + info, err := os.Stat(fullPath) + if err != nil { + return false + } + return info.IsDir() +} diff --git a/pkg/io/local/client_test.go b/pkg/io/local/client_test.go index 191f4f1d..d904c9f2 100644 --- a/pkg/io/local/client_test.go +++ b/pkg/io/local/client_test.go @@ -199,3 +199,198 @@ func TestFileGetFileSet_Good(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "path traversal attempt detected") } + +func TestDelete_Good(t *testing.T) { + testRoot, err := os.MkdirTemp("", "local_delete_test") + assert.NoError(t, err) + defer os.RemoveAll(testRoot) + + medium, err := New(testRoot) + assert.NoError(t, err) + + // Create and delete a file + err = medium.Write("file.txt", "content") + assert.NoError(t, err) + assert.True(t, medium.IsFile("file.txt")) + + err = medium.Delete("file.txt") + assert.NoError(t, err) + assert.False(t, medium.IsFile("file.txt")) + + // Create and delete an empty directory + err = medium.EnsureDir("emptydir") + assert.NoError(t, err) + err = medium.Delete("emptydir") + assert.NoError(t, err) + assert.False(t, medium.IsDir("emptydir")) +} + +func TestDelete_Bad_NotEmpty(t *testing.T) { + testRoot, err := os.MkdirTemp("", "local_delete_notempty_test") + assert.NoError(t, err) + defer os.RemoveAll(testRoot) + + medium, err := New(testRoot) + assert.NoError(t, err) + + // Create a directory with a file + err = medium.Write("mydir/file.txt", "content") + assert.NoError(t, err) + + // Try to delete non-empty directory + err = medium.Delete("mydir") + assert.Error(t, err) +} + +func TestDeleteAll_Good(t *testing.T) { + testRoot, err := os.MkdirTemp("", "local_deleteall_test") + assert.NoError(t, err) + defer os.RemoveAll(testRoot) + + medium, err := New(testRoot) + assert.NoError(t, err) + + // Create nested structure + err = medium.Write("mydir/file1.txt", "content1") + assert.NoError(t, err) + err = medium.Write("mydir/subdir/file2.txt", "content2") + assert.NoError(t, err) + + // Delete all + err = medium.DeleteAll("mydir") + assert.NoError(t, err) + assert.False(t, medium.Exists("mydir")) + assert.False(t, medium.Exists("mydir/file1.txt")) + assert.False(t, medium.Exists("mydir/subdir/file2.txt")) +} + +func TestRename_Good(t *testing.T) { + testRoot, err := os.MkdirTemp("", "local_rename_test") + assert.NoError(t, err) + defer os.RemoveAll(testRoot) + + medium, err := New(testRoot) + assert.NoError(t, err) + + // Rename a file + err = medium.Write("old.txt", "content") + assert.NoError(t, err) + err = medium.Rename("old.txt", "new.txt") + assert.NoError(t, err) + assert.False(t, medium.IsFile("old.txt")) + assert.True(t, medium.IsFile("new.txt")) + + content, err := medium.Read("new.txt") + assert.NoError(t, err) + assert.Equal(t, "content", content) +} + +func TestRename_Bad_Traversal(t *testing.T) { + testRoot, err := os.MkdirTemp("", "local_rename_traversal_test") + assert.NoError(t, err) + defer os.RemoveAll(testRoot) + + medium, err := New(testRoot) + assert.NoError(t, err) + + err = medium.Write("file.txt", "content") + assert.NoError(t, err) + + err = medium.Rename("file.txt", "../escaped.txt") + assert.Error(t, err) + assert.Contains(t, err.Error(), "path traversal") +} + +func TestList_Good(t *testing.T) { + testRoot, err := os.MkdirTemp("", "local_list_test") + assert.NoError(t, err) + defer os.RemoveAll(testRoot) + + medium, err := New(testRoot) + assert.NoError(t, err) + + // Create some files and directories + err = medium.Write("file1.txt", "content1") + assert.NoError(t, err) + err = medium.Write("file2.txt", "content2") + assert.NoError(t, err) + err = medium.EnsureDir("subdir") + assert.NoError(t, err) + + // List root + entries, err := medium.List(".") + assert.NoError(t, err) + assert.Len(t, entries, 3) + + names := make(map[string]bool) + for _, e := range entries { + names[e.Name()] = true + } + assert.True(t, names["file1.txt"]) + assert.True(t, names["file2.txt"]) + assert.True(t, names["subdir"]) +} + +func TestStat_Good(t *testing.T) { + testRoot, err := os.MkdirTemp("", "local_stat_test") + assert.NoError(t, err) + defer os.RemoveAll(testRoot) + + medium, err := New(testRoot) + assert.NoError(t, err) + + // Stat a file + err = medium.Write("file.txt", "hello world") + assert.NoError(t, err) + info, err := medium.Stat("file.txt") + assert.NoError(t, err) + assert.Equal(t, "file.txt", info.Name()) + assert.Equal(t, int64(11), info.Size()) + assert.False(t, info.IsDir()) + + // Stat a directory + err = medium.EnsureDir("mydir") + assert.NoError(t, err) + info, err = medium.Stat("mydir") + assert.NoError(t, err) + assert.Equal(t, "mydir", info.Name()) + assert.True(t, info.IsDir()) +} + +func TestExists_Good(t *testing.T) { + testRoot, err := os.MkdirTemp("", "local_exists_test") + assert.NoError(t, err) + defer os.RemoveAll(testRoot) + + medium, err := New(testRoot) + assert.NoError(t, err) + + assert.False(t, medium.Exists("nonexistent")) + + err = medium.Write("file.txt", "content") + assert.NoError(t, err) + assert.True(t, medium.Exists("file.txt")) + + err = medium.EnsureDir("mydir") + assert.NoError(t, err) + assert.True(t, medium.Exists("mydir")) +} + +func TestIsDir_Good(t *testing.T) { + testRoot, err := os.MkdirTemp("", "local_isdir_test") + assert.NoError(t, err) + defer os.RemoveAll(testRoot) + + medium, err := New(testRoot) + assert.NoError(t, err) + + err = medium.Write("file.txt", "content") + assert.NoError(t, err) + assert.False(t, medium.IsDir("file.txt")) + + err = medium.EnsureDir("mydir") + assert.NoError(t, err) + assert.True(t, medium.IsDir("mydir")) + + assert.False(t, medium.IsDir("nonexistent")) +}