diff --git a/README.md b/README.md index f56b49e..ccf0950 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,8 @@ Synced verbatim, those would be **different projects** and `claude --resume` on Sessions under either directory sync to the shared `${WORK}` namespace and resume correctly on both machines. +- **Symlinked project roots are canonicalized.** If a mapped directory is a symlink (e.g. `~/Developer` pointing at `~/Documents/Projects`), the real target is used, so sessions recorded through the symlink resume under the path `claude --resume` looks them up by. To keep the literal symlink path instead, set `resolve_symlinks: false` in `~/.claude-sync/config.yaml`. + **Upgrading from an older version?** Run `claude-sync migrate` once on each device to convert existing remote data to portable keys. Paths the current device doesn't own are left for the other device's migrate run. ## Commands diff --git a/cmd/claude-sync/main.go b/cmd/claude-sync/main.go index 4f0d47f..15afa2d 100644 --- a/cmd/claude-sync/main.go +++ b/cmd/claude-sync/main.go @@ -1501,18 +1501,12 @@ func findConflicts(claudeDir string) ([]conflictFile, error) { return nil } - // Look for .conflict. pattern - if strings.Contains(filepath.Base(path), ".conflict.") { - // Extract original path and timestamp - // Format: filename.ext.conflict.20260208-095132 - parts := strings.Split(path, ".conflict.") - if len(parts) == 2 { - conflicts = append(conflicts, conflictFile{ - ConflictPath: path, - OriginalPath: parts[0], - Timestamp: parts[1], - }) - } + if original, timestamp, ok := sync.SplitConflictPath(path); ok { + conflicts = append(conflicts, conflictFile{ + ConflictPath: path, + OriginalPath: original, + Timestamp: timestamp, + }) } return nil }) diff --git a/internal/config/config.go b/internal/config/config.go index bb0788e..f842dec 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -70,6 +70,12 @@ type Config struct { // ~/Projects: WORK PathMap map[string]string `yaml:"path_map,omitempty"` + // ResolveSymlinks canonicalizes a mapped prefix that is a symlink to its + // target, so sessions recorded through the symlink resume under the real + // path Claude Code looks them up by. Nil defaults to enabled; set false to + // keep the literal symlink path. See ResolveSymlinksEnabled. + ResolveSymlinks *bool `yaml:"resolve_symlinks,omitempty"` + // ClaudeDirOverride allows overriding the default ~/.claude path (for testing) ClaudeDirOverride string `yaml:"-"` @@ -317,6 +323,12 @@ func (c *Config) SetMCPSync(enabled bool) { c.MCPSync = &enabled } +// ResolveSymlinksEnabled reports whether symlinked prefixes are canonicalized. +// Nil (unset) defaults to true; only an explicit false disables it. +func (c *Config) ResolveSymlinksEnabled() bool { + return c.ResolveSymlinks == nil || *c.ResolveSymlinks +} + // IsExcluded returns true if the given relative path matches any exclude pattern. // Patterns support: // - Full doublestar glob syntax including ** for recursive matching diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e94d749..57360b6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -552,6 +552,27 @@ func TestIsMCPSyncEnabled(t *testing.T) { } } +func TestResolveSymlinksEnabled(t *testing.T) { + tests := []struct { + name string + value *bool + want bool + }{ + {"nil (unset) defaults to true", nil, true}, + {"true returns true", boolPtr(true), true}, + {"false returns false", boolPtr(false), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{ResolveSymlinks: tt.value} + if got := cfg.ResolveSymlinksEnabled(); got != tt.want { + t.Errorf("ResolveSymlinksEnabled() = %v, want %v", got, tt.want) + } + }) + } +} + func TestSetMCPSync(t *testing.T) { cfg := &Config{} diff --git a/internal/sync/paths.go b/internal/sync/paths.go index eb21176..3222c28 100644 --- a/internal/sync/paths.go +++ b/internal/sync/paths.go @@ -2,8 +2,10 @@ package sync import ( "bytes" + "encoding/json" "fmt" "path" + "path/filepath" "regexp" "sort" "strings" @@ -27,6 +29,11 @@ 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. +// +// A mapped prefix that is a symlink is canonicalized to its target, because +// that is the path `claude --resume` looks sessions up under. The symlink +// spelling is still recognized on the way in, so sessions recorded through +// either form collapse to one token. type PathMapper struct { // mappings ordered longest local path first so the most specific prefix wins mappings []pathMapping @@ -34,7 +41,22 @@ type PathMapper struct { type pathMapping struct { name string // token name, e.g. "HOME", "WORK" - localPath string // absolute local path, no trailing slash + localPath string // absolute local path, symlink-resolved, no trailing slash + encLocal string // localPath in Claude Code's directory encoding + // aliases are the local spellings normalization accepts, longest first: + // localPath plus, when the configured path is a symlink, the unresolved + // path Claude Code recorded when the user cd'd through it. + aliases []pathAlias + // resolveRe captures the token and its path tail so the tail's separators + // can follow localPath's convention; otherwise a foreign-OS separator + // survives after the prefix (e.g. C:\Users\bob/foo) and cwd matching fails. + resolveRe *regexp.Regexp +} + +// pathAlias is one local spelling of a mapped prefix, with the precomputed +// forms used to match it in a remote key and in file content. +type pathAlias struct { + localPath string encLocal string // localPath in Claude Code's directory encoding normRe *regexp.Regexp normRepl []byte // replacement template: token ($-escaped) + boundary group @@ -43,10 +65,25 @@ type pathMapping struct { var pathTokenNameRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) // NewPathMapper builds a mapper for this device. userMap maps local absolute -// paths (already ~-expanded) to token names shared across devices. -func NewPathMapper(homeDir string, userMap map[string]string) (*PathMapper, error) { +// paths (already ~-expanded) to token names shared across devices. When +// resolveSymlinks is true a mapped prefix that is a symlink is canonicalized to +// its target, with the symlink spelling kept as an alias. +func NewPathMapper(homeDir string, userMap map[string]string, resolveSymlinks bool) (*PathMapper, error) { m := &PathMapper{} + newAlias := func(name, localPath string) pathAlias { + return pathAlias{ + localPath: localPath, + encLocal: EncodeClaudePath(localPath), + // Boundary-aware: only replace the path when it is not followed by a + // name character, so /Users/merv never matches inside /Users/mervynlally. + normRe: regexp.MustCompile(regexp.QuoteMeta(localPath) + `([^A-Za-z0-9_.-]|$)`), + // "$$" = literal "$" in a regexp replacement template; without it + // "${HOME}" would itself be read as a group reference + normRepl: []byte("$${" + name + "}${1}"), + } + } + add := func(name, localPath string) error { localPath = strings.TrimRight(localPath, "/") if localPath == "" { @@ -55,17 +92,25 @@ func NewPathMapper(homeDir string, userMap map[string]string) (*PathMapper, erro 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_.-]|$)`) + + realPath := localPath + if resolveSymlinks { + realPath = resolveSymlinkPath(localPath) + } + aliases := []pathAlias{newAlias(name, realPath)} + if realPath != localPath { + aliases = append(aliases, newAlias(name, localPath)) + } + sort.SliceStable(aliases, func(i, j int) bool { + return len(aliases[i].localPath) > len(aliases[j].localPath) + }) + m.mappings = append(m.mappings, 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}"), + localPath: realPath, + encLocal: EncodeClaudePath(realPath), + aliases: aliases, + resolveRe: regexp.MustCompile(regexp.QuoteMeta(pathToken(name)) + `([/\\][^"\s]*)?`), }) return nil } @@ -92,6 +137,19 @@ func NewPathMapper(homeDir string, userMap map[string]string) (*PathMapper, erro return m, nil } +// resolveSymlinkPath returns p with symlinks resolved. Claude Code keys +// sessions by the path the user cd'd through, but `claude --resume` looks them +// up under the resolved real path, so the real path is the canonical form. A +// path that does not exist on this device is returned unchanged: a prefix is +// still mappable on a machine where the directory is absent. +func resolveSymlinkPath(p string) string { + resolved, err := filepath.EvalSymlinks(p) + if err != nil { + return p + } + return strings.TrimRight(filepath.Clean(resolved), `/\`) +} + // EncodeClaudePath applies Claude Code's project directory encoding: every // character outside [A-Za-z0-9] becomes "-". func EncodeClaudePath(p string) string { @@ -142,8 +200,10 @@ func (m *PathMapper) NormalizeRelPath(relPath string) string { return relPath } for _, mp := range m.mappings { - if seg == mp.encLocal || strings.HasPrefix(seg, mp.encLocal+"-") { - return "projects/" + pathToken(mp.name) + seg[len(mp.encLocal):] + rest + for _, a := range mp.aliases { + if seg == a.encLocal || strings.HasPrefix(seg, a.encLocal+"-") { + return "projects/" + pathToken(mp.name) + seg[len(a.encLocal):] + rest + } } } return relPath @@ -177,30 +237,67 @@ func (m *PathMapper) NormalizeContent(data []byte) []byte { return data } for _, mp := range m.mappings { - data = mp.normRe.ReplaceAll(data, mp.normRepl) + for _, a := range mp.aliases { + data = a.normRe.ReplaceAll(data, a.normRepl) + } } return data } -// ResolveContent replaces portable tokens with this device's local paths. +// ResolveContent replaces portable tokens with this device's local paths, +// rewriting the following path tail's separators to localPath's convention so a +// path authored on another OS resolves to a valid native path. func (m *PathMapper) ResolveContent(data []byte) []byte { if m == nil { return data } for _, mp := range m.mappings { - data = bytes.ReplaceAll(data, []byte(pathToken(mp.name)), []byte(mp.localPath)) + sep := pathSep(mp.localPath) + data = mp.resolveRe.ReplaceAllFunc(data, func(match []byte) []byte { + tail := match[len(pathToken(mp.name)):] + return append([]byte(mp.localPath), replaceSeps(string(tail), sep)...) + }) } return data } +// portableStatePaths are files outside projects/ that embed absolute paths. +// A JSON file is translated by decoding it and rewriting string values: a +// Windows path is escaped in the source ("C:\\Users\\bob"), so raw byte +// replacement would eat an escape and leave the document invalid. +var portableStatePaths = map[string]bool{ + "history.jsonl": true, + "plugins/known_marketplaces.json": true, + "plugins/installed_plugins.json": true, +} + +// basePath is the path a translation rule applies to: a conflict copy follows +// the file it was made from, so its content is resolved for this device and +// stays comparable against the local version. +func basePath(relPath string) string { + base, _, _ := SplitConflictPath(relPath) + return path.Clean(base) +} + +// isPortableJSONPath reports whether relPath is translated as a single JSON +// document rather than raw bytes. +func isPortableJSONPath(relPath string) bool { + return path.Ext(relPath) == ".json" +} + +// isPortableJSONLPath reports whether relPath is JSON-lines, translated one +// JSON document per line. +func isPortableJSONLPath(relPath string) bool { + return path.Ext(relPath) == ".jsonl" +} + // IsPortableContentPath reports whether content path translation applies to -// this relative path: text formats under projects/ plus the prompt history. +// this relative path: text formats under projects/, the prompt history, and +// the plugin state files. // Conflict copies (path.conflict.) inherit the base path's rule. func IsPortableContentPath(relPath string) bool { - if i := strings.Index(relPath, ".conflict."); i >= 0 { - relPath = relPath[:i] - } - if relPath == "history.jsonl" { + relPath = basePath(relPath) + if portableStatePaths[relPath] { return true } if !strings.HasPrefix(relPath, "projects/") { @@ -212,3 +309,146 @@ func IsPortableContentPath(relPath string) bool { } return false } + +// NormalizeFile replaces this device's mapped path prefixes with portable +// tokens in the content of relPath. JSON and JSON-lines files are translated +// through a JSON decode/encode so an inserted path stays correctly escaped and +// re-separated; other text is translated with boundary-aware byte replacement. +func (m *PathMapper) NormalizeFile(relPath string, data []byte) []byte { + return m.mapFile(relPath, data, false) +} + +// ResolveFile replaces portable tokens with this device's local paths in the +// content of relPath, mirroring NormalizeFile. +func (m *PathMapper) ResolveFile(relPath string, data []byte) []byte { + return m.mapFile(relPath, data, true) +} + +func (m *PathMapper) mapFile(relPath string, data []byte, resolve bool) []byte { + if m == nil { + return data + } + base := basePath(relPath) + switch { + case isPortableJSONPath(base): + return m.mapJSON(data, resolve, true) + case isPortableJSONLPath(base): + return m.mapJSONL(data, resolve) + case resolve: + return m.ResolveContent(data) + default: + return m.NormalizeContent(data) + } +} + +// mapJSON translates every mapping in a single JSON document. resolve picks the +// direction: false normalizes local paths to tokens, true resolves tokens back. +func (m *PathMapper) mapJSON(data []byte, resolve, indent bool) []byte { + for _, mp := range m.mappings { + if resolve { + data = mapJSONPaths(data, pathToken(mp.name), mp.localPath, indent) + continue + } + for _, a := range mp.aliases { + data = mapJSONPaths(data, a.localPath, pathToken(mp.name), indent) + } + } + return data +} + +// mapJSONL translates a JSON-lines file one document per line, preserving line +// endings. Lines that are not valid JSON pass through unchanged. +func (m *PathMapper) mapJSONL(data []byte, resolve bool) []byte { + lines := bytes.Split(data, []byte("\n")) + for i, line := range lines { + trimmed := bytes.TrimRight(line, "\r") + if len(bytes.TrimSpace(trimmed)) == 0 { + continue + } + mapped := m.mapJSON(trimmed, resolve, false) + mapped = append(mapped, line[len(trimmed):]...) + lines[i] = mapped + } + return bytes.Join(lines, []byte("\n")) +} + +// pathSep reports the separator p is written with, so a translated path keeps +// the target's convention rather than the running platform's. +func pathSep(p string) byte { + if strings.Contains(p, `\`) { + return '\\' + } + return '/' +} + +// replaceSeps rewrites every separator in p to sep. +func replaceSeps(p string, sep byte) string { + return strings.Map(func(r rune) rune { + if r == '/' || r == '\\' { + return rune(sep) + } + return r + }, p) +} + +// mapJSONPaths decodes data as JSON and rewrites every string value that +// begins with from, swapping that prefix for to and re-separating the +// remainder in to's convention. Escaping is left to the encoder, so a Windows +// path stays valid; content that is not valid JSON is returned unchanged. +// Indented output re-indents state files for readability; JSON-lines callers +// pass indent=false to keep each document on a single line. +func mapJSONPaths(data []byte, from, to string, indent bool) []byte { + var doc any + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + if err := dec.Decode(&doc); err != nil { + return data + } + + mapPath := func(s string) string { + if !strings.HasPrefix(s, from) { + return s + } + rest := s[len(from):] + if rest != "" && rest[0] != '/' && rest[0] != '\\' { + return s + } + return to + replaceSeps(rest, pathSep(to)) + } + + var walk func(any) any + walk = func(v any) any { + switch t := v.(type) { + case string: + return mapPath(t) + case []any: + for i, e := range t { + t[i] = walk(e) + } + return t + case map[string]any: + for k, e := range t { + t[k] = walk(e) + } + return t + } + return v + } + + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if indent { + enc.SetIndent("", " ") + } + if err := enc.Encode(walk(doc)); err != nil { + return data + } + out := buf.Bytes() + if !indent { + // A JSON-lines document is one line; Encode always appends a newline the + // caller rejoins itself. State files keep the newline they had. + out = bytes.TrimSuffix(out, []byte("\n")) + } + return out +} diff --git a/internal/sync/paths_test.go b/internal/sync/paths_test.go index 125dd96..a666d79 100644 --- a/internal/sync/paths_test.go +++ b/internal/sync/paths_test.go @@ -1,15 +1,19 @@ package sync import ( + "bytes" "context" + "encoding/json" "os" "path/filepath" + "strings" "testing" + "time" ) func mustMapper(t *testing.T, home string, userMap map[string]string) *PathMapper { t.Helper() - m, err := NewPathMapper(home, userMap) + m, err := NewPathMapper(home, userMap, true) if err != nil { t.Fatalf("NewPathMapper: %v", err) } @@ -127,6 +131,72 @@ func TestContentRoundTrip(t *testing.T) { } } +func TestResolveContentCrossOSSeparators(t *testing.T) { + win := mustMapper(t, `C:\Users\bob`, nil) + + got := string(win.ResolveContent([]byte(`{"cwd":"${HOME}/Developer/TypeScript/hivemind","bare":"${HOME}"}`))) + want := `{"cwd":"C:\Users\bob\Developer\TypeScript\hivemind","bare":"C:\Users\bob"}` + if got != want { + t.Fatalf("ResolveContent cross-OS = %s, want %s", got, want) + } + + mac := mustMapper(t, "/Users/bob", nil) + got = string(mac.ResolveContent([]byte(`{"cwd":"${HOME}\Developer\hivemind"}`))) + want = `{"cwd":"/Users/bob/Developer/hivemind"}` + if got != want { + t.Fatalf("ResolveContent win->posix = %s, want %s", got, want) + } +} + +func TestResolveFileJSONLEscaping(t *testing.T) { + win := mustMapper(t, `C:\Users\bob`, nil) + + line := []byte(`{"type":"user","cwd":"${HOME}/Developer/hivemind","home":"${HOME}"}`) + got := win.ResolveFile("projects/-x/sess.jsonl", line) + + var doc map[string]any + if err := json.Unmarshal(got, &doc); err != nil { + t.Fatalf("resolved JSONL line is not valid JSON: %v\n%s", err, got) + } + if doc["cwd"] != `C:\Users\bob\Developer\hivemind` { + t.Fatalf("cwd = %q, want native Windows path", doc["cwd"]) + } + if doc["home"] != `C:\Users\bob` { + t.Fatalf("home = %q", doc["home"]) + } + + // Round trip: normalizing the resolved line on macOS returns the token form. + mac := mustMapper(t, "/Users/bob", nil) + back := mac.NormalizeFile("projects/-x/sess.jsonl", mac.ResolveFile("projects/-x/sess.jsonl", line)) + var backDoc map[string]any + if err := json.Unmarshal(back, &backDoc); err != nil { + t.Fatalf("round-tripped line invalid: %v", err) + } + if backDoc["cwd"] != "${HOME}/Developer/hivemind" { + t.Fatalf("round-trip cwd = %q, want token form", backDoc["cwd"]) + } +} + +func TestMapJSONLPreservesLines(t *testing.T) { + win := mustMapper(t, `C:\Users\bob`, nil) + + in := []byte("{\"cwd\":\"${HOME}/a\"}\n{\"cwd\":\"${HOME}/b\"}\n") + got := win.ResolveFile("projects/-x/sess.jsonl", in) + + lines := bytes.Split(bytes.TrimRight(got, "\n"), []byte("\n")) + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d: %q", len(lines), got) + } + for _, l := range lines { + if !json.Valid(l) { + t.Fatalf("line not valid JSON: %s", l) + } + } + if !bytes.HasSuffix(got, []byte("\n")) { + t.Fatal("trailing newline not preserved") + } +} + func TestContentBoundaries(t *testing.T) { m := mustMapper(t, "/Users/merv", nil) @@ -142,14 +212,172 @@ func TestContentBoundaries(t *testing.T) { } } +// mustSymlinkedHome creates a realPath directory and a symlink pointing at it, +// returning both paths. It skips the test when this OS/user cannot create +// symlinks (unprivileged Windows without developer mode). +func mustSymlinkedHome(t *testing.T) (link, realPath string) { + t.Helper() + tmp := t.TempDir() + realPath = filepath.Join(tmp, "Projects") + if err := os.MkdirAll(realPath, 0700); err != nil { + t.Fatal(err) + } + link = filepath.Join(tmp, "Developer") + if err := os.Symlink(realPath, link); err != nil { + t.Skipf("cannot create symlinks on this system: %v", err) + } + // EvalSymlinks also canonicalizes the parent chain (e.g. macOS /var -> + // /private/var), so compare against the same canonical form the mapper uses. + resolved, err := filepath.EvalSymlinks(realPath) + if err != nil { + t.Fatal(err) + } + return link, resolved +} + +func TestSymlinkedHomeNormalizesToRealPath(t *testing.T) { + link, realPath := mustSymlinkedHome(t) + m := mustMapper(t, link, nil) + + // A cwd recorded through the symlink and the same cwd recorded through the + // realPath path must produce the same token. + viaLink := m.NormalizeFile("projects/-x/sess.jsonl", []byte(`{"cwd":`+mustJSONString(t, filepath.Join(link, "app"))+`}`)) + viaReal := m.NormalizeFile("projects/-x/sess.jsonl", []byte(`{"cwd":`+mustJSONString(t, filepath.Join(realPath, "app"))+`}`)) + + for _, got := range [][]byte{viaLink, viaReal} { + var doc map[string]string + if err := json.Unmarshal(got, &doc); err != nil { + t.Fatalf("normalized line invalid: %v (%s)", err, got) + } + if want := "${HOME}/app"; doc["cwd"] != want { + t.Errorf("cwd = %q, want %q", doc["cwd"], want) + } + } + + // Raw (non-JSON) content takes the same route. + if got := string(m.NormalizeContent([]byte(filepath.Join(link, "app") + " "))); got != "${HOME}"+string(filepath.Separator)+"app " { + t.Errorf("NormalizeContent via symlink = %q", got) + } +} + +func TestSymlinkedHomeResolvesToRealPath(t *testing.T) { + link, realPath := mustSymlinkedHome(t) + m := mustMapper(t, link, nil) + + got := m.ResolveFile("projects/-x/sess.jsonl", []byte(`{"cwd":"${HOME}/app"}`)) + var doc map[string]string + if err := json.Unmarshal(got, &doc); err != nil { + t.Fatalf("resolved line invalid: %v (%s)", err, got) + } + want := filepath.Join(realPath, "app") + if doc["cwd"] != want { + t.Errorf("cwd = %q, want realPath path %q", doc["cwd"], want) + } + if strings.HasPrefix(doc["cwd"], link) { + t.Errorf("cwd kept the symlink prefix: %q", doc["cwd"]) + } +} + +func TestSymlinkedHomeRelPathBothEncodings(t *testing.T) { + link, realPath := mustSymlinkedHome(t) + m := mustMapper(t, link, nil) + + viaLink := "projects/" + EncodeClaudePath(link) + "-app/sess.jsonl" + viaReal := "projects/" + EncodeClaudePath(realPath) + "-app/sess.jsonl" + want := "projects/${HOME}-app/sess.jsonl" + + for _, in := range []string{viaLink, viaReal} { + if got := m.NormalizeRelPath(in); got != want { + t.Errorf("NormalizeRelPath(%q) = %q, want %q", in, got, want) + } + } + + // Pull writes under the realPath path, where `claude --resume` looks. + got, ok := m.ResolveRelPath(want) + if !ok { + t.Fatalf("ResolveRelPath(%q) unresolvable", want) + } + if got != viaReal { + t.Errorf("ResolveRelPath(%q) = %q, want %q", want, got, viaReal) + } +} + +func TestSymlinkedPathMapEntry(t *testing.T) { + link, realPath := mustSymlinkedHome(t) + m := mustMapper(t, filepath.Join(t.TempDir(), "home"), map[string]string{link: "WORK"}) + + in := "projects/" + EncodeClaudePath(link) + "-api/sess.jsonl" + want := "projects/${WORK}-api/sess.jsonl" + if got := m.NormalizeRelPath(in); got != want { + t.Errorf("NormalizeRelPath(%q) = %q, want %q", in, got, want) + } + + got, ok := m.ResolveRelPath(want) + if !ok { + t.Fatalf("ResolveRelPath(%q) unresolvable", want) + } + if wantReal := "projects/" + EncodeClaudePath(realPath) + "-api/sess.jsonl"; got != wantReal { + t.Errorf("ResolveRelPath(%q) = %q, want %q", want, got, wantReal) + } +} + +func TestResolveSymlinksDisabledKeepsLiteralPath(t *testing.T) { + link, realPath := mustSymlinkedHome(t) + + m, err := NewPathMapper(link, nil, false) + if err != nil { + t.Fatalf("NewPathMapper: %v", err) + } + + // With resolution off the symlink spelling is the token; the real path is + // a foreign directory and stays untouched. + viaLink := "projects/" + EncodeClaudePath(link) + "-app/sess.jsonl" + if got := m.NormalizeRelPath(viaLink); got != "projects/${HOME}-app/sess.jsonl" { + t.Errorf("NormalizeRelPath(%q) = %q", viaLink, got) + } + viaReal := "projects/" + EncodeClaudePath(realPath) + "-app/sess.jsonl" + if got := m.NormalizeRelPath(viaReal); got != viaReal { + t.Errorf("NormalizeRelPath(%q) = %q, want unchanged", viaReal, got) + } + + got, ok := m.ResolveRelPath("projects/${HOME}-app/sess.jsonl") + if !ok || got != viaLink { + t.Errorf("ResolveRelPath = (%q, %v), want (%q, true)", got, ok, viaLink) + } +} + +func TestMissingDirectoryKeepsLiteralPath(t *testing.T) { + // A prefix that does not exist on this device must still map, unchanged. + absent := filepath.Join(t.TempDir(), "no-such-dir") + m := mustMapper(t, absent, nil) + + in := "projects/" + EncodeClaudePath(absent) + "-app/sess.jsonl" + if got := m.NormalizeRelPath(in); got != "projects/${HOME}-app/sess.jsonl" { + t.Errorf("NormalizeRelPath(%q) = %q", in, got) + } + got, ok := m.ResolveRelPath("projects/${HOME}-app/sess.jsonl") + if !ok || got != in { + t.Errorf("ResolveRelPath = (%q, %v), want (%q, true)", got, ok, in) + } +} + +func mustJSONString(t *testing.T, s string) string { + t.Helper() + b, err := json.Marshal(s) + if err != nil { + t.Fatal(err) + } + return string(b) +} + func TestPathMapperValidation(t *testing.T) { - if _, err := NewPathMapper("/Users/a", map[string]string{"/x": "home"}); err == nil { + if _, err := NewPathMapper("/Users/a", map[string]string{"/x": "home"}, true); err == nil { t.Error("expected reserved-name error for HOME (case-insensitive)") } - if _, err := NewPathMapper("/Users/a", map[string]string{"/x": "my-work"}); err == nil { + if _, err := NewPathMapper("/Users/a", map[string]string{"/x": "my-work"}, true); err == nil { t.Error("expected invalid token name error") } - if _, err := NewPathMapper("/Users/a", map[string]string{"/x": "WORK_2"}); err != nil { + if _, err := NewPathMapper("/Users/a", map[string]string{"/x": "WORK_2"}, true); err != nil { t.Errorf("valid token rejected: %v", err) } } @@ -163,6 +391,10 @@ func TestIsPortableContentPath(t *testing.T) { "projects/-Users-a-x/img.png": false, "settings.json": false, "agents/foo.md": false, + "plugins/known_marketplaces.json": true, + "plugins/installed_plugins.json": true, + "plugins/cache/foo/plugin.json": false, + "plugins/marketplaces/foo/.git/config": false, } for in, want := range cases { if got := IsPortableContentPath(in); got != want { @@ -297,3 +529,167 @@ func TestMigratePaths(t *testing.T) { t.Errorf("second migrate should migrate nothing, got %v", result2.Migrated) } } + +const marketplacesPath = "plugins/known_marketplaces.json" + +func TestPluginStateJSONRoundTrip(t *testing.T) { + mac := mustMapper(t, "/Users/alice", nil) + local := []byte(`{"m":{"installLocation":"/Users/alice/.claude/plugins/marketplaces/x"}}`) + + normalized := mac.NormalizeFile(marketplacesPath, local) + if !bytes.Contains(normalized, []byte("${HOME}/.claude/plugins/marketplaces/x")) { + t.Fatalf("normalize did not tokenize home: %s", normalized) + } + + back := mac.ResolveFile(marketplacesPath, normalized) + var got map[string]map[string]string + if err := json.Unmarshal(back, &got); err != nil { + t.Fatalf("resolved content is not valid JSON: %v (%s)", err, back) + } + want := "/Users/alice/.claude/plugins/marketplaces/x" + if got["m"]["installLocation"] != want { + t.Errorf("round trip = %q, want %q", got["m"]["installLocation"], want) + } +} + +func TestNormalizeJSONPreservesEscaping(t *testing.T) { + win := mustMapper(t, `C:\Users\bob`, nil) + local, err := json.Marshal(map[string]string{ + "installPath": `C:\Users\bob\.claude\plugins\cache\p`, + }) + if err != nil { + t.Fatal(err) + } + + normalized := win.NormalizeFile(marketplacesPath, local) + + var got map[string]string + if err := json.Unmarshal(normalized, &got); err != nil { + t.Fatalf("normalized content is not valid JSON: %v (%s)", err, normalized) + } + // The token form is separator-neutral; resolve applies the target's. + if want := "${HOME}/.claude/plugins/cache/p"; got["installPath"] != want { + t.Errorf("normalized = %q, want %q", got["installPath"], want) + } +} + +func TestPluginStateWindowsToUnix(t *testing.T) { + win := mustMapper(t, `C:\Users\bob`, nil) + mac := mustMapper(t, "/Users/alice", nil) + + local, err := json.Marshal(map[string]string{ + "installLocation": `C:\Users\bob\.claude\plugins\marketplaces\x`, + }) + if err != nil { + t.Fatal(err) + } + + onMac := mac.ResolveFile(marketplacesPath, win.NormalizeFile(marketplacesPath, local)) + + var got map[string]string + if err := json.Unmarshal(onMac, &got); err != nil { + t.Fatalf("not valid JSON: %v (%s)", err, onMac) + } + if want := "/Users/alice/.claude/plugins/marketplaces/x"; got["installLocation"] != want { + t.Errorf("windows -> unix = %q, want %q", got["installLocation"], want) + } + if strings.Contains(got["installLocation"], `\`) { + t.Errorf("result kept windows separators: %q", got["installLocation"]) + } +} + +func TestNormalizeJSONLeavesUnrelatedBytes(t *testing.T) { + m := mustMapper(t, "/Users/alice", nil) + in := []byte(`{"url":"https://x.test/r?a=1&b=2","note":"a < b && c","port":49152}`) + + normalized := m.NormalizeFile(marketplacesPath, in) + + // HTML metacharacters and integers must survive verbatim: only path + // prefixes are translated, and nothing here begins with a mapped path. + for _, want := range []string{`"https://x.test/r?a=1&b=2"`, `"a < b && c"`, `"port": 49152`} { + if !bytes.Contains(normalized, []byte(want)) { + t.Errorf("normalize altered unrelated content, missing %s:\n%s", want, normalized) + } + } +} + +func TestJSONPathBoundaries(t *testing.T) { + m := mustMapper(t, "/Users/merv", nil) + + // a longer username must not match the shorter mapped prefix + in, err := json.Marshal(map[string]string{"p": "/Users/mervynlally/x"}) + if err != nil { + t.Fatal(err) + } + var got map[string]string + if err := json.Unmarshal(m.NormalizeFile(marketplacesPath, in), &got); err != nil { + t.Fatal(err) + } + if got["p"] != "/Users/mervynlally/x" { + t.Errorf("normalized = %q, want unchanged", got["p"]) + } + + // the mapped path itself, with no remainder, still tokenizes + in, err = json.Marshal(map[string]string{"p": "/Users/merv"}) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(m.NormalizeFile(marketplacesPath, in), &got); err != nil { + t.Fatal(err) + } + if got["p"] != "${HOME}" { + t.Errorf("normalized = %q, want ${HOME}", got["p"]) + } +} + +func TestSplitConflictPath(t *testing.T) { + cases := []struct { + in string + base string + timestamp string + ok bool + }{ + {"plugins/known_marketplaces.json", "plugins/known_marketplaces.json", "", false}, + {"a/sess.jsonl.conflict.20260610-120000", "a/sess.jsonl", "20260610-120000", true}, + // a conflict copy of a conflict copy still reports the original file + {"a/x.json.conflict.A.conflict.B", "a/x.json", "A.conflict.B", true}, + // a parent directory containing the marker is not a conflict copy + {"a.conflict.b/sess.jsonl", "a.conflict.b/sess.jsonl", "", false}, + {`a.conflict.b\sess.jsonl`, `a.conflict.b\sess.jsonl`, "", false}, + } + for _, c := range cases { + base, ts, ok := SplitConflictPath(c.in) + if base != c.base || ts != c.timestamp || ok != c.ok { + t.Errorf("SplitConflictPath(%q) = (%q, %q, %v), want (%q, %q, %v)", + c.in, base, ts, ok, c.base, c.timestamp, c.ok) + } + } +} + +func TestConflictPathRoundTrip(t *testing.T) { + at := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + p := ConflictPath("plugins/known_marketplaces.json", at) + + base, ts, ok := SplitConflictPath(p) + if !ok { + t.Fatalf("SplitConflictPath(%q) reported no conflict", p) + } + if base != "plugins/known_marketplaces.json" || ts != "20260610-120000" { + t.Errorf("round trip = (%q, %q)", base, ts) + } + + // the copy is translated as JSON, like the file it came from: raw byte + // replacement would leave the escaped Windows path invalid. + win := mustMapper(t, `C:\Users\bob`, nil) + local, err := json.Marshal(map[string]string{"installLocation": `C:\Users\bob\x`}) + if err != nil { + t.Fatal(err) + } + var got map[string]string + if err := json.Unmarshal(win.NormalizeFile(p, local), &got); err != nil { + t.Fatalf("conflict copy not valid JSON: %v", err) + } + if got["installLocation"] != "${HOME}/x" { + t.Errorf("conflict copy not translated: %q", got["installLocation"]) + } +} diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 0a45bec..749499c 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -104,7 +104,7 @@ func NewSyncer(cfg *config.Config, quiet bool) (*Syncer, error) { } homeDir, _ := os.UserHomeDir() - mapper, err := NewPathMapper(homeDir, cfg.PathMap) + mapper, err := NewPathMapper(homeDir, cfg.PathMap, cfg.ResolveSymlinksEnabled()) if err != nil { return nil, err } @@ -123,7 +123,7 @@ func NewSyncer(cfg *config.Config, quiet bool) (*Syncer, error) { // NewSyncerWith creates a Syncer with pre-built dependencies (for testing). func NewSyncerWith(cfg *config.Config, store storage.Storage, enc *crypto.Encryptor, state *SyncState, claudeDir string, quiet bool) *Syncer { homeDir, _ := os.UserHomeDir() - mapper, _ := NewPathMapper(homeDir, cfg.PathMap) + mapper, _ := NewPathMapper(homeDir, cfg.PathMap, cfg.ResolveSymlinksEnabled()) return &Syncer{ storage: store, encryptor: enc, @@ -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.NormalizeFile(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.ResolveFile(relativePath, data) } // Guard against path traversal from crafted remote keys @@ -524,11 +524,34 @@ func (s *Syncer) downloadFile(ctx context.Context, relativePath, remoteKey strin return nil } +// conflictMarker separates a path from the timestamp of the remote copy saved +// beside it when both sides changed. +const conflictMarker = ".conflict." + +// ConflictPath names the copy that holds the remote version of relPath. +func ConflictPath(relPath string, at time.Time) string { + return relPath + conflictMarker + at.Format("20060102-150405") +} + +// SplitConflictPath returns the path a conflict copy was made from and its +// timestamp. ok is false when p is not a conflict copy. The marker is only +// honored in the final segment, so a parent directory that happens to contain +// ".conflict." never triggers a false match. +func SplitConflictPath(p string) (base, timestamp string, ok bool) { + seg := strings.LastIndexAny(p, `/\`) + 1 + i := strings.Index(p[seg:], conflictMarker) + if i < 0 { + return p, "", false + } + i += seg + return p[:i], p[i+len(conflictMarker):], true +} + func (s *Syncer) handleConflict(ctx context.Context, relativePath string, remoteObj storage.ObjectInfo) error { s.log("Conflict detected: %s (keeping local, saving remote as .conflict)", relativePath) // Download remote version with conflict suffix - conflictPath := relativePath + ".conflict." + time.Now().Format("20060102-150405") + conflictPath := ConflictPath(relativePath, time.Now()) if err := s.downloadFile(ctx, conflictPath, remoteObj.Key, nil); err != nil { return fmt.Errorf("failed to save conflict file: %w", err) }