673 lines
21 KiB
Go
673 lines
21 KiB
Go
// Package copyfiles copies user-specified files (literal paths, directories, or
|
|
// globs) from a source directory into a freshly-created target directory while
|
|
// preserving relative paths. It is designed for the worktree feature that
|
|
// seeds a new worktree with environment / config files that are normally
|
|
// gitignored.
|
|
//
|
|
// Pattern syntax (via github.com/bmatcuk/doublestar):
|
|
// - `*` matches any run of non-separator characters
|
|
// - `?` matches a single non-separator character
|
|
// - `[abc]` / `[a-z]` character classes
|
|
// - `**` matches zero or more path segments (e.g. `**/.env` or `apps/**/config.yml`)
|
|
// - `{a,b}` brace alternation
|
|
//
|
|
// Two entry points:
|
|
//
|
|
// - Copy: host-side, streams files directly to disk. Used by the worktree
|
|
// preparer when source and target both live on the host filesystem.
|
|
// - Plan + WriteEntries: split for remote executors. Plan reads source
|
|
// bytes into memory (with a per-file size cap); WriteEntries writes a
|
|
// pre-loaded batch into a target directory. The agentctl HTTP path
|
|
// ships Plan output across the wire and calls WriteEntries on the
|
|
// receiving side.
|
|
package copyfiles
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/bmatcuk/doublestar/v4"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// MaxEntryBytes caps the in-memory payload for a single planned entry. A
|
|
// stray pattern like `node_modules` would otherwise pull gigabytes into RAM
|
|
// and across the agentctl wire. Tuned to comfortably hold reasonable
|
|
// .env / config / certificate files while rejecting an accidental
|
|
// directory-of-binaries.
|
|
const MaxEntryBytes int64 = 5 * 1024 * 1024
|
|
|
|
// Entry is a single file ready to be written into a target directory.
|
|
// RelPath uses forward slashes regardless of host OS — agentctl converts
|
|
// per-platform on write.
|
|
type Entry struct {
|
|
RelPath string `json:"rel_path"`
|
|
Mode os.FileMode `json:"mode"`
|
|
Content []byte `json:"content"`
|
|
}
|
|
|
|
// Parse splits a comma-separated user spec into trimmed, deduplicated,
|
|
// non-empty patterns. Order is preserved (first occurrence wins on dedupe).
|
|
// Commas inside `{...}` are treated as part of the pattern (brace alternation),
|
|
// so `config/{local,dev}.yml` is parsed as a single pattern.
|
|
func Parse(spec string) []string {
|
|
if spec == "" {
|
|
return nil
|
|
}
|
|
parts := splitTopLevelCommas(spec)
|
|
out := make([]string, 0, len(parts))
|
|
seen := make(map[string]struct{}, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[p]; ok {
|
|
continue
|
|
}
|
|
seen[p] = struct{}{}
|
|
out = append(out, p)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// splitTopLevelCommas splits s on commas that sit outside any `{...}` group,
|
|
// so brace alternation patterns like `config/{local,dev}.yml` survive intact.
|
|
// Nested braces are tracked; an unbalanced `}` is treated as a literal.
|
|
func splitTopLevelCommas(s string) []string {
|
|
out := make([]string, 0, 4)
|
|
depth := 0
|
|
start := 0
|
|
for i := 0; i < len(s); i++ {
|
|
switch s[i] {
|
|
case '{':
|
|
depth++
|
|
case '}':
|
|
if depth > 0 {
|
|
depth--
|
|
}
|
|
case ',':
|
|
if depth == 0 {
|
|
out = append(out, s[start:i])
|
|
start = i + 1
|
|
}
|
|
}
|
|
}
|
|
out = append(out, s[start:])
|
|
return out
|
|
}
|
|
|
|
// Copy resolves each pattern relative to sourceDir and copies matches into
|
|
// targetDir, preserving relative paths. It returns the relative paths of
|
|
// files newly written into targetDir (skip-if-exists matches are NOT
|
|
// included), one warning per problematic pattern or rejected match, and an
|
|
// error only for IO failures that would corrupt the target.
|
|
func Copy(ctx context.Context, sourceDir, targetDir string, patterns []string, log *zap.Logger) ([]string, []string, error) {
|
|
if len(patterns) == 0 {
|
|
return nil, nil, nil
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, nil, fmt.Errorf("copyfiles: %w", err)
|
|
}
|
|
|
|
canonRoot, err := filepath.EvalSymlinks(sourceDir)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("copyfiles: resolve source: %w", err)
|
|
}
|
|
|
|
state := ©State{
|
|
ctx: ctx,
|
|
targetDir: targetDir,
|
|
canonRoot: canonRoot,
|
|
log: log,
|
|
copied: make(map[string]struct{}),
|
|
}
|
|
|
|
for _, pattern := range patterns {
|
|
if err := ctx.Err(); err != nil {
|
|
return state.copiedRel, state.warnings, fmt.Errorf("copyfiles: %w", err)
|
|
}
|
|
if err := state.expandPattern(pattern); err != nil {
|
|
return state.copiedRel, state.warnings, err
|
|
}
|
|
}
|
|
return state.copiedRel, state.warnings, nil
|
|
}
|
|
|
|
type copyState struct {
|
|
ctx context.Context
|
|
targetDir string
|
|
canonRoot string
|
|
log *zap.Logger
|
|
copied map[string]struct{}
|
|
copiedRel []string
|
|
warnings []string
|
|
|
|
// planMode collects file payloads into entries instead of writing to
|
|
// targetDir. Set by Plan(). When true, copyFile reads bytes into memory
|
|
// (respecting MaxEntryBytes) and appends to entries rather than touching
|
|
// the filesystem.
|
|
planMode bool
|
|
entries []Entry
|
|
}
|
|
|
|
// Plan resolves each pattern relative to sourceDir and reads every match
|
|
// into memory as an Entry — without touching any target filesystem. It is
|
|
// the read half of a remote copy: the agentctl HTTP client ships the
|
|
// returned entries across the wire, and WriteEntries writes them on the
|
|
// receiving side. Files larger than MaxEntryBytes emit a warning and are
|
|
// skipped to keep launch payloads bounded.
|
|
//
|
|
// Returned entries preserve forward-slash relative paths; consumers must
|
|
// not assume host-native separators.
|
|
func Plan(ctx context.Context, sourceDir string, patterns []string, log *zap.Logger) ([]Entry, []string, error) {
|
|
if len(patterns) == 0 {
|
|
return nil, nil, nil
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, nil, fmt.Errorf("copyfiles: %w", err)
|
|
}
|
|
|
|
canonRoot, err := filepath.EvalSymlinks(sourceDir)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("copyfiles: resolve source: %w", err)
|
|
}
|
|
|
|
state := ©State{
|
|
ctx: ctx,
|
|
canonRoot: canonRoot,
|
|
log: log,
|
|
copied: make(map[string]struct{}),
|
|
planMode: true,
|
|
}
|
|
|
|
for _, pattern := range patterns {
|
|
if err := ctx.Err(); err != nil {
|
|
return state.entries, state.warnings, fmt.Errorf("copyfiles: %w", err)
|
|
}
|
|
if err := state.expandPattern(pattern); err != nil {
|
|
return state.entries, state.warnings, err
|
|
}
|
|
}
|
|
return state.entries, state.warnings, nil
|
|
}
|
|
|
|
// WriteEntries writes a pre-planned batch into targetDir, preserving
|
|
// relative paths and file modes. Idempotent: entries whose destination
|
|
// already exists are silently skipped (matches Copy's resume semantics).
|
|
// Returns the list of newly-written relative paths plus per-entry
|
|
// warnings; an error only for IO failures that would corrupt the target.
|
|
//
|
|
// Per-entry RelPath is treated as user-controlled — each is rejected
|
|
// unless it canonicalizes to a path strictly inside targetDir. This is
|
|
// the agentctl-side guard: the planner already validates against the
|
|
// source root, but a compromised sender could craft paths that escape
|
|
// the target.
|
|
func WriteEntries(ctx context.Context, containmentRoot, targetDir string, entries []Entry, log *zap.Logger) ([]string, []string, error) {
|
|
if len(entries) == 0 {
|
|
return nil, nil, nil
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, nil, fmt.Errorf("copyfiles: %w", err)
|
|
}
|
|
// Explicit containment check: targetDir must lie inside containmentRoot
|
|
// after both are canonicalized via EvalSymlinks. Gives the WriteEntries
|
|
// surface a machine-checkable trust boundary — callers pass the workspace
|
|
// root they control plus a candidate subdir, and any target that escapes
|
|
// the root is rejected before any filesystem sink (os.Stat, os.WriteFile).
|
|
canonRoot, err := filepath.EvalSymlinks(containmentRoot)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("copyfiles: resolve containment root: %w", err)
|
|
}
|
|
canonTarget, err := filepath.EvalSymlinks(targetDir)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("copyfiles: resolve target: %w", err)
|
|
}
|
|
if !pathInsideRoot(canonTarget, canonRoot) {
|
|
return nil, nil, fmt.Errorf("copyfiles: target %q outside containment root %q", canonTarget, canonRoot)
|
|
}
|
|
info, err := os.Stat(canonTarget)
|
|
if err != nil || !info.IsDir() {
|
|
return nil, nil, fmt.Errorf("copyfiles: target %q is not a directory", canonTarget)
|
|
}
|
|
absTarget := canonTarget
|
|
|
|
var copied, warnings []string
|
|
for _, e := range entries {
|
|
if err := ctx.Err(); err != nil {
|
|
return copied, warnings, fmt.Errorf("copyfiles: %w", err)
|
|
}
|
|
written, warn, err := writeOneEntry(absTarget, e, log)
|
|
if warn != "" {
|
|
warnings = append(warnings, warn)
|
|
}
|
|
if err != nil {
|
|
return copied, warnings, err
|
|
}
|
|
if written {
|
|
copied = append(copied, filepath.ToSlash(e.RelPath))
|
|
}
|
|
}
|
|
return copied, warnings, nil
|
|
}
|
|
|
|
// writeOneEntry validates a single Entry, refusing paths that escape
|
|
// targetDir, then writes it (skip-if-exists for idempotency). Resolves
|
|
// symlinks on the destination's parent directory so a pre-existing
|
|
// `config -> /etc` symlink in the target can't smuggle the write
|
|
// outside absTarget.
|
|
func writeOneEntry(absTarget string, e Entry, log *zap.Logger) (written bool, warning string, err error) {
|
|
rel := filepath.FromSlash(e.RelPath)
|
|
if rel == "" {
|
|
return false, "rejected empty path", nil
|
|
}
|
|
if filepath.IsAbs(rel) {
|
|
return false, fmt.Sprintf("rejected absolute path %q", e.RelPath), nil
|
|
}
|
|
cleanDst, warn, err := secureDestination(absTarget, rel)
|
|
if err != nil {
|
|
return false, "", err
|
|
}
|
|
if warn != "" {
|
|
return false, warn, nil
|
|
}
|
|
if _, err := os.Lstat(cleanDst); err == nil {
|
|
if log != nil {
|
|
log.Debug("copyfiles: skip existing", zap.String("rel", e.RelPath))
|
|
}
|
|
return false, "", nil
|
|
}
|
|
mode := e.Mode.Perm()
|
|
if mode == 0 {
|
|
mode = 0o644
|
|
}
|
|
if err := os.WriteFile(cleanDst, e.Content, mode); err != nil {
|
|
// Best-effort cleanup so a half-written file doesn't poison the
|
|
// next idempotent run's skip-if-exists branch (the streaming
|
|
// copyFile path does the same). Ignore unlink failure.
|
|
_ = os.Remove(cleanDst)
|
|
return false, "", fmt.Errorf("copyfiles: write %s: %w", cleanDst, err)
|
|
}
|
|
if log != nil {
|
|
log.Debug("copyfiles: wrote entry", zap.String("rel", e.RelPath))
|
|
}
|
|
return true, "", nil
|
|
}
|
|
|
|
// pathInsideRoot reports whether candidate (already cleaned via
|
|
// EvalSymlinks) sits at or below root. Used as the CodeQL-recognized
|
|
// path-injection sanitizer at the WriteEntries entry point: both
|
|
// arguments must already be canonical (no symlinks, no "..").
|
|
func pathInsideRoot(candidate, root string) bool {
|
|
if candidate == root {
|
|
return true
|
|
}
|
|
rel, err := filepath.Rel(root, candidate)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if rel == "." {
|
|
return true
|
|
}
|
|
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// secureDestination resolves the destination path for a relative entry,
|
|
// creating parent directories only after every existing component along
|
|
// the chain has been verified to be a regular directory (not a symlink).
|
|
// Returns:
|
|
//
|
|
// - (cleanDst, "", nil) on success — safe to write.
|
|
// - ("", warn, nil) when the path is rejected (warn surfaced to caller).
|
|
// - ("", "", err) only on mkdir IO failure — fatal for the batch.
|
|
//
|
|
// Two-step validation closes the symlink-target-escape vector: a
|
|
// pre-existing component like `config -> /etc` would cause MkdirAll to
|
|
// silently create directories at /etc/... because Go's MkdirAll follows
|
|
// symlinks via Stat. validateParentChain refuses to proceed if any
|
|
// existing component is a symlink, and a final EvalSymlinks check on
|
|
// the parent re-confirms containment after the dirs are created.
|
|
func secureDestination(absTarget, rel string) (cleanDst, warning string, err error) {
|
|
lexical := filepath.Join(absTarget, rel)
|
|
cleanDst, absErr := filepath.Abs(lexical)
|
|
if absErr != nil {
|
|
return "", fmt.Sprintf("rejected %q: %v", rel, absErr), nil
|
|
}
|
|
if relCheck, relErr := filepath.Rel(absTarget, cleanDst); relErr != nil ||
|
|
relCheck == ".." ||
|
|
strings.HasPrefix(relCheck, ".."+string(filepath.Separator)) {
|
|
return "", fmt.Sprintf("rejected %q: escapes target", rel), nil
|
|
}
|
|
parent := filepath.Dir(cleanDst)
|
|
if warn, ok := validateParentChain(absTarget, parent); !ok {
|
|
return "", fmt.Sprintf("rejected %q: %s", rel, warn), nil
|
|
}
|
|
if err := os.MkdirAll(parent, 0o755); err != nil {
|
|
return "", "", fmt.Errorf("copyfiles: mkdir %s: %w", parent, err)
|
|
}
|
|
canonParent, evalErr := filepath.EvalSymlinks(parent)
|
|
if evalErr != nil {
|
|
return "", fmt.Sprintf("rejected %q: parent resolve failed: %v", rel, evalErr), nil
|
|
}
|
|
final := filepath.Join(canonParent, filepath.Base(cleanDst))
|
|
if relCheck, relErr := filepath.Rel(absTarget, final); relErr != nil ||
|
|
relCheck == ".." ||
|
|
strings.HasPrefix(relCheck, ".."+string(filepath.Separator)) {
|
|
return "", fmt.Sprintf("rejected %q: symlink in parent escapes target", rel), nil
|
|
}
|
|
return final, "", nil
|
|
}
|
|
|
|
// validateParentChain walks from absTarget down to parent (the directory
|
|
// that will host the final file). For every existing component, if it
|
|
// is a symlink the function refuses by returning ok=false plus a human
|
|
// warning. Non-existent components are fine — MkdirAll will create
|
|
// them. Skipping symlinks is intentional: it gives a simple,
|
|
// machine-checkable guarantee that no subsequent MkdirAll call can
|
|
// reach a directory outside absTarget through a pre-planted symlink.
|
|
func validateParentChain(absTarget, parent string) (warning string, ok bool) {
|
|
rel, err := filepath.Rel(absTarget, parent)
|
|
if err != nil {
|
|
return fmt.Sprintf("rel %q: %v", parent, err), false
|
|
}
|
|
if rel == "." || rel == "" {
|
|
return "", true
|
|
}
|
|
cur := absTarget
|
|
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
|
cur = filepath.Join(cur, part)
|
|
info, lerr := os.Lstat(cur)
|
|
if lerr != nil {
|
|
// Doesn't exist yet — MkdirAll handles the tail safely from here.
|
|
return "", true
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Sprintf("symlink in parent path at %q", cur), false
|
|
}
|
|
}
|
|
return "", true
|
|
}
|
|
|
|
func (s *copyState) warn(format string, args ...any) {
|
|
msg := fmt.Sprintf(format, args...)
|
|
s.warnings = append(s.warnings, msg)
|
|
if s.log != nil {
|
|
s.log.Warn("copyfiles: " + msg)
|
|
}
|
|
}
|
|
|
|
func (s *copyState) debug(msg string, fields ...zap.Field) {
|
|
if s.log != nil {
|
|
s.log.Debug("copyfiles: "+msg, fields...)
|
|
}
|
|
}
|
|
|
|
// expandPattern handles literal files, literal directories, and globs.
|
|
// Globs use doublestar syntax — see the package doc for the full grammar
|
|
// (notably `**` for recursive descent and `{a,b}` for alternation).
|
|
func (s *copyState) expandPattern(pattern string) error {
|
|
joined := pattern
|
|
if !filepath.IsAbs(pattern) {
|
|
joined = filepath.Join(s.canonRoot, pattern)
|
|
}
|
|
|
|
// Fast path: literal existing entry.
|
|
if _, err := os.Lstat(joined); err == nil {
|
|
return s.handleMatch(joined, pattern)
|
|
}
|
|
|
|
matches, err := doublestar.FilepathGlob(joined)
|
|
if err != nil {
|
|
s.warn("invalid pattern %q: %v", pattern, err)
|
|
return nil
|
|
}
|
|
if len(matches) == 0 {
|
|
s.warn("no matches for pattern %q", pattern)
|
|
return nil
|
|
}
|
|
for _, m := range matches {
|
|
if err := s.ctx.Err(); err != nil {
|
|
return fmt.Errorf("copyfiles: %w", err)
|
|
}
|
|
if _, err := os.Lstat(m); err != nil {
|
|
s.warn("stat %q: %v", m, err)
|
|
continue
|
|
}
|
|
if err := s.handleMatch(m, pattern); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// handleMatch dispatches a single literal/match path to file or directory copy.
|
|
func (s *copyState) handleMatch(matchPath, pattern string) error {
|
|
safe, ok := s.safePath(matchPath)
|
|
if !ok {
|
|
s.warn("path %q is outside source dir (pattern %q)", matchPath, pattern)
|
|
return nil
|
|
}
|
|
|
|
// Resolve via EvalSymlinks for the actual final target (follows symlinks).
|
|
resolved, err := filepath.EvalSymlinks(safe)
|
|
if err != nil {
|
|
s.warn("resolve %q: %v", matchPath, err)
|
|
return nil
|
|
}
|
|
if !s.underRoot(resolved) {
|
|
s.warn("symlink %q resolves outside source dir", matchPath)
|
|
return nil
|
|
}
|
|
|
|
// Use the original match path to compute the relative dest, NOT the
|
|
// resolved path — a symlink should land at the symlink's location.
|
|
rel, err := filepath.Rel(s.canonRoot, safe)
|
|
if err != nil {
|
|
s.warn("rel %q: %v", matchPath, err)
|
|
return nil
|
|
}
|
|
|
|
rInfo, err := os.Stat(resolved)
|
|
if err != nil {
|
|
s.warn("stat resolved %q: %v", resolved, err)
|
|
return nil
|
|
}
|
|
|
|
if rInfo.IsDir() {
|
|
return s.copyDir(resolved, rel)
|
|
}
|
|
return s.copyFile(resolved, rel, rInfo)
|
|
}
|
|
|
|
// copyDir walks src recursively and copies every regular file inside.
|
|
func (s *copyState) copyDir(src, relRoot string) error {
|
|
return filepath.WalkDir(src, func(path string, d fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
s.warn("walk %q: %v", path, walkErr)
|
|
return nil
|
|
}
|
|
if err := s.ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
s.warn("info %q: %v", path, err)
|
|
return nil
|
|
}
|
|
rel, err := filepath.Rel(src, path)
|
|
if err != nil {
|
|
s.warn("rel %q: %v", path, err)
|
|
return nil
|
|
}
|
|
// If the entry is itself a symlink, resolve and validate it stays in root.
|
|
resolved := path
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
r, rerr := filepath.EvalSymlinks(path)
|
|
if rerr != nil {
|
|
s.warn("resolve %q: %v", path, rerr)
|
|
return nil
|
|
}
|
|
if !s.underRoot(r) {
|
|
s.warn("symlink %q resolves outside source dir", path)
|
|
return nil
|
|
}
|
|
ri, rerr := os.Stat(r)
|
|
if rerr != nil {
|
|
s.warn("stat %q: %v", r, rerr)
|
|
return nil
|
|
}
|
|
if ri.IsDir() {
|
|
s.warn("symlink %q resolves to a directory, skipping", path)
|
|
return nil
|
|
}
|
|
info = ri
|
|
resolved = r
|
|
}
|
|
return s.copyFile(resolved, filepath.Join(relRoot, rel), info)
|
|
})
|
|
}
|
|
|
|
// copyFile copies a single regular file to targetDir/rel, creating parents.
|
|
// In planMode it instead reads the file's bytes into an Entry (subject to
|
|
// MaxEntryBytes), used by Plan() for the remote-executor path.
|
|
func (s *copyState) copyFile(src, rel string, info os.FileInfo) error {
|
|
if err := s.ctx.Err(); err != nil {
|
|
return fmt.Errorf("copyfiles: %w", err)
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
s.warn("skipping non-regular file %q (mode %s)", rel, info.Mode())
|
|
return nil
|
|
}
|
|
if s.planMode {
|
|
return s.planFile(src, rel, info)
|
|
}
|
|
dst := filepath.Join(s.targetDir, rel)
|
|
if _, dup := s.copied[dst]; dup {
|
|
return nil
|
|
}
|
|
|
|
// Skip-if-exists for idempotency on resume.
|
|
if _, err := os.Lstat(dst); err == nil {
|
|
s.copied[dst] = struct{}{}
|
|
s.debug("skip existing", zap.String("rel", rel))
|
|
return nil
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
|
return fmt.Errorf("copyfiles: mkdir %s: %w", filepath.Dir(dst), err)
|
|
}
|
|
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
s.warn("open %q: %v", src, err)
|
|
return nil
|
|
}
|
|
defer func() { _ = in.Close() }()
|
|
|
|
out, err := os.Create(dst)
|
|
if err != nil {
|
|
return fmt.Errorf("copyfiles: create %s: %w", dst, err)
|
|
}
|
|
if _, err := io.Copy(out, in); err != nil {
|
|
_ = out.Close()
|
|
_ = os.Remove(dst)
|
|
return fmt.Errorf("copyfiles: copy %s: %w", dst, err)
|
|
}
|
|
if err := out.Close(); err != nil {
|
|
_ = os.Remove(dst)
|
|
return fmt.Errorf("copyfiles: close %s: %w", dst, err)
|
|
}
|
|
if err := os.Chmod(dst, info.Mode().Perm()); err != nil {
|
|
return fmt.Errorf("copyfiles: chmod %s: %w", dst, err)
|
|
}
|
|
|
|
s.copied[dst] = struct{}{}
|
|
s.copiedRel = append(s.copiedRel, filepath.ToSlash(rel))
|
|
s.debug("copied", zap.String("rel", rel))
|
|
return nil
|
|
}
|
|
|
|
// planFile reads a source file into an in-memory Entry instead of writing
|
|
// it. Files larger than MaxEntryBytes are skipped with a warning; users
|
|
// who hit this are typically pattern-matching too broadly (e.g. catching
|
|
// node_modules) and want feedback, not a silent partial copy.
|
|
func (s *copyState) planFile(src, rel string, info os.FileInfo) error {
|
|
dedup := filepath.ToSlash(rel)
|
|
if _, dup := s.copied[dedup]; dup {
|
|
return nil
|
|
}
|
|
if info.Size() > MaxEntryBytes {
|
|
s.warn("skipping %q: %d bytes exceeds %d-byte plan cap", rel, info.Size(), MaxEntryBytes)
|
|
return nil
|
|
}
|
|
f, err := os.Open(src)
|
|
if err != nil {
|
|
s.warn("open %q: %v", src, err)
|
|
return nil
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
// LimitReader as belt-and-suspenders against TOCTOU growth between Stat
|
|
// and Read — a file appended-to mid-walk shouldn't bypass MaxEntryBytes.
|
|
content, err := io.ReadAll(io.LimitReader(f, MaxEntryBytes+1))
|
|
if err != nil {
|
|
s.warn("read %q: %v", src, err)
|
|
return nil
|
|
}
|
|
if int64(len(content)) > MaxEntryBytes {
|
|
s.warn("skipping %q: read exceeded %d-byte plan cap", rel, MaxEntryBytes)
|
|
return nil
|
|
}
|
|
s.copied[dedup] = struct{}{}
|
|
s.entries = append(s.entries, Entry{
|
|
RelPath: filepath.ToSlash(rel),
|
|
Mode: info.Mode().Perm(),
|
|
Content: content,
|
|
})
|
|
s.copiedRel = append(s.copiedRel, filepath.ToSlash(rel))
|
|
s.debug("planned", zap.String("rel", rel))
|
|
return nil
|
|
}
|
|
|
|
// safePath returns the lexically-cleaned absolute path within sourceDir, or
|
|
// false if the path escapes the source root before any symlink resolution.
|
|
func (s *copyState) safePath(p string) (string, bool) {
|
|
abs := p
|
|
if !filepath.IsAbs(abs) {
|
|
abs = filepath.Join(s.canonRoot, abs)
|
|
}
|
|
abs = filepath.Clean(abs)
|
|
if !s.underRoot(abs) {
|
|
return "", false
|
|
}
|
|
return abs, true
|
|
}
|
|
|
|
// underRoot reports whether path is canonRoot itself or lies inside canonRoot.
|
|
func (s *copyState) underRoot(path string) bool {
|
|
rel, err := filepath.Rel(s.canonRoot, path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if rel == "." {
|
|
return true
|
|
}
|
|
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|