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/sync/paths.go b/internal/sync/paths.go index eb21176..c978dbb 100644 --- a/internal/sync/paths.go +++ b/internal/sync/paths.go @@ -2,6 +2,7 @@ package sync import ( "bytes" + "encoding/json" "fmt" "path" "regexp" @@ -38,6 +39,10 @@ type pathMapping struct { encLocal string // localPath in Claude Code's directory encoding normRe *regexp.Regexp normRepl []byte // replacement template: token ($-escaped) + boundary group + // 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 } var pathTokenNameRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) @@ -65,7 +70,8 @@ func NewPathMapper(homeDir string, userMap map[string]string) (*PathMapper, erro normRe: re, // "$$" = literal "$" in a regexp replacement template; without it // "${HOME}" would itself be read as a group reference - normRepl: []byte("$${" + name + "}${1}"), + normRepl: []byte("$${" + name + "}${1}"), + resolveRe: regexp.MustCompile(regexp.QuoteMeta(pathToken(name)) + `([/\\][^"\s]*)?`), }) return nil } @@ -182,25 +188,60 @@ func (m *PathMapper) NormalizeContent(data []byte) []byte { 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 +253,144 @@ 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 { + from, to := mp.localPath, pathToken(mp.name) + if resolve { + from, to = to, from + } + data = mapJSONPaths(data, from, to, 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..70bea25 100644 --- a/internal/sync/paths_test.go +++ b/internal/sync/paths_test.go @@ -1,10 +1,14 @@ 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 { @@ -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) @@ -163,6 +233,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 +371,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..b9e726f 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.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) }