f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
262 lines
7.0 KiB
Go
262 lines
7.0 KiB
Go
package remotesync
|
|
|
|
import (
|
|
"archive/tar"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
extractDirPerm = 0o755
|
|
extractFilePerm = 0o644
|
|
)
|
|
|
|
// ExtractTarStream reads a tar stream from r and writes its entries
|
|
// under dst. Extraction is fail-closed: it tolerates exactly one
|
|
// anomaly, self-referential hardlinks (an entry whose link target is
|
|
// itself, which macOS bsdtar emits for some Antigravity data and which
|
|
// carries no content), and treats every other problem as fatal so a
|
|
// truncated or corrupt transfer can never masquerade as a successful
|
|
// sync. Unexpected EOF, bad headers, paths escaping dst, and
|
|
// write/short-read errors all return an error. Returns the number of
|
|
// self-referential hardlinks skipped.
|
|
func ExtractTarStream(
|
|
ctx context.Context, r io.Reader, dst string,
|
|
) (int, error) {
|
|
return extractTarStream(ctx, r, dst)
|
|
}
|
|
|
|
func extractTarStream(
|
|
ctx context.Context, r io.Reader, dst string,
|
|
) (int, error) {
|
|
endMarkerReader := &tarEndMarkerReader{r: r}
|
|
tr := tar.NewReader(endMarkerReader)
|
|
skipped := 0
|
|
for {
|
|
if err := ctx.Err(); err != nil {
|
|
return skipped, err
|
|
}
|
|
beforeNext := endMarkerReader.total
|
|
hdr, err := tr.Next()
|
|
if errors.Is(err, io.EOF) {
|
|
trailerBytes := endMarkerReader.total - beforeNext
|
|
if _, drainErr := io.Copy(io.Discard, endMarkerReader); drainErr != nil {
|
|
return skipped, fmt.Errorf("read tar trailer: %w", drainErr)
|
|
}
|
|
if trailerBytes < tarEndMarkerSize || !endMarkerReader.hasEndMarker() {
|
|
return skipped, fmt.Errorf("missing tar end marker")
|
|
}
|
|
return skipped, nil
|
|
}
|
|
if err != nil {
|
|
return skipped, fmt.Errorf("read tar entry: %w", err)
|
|
}
|
|
selfLink, err := extractEntry(tr, dst, hdr)
|
|
if err != nil {
|
|
return skipped, err
|
|
}
|
|
if selfLink {
|
|
skipped++
|
|
}
|
|
}
|
|
}
|
|
|
|
type tarEndMarkerReader struct {
|
|
r io.Reader
|
|
tail []byte
|
|
total int
|
|
}
|
|
|
|
func (r *tarEndMarkerReader) Read(p []byte) (int, error) {
|
|
n, err := r.r.Read(p)
|
|
if n > 0 {
|
|
r.total += n
|
|
r.tail = append(r.tail, p[:n]...)
|
|
if len(r.tail) > tarEndMarkerSize {
|
|
r.tail = r.tail[len(r.tail)-tarEndMarkerSize:]
|
|
}
|
|
}
|
|
return n, err
|
|
}
|
|
|
|
func (r *tarEndMarkerReader) hasEndMarker() bool {
|
|
if r.total < tarEndMarkerSize || len(r.tail) < tarEndMarkerSize {
|
|
return false
|
|
}
|
|
for _, b := range r.tail[len(r.tail)-tarEndMarkerSize:] {
|
|
if b != 0 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
const tarEndMarkerSize = 1024
|
|
|
|
// extractEntry writes a single tar entry under dst. It reports whether
|
|
// the entry was a self-referential hardlink (skipped, no error).
|
|
func extractEntry(
|
|
tr *tar.Reader, dst string, hdr *tar.Header,
|
|
) (bool, error) {
|
|
target, err := safeJoin(dst, hdr.Name)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
switch hdr.Typeflag {
|
|
case tar.TypeDir:
|
|
return false, mkdirAll(target, hdr.Name)
|
|
case tar.TypeReg:
|
|
return false, writeRegular(target, tr, hdr)
|
|
case tar.TypeSymlink:
|
|
// Symlinks are not session data, and a symlink restored from
|
|
// an archive can redirect later writes outside the extraction
|
|
// dir. Any regular file a symlink might alias is extracted on
|
|
// its own, so skip symlinks entirely.
|
|
return false, nil
|
|
case tar.TypeLink:
|
|
return writeHardlink(dst, target, hdr)
|
|
default:
|
|
// Char/block/fifo and similar special files do not appear
|
|
// in agent session directories; there is no content to lose
|
|
// by ignoring them.
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
// safeJoin resolves name against dst and rejects any path that escapes
|
|
// dst (via "..", an absolute component, or symlink-free traversal).
|
|
func safeJoin(dst, name string) (string, error) {
|
|
target, err := safeLocalArchivePath(dst, name)
|
|
if err != nil {
|
|
return "", fmt.Errorf("tar entry %q escapes extraction dir: %w", name, err)
|
|
}
|
|
return target, nil
|
|
}
|
|
|
|
// within reports whether p is dst itself or lies inside dst.
|
|
func within(dst, p string) bool {
|
|
rel, err := filepath.Rel(dst, p)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if rel == ".." {
|
|
return false
|
|
}
|
|
return !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
|
}
|
|
|
|
func mkdirAll(path, name string) error {
|
|
if err := os.MkdirAll(path, extractDirPerm); err != nil {
|
|
return fmt.Errorf("mkdir %q: %w", name, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// writeRegular extracts a regular file, failing on a short read so a
|
|
// truncated stream cannot leave a half-written file behind. On any
|
|
// failure the partial file is removed, so an aborted entry never looks
|
|
// complete.
|
|
func writeRegular(
|
|
target string, tr io.Reader, hdr *tar.Header,
|
|
) (err error) {
|
|
if e := mkdirAll(filepath.Dir(target), hdr.Name); e != nil {
|
|
return e
|
|
}
|
|
f, e := os.OpenFile(
|
|
target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, extractFilePerm,
|
|
)
|
|
if e != nil {
|
|
return fmt.Errorf("create %q: %w", hdr.Name, e)
|
|
}
|
|
defer func() {
|
|
if err != nil {
|
|
_ = os.Remove(target)
|
|
}
|
|
}()
|
|
n, copyErr := io.Copy(f, tr)
|
|
closeErr := f.Close()
|
|
if copyErr == nil {
|
|
copyErr = closeErr
|
|
}
|
|
if copyErr != nil {
|
|
return fmt.Errorf("write %q: %w", hdr.Name, copyErr)
|
|
}
|
|
if n != hdr.Size {
|
|
return fmt.Errorf(
|
|
"short write %q: got %d of %d bytes",
|
|
hdr.Name, n, hdr.Size,
|
|
)
|
|
}
|
|
// Restore the archived mtime: the incremental skip cache keys on
|
|
// (path, mtime), so files must keep their remote mtime across
|
|
// syncs or nothing is ever skipped. Best-effort: a failure only
|
|
// forces a redundant resync, never data loss, so it must not
|
|
// discard an otherwise complete extraction.
|
|
if !hdr.ModTime.IsZero() {
|
|
_ = os.Chtimes(target, hdr.ModTime, hdr.ModTime)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// writeHardlink recreates a hardlink. A self-referential hardlink
|
|
// (target equals the entry itself) carries no content and is skipped;
|
|
// the bool return reports that case. Any other failure is fatal.
|
|
func writeHardlink(
|
|
dst, target string, hdr *tar.Header,
|
|
) (bool, error) {
|
|
linkTarget, err := safeJoin(dst, hdr.Linkname)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if linkTarget == target {
|
|
return true, nil
|
|
}
|
|
if err := mkdirAll(filepath.Dir(target), hdr.Name); err != nil {
|
|
return false, err
|
|
}
|
|
if err := os.Link(linkTarget, target); err != nil {
|
|
return false, fmt.Errorf(
|
|
"hardlink %q -> %q: %w", hdr.Name, hdr.Linkname, err,
|
|
)
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// RemapToRemotePath converts a temp-dir path back to the original
|
|
// remote path. Strips the temp dir prefix so the remainder is the
|
|
// absolute path as it existed on the remote host.
|
|
//
|
|
// Example:
|
|
//
|
|
// tempDir="/tmp/sync-123"
|
|
// localPath="/tmp/sync-123/home/wes/.claude/foo.jsonl"
|
|
// result="/home/wes/.claude/foo.jsonl"
|
|
func RemapToRemotePath(tempDir, remoteDir, localPath string) string {
|
|
if remoteDir != "" {
|
|
if rel, ok := localPathRel(RemappedDir(tempDir, remoteDir), localPath); ok {
|
|
return joinRemoteRelative(remoteDir, rel)
|
|
}
|
|
}
|
|
rel, err := filepath.Rel(tempDir, localPath)
|
|
if err != nil {
|
|
return localPath
|
|
}
|
|
return "/" + filepath.ToSlash(rel)
|
|
}
|
|
|
|
// RemappedDir returns the temp-dir equivalent of a remote dir.
|
|
//
|
|
// Example:
|
|
//
|
|
// tempDir="/tmp/sync-123"
|
|
// remoteDir="/home/wes/.claude"
|
|
// result="/tmp/sync-123/home/wes/.claude"
|
|
func RemappedDir(tempDir, remoteDir string) string {
|
|
return remappedRemotePath(tempDir, remoteDir)
|
|
}
|