Files
hanzo-dev 811ff08010 git: a project-scoped repo names its project in the path
The project sub-scope rode X-Project-Id alone, and a git client sends no
headers, so a repo outside the org's default scope had no remote a client could
reach: cloneURL emitted /v1/git/<org>/<name>.git for every repo, and
resolvePackRepo dropped the scope entirely for anonymous reads.

Smart-HTTP and SSH both take the scope as an optional middle segment —
/v1/git/:org/:project/:repo and git@host:org/project/repo.git — beside the
existing two-segment routes, which keep their exact meaning. cloneURL and sshURL
advertise whichever form matches the repo, so a caller is never told a URL that
does not work.

The path wins over the header when both are present, because the path is what a
client can express. An anonymous caller may use it: naming a project addresses a
repo rather than asserting a scope, and the repo's Public flag still decides the
read, whereas an unauthenticated X-Project-Id stays unvalidated input and is
ignored as before. The segment is checked against projectRE, since it becomes a
storage path segment.

This is what lets one Hanzo org hold repos from several GitHub owners:
hanzo/hanzo-apps/ai and hanzo/hanzo-docs/ai are distinct repos rather than two
upstreams fighting over hanzo/_/ai.git.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 20:25:10 -07:00

275 lines
9.6 KiB
Go

package git
import (
"crypto/ed25519"
"crypto/rand"
"encoding/json"
"encoding/pem"
"fmt"
"net"
"net/http"
"strings"
"testing"
"time"
"github.com/go-git/go-billy/v5/memfs"
gogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing/object"
gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
"github.com/go-git/go-git/v5/storage/memory"
xssh "golang.org/x/crypto/ssh"
)
// genClientKey returns a fresh ed25519 keypair as (privatePEM, authorizedKeyLine).
// The private PEM feeds the go-git SSH client; the authorized-key line is what a
// user registers via POST /v1/git/keys.
func genClientKey(t *testing.T) (privPEM []byte, authLine string) {
t.Helper()
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("gen key: %v", err)
}
block, err := xssh.MarshalPrivateKey(priv, "")
if err != nil {
t.Fatalf("marshal priv: %v", err)
}
pub, err := xssh.NewPublicKey(priv.Public())
if err != nil {
t.Fatalf("pub: %v", err)
}
return pem.EncodeToMemory(block), strings.TrimSpace(string(xssh.MarshalAuthorizedKey(pub)))
}
// sshClientAuth builds a go-git SSH auth method from a client private PEM,
// ignoring the host key (the in-process listener's host key is ephemeral).
func sshClientAuth(t *testing.T, privPEM []byte) *gitssh.PublicKeys {
t.Helper()
auth, err := gitssh.NewPublicKeys("git", privPEM, "")
if err != nil {
t.Fatalf("client auth: %v", err)
}
auth.HostKeyCallback = xssh.InsecureIgnoreHostKey()
return auth
}
// TestSSHKeyRegistrationAndAuth proves the fingerprint→org resolution used by
// the PublicKeyCallback: a registered key resolves to its org; an unregistered
// key fails closed; and a key belongs to exactly one org (no cross-org).
func TestSSHKeyRegistrationAndAuth(t *testing.T) {
app := mountApp(t)
_, authLine := genClientKey(t)
// Register the key for acme via the control plane.
code, body := do(t, app, http.MethodPost, "/v1/git/keys", "acme",
map[string]any{"title": "laptop", "publicKey": authLine})
if code != http.StatusCreated {
t.Fatalf("register key want 201, got %d (%s)", code, body)
}
var kv keyView
if err := json.Unmarshal(body, &kv); err != nil {
t.Fatalf("key view json: %v (%s)", err, body)
}
if kv.Fingerprint == "" || !strings.HasPrefix(kv.Fingerprint, "SHA256:") {
t.Fatalf("unexpected fingerprint: %q", kv.Fingerprint)
}
// The registered key resolves to acme via the auth callback path.
pub, _, _, _, err := xssh.ParseAuthorizedKey([]byte(authLine))
if err != nil {
t.Fatalf("parse authline: %v", err)
}
perms, err := mounted.Load().State.ssh.authPublicKey(fakeConnMeta{}, pub)
if err != nil {
t.Fatalf("registered key must authenticate, got: %v", err)
}
if perms.Extensions["git-org"] != "acme" {
t.Fatalf("resolved org = %q, want acme", perms.Extensions["git-org"])
}
// An UNREGISTERED key fails closed.
otherPub, _ := genClientKeyPub(t)
if _, err := mounted.Load().State.ssh.authPublicKey(fakeConnMeta{}, otherPub); err == nil {
t.Fatalf("unregistered key must be rejected")
}
// The SAME key cannot be re-registered under a DIFFERENT org (fingerprint is
// globally unique — a key belongs to exactly one org).
if code, _ := do(t, app, http.MethodPost, "/v1/git/keys", "beta",
map[string]any{"title": "steal", "publicKey": authLine}); code != http.StatusConflict {
t.Fatalf("cross-org re-register want 409, got %d", code)
}
// beta lists ZERO keys — never acme's.
code, body = do(t, app, http.MethodGet, "/v1/git/keys", "beta", nil)
if code != http.StatusOK {
t.Fatalf("beta list keys want 200, got %d", code)
}
var lst struct {
Data []keyView `json:"data"`
}
_ = json.Unmarshal(body, &lst)
if len(lst.Data) != 0 {
t.Fatalf("beta must see zero keys, got %+v", lst.Data)
}
// Delete the key, then it no longer authenticates (fail closed again).
if code, _ := do(t, app, http.MethodDelete, "/v1/git/keys/"+kv.ID, "acme", nil); code != http.StatusNoContent {
t.Fatalf("delete key want 204, got %d", code)
}
if _, err := mounted.Load().State.ssh.authPublicKey(fakeConnMeta{}, pub); err == nil {
t.Fatalf("deleted key must be rejected")
}
}
// TestSSHClonePushRoundTrip is the end-to-end SSH proof: register a client key,
// create a repo, push over SSH, then clone over SSH in a fresh client and SEE the
// pushed commit — all through the in-process SSH listener, driving the SAME pack
// code path smart-HTTP uses.
func TestSSHClonePushRoundTrip(t *testing.T) {
app := mountApp(t)
privPEM, authLine := genClientKey(t)
if code, body := do(t, app, http.MethodPost, "/v1/git/keys", "acme",
map[string]any{"title": "ci", "publicKey": authLine}); code != http.StatusCreated {
t.Fatalf("register key: %d %s", code, body)
}
if code, body := do(t, app, http.MethodPost, "/v1/git/repos", "acme",
map[string]any{"name": "code"}); code != http.StatusCreated {
t.Fatalf("create repo: %d %s", code, body)
}
sshURL := fmt.Sprintf("ssh://git@%s/acme/code.git", mounted.Load().State.ssh.addr())
auth := sshClientAuth(t, privPEM)
// Build a local repo, commit, and push over SSH.
fs := memfs.New()
local, err := gogit.Init(memory.NewStorage(), fs)
if err != nil {
t.Fatalf("init: %v", err)
}
wt, _ := local.Worktree()
f, _ := fs.Create("README.md")
_, _ = f.Write([]byte("# ssh native\n"))
_ = f.Close()
if _, err := wt.Add("README.md"); err != nil {
t.Fatalf("add: %v", err)
}
commit, err := wt.Commit("via ssh", &gogit.CommitOptions{
Author: &object.Signature{Name: "hanzo-dev", Email: "dev@hanzo.ai", When: time.Now()},
})
if err != nil {
t.Fatalf("commit: %v", err)
}
if _, err := local.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{sshURL}}); err != nil {
t.Fatalf("remote: %v", err)
}
if err := local.Push(&gogit.PushOptions{
RemoteName: "origin",
RefSpecs: []config.RefSpec{"refs/heads/master:refs/heads/main"},
Auth: auth,
}); err != nil {
t.Fatalf("ssh push: %v", err)
}
// Fresh clone over SSH — the pushed commit must be there.
cloned, err := gogit.Clone(memory.NewStorage(), memfs.New(), &gogit.CloneOptions{URL: sshURL, Auth: auth})
if err != nil {
t.Fatalf("ssh clone: %v", err)
}
head, err := cloned.Head()
if err != nil {
t.Fatalf("head: %v", err)
}
if head.Hash() != commit {
t.Fatalf("cloned HEAD %s != pushed %s", head.Hash(), commit)
}
}
// TestSSHCrossTenantRejected proves a key bound to acme cannot reach beta's
// namespace even when it crafts a beta path — the SSH org-scope guard.
func TestSSHCrossTenantRejected(t *testing.T) {
app := mountApp(t)
privPEM, authLine := genClientKey(t)
if code, _ := do(t, app, http.MethodPost, "/v1/git/keys", "acme",
map[string]any{"publicKey": authLine}); code != http.StatusCreated {
t.Fatal("register acme key failed")
}
// beta owns a repo the acme key must NOT reach.
if code, _ := do(t, app, http.MethodPost, "/v1/git/repos", "beta",
map[string]any{"name": "secret"}); code != http.StatusCreated {
t.Fatal("create beta repo failed")
}
sshURL := fmt.Sprintf("ssh://git@%s/beta/secret.git", mounted.Load().State.ssh.addr())
auth := sshClientAuth(t, privPEM)
// acme's key authenticates, but the beta path is outside its org → the exec
// handler denies and the clone fails.
_, err := gogit.Clone(memory.NewStorage(), memfs.New(), &gogit.CloneOptions{URL: sshURL, Auth: auth})
if err == nil {
t.Fatalf("acme key cloning beta repo must fail")
}
}
// TestSSHParseRepoPath covers the path parser's accept/reject cases directly.
// The middle segment is the optional project sub-scope, so an org-level path
// still yields an empty project and every segment stays traversal-safe.
func TestSSHParseRepoPath(t *testing.T) {
cases := []struct {
in, org, project, repo string
ok bool
}{
{"acme/code.git", "acme", "", "code", true},
{"/acme/code.git", "acme", "", "code", true},
{"acme/code", "acme", "", "code", true},
{"acme/site/code.git", "acme", "site", "code", true},
{"/acme/site/code.git", "acme", "site", "code", true},
{"hanzo/hanzo-apps/ai.git", "hanzo", "hanzo-apps", "ai", true},
{"../etc/passwd", "", "", "", false},
{"acme/../beta/x.git", "", "", "", false},
{"acme/./x.git", "", "", "", false},
{"acme", "", "", "", false},
{"a/b/c/d.git", "", "", "", false}, // four segments is not a repo path
}
for _, tc := range cases {
org, project, repo, err := parseRepoPath(tc.in)
if tc.ok {
if err != nil || org != tc.org || project != tc.project || repo != tc.repo {
t.Fatalf("parseRepoPath(%q) = (%q,%q,%q,%v), want (%q,%q,%q,nil)",
tc.in, org, project, repo, err, tc.org, tc.project, tc.repo)
}
} else if err == nil {
t.Fatalf("parseRepoPath(%q) should have failed, got (%q,%q,%q)", tc.in, org, project, repo)
}
}
}
// --- test doubles ---
// fakeConnMeta is a minimal ssh.ConnMetadata for exercising authPublicKey
// directly (the callback only reads the presented key, not the conn).
type fakeConnMeta struct{}
func (fakeConnMeta) User() string { return "git" }
func (fakeConnMeta) SessionID() []byte { return nil }
func (fakeConnMeta) ClientVersion() []byte { return []byte("SSH-2.0-test") }
func (fakeConnMeta) ServerVersion() []byte { return []byte("SSH-2.0-hanzo") }
func (fakeConnMeta) RemoteAddr() net.Addr { return &net.TCPAddr{} }
func (fakeConnMeta) LocalAddr() net.Addr { return &net.TCPAddr{} }
// genClientKeyPub returns just an ssh.PublicKey for the unregistered-key case.
func genClientKeyPub(t *testing.T) (xssh.PublicKey, ed25519.PrivateKey) {
t.Helper()
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("gen key: %v", err)
}
pub, err := xssh.NewPublicKey(priv.Public())
if err != nil {
t.Fatalf("pub: %v", err)
}
return pub, priv
}