Skip to content

Commit 8e05a4c

Browse files
wesmclaude
andcommitted
fix(ssh): stop benign tar warnings from aborting remote sync
Remote sync streamed 'tar cf' over SSH into 'tar xf -' and treated any non-zero exit from either tar as fatal, deleting the extracted temp dir. macOS bsdtar exits 1 for self-referential hardlinks (Antigravity .system_generated logs) yet extracts everything else fine, so a whole multi-GB sync was discarded over a skipped junk file. The remote 'tar cf' likewise exits 1 for "file changed as we read it", aborting through cleanup()/Wait(). Exit code 1 is not a safe warning boundary: bsdtar also returns 1 for truncated and corrupt archives, so tolerating exit 1 would persist partial transfers as successful syncs and poison the authoritative mtime skip cache. Replace the local 'tar xf' with a stdlib archive/tar extractor that skips only self-referential hardlinks and fails closed on unexpected EOF, bad headers, path escapes, and short writes. Classify the remote tar exit by stderr, tolerating only file-changed/removed warnings (plus the delayed-exit summary as attached fallout) and treating everything else as fatal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ab3d5c4 commit 8e05a4c

5 files changed

Lines changed: 614 additions & 19 deletions

File tree

internal/ssh/classify_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package ssh
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestRemoteTarStderrBenign(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
stderr string
15+
want bool
16+
}{
17+
{
18+
name: "file changed with delayed-exit fallout",
19+
stderr: "tar: home/wes/.claude/x.jsonl: " +
20+
"file changed as we read it\n" +
21+
"tar: Exiting with failure status " +
22+
"due to previous errors",
23+
want: true,
24+
},
25+
{
26+
name: "file removed before read",
27+
stderr: "tar: home/wes/.codex/y.json: " +
28+
"file removed before we read it",
29+
want: true,
30+
},
31+
{
32+
name: "permission denied is fatal",
33+
stderr: "tar: home/wes/.claude: Cannot open: " +
34+
"Permission denied\n" +
35+
"tar: Exiting with failure status " +
36+
"due to previous errors",
37+
want: false,
38+
},
39+
{
40+
name: "remote truncation is fatal",
41+
stderr: "tar: Unexpected EOF in archive",
42+
want: false,
43+
},
44+
{
45+
name: "delayed-exit summary alone is not benign",
46+
stderr: "tar: Exiting with failure status " +
47+
"due to previous errors",
48+
want: false,
49+
},
50+
{
51+
name: "empty stderr is not benign",
52+
stderr: "",
53+
want: false,
54+
},
55+
{
56+
name: "ssh connection failure is fatal",
57+
stderr: "ssh: connect to host devbox port 22: " +
58+
"Connection refused",
59+
want: false,
60+
},
61+
{
62+
name: "benign mixed with fatal stays fatal",
63+
stderr: "tar: a: file changed as we read it\n" +
64+
"tar: b: Cannot stat: No such file or directory",
65+
want: false,
66+
},
67+
}
68+
for _, tt := range tests {
69+
t.Run(tt.name, func(t *testing.T) {
70+
err := &commandError{
71+
Host: "devbox",
72+
Stderr: tt.stderr,
73+
Err: errors.New("exit status 1"),
74+
}
75+
assert.Equal(t, tt.want, remoteTarStderrBenign(err))
76+
})
77+
}
78+
}
79+
80+
func TestRemoteTarStderrBenignNonCommandError(t *testing.T) {
81+
// Anything that is not a classified SSH command failure must
82+
// never be treated as benign.
83+
assert.False(t, remoteTarStderrBenign(errors.New("boom")))
84+
assert.False(t, remoteTarStderrBenign(nil))
85+
}
86+
87+
func TestRemoteTarStderrBenignWrapped(t *testing.T) {
88+
// The classifier must see through fmt.Errorf wrapping, since
89+
// downloadAndExtract wraps cleanup errors as "ssh tar: %w".
90+
base := &commandError{
91+
Host: "devbox",
92+
Stderr: "tar: f: file changed as we read it",
93+
Err: errors.New("exit status 1"),
94+
}
95+
wrapped := fmt.Errorf("ssh tar: %w", base)
96+
assert.True(t, remoteTarStderrBenign(wrapped))
97+
}

internal/ssh/extract.go

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
package ssh
2+
3+
import (
4+
"archive/tar"
5+
"context"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"os"
10+
"path/filepath"
11+
"strings"
12+
)
13+
14+
const (
15+
extractDirPerm = 0o755
16+
extractFilePerm = 0o644
17+
)
18+
19+
// extractTarStream reads a tar stream from r and writes its entries
20+
// under dst. Extraction is fail-closed: it tolerates exactly one
21+
// anomaly, self-referential hardlinks (an entry whose link target is
22+
// itself, which macOS bsdtar emits for some Antigravity data and which
23+
// carries no content), and treats every other problem as fatal so a
24+
// truncated or corrupt transfer can never masquerade as a successful
25+
// sync. Unexpected EOF, bad headers, paths escaping dst, and
26+
// write/short-read errors all return an error. Returns the number of
27+
// self-referential hardlinks skipped.
28+
func extractTarStream(
29+
ctx context.Context, r io.Reader, dst string,
30+
) (int, error) {
31+
tr := tar.NewReader(r)
32+
skipped := 0
33+
for {
34+
if err := ctx.Err(); err != nil {
35+
return skipped, err
36+
}
37+
hdr, err := tr.Next()
38+
if errors.Is(err, io.EOF) {
39+
return skipped, nil
40+
}
41+
if err != nil {
42+
return skipped, fmt.Errorf("read tar entry: %w", err)
43+
}
44+
selfLink, err := extractEntry(tr, dst, hdr)
45+
if err != nil {
46+
return skipped, err
47+
}
48+
if selfLink {
49+
skipped++
50+
}
51+
}
52+
}
53+
54+
// extractEntry writes a single tar entry under dst. It reports whether
55+
// the entry was a self-referential hardlink (skipped, no error).
56+
func extractEntry(
57+
tr *tar.Reader, dst string, hdr *tar.Header,
58+
) (bool, error) {
59+
target, err := safeJoin(dst, hdr.Name)
60+
if err != nil {
61+
return false, err
62+
}
63+
switch hdr.Typeflag {
64+
case tar.TypeDir:
65+
return false, mkdirAll(target, hdr.Name)
66+
case tar.TypeReg:
67+
return false, writeRegular(target, tr, hdr)
68+
case tar.TypeSymlink:
69+
return false, writeSymlink(dst, target, hdr)
70+
case tar.TypeLink:
71+
return writeHardlink(dst, target, hdr)
72+
default:
73+
// Char/block/fifo and similar special files do not appear
74+
// in agent session directories; there is no content to lose
75+
// by ignoring them.
76+
return false, nil
77+
}
78+
}
79+
80+
// safeJoin resolves name against dst and rejects any path that escapes
81+
// dst (via "..", an absolute component, or symlink-free traversal).
82+
func safeJoin(dst, name string) (string, error) {
83+
target := filepath.Join(dst, filepath.FromSlash(name))
84+
if !within(dst, target) {
85+
return "", fmt.Errorf(
86+
"tar entry %q escapes extraction dir", name,
87+
)
88+
}
89+
return target, nil
90+
}
91+
92+
// within reports whether p is dst itself or lies inside dst.
93+
func within(dst, p string) bool {
94+
rel, err := filepath.Rel(dst, p)
95+
if err != nil {
96+
return false
97+
}
98+
if rel == ".." {
99+
return false
100+
}
101+
return !strings.HasPrefix(rel, ".."+string(filepath.Separator))
102+
}
103+
104+
func mkdirAll(path, name string) error {
105+
if err := os.MkdirAll(path, extractDirPerm); err != nil {
106+
return fmt.Errorf("mkdir %q: %w", name, err)
107+
}
108+
return nil
109+
}
110+
111+
// writeRegular extracts a regular file, failing on a short read so a
112+
// truncated stream cannot leave a half-written file behind. On any
113+
// failure the partial file is removed, so an aborted entry never looks
114+
// complete.
115+
func writeRegular(
116+
target string, tr io.Reader, hdr *tar.Header,
117+
) (err error) {
118+
if e := mkdirAll(filepath.Dir(target), hdr.Name); e != nil {
119+
return e
120+
}
121+
f, e := os.OpenFile(
122+
target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, extractFilePerm,
123+
)
124+
if e != nil {
125+
return fmt.Errorf("create %q: %w", hdr.Name, e)
126+
}
127+
defer func() {
128+
if err != nil {
129+
_ = os.Remove(target)
130+
}
131+
}()
132+
n, copyErr := io.Copy(f, tr)
133+
closeErr := f.Close()
134+
if copyErr == nil {
135+
copyErr = closeErr
136+
}
137+
if copyErr != nil {
138+
return fmt.Errorf("write %q: %w", hdr.Name, copyErr)
139+
}
140+
if n != hdr.Size {
141+
return fmt.Errorf(
142+
"short write %q: got %d of %d bytes",
143+
hdr.Name, n, hdr.Size,
144+
)
145+
}
146+
return nil
147+
}
148+
149+
// writeSymlink recreates a symlink only if its target stays within
150+
// dst; a link pointing outside the extraction dir is rejected so it
151+
// cannot be used to write through to the real filesystem.
152+
func writeSymlink(dst, target string, hdr *tar.Header) error {
153+
link := hdr.Linkname
154+
var resolved string
155+
if filepath.IsAbs(link) {
156+
resolved = filepath.Clean(link)
157+
} else {
158+
resolved = filepath.Join(filepath.Dir(target), link)
159+
}
160+
if !within(dst, resolved) {
161+
return fmt.Errorf(
162+
"symlink %q points outside extraction dir", hdr.Name,
163+
)
164+
}
165+
if err := mkdirAll(filepath.Dir(target), hdr.Name); err != nil {
166+
return err
167+
}
168+
if err := os.Symlink(link, target); err != nil {
169+
return fmt.Errorf("symlink %q: %w", hdr.Name, err)
170+
}
171+
return nil
172+
}
173+
174+
// writeHardlink recreates a hardlink. A self-referential hardlink
175+
// (target equals the entry itself) carries no content and is skipped;
176+
// the bool return reports that case. Any other failure is fatal.
177+
func writeHardlink(
178+
dst, target string, hdr *tar.Header,
179+
) (bool, error) {
180+
linkTarget, err := safeJoin(dst, hdr.Linkname)
181+
if err != nil {
182+
return false, err
183+
}
184+
if linkTarget == target {
185+
return true, nil
186+
}
187+
if err := mkdirAll(filepath.Dir(target), hdr.Name); err != nil {
188+
return false, err
189+
}
190+
if err := os.Link(linkTarget, target); err != nil {
191+
return false, fmt.Errorf(
192+
"hardlink %q -> %q: %w", hdr.Name, hdr.Linkname, err,
193+
)
194+
}
195+
return false, nil
196+
}

0 commit comments

Comments
 (0)