Files
Samuel Cui b5f28fa474 Release ACP v0.2.7
Add ordered content sources and targets to the shared copy pipeline. Observe
fs.File metadata before target creation, bind fs.FS sources, and expose
fatal-stop notification while preserving native copy behavior.
2026-10-15 18:18:39 +08:00

216 lines
6.4 KiB
Go

package acp
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"syscall"
mapset "github.com/deckarep/golang-set/v2"
"github.com/samuelncui/acp/internal/fileio"
)
// Allocation and metadata restoration retain their platform-specific implementations.
var (
allocateTarget = truncate
restoreTarget = writeSysStat
commitTarget = (*fileio.Output).Commit
resolveDirectory = filepath.EvalSymlinks
)
// targetSpec holds decisions made once during indexing. name is the caller's logical path;
// path is its resolved destination, which may be a symlink referent.
type targetSpec struct {
name, path, device string
content ContentTarget
direct bool
output *fileio.Output
}
func (c *StreamCopyer) indexTarget(name string, source os.FileInfo) (targetSpec, error) {
// Resolve existence and source aliases before any target mutation.
target := targetSpec{name: name}
info, err := os.Lstat(name)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return target, fmt.Errorf("stat target failed, %w", err)
}
if info != nil && info.Mode()&os.ModeSymlink != 0 {
// An existing link keeps its identity: only its live referent can be replaced.
target.path, err = resolveOutputPath(name)
if err != nil {
return target, fmt.Errorf("resolve target link failed, %w", err)
}
info, err = os.Stat(target.path)
if err != nil {
return target, fmt.Errorf("stat target referent failed, %w", err)
}
} else {
// Files in one parent share its stable resolution, avoiding a symlink walk per file.
// Split preserves link/.. for filesystem resolution; Dir would clean it too early.
parent, base := filepath.Split(name)
if base == "" || base == "." || base == ".." {
return target, fmt.Errorf("target path has no filename")
}
if parent == "" {
parent = "."
}
dir, found := c.targetDirs[parent]
if !found {
dir, err = resolveOutputPath(parent)
if err != nil {
return target, fmt.Errorf("resolve target directory failed, %w", err)
}
if c.targetDirs == nil {
c.targetDirs = make(map[string]string)
}
c.targetDirs[parent] = dir
}
target.path = filepath.Join(dir, base)
}
// Refuse source aliases and unsupported target types before any output is created.
if info != nil {
if source != nil && os.SameFile(source, info) {
return target, fmt.Errorf("source and target are the same file")
}
if c.createFlag&os.O_TRUNC == 0 {
return target, fmt.Errorf("target already exists, %w", os.ErrExist)
}
target.direct = info.Mode()&os.ModeDevice != 0
if !info.Mode().IsRegular() && !target.direct {
return target, fmt.Errorf("target is not a regular file or device")
}
}
// Retain the mount observation alongside the canonical destination identity.
target.device, err = c.getDevice(target.path)
if err != nil {
return target, fmt.Errorf("get target device failed, %w", err)
}
return target, nil
}
func resolveOutputPath(path string) (string, error) {
// Find the existing ancestor before walking symlinks, keeping native link/.. semantics.
var missing []string
for {
_, err := os.Lstat(path)
if err == nil {
break
}
if !errors.Is(err, os.ErrNotExist) {
return "", err
}
for len(path) > 0 && os.IsPathSeparator(path[len(path)-1]) {
path = path[:len(path)-1]
}
parent, base := filepath.Split(path)
if base == "" || base == ".." {
return "", err
}
missing = append(missing, base)
if parent == "" {
parent = "."
}
path = parent
}
// Anchor relative paths without cleaning away link/.. before the symlink walk.
// Windows drive-relative paths use that drive's cwd; root-relative paths use its volume.
if !filepath.IsAbs(path) {
volume := filepath.VolumeName(path)
base, err := filepath.Abs(volume + ".")
if err != nil {
return "", err
}
path = path[len(volume):]
if len(path) > 0 && os.IsPathSeparator(path[0]) {
path = filepath.VolumeName(base) + path
} else {
path = base + string(filepath.Separator) + path
}
}
// Resolve once, then retain missing descendants beneath that physical directory.
resolved, err := resolveDirectory(path)
if err != nil {
return "", err
}
for i := len(missing) - 1; i >= 0; i-- {
resolved = filepath.Join(resolved, missing[i])
}
return resolved, nil
}
func (c *StreamCopyer) prepareTarget(job *writeJob, target targetSpec) (out *fileio.Output, err error) {
// Linear media cannot preallocate; reject insufficient estimated space before output creation.
if c.toDevice.linear {
if err := c.checkLinearSpace(job, target); err != nil {
return nil, err
}
}
// Rewrite has already allocated and recorded its output; ordinary files allocate here.
out = target.output
defer func() {
if value := recover(); value != nil {
err = panicError("prepare target", value)
c.setError(err)
c.stopHard()
}
if err != nil && out != nil {
err = errors.Join(err, out.Discard())
out = nil
}
}()
if out == nil {
if err := os.MkdirAll(filepath.Dir(target.path), os.ModePerm); err != nil {
return nil, fmt.Errorf("mkdir target failed, %w", err)
}
if target.direct {
file, err := os.OpenFile(target.path, os.O_WRONLY, 0)
if err != nil {
return nil, fmt.Errorf("open device failed, %w", err)
}
out = &fileio.Output{File: file, Path: target.path}
} else {
out, err = fileio.NewOutput(target.path)
if err != nil {
return nil, fmt.Errorf("create temporary target failed, %w", err)
}
}
}
if !c.toDevice.linear && job.stat.size > 0 {
if err := allocateTarget(out.File, job.stat.size); err != nil {
return out, fmt.Errorf("preallocate target failed, %w", err)
}
}
return out, nil
}
func (c *StreamCopyer) targetFailed(job *baseJob, path, dev string, err error, exhausted mapset.Set[string]) {
// Classify one target's primary and cleanup failures together without losing syscall identities.
err = mappingError(err)
// Only actual device errors permanently exhaust a mount; an estimate can be retried.
if errors.Is(err, syscall.ENOSPC) || errors.Is(err, syscall.EROFS) {
exhausted.Add(dev)
}
c.endLinearTarget(err)
job.fail(path, err)
}
func writeChunk(writer io.Writer, chunk *chunkBuffer) error {
// The writer releases its reference even on panic or a short write.
defer chunk.release()
n, err := writer.Write(chunk.data)
if err != nil {
return fmt.Errorf("write target failed, %w", err)
}
if n != len(chunk.data) {
return fmt.Errorf("write target failed, %w", io.ErrShortWrite)
}
return nil
}