mirror of
https://github.com/samuelncui/acp.git
synced 2026-11-08 04:21:16 +00:00
Overlap source reads, hashing, ordered linear writes, and asynchronous cleanup with bounded buffers. Preserve conflicting output order, API behavior, and no-space diagnostics. Add an LTFS drive-buffer model and reproducible performance checks.
215 lines
6.3 KiB
Go
215 lines
6.3 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
|
|
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
|
|
}
|