From 45ae9c5967cc34d45d083185bcba72d804526a53 Mon Sep 17 00:00:00 2001 From: "Mario P." Date: Fri, 31 Jul 2026 05:31:28 +0100 Subject: [PATCH 1/4] fix: translate absolute paths in plugin state files known_marketplaces.json and installed_plugins.json record absolute install locations, but content translation only covered history.jsonl and projects/, so a pull left them pointing at the pushing device and Claude Code rejected the marketplace. Both go through the path mapper now, decoded as JSON rather than replaced as raw bytes: a Windows path is escaped in the file, so byte replacement would eat an escape and leave the document invalid. --- cmd/claude-sync/main.go | 18 ++--- internal/sync/paths.go | 123 +++++++++++++++++++++++++++++-- internal/sync/paths_test.go | 141 ++++++++++++++++++++++++++++++++++++ internal/sync/sync.go | 29 +++++++- 4 files changed, 291 insertions(+), 20 deletions(-) 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..234958b 100644 --- a/internal/sync/paths.go +++ b/internal/sync/paths.go @@ -2,6 +2,7 @@ package sync import ( "bytes" + "encoding/json" "fmt" "path" "regexp" @@ -193,14 +194,126 @@ func (m *PathMapper) ResolveContent(data []byte) []byte { return data } +// portableStatePaths are files outside projects/ that embed absolute paths. +var portableStatePaths = map[string]bool{ + "history.jsonl": true, + "plugins/known_marketplaces.json": true, + "plugins/installed_plugins.json": true, +} + +// basePath is the path translation rules apply 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 this path is translated as JSON rather +// than raw bytes. A Windows path is escaped in the file ("C:\\Users\\bob"), so +// byte replacement would eat an escape and leave the document invalid. +func IsPortableJSONPath(relPath string) bool { + relPath = basePath(relPath) + return portableStatePaths[relPath] && path.Ext(relPath) == ".json" +} + +func isPathSep(c byte) bool { return c == '/' || c == '\\' } + +// 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 '/' +} + +func replaceSeps(p string, sep byte) string { + return strings.Map(func(r rune) rune { + if r == '/' || r == '\\' { + return rune(sep) + } + return r + }, p) +} + +// mapJSONStrings applies fn to every string value, leaving escaping to the +// encoder. Content that is not valid JSON is returned unchanged. +func mapJSONStrings(data []byte, fn func(string) string) []byte { + var doc any + if err := json.Unmarshal(data, &doc); err != nil { + return data + } + + var walk func(any) any + walk = func(v any) any { + switch t := v.(type) { + case string: + return fn(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 + } + + out, err := json.MarshalIndent(walk(doc), "", " ") + if err != nil { + return data + } + return append(out, '\n') +} + +// mapJSONPaths rewrites every JSON string value that starts with a mapping's +// from prefix, swapping it for to and re-separating the remainder. +func (m *PathMapper) mapJSONPaths(data []byte, from, to func(pathMapping) string) []byte { + if m == nil { + return data + } + return mapJSONStrings(data, func(s string) string { + for _, mp := range m.mappings { + prefix := from(mp) + if !strings.HasPrefix(s, prefix) { + continue + } + rest := s[len(prefix):] + if rest != "" && !isPathSep(rest[0]) { + continue + } + return to(mp) + replaceSeps(rest, pathSep(to(mp))) + } + return s + }) +} + +// NormalizeJSONContent is NormalizeContent for JSON string values. +func (m *PathMapper) NormalizeJSONContent(data []byte) []byte { + return m.mapJSONPaths(data, + func(mp pathMapping) string { return mp.localPath }, + func(mp pathMapping) string { return pathToken(mp.name) }) +} + +// ResolveJSONContent is ResolveContent for JSON string values. +func (m *PathMapper) ResolveJSONContent(data []byte) []byte { + return m.mapJSONPaths(data, + func(mp pathMapping) string { return pathToken(mp.name) }, + func(mp pathMapping) string { return mp.localPath }) +} + // 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/") { diff --git a/internal/sync/paths_test.go b/internal/sync/paths_test.go index 125dd96..07b3f72 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 { @@ -163,6 +167,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 +305,136 @@ func TestMigratePaths(t *testing.T) { t.Errorf("second migrate should migrate nothing, got %v", result2.Migrated) } } + +func TestPluginStateJSONRoundTrip(t *testing.T) { + mac := mustMapper(t, "/Users/alice", nil) + local := []byte(`{"m":{"installLocation":"/Users/alice/.claude/plugins/marketplaces/x"}}`) + + normalized := mac.NormalizeJSONContent(local) + if !bytes.Contains(normalized, []byte("${HOME}/.claude/plugins/marketplaces/x")) { + t.Fatalf("normalize did not tokenize home: %s", normalized) + } + + back := mac.ResolveJSONContent(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.NormalizeJSONContent(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.ResolveJSONContent(win.NormalizeJSONContent(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 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.NormalizeJSONContent(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.NormalizeJSONContent(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}, + } + 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 like the file it came from + if !IsPortableJSONPath(p) { + t.Errorf("IsPortableJSONPath(%q) = false, want true", p) + } +} diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 0a45bec..c65cd0d 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -431,7 +431,9 @@ func (s *Syncer) uploadFile(ctx context.Context, relativePath string) error { } // Replace machine-specific paths with portable tokens in session content - if IsPortableContentPath(relativePath) { + if IsPortableJSONPath(relativePath) { + data = s.paths.NormalizeJSONContent(data) + } else if IsPortableContentPath(relativePath) { data = s.paths.NormalizeContent(data) } @@ -486,7 +488,9 @@ func (s *Syncer) downloadFile(ctx context.Context, relativePath, remoteKey strin } // Replace portable tokens with this device's paths in session content - if IsPortableContentPath(relativePath) { + if IsPortableJSONPath(relativePath) { + data = s.paths.ResolveJSONContent(data) + } else if IsPortableContentPath(relativePath) { data = s.paths.ResolveContent(data) } @@ -524,11 +528,30 @@ 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. +func SplitConflictPath(p string) (base, timestamp string, ok bool) { + i := strings.Index(p, conflictMarker) + if i < 0 { + return p, "", false + } + 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) } From 4d89e57c1c8dd8b8331655cd97803f6a6ca3dac7 Mon Sep 17 00:00:00 2001 From: "Mario P." Date: Fri, 31 Jul 2026 06:17:42 +0100 Subject: [PATCH 2/4] refactor(sync): unify path translation dispatch behind NormalizeFile/ResolveFile Fold the parallel JSON path API (NormalizeJSONContent/ResolveJSONContent, IsPortableJSONPath, mapJSONStrings) into NormalizeFile/ResolveFile, which pick JSON-aware vs byte translation internally. Call sites drop the two-way branch and the ordering dependency between the two path predicates. Also scope SplitConflictPath's marker match to the final path segment so a parent directory containing ".conflict." no longer triggers a false match or a wrong original path. --- internal/sync/paths.go | 140 +++++++++++++++++++----------------- internal/sync/paths_test.go | 34 ++++++--- internal/sync/sync.go | 20 +++--- 3 files changed, 109 insertions(+), 85 deletions(-) diff --git a/internal/sync/paths.go b/internal/sync/paths.go index 234958b..cb78cf8 100644 --- a/internal/sync/paths.go +++ b/internal/sync/paths.go @@ -195,13 +195,16 @@ func (m *PathMapper) ResolveContent(data []byte) []byte { } // 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 translation rules apply to: a conflict copy follows +// 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 { @@ -209,15 +212,61 @@ func basePath(relPath string) string { return path.Clean(base) } -// IsPortableJSONPath reports whether this path is translated as JSON rather -// than raw bytes. A Windows path is escaped in the file ("C:\\Users\\bob"), so -// byte replacement would eat an escape and leave the document invalid. -func IsPortableJSONPath(relPath string) bool { - relPath = basePath(relPath) +// isPortableJSONPath reports whether relPath is translated as JSON rather than +// raw bytes. +func isPortableJSONPath(relPath string) bool { return portableStatePaths[relPath] && path.Ext(relPath) == ".json" } -func isPathSep(c byte) bool { return c == '/' || c == '\\' } +// IsPortableContentPath reports whether content path translation applies to +// 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 { + relPath = basePath(relPath) + if portableStatePaths[relPath] { + return true + } + if !strings.HasPrefix(relPath, "projects/") { + return false + } + switch path.Ext(relPath) { + case ".jsonl", ".json", ".md", ".txt": + return true + } + return false +} + +// NormalizeFile replaces this device's mapped path prefixes with portable +// tokens in the content of relPath, using JSON-aware translation for the +// plugin state files and boundary-aware byte replacement otherwise. +func (m *PathMapper) NormalizeFile(relPath string, data []byte) []byte { + if m == nil { + return data + } + if isPortableJSONPath(basePath(relPath)) { + for _, mp := range m.mappings { + data = mapJSONPaths(data, mp.localPath, pathToken(mp.name)) + } + return data + } + return m.NormalizeContent(data) +} + +// 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 { + if m == nil { + return data + } + if isPortableJSONPath(basePath(relPath)) { + for _, mp := range m.mappings { + data = mapJSONPaths(data, pathToken(mp.name), mp.localPath) + } + return data + } + return m.ResolveContent(data) +} // pathSep reports the separator p is written with, so a translated path keeps // the target's convention rather than the running platform's. @@ -228,6 +277,7 @@ func pathSep(p string) byte { 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 == '\\' { @@ -237,19 +287,32 @@ func replaceSeps(p string, sep byte) string { }, p) } -// mapJSONStrings applies fn to every string value, leaving escaping to the -// encoder. Content that is not valid JSON is returned unchanged. -func mapJSONStrings(data []byte, fn func(string) string) []byte { +// 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. +func mapJSONPaths(data []byte, from, to string) []byte { var doc any if err := json.Unmarshal(data, &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 fn(t) + return mapPath(t) case []any: for i, e := range t { t[i] = walk(e) @@ -270,58 +333,3 @@ func mapJSONStrings(data []byte, fn func(string) string) []byte { } return append(out, '\n') } - -// mapJSONPaths rewrites every JSON string value that starts with a mapping's -// from prefix, swapping it for to and re-separating the remainder. -func (m *PathMapper) mapJSONPaths(data []byte, from, to func(pathMapping) string) []byte { - if m == nil { - return data - } - return mapJSONStrings(data, func(s string) string { - for _, mp := range m.mappings { - prefix := from(mp) - if !strings.HasPrefix(s, prefix) { - continue - } - rest := s[len(prefix):] - if rest != "" && !isPathSep(rest[0]) { - continue - } - return to(mp) + replaceSeps(rest, pathSep(to(mp))) - } - return s - }) -} - -// NormalizeJSONContent is NormalizeContent for JSON string values. -func (m *PathMapper) NormalizeJSONContent(data []byte) []byte { - return m.mapJSONPaths(data, - func(mp pathMapping) string { return mp.localPath }, - func(mp pathMapping) string { return pathToken(mp.name) }) -} - -// ResolveJSONContent is ResolveContent for JSON string values. -func (m *PathMapper) ResolveJSONContent(data []byte) []byte { - return m.mapJSONPaths(data, - func(mp pathMapping) string { return pathToken(mp.name) }, - func(mp pathMapping) string { return mp.localPath }) -} - -// IsPortableContentPath reports whether content path translation applies to -// 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 { - relPath = basePath(relPath) - if portableStatePaths[relPath] { - return true - } - if !strings.HasPrefix(relPath, "projects/") { - return false - } - switch path.Ext(relPath) { - case ".jsonl", ".json", ".md", ".txt": - return true - } - return false -} diff --git a/internal/sync/paths_test.go b/internal/sync/paths_test.go index 07b3f72..2c31804 100644 --- a/internal/sync/paths_test.go +++ b/internal/sync/paths_test.go @@ -306,16 +306,18 @@ func TestMigratePaths(t *testing.T) { } } +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.NormalizeJSONContent(local) + 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.ResolveJSONContent(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) @@ -335,7 +337,7 @@ func TestNormalizeJSONPreservesEscaping(t *testing.T) { t.Fatal(err) } - normalized := win.NormalizeJSONContent(local) + normalized := win.NormalizeFile(marketplacesPath, local) var got map[string]string if err := json.Unmarshal(normalized, &got); err != nil { @@ -358,7 +360,7 @@ func TestPluginStateWindowsToUnix(t *testing.T) { t.Fatal(err) } - onMac := mac.ResolveJSONContent(win.NormalizeJSONContent(local)) + onMac := mac.ResolveFile(marketplacesPath, win.NormalizeFile(marketplacesPath, local)) var got map[string]string if err := json.Unmarshal(onMac, &got); err != nil { @@ -381,7 +383,7 @@ func TestJSONPathBoundaries(t *testing.T) { t.Fatal(err) } var got map[string]string - if err := json.Unmarshal(m.NormalizeJSONContent(in), &got); err != nil { + if err := json.Unmarshal(m.NormalizeFile(marketplacesPath, in), &got); err != nil { t.Fatal(err) } if got["p"] != "/Users/mervynlally/x" { @@ -393,7 +395,7 @@ func TestJSONPathBoundaries(t *testing.T) { if err != nil { t.Fatal(err) } - if err := json.Unmarshal(m.NormalizeJSONContent(in), &got); err != nil { + if err := json.Unmarshal(m.NormalizeFile(marketplacesPath, in), &got); err != nil { t.Fatal(err) } if got["p"] != "${HOME}" { @@ -412,6 +414,9 @@ func TestSplitConflictPath(t *testing.T) { {"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) @@ -433,8 +438,19 @@ func TestConflictPathRoundTrip(t *testing.T) { if base != "plugins/known_marketplaces.json" || ts != "20260610-120000" { t.Errorf("round trip = (%q, %q)", base, ts) } - // the copy is translated like the file it came from - if !IsPortableJSONPath(p) { - t.Errorf("IsPortableJSONPath(%q) = false, want true", p) + + // 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 c65cd0d..b9e726f 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -431,10 +431,8 @@ func (s *Syncer) uploadFile(ctx context.Context, relativePath string) error { } // Replace machine-specific paths with portable tokens in session content - if IsPortableJSONPath(relativePath) { - data = s.paths.NormalizeJSONContent(data) - } else if IsPortableContentPath(relativePath) { - data = s.paths.NormalizeContent(data) + if IsPortableContentPath(relativePath) { + data = s.paths.NormalizeFile(relativePath, data) } // Compress @@ -488,10 +486,8 @@ func (s *Syncer) downloadFile(ctx context.Context, relativePath, remoteKey strin } // Replace portable tokens with this device's paths in session content - if IsPortableJSONPath(relativePath) { - data = s.paths.ResolveJSONContent(data) - } else if IsPortableContentPath(relativePath) { - data = s.paths.ResolveContent(data) + if IsPortableContentPath(relativePath) { + data = s.paths.ResolveFile(relativePath, data) } // Guard against path traversal from crafted remote keys @@ -538,12 +534,16 @@ func ConflictPath(relPath string, at time.Time) string { } // SplitConflictPath returns the path a conflict copy was made from and its -// timestamp. ok is false when p is not a conflict copy. +// 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) { - i := strings.Index(p, conflictMarker) + 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 } From 571b13d4f8e33539f29110cac73ed54fe2a98194 Mon Sep 17 00:00:00 2001 From: "Mario P." Date: Fri, 31 Jul 2026 06:24:54 +0100 Subject: [PATCH 3/4] fix(sync): preserve non-path bytes when translating plugin JSON The JSON round-trip re-encoded with default settings, which HTML-escaped &, < and > (mangling marketplace repo URLs) and widened integers via float64 decoding. Decode with UseNumber and encode with SetEscapeHTML(false) so only translated path prefixes change. --- internal/sync/paths.go | 13 +++++++++---- internal/sync/paths_test.go | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/internal/sync/paths.go b/internal/sync/paths.go index cb78cf8..72645e2 100644 --- a/internal/sync/paths.go +++ b/internal/sync/paths.go @@ -293,7 +293,9 @@ func replaceSeps(p string, sep byte) string { // path stays valid; content that is not valid JSON is returned unchanged. func mapJSONPaths(data []byte, from, to string) []byte { var doc any - if err := json.Unmarshal(data, &doc); err != nil { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + if err := dec.Decode(&doc); err != nil { return data } @@ -327,9 +329,12 @@ func mapJSONPaths(data []byte, from, to string) []byte { return v } - out, err := json.MarshalIndent(walk(doc), "", " ") - if err != nil { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(walk(doc)); err != nil { return data } - return append(out, '\n') + return buf.Bytes() } diff --git a/internal/sync/paths_test.go b/internal/sync/paths_test.go index 2c31804..1bd6329 100644 --- a/internal/sync/paths_test.go +++ b/internal/sync/paths_test.go @@ -374,6 +374,21 @@ func TestPluginStateWindowsToUnix(t *testing.T) { } } +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) From 1729df8b2b53495bec93bb520bfb829b722b9964 Mon Sep 17 00:00:00 2001 From: "Mario P." Date: Fri, 31 Jul 2026 08:21:13 +0100 Subject: [PATCH 4/4] fix(sync): JSON-escape and re-separate paths in .jsonl/.json content ResolveContent spliced local paths into session content with raw byte replacement, producing invalid JSON escapes and mixed separators on Windows (e.g. C:\Users\bob/foo). Route .jsonl (per line) and .json content through JSON-aware translation so inserted paths stay escaped and follow the local separator convention. --- internal/sync/paths.go | 108 +++++++++++++++++++++++++++--------- internal/sync/paths_test.go | 66 ++++++++++++++++++++++ 2 files changed, 148 insertions(+), 26 deletions(-) diff --git a/internal/sync/paths.go b/internal/sync/paths.go index 72645e2..c978dbb 100644 --- a/internal/sync/paths.go +++ b/internal/sync/paths.go @@ -39,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_]*$`) @@ -66,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 } @@ -183,13 +188,19 @@ 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 } @@ -212,10 +223,16 @@ func basePath(relPath string) string { return path.Clean(base) } -// isPortableJSONPath reports whether relPath is translated as JSON rather than -// raw bytes. +// isPortableJSONPath reports whether relPath is translated as a single JSON +// document rather than raw bytes. func isPortableJSONPath(relPath string) bool { - return portableStatePaths[relPath] && path.Ext(relPath) == ".json" + 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 @@ -238,34 +255,63 @@ func IsPortableContentPath(relPath string) bool { } // NormalizeFile replaces this device's mapped path prefixes with portable -// tokens in the content of relPath, using JSON-aware translation for the -// plugin state files and boundary-aware byte replacement otherwise. +// 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 { - if m == nil { - return data - } - if isPortableJSONPath(basePath(relPath)) { - for _, mp := range m.mappings { - data = mapJSONPaths(data, mp.localPath, pathToken(mp.name)) - } - return data - } - return m.NormalizeContent(data) + 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 } - if isPortableJSONPath(basePath(relPath)) { - for _, mp := range m.mappings { - data = mapJSONPaths(data, pathToken(mp.name), mp.localPath) + 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 } - return data + data = mapJSONPaths(data, from, to, indent) } - return m.ResolveContent(data) + 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 @@ -291,7 +337,9 @@ func replaceSeps(p string, sep byte) string { // 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. -func mapJSONPaths(data []byte, from, to string) []byte { +// 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() @@ -332,9 +380,17 @@ func mapJSONPaths(data []byte, from, to string) []byte { var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) - enc.SetIndent("", " ") + if indent { + enc.SetIndent("", " ") + } if err := enc.Encode(walk(doc)); err != nil { return data } - return buf.Bytes() + 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 1bd6329..70bea25 100644 --- a/internal/sync/paths_test.go +++ b/internal/sync/paths_test.go @@ -131,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)