Merge io-batch to get Medium interface with Delete
This commit is contained in:
commit
f48544331b
13 changed files with 750 additions and 47 deletions
|
|
@ -18,6 +18,7 @@ import (
|
||||||
"github.com/host-uk/core/pkg/errors"
|
"github.com/host-uk/core/pkg/errors"
|
||||||
"github.com/host-uk/core/pkg/git"
|
"github.com/host-uk/core/pkg/git"
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
"github.com/host-uk/core/pkg/io"
|
||||||
"github.com/host-uk/core/pkg/repos"
|
"github.com/host-uk/core/pkg/repos"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -76,8 +77,8 @@ func runApply() error {
|
||||||
|
|
||||||
// Validate script exists
|
// Validate script exists
|
||||||
if applyScript != "" {
|
if applyScript != "" {
|
||||||
if _, err := os.Stat(applyScript); err != nil {
|
if !io.Local.Exists(applyScript) {
|
||||||
return errors.E("dev.apply", "script not found: "+applyScript, err)
|
return errors.E("dev.apply", "script not found: "+applyScript, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
package dev
|
package dev
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/host-uk/core/pkg/cli"
|
"github.com/host-uk/core/pkg/cli"
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
"github.com/host-uk/core/pkg/io"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Workflow command flags
|
// Workflow command flags
|
||||||
|
|
@ -156,7 +156,7 @@ func runWorkflowSync(registryPath string, workflowFile string, dryRun bool) erro
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read template content
|
// Read template content
|
||||||
templateContent, err := os.ReadFile(templatePath)
|
templateContent, err := io.Local.Read(templatePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cli.Wrap(err, i18n.T("cmd.dev.workflow.read_template_error"))
|
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)
|
destPath := filepath.Join(destDir, workflowFile)
|
||||||
|
|
||||||
// Check if workflow already exists and is identical
|
// Check if workflow already exists and is identical
|
||||||
if existingContent, err := os.ReadFile(destPath); err == nil {
|
if existingContent, err := io.Local.Read(destPath); err == nil {
|
||||||
if string(existingContent) == string(templateContent) {
|
if existingContent == templateContent {
|
||||||
cli.Print(" %s %s %s\n",
|
cli.Print(" %s %s %s\n",
|
||||||
dimStyle.Render("-"),
|
dimStyle.Render("-"),
|
||||||
repoNameStyle.Render(repo.Name),
|
repoNameStyle.Render(repo.Name),
|
||||||
|
|
@ -210,7 +210,7 @@ func runWorkflowSync(registryPath string, workflowFile string, dryRun bool) erro
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create .github/workflows directory if needed
|
// 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",
|
cli.Print(" %s %s %s\n",
|
||||||
errorStyle.Render(cli.Glyph(":cross:")),
|
errorStyle.Render(cli.Glyph(":cross:")),
|
||||||
repoNameStyle.Render(repo.Name),
|
repoNameStyle.Render(repo.Name),
|
||||||
|
|
@ -220,7 +220,7 @@ func runWorkflowSync(registryPath string, workflowFile string, dryRun bool) erro
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write workflow file
|
// 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",
|
cli.Print(" %s %s %s\n",
|
||||||
errorStyle.Render(cli.Glyph(":cross:")),
|
errorStyle.Render(cli.Glyph(":cross:")),
|
||||||
repoNameStyle.Render(repo.Name),
|
repoNameStyle.Render(repo.Name),
|
||||||
|
|
@ -264,7 +264,7 @@ func findWorkflows(dir string) []string {
|
||||||
workflowsDir = dir
|
workflowsDir = dir
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, err := os.ReadDir(workflowsDir)
|
entries, err := io.Local.List(workflowsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -298,7 +298,7 @@ func findTemplateWorkflow(registryDir, workflowFile string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
if _, err := os.Stat(candidate); err == nil {
|
if io.Local.Exists(candidate) {
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"github.com/host-uk/core/internal/cmd/workspace"
|
"github.com/host-uk/core/internal/cmd/workspace"
|
||||||
"github.com/host-uk/core/pkg/cli"
|
"github.com/host-uk/core/pkg/cli"
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
"github.com/host-uk/core/pkg/io"
|
||||||
"github.com/host-uk/core/pkg/repos"
|
"github.com/host-uk/core/pkg/repos"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -93,28 +94,28 @@ func scanRepoDocs(repo *repos.Repo) RepoDocInfo {
|
||||||
|
|
||||||
// Check for README.md
|
// Check for README.md
|
||||||
readme := filepath.Join(repo.Path, "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.Readme = readme
|
||||||
info.HasDocs = true
|
info.HasDocs = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for CLAUDE.md
|
// Check for CLAUDE.md
|
||||||
claudeMd := filepath.Join(repo.Path, "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.ClaudeMd = claudeMd
|
||||||
info.HasDocs = true
|
info.HasDocs = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for CHANGELOG.md
|
// Check for CHANGELOG.md
|
||||||
changelog := filepath.Join(repo.Path, "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.Changelog = changelog
|
||||||
info.HasDocs = true
|
info.HasDocs = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recursively scan docs/ directory for .md files
|
// Recursively scan docs/ directory for .md files
|
||||||
docsDir := filepath.Join(repo.Path, "docs")
|
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 {
|
filepath.WalkDir(docsDir, func(path string, d fs.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -139,9 +140,5 @@ func scanRepoDocs(repo *repos.Repo) RepoDocInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyFile(src, dst string) error {
|
func copyFile(src, dst string) error {
|
||||||
data, err := os.ReadFile(src)
|
return io.Copy(io.Local, src, io.Local, dst)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.WriteFile(dst, data, 0644)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
package docs
|
package docs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/host-uk/core/pkg/cli"
|
"github.com/host-uk/core/pkg/cli"
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
"github.com/host-uk/core/pkg/io"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Flag variables for sync command
|
// Flag variables for sync command
|
||||||
|
|
@ -127,9 +127,9 @@ func runDocsSync(registryPath string, outputDir string, dryRun bool) error {
|
||||||
repoOutDir := filepath.Join(outputDir, outName)
|
repoOutDir := filepath.Join(outputDir, outName)
|
||||||
|
|
||||||
// Clear existing directory
|
// 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)
|
cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), info.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -139,7 +139,7 @@ func runDocsSync(registryPath string, outputDir string, dryRun bool) error {
|
||||||
for _, f := range info.DocsFiles {
|
for _, f := range info.DocsFiles {
|
||||||
src := filepath.Join(docsDir, f)
|
src := filepath.Join(docsDir, f)
|
||||||
dst := filepath.Join(repoOutDir, 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 {
|
if err := copyFile(src, dst); err != nil {
|
||||||
cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), f, err)
|
cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), f, err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
|
|
||||||
"github.com/host-uk/core/internal/cmd/workspace"
|
"github.com/host-uk/core/internal/cmd/workspace"
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
"github.com/host-uk/core/pkg/io"
|
||||||
"github.com/host-uk/core/pkg/repos"
|
"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)
|
fmt.Printf("%s %s: %s\n", dimStyle.Render(">>"), i18n.T("cmd.setup.creating_project_dir"), projectName)
|
||||||
|
|
||||||
if !dryRun {
|
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)
|
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
|
// Clone core-devops first
|
||||||
devopsPath := filepath.Join(targetDir, devopsRepo)
|
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)
|
fmt.Printf("%s %s %s...\n", dimStyle.Render(">>"), i18n.T("common.status.cloning"), devopsRepo)
|
||||||
|
|
||||||
if !dryRun {
|
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.
|
// isGitRepoRoot returns true if the directory is a git repository root.
|
||||||
func isGitRepoRoot(path string) bool {
|
func isGitRepoRoot(path string) bool {
|
||||||
_, err := os.Stat(filepath.Join(path, ".git"))
|
return io.Local.Exists(filepath.Join(path, ".git"))
|
||||||
return err == nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// isDirEmpty returns true if the directory is empty or contains only hidden files.
|
// isDirEmpty returns true if the directory is empty or contains only hidden files.
|
||||||
func isDirEmpty(path string) (bool, error) {
|
func isDirEmpty(path string) (bool, error) {
|
||||||
entries, err := os.ReadDir(path)
|
entries, err := io.Local.List(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
"github.com/host-uk/core/pkg/cli"
|
"github.com/host-uk/core/pkg/cli"
|
||||||
|
"github.com/host-uk/core/pkg/io"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
@ -51,9 +52,9 @@ func LoadCIConfig() *CIConfig {
|
||||||
|
|
||||||
for {
|
for {
|
||||||
configPath := filepath.Join(dir, ".core", "ci.yaml")
|
configPath := filepath.Join(dir, ".core", "ci.yaml")
|
||||||
data, err := os.ReadFile(configPath)
|
data, err := io.Local.Read(configPath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if err := yaml.Unmarshal(data, cfg); err == nil {
|
if err := yaml.Unmarshal([]byte(data), cfg); err == nil {
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import (
|
||||||
"github.com/host-uk/core/internal/cmd/workspace"
|
"github.com/host-uk/core/internal/cmd/workspace"
|
||||||
"github.com/host-uk/core/pkg/cli"
|
"github.com/host-uk/core/pkg/cli"
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
"github.com/host-uk/core/pkg/io"
|
||||||
"github.com/host-uk/core/pkg/repos"
|
"github.com/host-uk/core/pkg/repos"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -80,7 +81,7 @@ func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryP
|
||||||
|
|
||||||
// Ensure base path exists
|
// Ensure base path exists
|
||||||
if !dryRun {
|
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)
|
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
|
// Check if already exists
|
||||||
repoPath := filepath.Join(basePath, repo.Name)
|
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++
|
exists++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -145,7 +146,7 @@ func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryP
|
||||||
|
|
||||||
// Check if already exists
|
// Check if already exists
|
||||||
repoPath := filepath.Join(basePath, repo.Name)
|
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++
|
exists++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,12 @@ package setup
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/host-uk/core/pkg/i18n"
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
"github.com/host-uk/core/pkg/io"
|
||||||
)
|
)
|
||||||
|
|
||||||
// runRepoSetup sets up the current repository with .core/ configuration.
|
// runRepoSetup sets up the current repository with .core/ configuration.
|
||||||
|
|
@ -27,7 +27,7 @@ func runRepoSetup(repoPath string, dryRun bool) error {
|
||||||
// Create .core directory
|
// Create .core directory
|
||||||
coreDir := filepath.Join(repoPath, ".core")
|
coreDir := filepath.Join(repoPath, ".core")
|
||||||
if !dryRun {
|
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)
|
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 {
|
for filename, content := range configs {
|
||||||
configPath := filepath.Join(coreDir, filename)
|
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)
|
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)
|
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.
|
// detectProjectType identifies the project type from files present.
|
||||||
func detectProjectType(path string) string {
|
func detectProjectType(path string) string {
|
||||||
// Check in priority order
|
// 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"
|
return "wails"
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(path, "go.mod")); err == nil {
|
if io.Local.IsFile(filepath.Join(path, "go.mod")) {
|
||||||
return "go"
|
return "go"
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(path, "composer.json")); err == nil {
|
if io.Local.IsFile(filepath.Join(path, "composer.json")) {
|
||||||
return "php"
|
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 "node"
|
||||||
}
|
}
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/pkg/io"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -64,13 +65,13 @@ type SecurityConfig struct {
|
||||||
|
|
||||||
// LoadGitHubConfig reads and parses a GitHub configuration file.
|
// LoadGitHubConfig reads and parses a GitHub configuration file.
|
||||||
func LoadGitHubConfig(path string) (*GitHubConfig, error) {
|
func LoadGitHubConfig(path string) (*GitHubConfig, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := io.Local.Read(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expand environment variables before parsing
|
// Expand environment variables before parsing
|
||||||
expanded := expandEnvVars(string(data))
|
expanded := expandEnvVars(data)
|
||||||
|
|
||||||
var config GitHubConfig
|
var config GitHubConfig
|
||||||
if err := yaml.Unmarshal([]byte(expanded), &config); err != nil {
|
if err := yaml.Unmarshal([]byte(expanded), &config); err != nil {
|
||||||
|
|
@ -127,7 +128,7 @@ func expandEnvVars(input string) string {
|
||||||
// 3. github.yaml (relative to registry)
|
// 3. github.yaml (relative to registry)
|
||||||
func FindGitHubConfig(registryDir, specifiedPath string) (string, error) {
|
func FindGitHubConfig(registryDir, specifiedPath string) (string, error) {
|
||||||
if specifiedPath != "" {
|
if specifiedPath != "" {
|
||||||
if _, err := os.Stat(specifiedPath); err == nil {
|
if io.Local.Exists(specifiedPath) {
|
||||||
return specifiedPath, nil
|
return specifiedPath, nil
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("config file not found: %s", specifiedPath)
|
return "", fmt.Errorf("config file not found: %s", specifiedPath)
|
||||||
|
|
@ -140,7 +141,7 @@ func FindGitHubConfig(registryDir, specifiedPath string) (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, path := range candidates {
|
for _, path := range candidates {
|
||||||
if _, err := os.Stat(path); err == nil {
|
if io.Local.Exists(path) {
|
||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,127 @@ func TestMockMedium_FileSet_Good(t *testing.T) {
|
||||||
assert.Equal(t, "content", m.Files["test.txt"])
|
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 ---
|
// --- Wrapper Function Tests ---
|
||||||
|
|
||||||
func TestRead_Good(t *testing.T) {
|
func TestRead_Good(t *testing.T) {
|
||||||
|
|
|
||||||
305
pkg/io/io.go
305
pkg/io/io.go
|
|
@ -1,7 +1,11 @@
|
||||||
package io
|
package io
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
coreerr "github.com/host-uk/core/pkg/framework/core"
|
coreerr "github.com/host-uk/core/pkg/framework/core"
|
||||||
"github.com/host-uk/core/pkg/io/local"
|
"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 is a convenience function that writes a file to the medium.
|
||||||
FileSet(path, content string) error
|
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.
|
// Local is a pre-initialized medium for the local filesystem.
|
||||||
// It uses "/" as root, providing unsandboxed access to the filesystem.
|
// It uses "/" as root, providing unsandboxed access to the filesystem.
|
||||||
// For sandboxed access, use NewSandboxed with a specific root path.
|
// 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 {
|
func (m *MockMedium) FileSet(path, content string) error {
|
||||||
return m.Write(path, content)
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package local
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -42,12 +43,20 @@ func (m *Medium) path(relativePath string) (string, error) {
|
||||||
return "", errors.New("path traversal attempt detected")
|
return "", errors.New("path traversal attempt detected")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject absolute paths - they bypass the sandbox
|
// When root is "/" (full filesystem access), allow absolute paths
|
||||||
if filepath.IsAbs(cleanPath) {
|
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")
|
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)
|
// Verify the resulting path is still within root (boundary-aware check)
|
||||||
// Must use separator to prevent /tmp/root matching /tmp/root2
|
// 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 {
|
func (m *Medium) FileSet(relativePath, content string) error {
|
||||||
return m.Write(relativePath, content)
|
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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -199,3 +199,198 @@ func TestFileGetFileSet_Good(t *testing.T) {
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "path traversal attempt detected")
|
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"))
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue