feat(v0.3): add CopySeedDir with task.yaml/.ctask skip

CopySeedDir is the v0.3 user seed overlay primitive: recursive copy
from a seed directory to a workspace, overwriting destination
files. task.yaml and the .ctask metadata directory at the seed root
are intentionally skipped so a stale or hostile seed cannot
overwrite ctask-owned state. Missing src is a no-op (the seed
directory is optional).
This commit is contained in:
2026-04-10 14:33:42 -04:00
parent d3e20821d7
commit 6fe28464d5
2 changed files with 200 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
package seed
import (
"errors"
"io"
"os"
"path/filepath"
)
// CopySeedDir recursively copies the contents of srcDir into dstDir.
// Files at the destination are overwritten. Subdirectories are preserved.
//
// The following entries at the top level of srcDir are intentionally skipped:
// - task.yaml (ctask owns this file)
// - .ctask (directory; reserved for ctask metadata)
//
// If srcDir does not exist, this function returns nil (no-op). Other I/O errors
// are returned to the caller.
func CopySeedDir(srcDir, dstDir string) error {
info, err := os.Stat(srcDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
if !info.IsDir() {
return nil
}
entries, err := os.ReadDir(srcDir)
if err != nil {
return err
}
for _, entry := range entries {
name := entry.Name()
if name == "task.yaml" || name == ".ctask" {
continue
}
srcPath := filepath.Join(srcDir, name)
dstPath := filepath.Join(dstDir, name)
if err := copyEntry(srcPath, dstPath); err != nil {
return err
}
}
return nil
}
func copyEntry(src, dst string) error {
info, err := os.Stat(src)
if err != nil {
return err
}
if info.IsDir() {
if err := os.MkdirAll(dst, 0755); err != nil {
return err
}
entries, err := os.ReadDir(src)
if err != nil {
return err
}
for _, e := range entries {
if err := copyEntry(filepath.Join(src, e.Name()), filepath.Join(dst, e.Name())); err != nil {
return err
}
}
return nil
}
return copyFile(src, dst, info.Mode())
}
func copyFile(src, dst string, mode os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode.Perm())
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return nil
}