From 03d358487fa77d25bd8357b15e07d4a76c11a1ad Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Wed, 3 Jun 2026 15:07:49 +0100 Subject: [PATCH] fix(cli): hash external project IDs bare (fix `-n ` 404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-project search/def/refs/symbols/files/summary against an EXTERNAL project (GitHub repo attached server-side) returned "API error (404): project not found: hash=..." for every `-n github.com/owner/repo@branch` lookup. Root cause: client.encodeProjectPath unconditionally wrapped the identity key as "local:{machineID}:{path}". The server only namespaces LOCAL projects (projects.Create wraps the key when MachineID != ""); external projects are stored with path_hash = hashPath(bare host_path). So the CLI computed sha1("local:{machine}:github.com/...@main") while the server had sha1("github.com/...@main") — guaranteed mismatch → 404. Workspace search was unaffected because it resolves projects server-side by ID, not via the client hash. Fix: namespace only ABSOLUTE filesystem paths (filepath.IsAbs), which is exactly when the server sets MachineID; hash non-absolute identifiers (external IDs) bare. Verified the CLI now emits the same hash the server stores (ed5aabb72e927702 for github.com/MythicalGames/pf3-backend@main). Adds encode_test.go pinning external-bare vs local-namespaced against the server's documented formula. Co-Authored-By: Claude Opus 4.8 --- cli/internal/client/client.go | 26 ++++++++++++++---- cli/internal/client/encode_test.go | 44 ++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 cli/internal/client/encode_test.go diff --git a/cli/internal/client/client.go b/cli/internal/client/client.go index e59039c..9bd9b1b 100644 --- a/cli/internal/client/client.go +++ b/cli/internal/client/client.go @@ -91,12 +91,28 @@ func (c *Client) do(method, path string, body interface{}) (*http.Response, erro } // encodeProjectPath returns the project's URL hash (first 16 hex chars of -// SHA1 of the identity key). Local projects are namespaced per machine — -// "local:{machine_id}:{path}" — so the same filesystem path on different -// machines/users maps to different projects. MUST stay byte-identical to the -// server's projects.LocalProjectKey + hashPath (server/internal/projects). +// SHA1 of the identity key). MUST stay byte-identical to the server's +// projects.Create key derivation (server/internal/projects/projects.go). +// +// The server namespaces ONLY local projects, and only those: it wraps the +// identity key as "local:{machine_id}:{path}" when MachineID != "" (set during +// `cix init` for a real filesystem project), and uses the path as-is otherwise. +// External projects (GitHub repos attached via the dashboard) have no MachineID +// and a globally-unique host_path like "github.com/owner/repo@branch", which is +// hashed bare. +// +// We mirror that split with filepath.IsAbs: local projects are always addressed +// by an ABSOLUTE filesystem path (the CLI runs filepath.Abs on cwd / -p before +// it gets here), so an absolute input is local → namespace it. A non-absolute +// input is an external project identifier (e.g. from `cix search -n +// github.com/owner/repo@branch`) → hash it bare so the hash matches the row the +// server stored. Wrapping external IDs in "local:" was a regression that made +// every `-n ` lookup 404. func encodeProjectPath(path string) string { - key := "local:" + machineID() + ":" + path + key := path + if filepath.IsAbs(path) { + key = "local:" + machineID() + ":" + path + } h := sha1.Sum([]byte(key)) return fmt.Sprintf("%x", h)[:16] } diff --git a/cli/internal/client/encode_test.go b/cli/internal/client/encode_test.go new file mode 100644 index 0000000..86e96c3 --- /dev/null +++ b/cli/internal/client/encode_test.go @@ -0,0 +1,44 @@ +package client + +import ( + "crypto/sha1" + "fmt" + "testing" +) + +// sha1Hex16 mirrors the server's projects.hashPath: first 16 hex chars of the +// SHA1 of the identity key. Kept local to the test so a drift in encodeProjectPath +// is caught against the server's documented formula, not against itself. +func sha1Hex16(s string) string { + h := sha1.Sum([]byte(s)) + return fmt.Sprintf("%x", h)[:16] +} + +// TestEncodeProjectPath_ExternalIsBare is the regression test for the prod bug +// where `cix search -n github.com/owner/repo@branch` 404'd: encodeProjectPath +// unconditionally wrapped every identity in "local:{machineID}:", but the server +// hashes EXTERNAL projects (MachineID=="") from the bare host_path. An external +// identifier is never an absolute filesystem path, so it must hash bare. +func TestEncodeProjectPath_ExternalIsBare(t *testing.T) { + ext := "github.com/MythicalGames/pf3-backend@main" + want := sha1Hex16(ext) // server: hashPath(host_path), no local: wrapper + if got := encodeProjectPath(ext); got != want { + t.Errorf("encodeProjectPath(%q) = %q, want %q (external must hash bare)", ext, got, want) + } +} + +// TestEncodeProjectPath_LocalIsNamespaced confirms the local path keeps the +// per-machine "local:{machineID}:{path}" namespacing the server applies when +// MachineID != "" (absolute filesystem paths only). +func TestEncodeProjectPath_LocalIsNamespaced(t *testing.T) { + abs := "/Users/dev/go/src/example" + want := sha1Hex16("local:" + machineID() + ":" + abs) + if got := encodeProjectPath(abs); got != want { + t.Errorf("encodeProjectPath(%q) = %q, want %q (local must be namespaced)", abs, got, want) + } + // And the two regimes must not collide: the same trailing string hashed + // bare vs namespaced differs. + if encodeProjectPath(abs) == sha1Hex16(abs) { + t.Errorf("local path hashed bare — namespacing not applied") + } +}