Skip to content

Commit 1e86fbc

Browse files
wesmclaude
andcommitted
fix(ssh): preserve archived mtimes and skip symlinks on extract
The archive/tar extractor regressed two behaviors of the tar xf path it replaced: - It wrote files with the current time instead of the archived mtime. Remote sync's incremental skip cache keys on (path, mtime) and the engine treats it as authoritative, so every sync produced fresh mtimes that never matched and nothing was ever skipped (caught by the TestSSHSyncIncremental integration test). Restore the archived mtime with os.Chtimes after each regular file is written. - It recreated symlinks, which CodeQL flagged (go/unsafe-unzip-symlink) because an extracted symlink can redirect a later write outside the extraction dir. Symlinks are not session data and any file they alias is extracted on its own, so skip them entirely; this removes the write-through risk rather than guarding it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8e05a4c commit 1e86fbc

2 files changed

Lines changed: 58 additions & 47 deletions

File tree

internal/ssh/extract.go

Lines changed: 12 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ func extractEntry(
6666
case tar.TypeReg:
6767
return false, writeRegular(target, tr, hdr)
6868
case tar.TypeSymlink:
69-
return false, writeSymlink(dst, target, hdr)
69+
// Symlinks are not session data, and a symlink restored from
70+
// an archive can redirect later writes outside the extraction
71+
// dir. Any regular file a symlink might alias is extracted on
72+
// its own, so skip symlinks entirely.
73+
return false, nil
7074
case tar.TypeLink:
7175
return writeHardlink(dst, target, hdr)
7276
default:
@@ -143,30 +147,13 @@ func writeRegular(
143147
hdr.Name, n, hdr.Size,
144148
)
145149
}
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)
150+
// Restore the archived mtime: the incremental skip cache keys on
151+
// (path, mtime), so files must keep their remote mtime across
152+
// syncs or nothing is ever skipped. Best-effort: a failure only
153+
// forces a redundant resync, never data loss, so it must not
154+
// discard an otherwise complete extraction.
155+
if !hdr.ModTime.IsZero() {
156+
_ = os.Chtimes(target, hdr.ModTime, hdr.ModTime)
170157
}
171158
return nil
172159
}

internal/ssh/extract_test.go

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"path/filepath"
99
"strings"
1010
"testing"
11+
"time"
1112

1213
"github.com/stretchr/testify/assert"
1314
"github.com/stretchr/testify/require"
@@ -19,6 +20,7 @@ type tarEntry struct {
1920
typeflag byte
2021
body string
2122
linkname string
23+
modTime time.Time
2224
}
2325

2426
// buildTestTar serializes entries into an in-memory tar archive.
@@ -32,6 +34,7 @@ func buildTestTar(t *testing.T, entries []tarEntry) []byte {
3234
Typeflag: e.typeflag,
3335
Linkname: e.linkname,
3436
Mode: 0o644,
37+
ModTime: e.modTime,
3538
}
3639
if e.typeflag == tar.TypeReg {
3740
hdr.Size = int64(len(e.body))
@@ -136,32 +139,47 @@ func TestExtractTarStreamRejectsRelativePathEscape(t *testing.T) {
136139
assert.NoFileExists(t, filepath.Join(filepath.Dir(dst), "escape.txt"))
137140
}
138141

139-
func TestExtractTarStreamRejectsRelativeSymlinkEscape(t *testing.T) {
142+
func TestExtractTarStreamSkipsSymlinks(t *testing.T) {
140143
dst := t.TempDir()
141144
data := buildTestTar(t, []tarEntry{
145+
{name: "home/target.txt", typeflag: tar.TypeReg, body: "data"},
146+
// An in-tree link plus relative and absolute escapes: all
147+
// are skipped, never created, so none can redirect a later
148+
// write outside the extraction dir.
149+
{
150+
name: "home/link.txt",
151+
typeflag: tar.TypeSymlink,
152+
linkname: "target.txt",
153+
},
142154
{
143-
name: "home/evil",
155+
name: "home/rel-escape",
144156
typeflag: tar.TypeSymlink,
145157
linkname: "../../../../etc",
146158
},
147-
})
148-
149-
_, err := extract(t, data, dst)
150-
require.Error(t, err)
151-
}
152-
153-
func TestExtractTarStreamRejectsAbsoluteSymlinkEscape(t *testing.T) {
154-
dst := t.TempDir()
155-
data := buildTestTar(t, []tarEntry{
156159
{
157-
name: "home/evil",
160+
name: "home/abs-escape",
158161
typeflag: tar.TypeSymlink,
159162
linkname: "/etc/passwd",
160163
},
161164
})
162165

163166
_, err := extract(t, data, dst)
164-
require.Error(t, err)
167+
require.NoError(t, err)
168+
169+
// The regular file is still extracted.
170+
body, err := os.ReadFile(filepath.Join(dst, "home/target.txt"))
171+
require.NoError(t, err)
172+
assert.Equal(t, "data", string(body))
173+
174+
// No symlink is created anywhere.
175+
for _, name := range []string{
176+
"home/link.txt", "home/rel-escape", "home/abs-escape",
177+
} {
178+
_, statErr := os.Lstat(filepath.Join(dst, name))
179+
assert.True(
180+
t, os.IsNotExist(statErr), "%s should be skipped", name,
181+
)
182+
}
165183
}
166184

167185
func TestExtractTarStreamNormalHardlink(t *testing.T) {
@@ -180,24 +198,30 @@ func TestExtractTarStreamNormalHardlink(t *testing.T) {
180198
assert.Equal(t, "shared", string(b))
181199
}
182200

183-
func TestExtractTarStreamSymlinkWithinDst(t *testing.T) {
201+
func TestExtractTarStreamPreservesModTime(t *testing.T) {
184202
dst := t.TempDir()
203+
// The incremental skip cache keys on (path, mtime) and the sync
204+
// engine treats it as authoritative, so extracted files must keep
205+
// their archived mtime across syncs or nothing is ever skipped.
206+
want := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)
185207
data := buildTestTar(t, []tarEntry{
186-
{name: "home/target.txt", typeflag: tar.TypeReg, body: "data"},
187208
{
188-
name: "home/link.txt",
189-
typeflag: tar.TypeSymlink,
190-
linkname: "target.txt",
209+
name: "home/wes/.claude/s.jsonl",
210+
typeflag: tar.TypeReg,
211+
body: "session",
212+
modTime: want,
191213
},
192214
})
193215

194-
skipped, err := extract(t, data, dst)
216+
_, err := extract(t, data, dst)
195217
require.NoError(t, err)
196-
assert.Equal(t, 0, skipped)
197218

198-
got, err := os.Readlink(filepath.Join(dst, "home/link.txt"))
219+
info, err := os.Stat(filepath.Join(dst, "home/wes/.claude/s.jsonl"))
199220
require.NoError(t, err)
200-
assert.Equal(t, "target.txt", got)
221+
assert.True(
222+
t, info.ModTime().Equal(want),
223+
"extracted mtime = %s, want %s", info.ModTime(), want,
224+
)
201225
}
202226

203227
func TestExtractTarStreamCreatesDirsAndFiles(t *testing.T) {

0 commit comments

Comments
 (0)