diff --git a/internal/sync/paths.go b/internal/sync/paths.go index eb21176..7d3d226 100644 --- a/internal/sync/paths.go +++ b/internal/sync/paths.go @@ -27,17 +27,60 @@ 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. +// +// 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 { 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 +91,56 @@ 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 } 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{ + + // 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. + if windows { + localPath = strings.ReplaceAll(localPath, "/", `\`) + } + + 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: windows, + } + + 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 + // than leaving one uncommon spelling unmapped. + 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.resolveRe = regexp.MustCompile(regexp.QuoteMeta(pathToken(name)) + `((?:/` + pathSegment + `)*)`) + + m.mappings = append(m.mappings, mp) return nil } @@ -92,6 +166,72 @@ 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 +// 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 `(?:\\\\|/)` + } + return `(?:\\|/)` +} + +// renderLocalPath spells an absolute local path for the given content kind. +// 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 +} + +// 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 +310,108 @@ 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 { + data = m.mappings[i].normalize(data, kind) } return data } -// ResolveContent replaces portable tokens with this device's local paths. -func (m *PathMapper) ResolveContent(data []byte) []byte { +// 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 { 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)) + + 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 { + out = append(out, bytes.ReplaceAll(tail, []byte("/"), sep)...) + } else { + out = append(out, tail...) + } + prev = mi[1] + } + data = append(out, data[prev:]...) } return data } +// canonicalizeTail rewrites the separators of a matched path tail to "/" so the +// 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("/")) + } + 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..bf5b2bf --- /dev/null +++ b/internal/sync/paths_crossos_test.go @@ -0,0 +1,433 @@ +package sync + +import ( + "encoding/json" + "regexp" + "strings" + "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) + } +} + +// 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) + } +} + +// 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) + } + } +} + +// 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) + } + + 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 = %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) + } + } + } +} + +// 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) + } +} 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