From c6863af016959bda565bf3e5e387c421a8fd406c Mon Sep 17 00:00:00 2001 From: KritBlade <256668062+KritBlade@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:08:06 +0100 Subject: [PATCH 1/6] fix: make content path mapping separator- and escaping-aware Content translation operated on raw bytes using a single spelling of the mapped path, but .jsonl and .json escape a backslash as a pair. A Windows device therefore never matched its own paths, and two things went wrong: - Its content was pushed unnormalized, so it never became portable and a POSIX device pulling it got paths it could not use. - Resolving a token on a Windows device substituted a raw backslash path into a JSON string, producing invalid escapes such as \G. Pulling POSIX-authored sessions onto Windows wrote .jsonl files that no longer parse ("invalid character 'G' in string escape code"). NormalizeContent and ResolveContent now take the file's relative path and derive a content kind from its extension: .json/.jsonl escape a backslash as a pair, .md/.txt hold it verbatim. Matched path tails are canonicalized to "/" in the remote form, so a Windows device and a POSIX device produce byte-identical remote content, and are rendered with the local separator, escaped for the target format, on pull. Only the root's native spelling is matched. Windows also accepts C:/like/this, but that form shows up mostly inside quoted error text and tool output, where rewriting a device's own prose would be worse than leaving one uncommon spelling unmapped. Remote key mapping is unchanged; it was already separator-agnostic because EncodeClaudePath flattens every non-alphanumeric character. Verified against 57 real transcripts from a Windows device: 35 of 36 files containing a mapped path round-trip byte-identically on the same device, and all 57 remain valid JSON after a POSIX device resolves them. The one exclusion is a transcript that quotes a literal ${HOME}, which is the pre-existing token ambiguity already documented on pathToken. Co-Authored-By: Claude Opus 5 --- internal/sync/paths.go | 174 ++++++++++++++++++++++++---- internal/sync/paths_crossos_test.go | 166 ++++++++++++++++++++++++++ internal/sync/paths_test.go | 8 +- internal/sync/sync.go | 4 +- 4 files changed, 324 insertions(+), 28 deletions(-) create mode 100644 internal/sync/paths_crossos_test.go diff --git a/internal/sync/paths.go b/internal/sync/paths.go index eb21176..f10ad47 100644 --- a/internal/sync/paths.go +++ b/internal/sync/paths.go @@ -27,17 +27,49 @@ import ( // HOME is always mapped. Additional prefixes (e.g. ~/work on one machine, // ~/Projects on another) can be mapped via the path_map config, with both // machines pointing their own local path at the same token name. +// +// Content rewriting is separator- and escaping-aware, because a Windows device +// and a POSIX device do not spell the same path the same way. Remotely, path +// tails are always canonicalized to "/" separators; on pull they are rendered +// with the local separator, escaped for the file format being written. type PathMapper struct { // mappings ordered longest local path first so the most specific prefix wins mappings []pathMapping } +// pathContentKind describes how a file format spells a path, which decides both +// what to match on push and what to emit on pull. +type pathContentKind int + +const ( + // pathContentPlain holds paths verbatim (.md, .txt). + pathContentPlain pathContentKind = iota + // pathContentJSON escapes a backslash as a pair (.json, .jsonl), so a Windows + // path reads as C:\\Users\\bob. Writing a raw backslash into one of these + // produces an invalid escape sequence and the file stops parsing. + pathContentJSON + numPathContentKinds +) + +// pathSegment matches one path component. Deliberately conservative: a tail +// stops at the first character that is not plainly part of a file name, so +// prose following a path is never rewritten. +const pathSegment = `[A-Za-z0-9_.-]*` + type pathMapping struct { name string // token name, e.g. "HOME", "WORK" - localPath string // absolute local path, no trailing slash + localPath string // absolute local path, no trailing separator encLocal string // localPath in Claude Code's directory encoding - normRe *regexp.Regexp - normRepl []byte // replacement template: token ($-escaped) + boundary group + windows bool // localPath uses backslash separators + + // normRe matches this device's spelling of localPath (plus any path tail) + // for a given content kind; localIn is how localPath is written back out. + normRe [numPathContentKinds]*regexp.Regexp + localIn [numPathContentKinds]string + + // resolveRe matches the portable token and its canonical "/" tail. The + // token is self-delimiting, so no trailing boundary is needed. + resolveRe *regexp.Regexp } var pathTokenNameRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) @@ -48,25 +80,38 @@ func NewPathMapper(homeDir string, userMap map[string]string) (*PathMapper, erro m := &PathMapper{} add := func(name, localPath string) error { - localPath = strings.TrimRight(localPath, "/") + localPath = strings.TrimRight(localPath, `/\`) if localPath == "" { return nil } if !pathTokenNameRe.MatchString(name) { return fmt.Errorf("invalid path_map token %q: use uppercase letters, digits, underscores (e.g. WORK)", name) } - // Boundary-aware: only replace the path when it is not followed by a - // name character, so /Users/merv never matches inside /Users/mervynlally. - re := regexp.MustCompile(regexp.QuoteMeta(localPath) + `([^A-Za-z0-9_.-]|$)`) - m.mappings = append(m.mappings, pathMapping{ + + mp := pathMapping{ name: name, localPath: localPath, encLocal: EncodeClaudePath(localPath), - normRe: re, - // "$$" = literal "$" in a regexp replacement template; without it - // "${HOME}" would itself be read as a group reference - normRepl: []byte("$${" + name + "}${1}"), - }) + windows: strings.Contains(localPath, `\`), + } + + for _, kind := range []pathContentKind{pathContentPlain, pathContentJSON} { + // Only the root's native spelling is matched. Windows also accepts + // C:/like/this, but that form shows up mostly inside quoted error + // text and tool output, and rewriting a device's own prose is worse + // than leaving one uncommon spelling unmapped. + root := regexp.QuoteMeta(renderLocalPath(localPath, kind, mp.windows)) + tail := `((?:` + separatorPattern(kind) + pathSegment + `)*)` + // Boundary-aware: only replace the path when it is not followed by a + // name character, so /Users/merv never matches inside /Users/mervynlally. + boundary := `([^A-Za-z0-9_.-]|$)` + mp.normRe[kind] = regexp.MustCompile(root + tail + boundary) + mp.localIn[kind] = renderLocalPath(localPath, kind, mp.windows) + } + + mp.resolveRe = regexp.MustCompile(regexp.QuoteMeta(pathToken(name)) + `((?:/` + pathSegment + `)*)`) + + m.mappings = append(m.mappings, mp) return nil } @@ -92,6 +137,47 @@ func NewPathMapper(homeDir string, userMap map[string]string) (*PathMapper, erro return m, nil } +// separatorPattern matches a path separator as it appears in this content kind: +// a forward slash, or a backslash spelled the way the format escapes it. +func separatorPattern(kind pathContentKind) string { + if kind == pathContentJSON { + return `(?:\\\\|/)` + } + return `(?:\\|/)` +} + +// renderLocalPath spells an absolute local path for the given content kind. +func renderLocalPath(p string, kind pathContentKind, windows bool) string { + if windows && kind == pathContentJSON { + return strings.ReplaceAll(p, `\`, `\\`) + } + return p +} + +// localSeparator is the separator to emit for this device and content kind. +func localSeparator(kind pathContentKind, windows bool) string { + if !windows { + return "/" + } + if kind == pathContentJSON { + return `\\` + } + return `\` +} + +// pathContentKindFor reports how the file at relPath spells paths. Conflict copies +// (path.conflict.) inherit the base path's format. +func pathContentKindFor(relPath string) pathContentKind { + if i := strings.Index(relPath, ".conflict."); i >= 0 { + relPath = relPath[:i] + } + switch path.Ext(relPath) { + case ".json", ".jsonl": + return pathContentJSON + } + return pathContentPlain +} + // EncodeClaudePath applies Claude Code's project directory encoding: every // character outside [A-Za-z0-9] becomes "-". func EncodeClaudePath(p string) string { @@ -170,29 +256,73 @@ func (m *PathMapper) ResolveRelPath(relPath string) (string, bool) { } // NormalizeContent replaces this device's mapped path prefixes with portable -// tokens in file content. Replacement is boundary-aware so one user's home -// path never matches inside a longer username. -func (m *PathMapper) NormalizeContent(data []byte) []byte { +// tokens in the content of the file at relPath. Replacement is boundary-aware +// so one user's home path never matches inside a longer username, and the +// matched path tail is canonicalized to "/" separators so a Windows device and +// a POSIX device produce byte-identical remote content. +func (m *PathMapper) NormalizeContent(relPath string, data []byte) []byte { if m == nil { return data } - for _, mp := range m.mappings { - data = mp.normRe.ReplaceAll(data, mp.normRepl) + kind := pathContentKindFor(relPath) + for i := range m.mappings { + mp := &m.mappings[i] + re := mp.normRe[kind] + token := []byte(pathToken(mp.name)) + data = re.ReplaceAllFunc(data, func(match []byte) []byte { + sub := re.FindSubmatch(match) + if sub == nil { + return match + } + out := make([]byte, 0, len(token)+len(sub[1])+len(sub[2])) + out = append(out, token...) + out = append(out, canonicalizeTail(sub[1], kind)...) + out = append(out, sub[2]...) + return out + }) } return data } -// ResolveContent replaces portable tokens with this device's local paths. -func (m *PathMapper) ResolveContent(data []byte) []byte { +// ResolveContent replaces portable tokens with this device's local paths, +// rendering separators and escaping for the format of the file at relPath. +func (m *PathMapper) ResolveContent(relPath string, data []byte) []byte { if m == nil { return data } - for _, mp := range m.mappings { - data = bytes.ReplaceAll(data, []byte(pathToken(mp.name)), []byte(mp.localPath)) + kind := pathContentKindFor(relPath) + for i := range m.mappings { + mp := &m.mappings[i] + re := mp.resolveRe + local := []byte(mp.localIn[kind]) + sep := []byte(localSeparator(kind, mp.windows)) + data = re.ReplaceAllFunc(data, func(match []byte) []byte { + sub := re.FindSubmatch(match) + if sub == nil { + return match + } + tail := sub[1] + if mp.windows { + tail = bytes.ReplaceAll(tail, []byte("/"), sep) + } + out := make([]byte, 0, len(local)+len(tail)) + out = append(out, local...) + out = append(out, tail...) + return out + }) } return data } +// canonicalizeTail rewrites the separators of a matched path tail to "/" so the +// remote form does not depend on which OS pushed it. +func canonicalizeTail(tail []byte, kind pathContentKind) []byte { + if kind == pathContentJSON { + return bytes.ReplaceAll(tail, []byte(`\\`), []byte("/")) + } + return bytes.ReplaceAll(tail, []byte(`\`), []byte("/")) +} + // IsPortableContentPath reports whether content path translation applies to // this relative path: text formats under projects/ plus the prompt history. // Conflict copies (path.conflict.) inherit the base path's rule. diff --git a/internal/sync/paths_crossos_test.go b/internal/sync/paths_crossos_test.go new file mode 100644 index 0000000..0b7dddc --- /dev/null +++ b/internal/sync/paths_crossos_test.go @@ -0,0 +1,166 @@ +package sync + +import ( + "encoding/json" + "testing" +) + +// Cross-OS content mapping. A Windows device and a POSIX device spell the same +// project path differently, and .jsonl escapes a backslash as a pair, so the +// normalized remote form has to be separator- and escaping-neutral. + +const ( + posixHome = `/Users/alice` + posixRoot = `/Users/alice/projects` + winHome = `C:\Users\bob` + winRoot = `D:\projects` + winRoot2 = `C:\work\projects` + + // Underscore and dash both belong to a path segment, so a name using them + // exercises the boundary rules. + project = `my_app-dev` + + sessionFile = "projects/whatever/s.jsonl" + memoFile = "projects/whatever/memory/note.md" +) + +func mapperFor(t *testing.T, home, root string) *PathMapper { + t.Helper() + m, err := NewPathMapper(home, map[string]string{root: "WORK"}) + if err != nil { + t.Fatalf("NewPathMapper(%q, %q): %v", home, root, err) + } + return m +} + +func TestContentNormalizesToSameRemoteFormOnEveryOS(t *testing.T) { + posix := mapperFor(t, posixHome, posixRoot) + win := mapperFor(t, winHome, winRoot) + + want := `{"cwd":"${WORK}/my_app-dev"}` + + got := string(posix.NormalizeContent(sessionFile, []byte(`{"cwd":"/Users/alice/projects/my_app-dev"}`))) + if got != want { + t.Errorf("posix normalize = %s, want %s", got, want) + } + + got = string(win.NormalizeContent(sessionFile, []byte(`{"cwd":"D:\\projects\\my_app-dev"}`))) + if got != want { + t.Errorf("windows normalize = %s, want %s", got, want) + } + + // A Windows root spelled with forward slashes is deliberately left alone: + // that form appears mostly in quoted error text and tool output, where + // rewriting the device's own prose would be worse than leaving it unmapped. + prose := `{"text":"failed at D:/projects/my_app-dev"}` + if got := string(win.NormalizeContent(sessionFile, []byte(prose))); got != prose { + t.Errorf("forward-slash root = %s, should be untouched", got) + } +} + +func TestContentRoundTripAcrossOS(t *testing.T) { + posix := mapperFor(t, posixHome, posixRoot) + win := mapperFor(t, winHome, winRoot2) + + cases := []struct { + name string + from, to *PathMapper + relPath string + in, want string + }{ + { + name: "posix to windows, jsonl stays valid JSON", + from: posix, + to: win, + relPath: sessionFile, + in: `{"cwd":"/Users/alice/projects/my_app-dev","home":"/Users/alice/.claude"}`, + want: `{"cwd":"C:\\work\\projects\\my_app-dev","home":"C:\\Users\\bob\\.claude"}`, + }, + { + name: "windows to posix, jsonl", + from: win, + to: posix, + relPath: sessionFile, + in: `{"cwd":"C:\\work\\projects\\my_app-dev","home":"C:\\Users\\bob\\.claude"}`, + want: `{"cwd":"/Users/alice/projects/my_app-dev","home":"/Users/alice/.claude"}`, + }, + { + name: "windows to posix, markdown keeps raw separators", + from: win, + to: posix, + relPath: memoFile, + in: `see C:\work\projects\my_app-dev for details`, + want: `see /Users/alice/projects/my_app-dev for details`, + }, + { + name: "posix to windows, markdown", + from: posix, + to: win, + relPath: memoFile, + in: `see /Users/alice/projects/my_app-dev for details`, + want: `see C:\work\projects\my_app-dev for details`, + }, + { + name: "windows to windows is unchanged end to end", + from: win, + to: win, + relPath: sessionFile, + in: `{"cwd":"C:\\work\\projects\\my_app-dev"}`, + want: `{"cwd":"C:\\work\\projects\\my_app-dev"}`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + norm := tc.from.NormalizeContent(tc.relPath, []byte(tc.in)) + got := string(tc.to.ResolveContent(tc.relPath, norm)) + if got != tc.want { + t.Errorf("round trip = %s\nwant = %s\n(remote form was %s)", got, tc.want, norm) + } + }) + } +} + +func TestResolvedJSONStaysParseable(t *testing.T) { + posix := mapperFor(t, posixHome, posixRoot) + win := mapperFor(t, winHome, winRoot) + + in := []byte(`{"cwd":"/Users/alice/projects/my_app-dev","home":"/Users/alice/.claude"}`) + out := win.ResolveContent(sessionFile, posix.NormalizeContent(sessionFile, in)) + + var v map[string]any + if err := json.Unmarshal(out, &v); err != nil { + t.Fatalf("resolved content is not valid JSON: %v\ncontent: %s", err, out) + } + if v["cwd"] != `D:\projects\my_app-dev` { + t.Errorf("decoded cwd = %v", v["cwd"]) + } +} + +func TestWindowsBoundariesNotOverMatched(t *testing.T) { + win := mapperFor(t, winHome, winRoot) + + // A longer sibling directory must not match the mapped root. + for _, s := range []string{ + `{"cwd":"D:\\projectsOther\\x"}`, + `{"cwd":"D:\\projects-archive\\x"}`, + } { + if got := string(win.NormalizeContent(sessionFile, []byte(s))); got != s { + t.Errorf("NormalizeContent(%s) = %s, should be untouched", s, got) + } + } +} + +func TestRemoteKeysMapAcrossOS(t *testing.T) { + posix := mapperFor(t, posixHome, posixRoot) + win := mapperFor(t, winHome, winRoot2) + + local := "projects/" + EncodeClaudePath(posixRoot+"/"+project) + "/s.jsonl" + remote := posix.NormalizeRelPath(local) + got, ok := win.ResolveRelPath(remote) + + want := "projects/" + EncodeClaudePath(winRoot2+`\`+project) + "/s.jsonl" + if !ok || got != want { + t.Errorf("ResolveRelPath(%q) = %q (ok=%v), want %q", remote, got, ok, want) + } +} diff --git a/internal/sync/paths_test.go b/internal/sync/paths_test.go index 125dd96..ce69514 100644 --- a/internal/sync/paths_test.go +++ b/internal/sync/paths_test.go @@ -113,14 +113,14 @@ func TestContentRoundTrip(t *testing.T) { bob := mustMapper(t, "/Users/mervynlally", nil) in := []byte(`{"cwd":"/Users/merv/nexura","note":"see /Users/mervynlally/nexura and /Users/merv"}`) - norm := alice.NormalizeContent(in) + norm := alice.NormalizeContent("projects/-Users-merv-nexura/s.jsonl", in) want := `{"cwd":"${HOME}/nexura","note":"see /Users/mervynlally/nexura and ${HOME}"}` if string(norm) != want { t.Fatalf("NormalizeContent = %s, want %s", norm, want) } - resolved := bob.ResolveContent(norm) + resolved := bob.ResolveContent("projects/-Users-merv-nexura/s.jsonl", norm) wantResolved := `{"cwd":"/Users/mervynlally/nexura","note":"see /Users/mervynlally/nexura and /Users/mervynlally"}` if string(resolved) != wantResolved { t.Fatalf("ResolveContent = %s, want %s", resolved, wantResolved) @@ -132,12 +132,12 @@ func TestContentBoundaries(t *testing.T) { // dotted and dashed continuations are part of a different name, not a boundary for _, s := range []string{"/Users/merv.bak/x", "/Users/merv-old/x", "/Users/mervyn/x"} { - if got := m.NormalizeContent([]byte(s)); string(got) != s { + if got := m.NormalizeContent("projects/x/notes.md", []byte(s)); string(got) != s { t.Errorf("NormalizeContent(%q) = %q, should be untouched", s, got) } } // end of data is a boundary - if got := m.NormalizeContent([]byte("/Users/merv")); string(got) != "${HOME}" { + if got := m.NormalizeContent("projects/x/notes.md", []byte("/Users/merv")); string(got) != "${HOME}" { t.Errorf("NormalizeContent at EOF = %q", got) } } diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 0a45bec..caa8410 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -432,7 +432,7 @@ func (s *Syncer) uploadFile(ctx context.Context, relativePath string) error { // Replace machine-specific paths with portable tokens in session content if IsPortableContentPath(relativePath) { - data = s.paths.NormalizeContent(data) + data = s.paths.NormalizeContent(relativePath, data) } // Compress @@ -487,7 +487,7 @@ func (s *Syncer) downloadFile(ctx context.Context, relativePath, remoteKey strin // Replace portable tokens with this device's paths in session content if IsPortableContentPath(relativePath) { - data = s.paths.ResolveContent(data) + data = s.paths.ResolveContent(relativePath, data) } // Guard against path traversal from crafted remote keys From 420200a55cd21f48771a8f5b5caff3ac078bc1c5 Mon Sep 17 00:00:00 2001 From: tawanorg <10970130+tawanorg@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:33:28 +1100 Subject: [PATCH 2/6] fix: treat a backslash as a separator only on Windows mappings The new separator-aware content mapping accepted a backslash as a path separator for every mapping, not just Windows ones. On a POSIX device a backslash inside a path is an escape, so a shell command quoted in a transcript round-tripped lossily: cd /Users/alice/My\ Documents -> cd /Users/alice/My/ Documents main does not have this problem, because it only ever rewrote the root prefix and left the tail alone. Canonicalizing the tail is what made the tail's backslashes reachable, so the two functions that do it now take the mapping's platform: a POSIX tail is already "/"-separated and is returned untouched, while a Windows tail keeps the existing behaviour. Adds round-trip guards for the escaped-space case in both content kinds, plus the stability, idempotency and longest-prefix properties the push/pull cycle depends on. Co-Authored-By: Claude Opus 5 --- internal/sync/paths.go | 24 ++++++--- internal/sync/paths_crossos_test.go | 82 +++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 7 deletions(-) diff --git a/internal/sync/paths.go b/internal/sync/paths.go index f10ad47..c2f30a8 100644 --- a/internal/sync/paths.go +++ b/internal/sync/paths.go @@ -101,7 +101,7 @@ func NewPathMapper(homeDir string, userMap map[string]string) (*PathMapper, erro // text and tool output, and rewriting a device's own prose is worse // than leaving one uncommon spelling unmapped. root := regexp.QuoteMeta(renderLocalPath(localPath, kind, mp.windows)) - tail := `((?:` + separatorPattern(kind) + pathSegment + `)*)` + tail := `((?:` + separatorPattern(kind, mp.windows) + pathSegment + `)*)` // Boundary-aware: only replace the path when it is not followed by a // name character, so /Users/merv never matches inside /Users/mervynlally. boundary := `([^A-Za-z0-9_.-]|$)` @@ -137,9 +137,15 @@ func NewPathMapper(homeDir string, userMap map[string]string) (*PathMapper, erro return m, nil } -// separatorPattern matches a path separator as it appears in this content kind: -// a forward slash, or a backslash spelled the way the format escapes it. -func separatorPattern(kind pathContentKind) string { +// separatorPattern matches a path separator as it appears in this content kind, +// for a mapping rooted on this kind of device. A backslash is only a separator +// on a Windows mapping; on a POSIX one it is an escape character, so treating it +// as a separator would rewrite "~/My\ Documents" into "~/My/ Documents" and +// corrupt any shell command quoted in a transcript. +func separatorPattern(kind pathContentKind, windows bool) string { + if !windows { + return "/" + } if kind == pathContentJSON { return `(?:\\\\|/)` } @@ -276,7 +282,7 @@ func (m *PathMapper) NormalizeContent(relPath string, data []byte) []byte { } out := make([]byte, 0, len(token)+len(sub[1])+len(sub[2])) out = append(out, token...) - out = append(out, canonicalizeTail(sub[1], kind)...) + out = append(out, canonicalizeTail(sub[1], kind, mp.windows)...) out = append(out, sub[2]...) return out }) @@ -315,8 +321,12 @@ func (m *PathMapper) ResolveContent(relPath string, data []byte) []byte { } // canonicalizeTail rewrites the separators of a matched path tail to "/" so the -// remote form does not depend on which OS pushed it. -func canonicalizeTail(tail []byte, kind pathContentKind) []byte { +// remote form does not depend on which OS pushed it. A POSIX tail is already +// "/"-separated; its backslashes are escapes and must survive untouched. +func canonicalizeTail(tail []byte, kind pathContentKind, windows bool) []byte { + if !windows { + return tail + } if kind == pathContentJSON { return bytes.ReplaceAll(tail, []byte(`\\`), []byte("/")) } diff --git a/internal/sync/paths_crossos_test.go b/internal/sync/paths_crossos_test.go index 0b7dddc..7384f87 100644 --- a/internal/sync/paths_crossos_test.go +++ b/internal/sync/paths_crossos_test.go @@ -164,3 +164,85 @@ func TestRemoteKeysMapAcrossOS(t *testing.T) { t.Errorf("ResolveRelPath(%q) = %q (ok=%v), want %q", remote, got, ok, want) } } + +// A backslash only separates path segments on a Windows mapping. On a POSIX +// mapping it is an escape character, and transcripts are full of shell commands +// that rely on it, so it has to survive a round trip untouched. +func TestPosixBackslashIsEscapeNotSeparator(t *testing.T) { + posix := mapperFor(t, posixHome, posixRoot) + + cases := []struct { + name string + relPath string + in string + }{ + {"escaped space in a jsonl command", sessionFile, `{"command":"cd /Users/alice/My\\ Documents"}`}, + {"escaped space in markdown", memoFile, `run cd /Users/alice/My\ Documents first`}, + {"newline escape after a mapped path", sessionFile, `{"t":"/Users/alice/projects\nnext"}`}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + remote := posix.NormalizeContent(tc.relPath, []byte(tc.in)) + got := string(posix.ResolveContent(tc.relPath, remote)) + if got != tc.in { + t.Errorf("round trip changed content\n in = %s\n out = %s\n remote = %s", tc.in, got, remote) + } + }) + } +} + +// WORK sits under HOME, so the longest local path has to win or the more +// specific token is never produced. +func TestNestedMappingPrefersLongestPath(t *testing.T) { + m := mapperFor(t, posixHome, posixRoot) + + in := []byte(`{"cwd":"/Users/alice/projects/app","home":"/Users/alice/.claude/x"}`) + want := `{"cwd":"${WORK}/app","home":"${HOME}/.claude/x"}` + if got := string(m.NormalizeContent(sessionFile, in)); got != want { + t.Errorf("NormalizeContent = %s, want %s", got, want) + } +} + +// Pull then push has to reproduce the remote bytes exactly. If it does not, every +// sync after a pull sees a changed hash and reports a phantom modification. +func TestResolveThenNormalizeIsStable(t *testing.T) { + devices := []struct { + name string + home, root string + }{ + {"posix", posixHome, posixRoot}, + {"windows", winHome, winRoot2}, + } + + for _, d := range devices { + t.Run(d.name, func(t *testing.T) { + m := mapperFor(t, d.home, d.root) + for _, relPath := range []string{sessionFile, memoFile} { + remote := []byte(`{"cwd":"${WORK}/app","h":"${HOME}/.claude"}`) + local := m.ResolveContent(relPath, remote) + if again := m.NormalizeContent(relPath, local); string(again) != string(remote) { + t.Errorf("%s is not stable\n remote = %s\n local = %s\n again = %s", relPath, remote, local, again) + } + } + }) + } +} + +func TestNormalizeContentIsIdempotent(t *testing.T) { + posix := mapperFor(t, posixHome, posixRoot) + + once := posix.NormalizeContent(sessionFile, []byte(`{"cwd":"/Users/alice/projects/app"}`)) + if twice := posix.NormalizeContent(sessionFile, once); string(twice) != string(once) { + t.Errorf("second pass changed content: %s then %s", once, twice) + } +} + +func TestConflictCopyInheritsContentKind(t *testing.T) { + if got := pathContentKindFor("projects/x/s.jsonl.conflict.20260101-120000"); got != pathContentJSON { + t.Errorf("conflict copy of .jsonl = %v, want JSON", got) + } + if got := pathContentKindFor("projects/x/n.md.conflict.20260101-120000"); got != pathContentPlain { + t.Errorf("conflict copy of .md = %v, want plain", got) + } +} From 554c2a8e6cb6de92a41844a81cccb3f8a51a68d0 Mon Sep 17 00:00:00 2001 From: tawanorg <10970130+tawanorg@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:34:53 +1100 Subject: [PATCH 3/6] fix: detect a Windows mapping by path shape, not by any backslash A mapping was treated as Windows whenever its local path contained a backslash. A backslash is a legal character in a POSIX directory name, so a home of /Users/al\ice claimed to be Windows and had its own "/" separators rewritten on pull: {"cwd":"/Users/al\\ice/projects/app"} -> {"cwd":"/Users/al\\ice\\projects\\app"} Test the path shape instead: a drive letter or a UNC share. Co-Authored-By: Claude Opus 5 --- internal/sync/paths.go | 18 +++++++++++++- internal/sync/paths_crossos_test.go | 37 +++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/internal/sync/paths.go b/internal/sync/paths.go index c2f30a8..69f3e03 100644 --- a/internal/sync/paths.go +++ b/internal/sync/paths.go @@ -92,7 +92,7 @@ func NewPathMapper(homeDir string, userMap map[string]string) (*PathMapper, erro name: name, localPath: localPath, encLocal: EncodeClaudePath(localPath), - windows: strings.Contains(localPath, `\`), + windows: isWindowsLocalPath(localPath), } for _, kind := range []pathContentKind{pathContentPlain, pathContentJSON} { @@ -137,6 +137,22 @@ func NewPathMapper(homeDir string, userMap map[string]string) (*PathMapper, erro return m, nil } +// isWindowsLocalPath reports whether an absolute path is spelled the Windows +// way: a drive letter ("C:\\...") or a UNC share ("\\\\server\\share"). A plain +// "contains a backslash" test would be wrong, because a backslash is a legal +// character in a POSIX directory name and claiming such a path is Windows +// rewrites its "/" separators into backslashes. +func isWindowsLocalPath(p string) bool { + if strings.HasPrefix(p, `\\`) { + return true + } + if len(p) >= 2 && p[1] == ':' { + c := p[0] + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') + } + return false +} + // separatorPattern matches a path separator as it appears in this content kind, // for a mapping rooted on this kind of device. A backslash is only a separator // on a Windows mapping; on a POSIX one it is an escape character, so treating it diff --git a/internal/sync/paths_crossos_test.go b/internal/sync/paths_crossos_test.go index 7384f87..d4c768f 100644 --- a/internal/sync/paths_crossos_test.go +++ b/internal/sync/paths_crossos_test.go @@ -246,3 +246,40 @@ func TestConflictCopyInheritsContentKind(t *testing.T) { t.Errorf("conflict copy of .md = %v, want plain", got) } } + +// A backslash is a legal character in a POSIX directory name, so "contains a +// backslash" is not the same question as "is a Windows path". Getting those +// confused rewrites a POSIX device's own "/" separators into backslashes. +func TestWindowsPathDetection(t *testing.T) { + cases := []struct { + path string + want bool + }{ + {`C:\Users\bob`, true}, + {`d:\projects`, true}, + {`\\server\share`, true}, + {`C:`, true}, + {`/Users/alice`, false}, + {`/Users/al\ice`, false}, + {`/home/user/My\ Documents`, false}, + } + + for _, tc := range cases { + if got := isWindowsLocalPath(tc.path); got != tc.want { + t.Errorf("isWindowsLocalPath(%q) = %v, want %v", tc.path, got, tc.want) + } + } +} + +func TestPosixHomeContainingBackslashRoundTrips(t *testing.T) { + m, err := NewPathMapper(`/Users/al\ice`, nil) + if err != nil { + t.Fatalf("NewPathMapper: %v", err) + } + + in := `{"cwd":"/Users/al\\ice/projects/app"}` + remote := m.NormalizeContent(sessionFile, []byte(in)) + if got := string(m.ResolveContent(sessionFile, remote)); got != in { + t.Errorf("round trip changed content\n in = %s\n out = %s\n remote = %s", in, got, remote) + } +} From 4a475e8641d062eb749b6ad80f6a4e8c7fc29bd7 Mon Sep 17 00:00:00 2001 From: tawanorg <10970130+tawanorg@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:46:55 +1100 Subject: [PATCH 4/6] fix: escape by file format, canonicalize a forward-slash Windows root Two defects found while reviewing the cross-OS content mapping. renderLocalPath only escaped a backslash for a Windows mapping, but escaping is a property of the file format, not of the platform. A POSIX home of /Users/al\ice was matched unescaped (so .jsonl never normalized, while .md did) and emitted unescaped, expanding ${HOME} into the invalid escape \i and leaving every pulled transcript unparseable. isWindowsLocalPath accepts C:/work, which is how a Windows root gets written in YAML, since "C:\work" is not a valid escape there. The root then matched with forward slashes while pull emitted backslashes, so a round trip returned mixed separators. The root is now canonicalized to the native separator; EncodeClaudePath flattens both, so remote keys are unchanged, and the length-based longest-prefix ordering is preserved. Also records the space-in-a-path limitation on pathSegment: a tail stops at a space, so the remainder keeps the pusher's separators. Admitting a space into a segment would make the tail swallow prose after a path, which is worse, so this stays a documented gap. Co-Authored-By: Claude Opus 5 --- internal/sync/paths.go | 29 +++++++++++++--- internal/sync/paths_crossos_test.go | 51 +++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/internal/sync/paths.go b/internal/sync/paths.go index 69f3e03..eabc1ea 100644 --- a/internal/sync/paths.go +++ b/internal/sync/paths.go @@ -54,6 +54,12 @@ const ( // pathSegment matches one path component. Deliberately conservative: a tail // stops at the first character that is not plainly part of a file name, so // prose following a path is never rewritten. +// +// Known limitation: a space is not part of a segment, so a tail stops there and +// the remainder keeps the pushing device's separators. "Application Support" on +// POSIX or "My Documents" on Windows therefore still crosses over with a mixed +// separator. Admitting a space into a segment is not the fix - the tail would +// then swallow the prose after a path, which is strictly worse. const pathSegment = `[A-Za-z0-9_.-]*` type pathMapping struct { @@ -88,11 +94,21 @@ func NewPathMapper(homeDir string, userMap map[string]string) (*PathMapper, erro return fmt.Errorf("invalid path_map token %q: use uppercase letters, digits, underscores (e.g. WORK)", name) } + // A Windows path is often configured with forward slashes, because + // "C:\work" is not a valid escape in YAML. Canonicalize it to the native + // separator so the matched root, the separator emitted on pull and the + // canonical remote tail all agree. EncodeClaudePath flattens both + // separators to "-", so remote keys are unaffected. + windows := isWindowsLocalPath(localPath) + if windows { + localPath = strings.ReplaceAll(localPath, "/", `\`) + } + mp := pathMapping{ name: name, localPath: localPath, encLocal: EncodeClaudePath(localPath), - windows: isWindowsLocalPath(localPath), + windows: windows, } for _, kind := range []pathContentKind{pathContentPlain, pathContentJSON} { @@ -100,13 +116,13 @@ func NewPathMapper(homeDir string, userMap map[string]string) (*PathMapper, erro // C:/like/this, but that form shows up mostly inside quoted error // text and tool output, and rewriting a device's own prose is worse // than leaving one uncommon spelling unmapped. - root := regexp.QuoteMeta(renderLocalPath(localPath, kind, mp.windows)) + root := regexp.QuoteMeta(renderLocalPath(localPath, kind)) tail := `((?:` + separatorPattern(kind, mp.windows) + pathSegment + `)*)` // Boundary-aware: only replace the path when it is not followed by a // name character, so /Users/merv never matches inside /Users/mervynlally. boundary := `([^A-Za-z0-9_.-]|$)` mp.normRe[kind] = regexp.MustCompile(root + tail + boundary) - mp.localIn[kind] = renderLocalPath(localPath, kind, mp.windows) + mp.localIn[kind] = renderLocalPath(localPath, kind) } mp.resolveRe = regexp.MustCompile(regexp.QuoteMeta(pathToken(name)) + `((?:/` + pathSegment + `)*)`) @@ -169,8 +185,11 @@ func separatorPattern(kind pathContentKind, windows bool) string { } // renderLocalPath spells an absolute local path for the given content kind. -func renderLocalPath(p string, kind pathContentKind, windows bool) string { - if windows && kind == pathContentJSON { +// Escaping follows the file format, not the platform: a backslash is legal in a +// POSIX directory name too, and writing it raw into .jsonl would produce an +// invalid escape sequence and stop the file parsing. +func renderLocalPath(p string, kind pathContentKind) string { + if kind == pathContentJSON { return strings.ReplaceAll(p, `\`, `\\`) } return p diff --git a/internal/sync/paths_crossos_test.go b/internal/sync/paths_crossos_test.go index d4c768f..225d26f 100644 --- a/internal/sync/paths_crossos_test.go +++ b/internal/sync/paths_crossos_test.go @@ -271,15 +271,62 @@ func TestWindowsPathDetection(t *testing.T) { } } +// A POSIX home holding a backslash has to be matched in its escaped spelling in +// .jsonl and emitted the same way, or the token expands to an invalid escape +// sequence and the transcript stops parsing. func TestPosixHomeContainingBackslashRoundTrips(t *testing.T) { m, err := NewPathMapper(`/Users/al\ice`, nil) if err != nil { t.Fatalf("NewPathMapper: %v", err) } - in := `{"cwd":"/Users/al\\ice/projects/app"}` + cases := []struct { + name string + relPath string + in, remote string + }{ + {"jsonl escapes the backslash", sessionFile, `{"cwd":"/Users/al\\ice/projects/app"}`, `{"cwd":"${HOME}/projects/app"}`}, + {"markdown holds it raw", memoFile, `see /Users/al\ice/projects/app`, `see ${HOME}/projects/app`}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + remote := m.NormalizeContent(tc.relPath, []byte(tc.in)) + if string(remote) != tc.remote { + t.Errorf("NormalizeContent = %s, want %s", remote, tc.remote) + } + if got := string(m.ResolveContent(tc.relPath, remote)); got != tc.in { + t.Errorf("round trip changed content\n in = %s\n out = %s", tc.in, got) + } + }) + } + + // The resolved .jsonl still has to parse. + remote := []byte(`{"cwd":"${HOME}/projects/app"}`) + out := m.ResolveContent(sessionFile, remote) + var v map[string]any + if err := json.Unmarshal(out, &v); err != nil { + t.Fatalf("resolved content is not valid JSON: %v\ncontent: %s", err, out) + } + if v["cwd"] != `/Users/al\ice/projects/app` { + t.Errorf("decoded cwd = %v", v["cwd"]) + } +} + +// A Windows root is often configured with forward slashes, because "C:\work" is +// not a valid escape in YAML. It must behave the same as the native spelling. +func TestWindowsRootConfiguredWithForwardSlashes(t *testing.T) { + m, err := NewPathMapper(winHome, map[string]string{`C:/work/projects`: "WORK"}) + if err != nil { + t.Fatalf("NewPathMapper: %v", err) + } + + in := `{"cwd":"C:\\work\\projects\\app"}` remote := m.NormalizeContent(sessionFile, []byte(in)) + if string(remote) != `{"cwd":"${WORK}/app"}` { + t.Errorf("NormalizeContent = %s, want %s", remote, `{"cwd":"${WORK}/app"}`) + } if got := string(m.ResolveContent(sessionFile, remote)); got != in { - t.Errorf("round trip changed content\n in = %s\n out = %s\n remote = %s", in, got, remote) + t.Errorf("round trip = %s, want %s", got, in) } } From f34cc1eba2004ccf406072d066fc3a933db76a0f Mon Sep 17 00:00:00 2001 From: tawanorg <10970130+tawanorg@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:51:16 +1100 Subject: [PATCH 5/6] test: pin POSIX content mapping as backward compatible The risk in separator-aware mapping is not the new Windows path, it is the POSIX content already sitting on every existing bucket. Assert that over a corpus of realistic transcript fragments the new mapping is byte-identical to the root-only mapping it replaces, in both content kinds. Co-Authored-By: Claude Opus 5 --- internal/sync/paths_crossos_test.go | 63 +++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/internal/sync/paths_crossos_test.go b/internal/sync/paths_crossos_test.go index 225d26f..c5eb5e6 100644 --- a/internal/sync/paths_crossos_test.go +++ b/internal/sync/paths_crossos_test.go @@ -2,6 +2,8 @@ package sync import ( "encoding/json" + "regexp" + "strings" "testing" ) @@ -330,3 +332,64 @@ func TestWindowsRootConfiguredWithForwardSlashes(t *testing.T) { t.Errorf("round trip = %s, want %s", got, in) } } + +// / mainResolve replicate the algorithm on main: replace only the +// +// root prefix, boundary-aware, and expand the token to the raw local path. +func rootOnlyNormalize(home string, data []byte) []byte { + re := regexp.MustCompile(regexp.QuoteMeta(home) + `([^A-Za-z0-9_.-]|$)`) + return re.ReplaceAll(data, []byte("$${HOME}${1}")) +} + +func rootOnlyResolve(home string, data []byte) []byte { + return []byte(strings.ReplaceAll(string(data), "${HOME}", home)) +} + +// Separator-aware mapping must be byte-identical to the root-only mapping for a +// POSIX device whose home holds no backslash - that is every existing POSIX +// user. Anything else would change the meaning of content already on a bucket. +func TestPosixContentMappingIsBackwardCompatible(t *testing.T) { + const home = "/Users/alice" + m, err := NewPathMapper(home, nil) + if err != nil { + t.Fatal(err) + } + + corpus := []string{ + `{"cwd":"/Users/alice/projects/app"}`, + `{"cwd":"/Users/alice"}`, + `/Users/alice`, + `/Users/alicia/x`, + `/Users/alice.bak/x`, + `/Users/alice-old/x`, + `{"f":"/Users/alice/.claude/settings.json","g":"/Users/alice/a/b/c.txt"}`, + `see /Users/alice/Library/Application Support/Code/User`, + `{"command":"cd /Users/alice/My\\ Documents && ls"}`, + `{"t":"/Users/alice/projects\nnext"}`, + `{"err":"no such file: /Users/alice/x/y.go:12"}`, + `{"a":"/Users/alice/x","b":"/Users/alice/x"}`, + `prefix/Users/alice/x`, + `{"u":"https://example.com/Users/alice/x"}`, + `{"p":"/Users/alice//double/slash"}`, + `{"p":"/Users/alice/trailing/"}`, + `{"home":"${HOME}/already/tokenized"}`, + `/Users/alice/a_b-c.d/e`, + `{"m":"/Users/alice/x", "n":"/Users/alicebob/y"}`, + } + + for _, relPath := range []string{"projects/x/s.jsonl", "projects/x/n.md"} { + for _, in := range corpus { + wantRemote := string(rootOnlyNormalize(home, []byte(in))) + gotRemote := string(m.NormalizeContent(relPath, []byte(in))) + if gotRemote != wantRemote { + t.Errorf("[%s] normalize diverges\n in = %s\n old = %s\n new = %s", relPath, in, wantRemote, gotRemote) + } + + wantLocal := string(rootOnlyResolve(home, []byte(wantRemote))) + gotLocal := string(m.ResolveContent(relPath, []byte(gotRemote))) + if gotLocal != wantLocal { + t.Errorf("[%s] resolve diverges\n remote = %s\n old = %s\n new = %s", relPath, gotRemote, wantLocal, gotLocal) + } + } + } +} From 7ab4e1e0b3d170f98cf950c9d9a2b0adbc66ce92 Mon Sep 17 00:00:00 2001 From: tawanorg <10970130+tawanorg@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:02:29 +1100 Subject: [PATCH 6/6] fix: restore nested-root tokenizing and halve the mapping overhead Four more defects from review. The greedy tail consumes a root nested inside another path as part of one match, and scanning resumes past it, so "~/a/Users/alice/b" left the second occurrence absolute where the root-only mapping tokenized it. The normalize pass now repeats while the literal root is still present, which a bytes.Contains guard keeps off the hot path. Both directions called ReplaceAllFunc and then re-ran the regex inside the callback to recover its submatches, tripling allocations. One FindAllSubmatchIndex pass gets them directly: 120k allocations and 19.7MB per 2.7MB of transcript drop to 40k and 10.7MB, matching the root-only mapping. The residual time is the tail pattern itself, which is the cost of canonicalizing a tail at all, and stays the same order as the gzip already in the pipeline. A trailing backslash was trimmed from POSIX roots too, so a path_map entry for a directory genuinely named "odd\" rooted at "odd" and captured the unrelated "odd/" subtree. Trimming is now gated on the path shape, which is the premise the rest of this work rests on. The kind loop hardcoded the two kinds while the arrays are sized by numPathContentKinds, so adding a third would have compiled and then panicked on a nil regexp. It iterates the full range instead. Also corrects the pathSegment comment, which named only the space. The class excludes "@", "(", ")", "+", "~", "=", "," and every non-ASCII rune, which makes node_modules\@babel\core cross to POSIX with literal backslashes - common, not a corner. Widening it reaches further into quoted tool output, so it stays narrow pending a decision on matching aggressiveness. Co-Authored-By: Claude Opus 5 --- internal/sync/paths.go | 112 +++++++++++++++++++--------- internal/sync/paths_crossos_test.go | 38 ++++++++++ 2 files changed, 116 insertions(+), 34 deletions(-) diff --git a/internal/sync/paths.go b/internal/sync/paths.go index eabc1ea..7d3d226 100644 --- a/internal/sync/paths.go +++ b/internal/sync/paths.go @@ -55,11 +55,16 @@ const ( // stops at the first character that is not plainly part of a file name, so // prose following a path is never rewritten. // -// Known limitation: a space is not part of a segment, so a tail stops there and -// the remainder keeps the pushing device's separators. "Application Support" on -// POSIX or "My Documents" on Windows therefore still crosses over with a mixed -// separator. Admitting a space into a segment is not the fix - the tail would -// then swallow the prose after a path, which is strictly worse. +// Known limitation: the class is narrower than a real file name. A space, "@", +// "(", ")", "+", "~", "=", "," and every non-ASCII rune all end a segment, so +// the tail stops there and the remainder keeps the pushing device's separators. +// A Windows device pushing node_modules\@babel\core therefore hands a POSIX +// device ".../node_modules/@babel\core", whose backslashes are literal - the +// path is wrong, not merely spelled oddly, and scoped packages make this the +// common case rather than a corner. Widening the class is not obviously safe +// either: the tail would reach further into quoted tool output and prose, which +// is how an earlier revision came to rewrite a PowerShell error message. Left +// deliberately narrow pending a decision on how aggressive matching should be. const pathSegment = `[A-Za-z0-9_.-]*` type pathMapping struct { @@ -86,7 +91,16 @@ func NewPathMapper(homeDir string, userMap map[string]string) (*PathMapper, erro m := &PathMapper{} add := func(name, localPath string) error { - localPath = strings.TrimRight(localPath, `/\`) + // Trim a trailing separator. A backslash only separates on a Windows + // path; on POSIX it is a legal character in a directory name, so a + // directory genuinely named `odd\` keeps its backslash rather than + // collapsing onto the unrelated `odd/` subtree. + windows := isWindowsLocalPath(localPath) + if windows { + localPath = strings.TrimRight(localPath, `/\`) + } else { + localPath = strings.TrimRight(localPath, "/") + } if localPath == "" { return nil } @@ -99,7 +113,6 @@ func NewPathMapper(homeDir string, userMap map[string]string) (*PathMapper, erro // separator so the matched root, the separator emitted on pull and the // canonical remote tail all agree. EncodeClaudePath flattens both // separators to "-", so remote keys are unaffected. - windows := isWindowsLocalPath(localPath) if windows { localPath = strings.ReplaceAll(localPath, "/", `\`) } @@ -111,7 +124,7 @@ func NewPathMapper(homeDir string, userMap map[string]string) (*PathMapper, erro windows: windows, } - for _, kind := range []pathContentKind{pathContentPlain, pathContentJSON} { + for kind := pathContentKind(0); kind < numPathContentKinds; kind++ { // Only the root's native spelling is matched. Windows also accepts // C:/like/this, but that form shows up mostly inside quoted error // text and tool output, and rewriting a device's own prose is worse @@ -307,24 +320,49 @@ func (m *PathMapper) NormalizeContent(relPath string, data []byte) []byte { } kind := pathContentKindFor(relPath) for i := range m.mappings { - mp := &m.mappings[i] - re := mp.normRe[kind] - token := []byte(pathToken(mp.name)) - data = re.ReplaceAllFunc(data, func(match []byte) []byte { - sub := re.FindSubmatch(match) - if sub == nil { - return match - } - out := make([]byte, 0, len(token)+len(sub[1])+len(sub[2])) - out = append(out, token...) - out = append(out, canonicalizeTail(sub[1], kind, mp.windows)...) - out = append(out, sub[2]...) - return out - }) + data = m.mappings[i].normalize(data, kind) } return data } +// normalize rewrites every occurrence of this mapping's root in data. +// +// The pass repeats while the literal root is still present, because the tail is +// greedy: "~/a/Users/alice/b" is consumed by a single match and scanning resumes +// past it, so a root nested inside another path would otherwise be uploaded +// verbatim and fail to resolve on the other device. The bytes.Contains guard +// keeps the repeat off the hot path - content holding one occurrence per match, +// which is nearly all of it, is done after a single pass. +func (mp *pathMapping) normalize(data []byte, kind pathContentKind) []byte { + re := mp.normRe[kind] + root := []byte(mp.localIn[kind]) + token := []byte(pathToken(mp.name)) + + for { + matches := re.FindAllSubmatchIndex(data, -1) + if len(matches) == 0 { + return data + } + + out := make([]byte, 0, len(data)) + prev := 0 + for _, mi := range matches { + out = append(out, data[prev:mi[0]]...) + out = append(out, token...) + out = append(out, canonicalizeTail(data[mi[2]:mi[3]], kind, mp.windows)...) + out = append(out, data[mi[4]:mi[5]]...) + prev = mi[1] + } + data = append(out, data[prev:]...) + + // Every match replaced one literal root with a token, so each pass makes + // progress and a pass that matches nothing ends the loop. + if !bytes.Contains(data, root) { + return data + } + } +} + // ResolveContent replaces portable tokens with this device's local paths, // rendering separators and escaping for the format of the file at relPath. func (m *PathMapper) ResolveContent(relPath string, data []byte) []byte { @@ -337,20 +375,26 @@ func (m *PathMapper) ResolveContent(relPath string, data []byte) []byte { re := mp.resolveRe local := []byte(mp.localIn[kind]) sep := []byte(localSeparator(kind, mp.windows)) - data = re.ReplaceAllFunc(data, func(match []byte) []byte { - sub := re.FindSubmatch(match) - if sub == nil { - return match - } - tail := sub[1] + + matches := re.FindAllSubmatchIndex(data, -1) + if len(matches) == 0 { + continue + } + + out := make([]byte, 0, len(data)) + prev := 0 + for _, mi := range matches { + out = append(out, data[prev:mi[0]]...) + out = append(out, local...) + tail := data[mi[2]:mi[3]] if mp.windows { - tail = bytes.ReplaceAll(tail, []byte("/"), sep) + out = append(out, bytes.ReplaceAll(tail, []byte("/"), sep)...) + } else { + out = append(out, tail...) } - out := make([]byte, 0, len(local)+len(tail)) - out = append(out, local...) - out = append(out, tail...) - return out - }) + prev = mi[1] + } + data = append(out, data[prev:]...) } return data } diff --git a/internal/sync/paths_crossos_test.go b/internal/sync/paths_crossos_test.go index c5eb5e6..bf5b2bf 100644 --- a/internal/sync/paths_crossos_test.go +++ b/internal/sync/paths_crossos_test.go @@ -393,3 +393,41 @@ func TestPosixContentMappingIsBackwardCompatible(t *testing.T) { } } } + +// The greedy tail consumes a root nested inside another path in a single match, +// so normalize has to repeat or the nested occurrence is uploaded verbatim and +// never resolves on the other device. +func TestNestedRootOccurrenceIsTokenized(t *testing.T) { + m := mapperFor(t, posixHome, posixRoot) + + cases := []struct{ in, want string }{ + {`/Users/alice/a/Users/alice/b`, `${HOME}/a${HOME}/b`}, + {`/Users/alice/backup/Users/alice/old/x`, `${HOME}/backup${HOME}/old/x`}, + {`/Users/alice/projects/a/Users/alice/projects/b`, `${WORK}/a${WORK}/b`}, + // A longer sibling must still not match, and must not loop forever. + {`/Users/alice/x /Users/alicexyz/y`, `${HOME}/x /Users/alicexyz/y`}, + } + + for _, tc := range cases { + if got := string(m.NormalizeContent(memoFile, []byte(tc.in))); got != tc.want { + t.Errorf("NormalizeContent(%s) = %s, want %s", tc.in, got, tc.want) + } + } +} + +// A trailing backslash is a separator to strip only on Windows. On POSIX it is +// part of the directory name, so stripping it would silently widen the token to +// a different directory. +func TestTrailingBackslashKeptOnPosixRoot(t *testing.T) { + m, err := NewPathMapper("/Users/alice", map[string]string{`/Users/alice/odd\`: "ODD"}) + if err != nil { + t.Fatalf("NewPathMapper: %v", err) + } + + if got := string(m.NormalizeContent(memoFile, []byte(`/Users/alice/odd/sub/x`))); strings.Contains(got, "${ODD}") { + t.Errorf("the odd/ subtree was captured by ODD: %s", got) + } + if got := string(m.NormalizeContent(memoFile, []byte(`/Users/alice/odd\/sub/x`))); !strings.Contains(got, "${ODD}") { + t.Errorf(`the odd\ directory was not matched: %s`, got) + } +}