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>
This commit is contained in:
+1
-1
@@ -21,7 +21,7 @@ func CloneURL(org, name string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return cloneURL(s, org, name)
|
||||
return cloneURL(s, org, "", name)
|
||||
}
|
||||
|
||||
// VerifyRef reports the tip commit of branch in an org's repo, reading the on-disk
|
||||
|
||||
+34
-7
@@ -23,6 +23,12 @@
|
||||
// POST /v1/git/:org/:repo/git-upload-pack (clone/fetch)
|
||||
// POST /v1/git/:org/:repo/git-receive-pack (push)
|
||||
//
|
||||
// A project-scoped repo names its project as a middle segment
|
||||
// (/v1/git/:org/:project/:repo/…, git@host:org/project/repo.git). The scope
|
||||
// otherwise rides X-Project-Id, and a git client sends no headers, so the path
|
||||
// is the only channel that reaches a remote. Two names are only unique within
|
||||
// one project — hanzo/hanzo-apps/ai and hanzo/hanzo-docs/ai are distinct repos.
|
||||
//
|
||||
// Storage is bare git repos on a real filesystem (osfs) rooted under
|
||||
// {DataDir}/git; go-git initializes + reads them, while the heavy clone/push/
|
||||
// mirror paths stream through the `git` CLI (gitexec.go) so multi-GB packs stay
|
||||
@@ -139,31 +145,41 @@ func rfc3339(unix int64) string {
|
||||
return time.Unix(unix, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func cloneURL(s *cloud.Service[state], org, name string) string {
|
||||
// cloneURL is the HTTPS remote for a repo. An org-level repo keeps the
|
||||
// two-segment path it has always had; a project-scoped one names its project as
|
||||
// the middle segment, because `git clone` sends no headers and the URL is the
|
||||
// only place the scope can travel.
|
||||
func cloneURL(s *cloud.Service[state], org, project, name string) string {
|
||||
host := s.Domain
|
||||
if host == "" {
|
||||
host = "api.hanzo.ai"
|
||||
}
|
||||
return fmt.Sprintf("https://%s/v1/git/%s/%s.git", host, org, name)
|
||||
if project == "" {
|
||||
return fmt.Sprintf("https://%s/v1/git/%s/%s.git", host, org, name)
|
||||
}
|
||||
return fmt.Sprintf("https://%s/v1/git/%s/%s/%s.git", host, org, project, name)
|
||||
}
|
||||
|
||||
// sshURL is the scp-style Git SSH remote: git@<sshHost>:<org>/<repo>.git. The
|
||||
// colon (not slash) after the host is the canonical scp-like syntax `git clone`
|
||||
// accepts; the org/repo tail is the same path the SSH exec handler parses.
|
||||
func sshURL(s *cloud.Service[state], org, name string) string {
|
||||
func sshURL(s *cloud.Service[state], org, project, name string) string {
|
||||
host := s.State.sshHost
|
||||
if host == "" {
|
||||
host = defaultSSHHost(s.Domain)
|
||||
}
|
||||
return fmt.Sprintf("git@%s:%s/%s.git", host, org, name)
|
||||
if project == "" {
|
||||
return fmt.Sprintf("git@%s:%s/%s.git", host, org, name)
|
||||
}
|
||||
return fmt.Sprintf("git@%s:%s/%s/%s.git", host, org, project, name)
|
||||
}
|
||||
|
||||
func toView(s *cloud.Service[state], r Repo, branches []string, head string) repoView {
|
||||
return repoView{
|
||||
ID: r.ID, Org: r.Org, Project: r.Project, Name: r.Name, Description: r.Description,
|
||||
DefaultBranch: r.DefaultBranch, Public: r.Public, Branches: branches, Head: head,
|
||||
CloneURL: cloneURL(s, r.Org, r.Name),
|
||||
SSHURL: sshURL(s, r.Org, r.Name),
|
||||
CloneURL: cloneURL(s, r.Org, r.Project, r.Name),
|
||||
SSHURL: sshURL(s, r.Org, r.Project, r.Name),
|
||||
SizeBytes: r.SizeBytes, CreatedAt: rfc3339(r.CreatedAt), UpdatedAt: rfc3339(r.UpdatedAt),
|
||||
}
|
||||
}
|
||||
@@ -335,15 +351,26 @@ func routes(app cloud.Router, s *cloud.Service[state]) {
|
||||
g.Post("/:org/:repo/git-upload-pack", cloud.Handle(s, uploadPack))
|
||||
g.Post("/:org/:repo/git-receive-pack", cloud.Handle(s, receivePack))
|
||||
|
||||
// The same protocol one segment deeper, for a project-scoped repo. The scope
|
||||
// otherwise rides X-Project-Id, which `git clone` cannot send, so without a
|
||||
// path form a project-scoped repo has no usable remote. Distinct segment
|
||||
// count from the routes above, so the org-level form is untouched.
|
||||
g.Get("/:org/:project/:repo/info/refs", cloud.Handle(s, infoRefs))
|
||||
g.Post("/:org/:project/:repo/git-upload-pack", cloud.Handle(s, uploadPack))
|
||||
g.Post("/:org/:project/:repo/git-receive-pack", cloud.Handle(s, receivePack))
|
||||
|
||||
// Root-level smart-HTTP on the git host so `git clone
|
||||
// https://git.hanzo.ai/<org>/<repo>.git` works with the canonical git URL
|
||||
// (no /v1/git prefix). Guarded to s.State.gitHost via onGitHost: on the
|
||||
// api/console hosts these fall through (c.Next()), so a root :org/:repo can
|
||||
// never shadow another surface. Same handlers, same :org/:repo params.
|
||||
// never shadow another surface. Same handlers, same params.
|
||||
onGit := onGitHost(s.State.gitHost)
|
||||
app.Get("/:org/:repo/info/refs", onGit(cloud.Handle(s, infoRefs)))
|
||||
app.Post("/:org/:repo/git-upload-pack", onGit(cloud.Handle(s, uploadPack)))
|
||||
app.Post("/:org/:repo/git-receive-pack", onGit(cloud.Handle(s, receivePack)))
|
||||
app.Get("/:org/:project/:repo/info/refs", onGit(cloud.Handle(s, infoRefs)))
|
||||
app.Post("/:org/:project/:repo/git-upload-pack", onGit(cloud.Handle(s, uploadPack)))
|
||||
app.Post("/:org/:project/:repo/git-receive-pack", onGit(cloud.Handle(s, receivePack)))
|
||||
|
||||
// Browser UI — Hanzo Git's web surface (repo list/browse/blob/commits) at
|
||||
// /git/*, Hanzo Git's native web surface (ui.go).
|
||||
|
||||
+1
-1
@@ -221,7 +221,7 @@ func subscribedTo(csv string, kind cloud.LifecycleKind) bool {
|
||||
// used verbatim as the link target.
|
||||
func lifecycleMessage(s *cloud.Service[state], ctx context.Context, ev cloud.LifecycleEvent) (string, []any) {
|
||||
repo := slackEscape(ev.Org + "/" + ev.Repo)
|
||||
link := cloneURL(s, ev.Org, ev.Repo)
|
||||
link := cloneURL(s, ev.Org, ev.Project, ev.Repo)
|
||||
|
||||
var emoji, title, summary string
|
||||
var fields []any
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// A project-scoped repo is reachable only through the three-segment path. The
|
||||
// scope otherwise rides X-Project-Id, and `git clone` sends no headers, so
|
||||
// without the path form such a repo has no usable remote at all.
|
||||
|
||||
// doScoped is do() with a project sub-scope, which is how a project-scoped repo
|
||||
// is created in the first place.
|
||||
func doScoped(t *testing.T, app *zip.App, method, path, org, project string, body any) (int, []byte) {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(b)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, r)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("X-Org-Id", org)
|
||||
req.Header.Set("X-User-Id", "u_"+org)
|
||||
if project != "" {
|
||||
req.Header.Set("X-Project-Id", project)
|
||||
}
|
||||
resp, err := app.Fiber().Test(req, testCfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Test %s %s: %v", method, path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, b
|
||||
}
|
||||
|
||||
func scopedHeaderArgs(org, project string) []string {
|
||||
args := orgHeaderArgs(org)
|
||||
if project != "" {
|
||||
args = append(args, "-c", "http.extraHeader=X-Project-Id: "+project)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// gitTry runs git and returns the error instead of failing the test, for the
|
||||
// cases where the refusal IS the assertion.
|
||||
func gitTry(t *testing.T, dir string, args ...string) error {
|
||||
t.Helper()
|
||||
_, err := gitTestCmd(dir, args...).CombinedOutput()
|
||||
return err
|
||||
}
|
||||
|
||||
// TestProjectPathClonePushRoundTrip is the before/after: the same repo name in
|
||||
// two projects, each pushed and cloned through its own three-segment URL, with
|
||||
// the commits proving they are distinct repositories rather than one.
|
||||
func TestProjectPathClonePushRoundTrip(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
base := liveServer(t, app)
|
||||
|
||||
type scoped struct{ project, content, commit, url string }
|
||||
repos := []*scoped{
|
||||
{project: "hanzo-apps", content: "# the site\n"},
|
||||
{project: "hanzo-docs", content: "# the docs\n"},
|
||||
}
|
||||
|
||||
for _, r := range repos {
|
||||
if code, b := doScoped(t, app, "POST", "/v1/git/repos", "hanzo", r.project, map[string]any{"name": "ai"}); code != 201 {
|
||||
t.Fatalf("create hanzo/%s/ai: %d %s", r.project, code, b)
|
||||
}
|
||||
r.url = base + "/v1/git/hanzo/" + r.project + "/ai.git"
|
||||
|
||||
work := t.TempDir()
|
||||
gitRun(t, work, "init", "-q", "-b", "main")
|
||||
if err := os.WriteFile(filepath.Join(work, "README.md"), []byte(r.content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gitRun(t, work, "add", "-A")
|
||||
gitRun(t, work, "commit", "-q", "-m", "first")
|
||||
r.commit = gitOut(t, work, "rev-parse", "HEAD")
|
||||
gitRun(t, work, "remote", "add", "origin", r.url)
|
||||
gitRun(t, work, append(scopedHeaderArgs("hanzo", r.project), "push", "origin", "main")...)
|
||||
}
|
||||
|
||||
if repos[0].commit == repos[1].commit {
|
||||
t.Fatal("fixture is degenerate: the two repos must hold different commits")
|
||||
}
|
||||
|
||||
// Each three-segment URL returns its OWN repo. Before this change both names
|
||||
// resolved to one storage path, so the second push would have collided with
|
||||
// the first instead of standing beside it.
|
||||
for _, r := range repos {
|
||||
dst := filepath.Join(t.TempDir(), "clone")
|
||||
gitRun(t, "", append(scopedHeaderArgs("hanzo", r.project), "clone", "-q", r.url, dst)...)
|
||||
if got := gitOut(t, dst, "rev-parse", "HEAD"); got != r.commit {
|
||||
t.Fatalf("%s cloned HEAD %s, want %s", r.url, got, r.commit)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(dst, "README.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: read cloned file: %v", r.url, err)
|
||||
}
|
||||
if string(got) != r.content {
|
||||
t.Fatalf("%s cloned %q, want %q", r.url, got, r.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrgLevelPathIsUnchanged is the compatibility half: the two-segment URL
|
||||
// every existing repo is cloned from still resolves to the org-level repo, and
|
||||
// adding the deeper route did not shadow it.
|
||||
func TestOrgLevelPathIsUnchanged(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
base := liveServer(t, app)
|
||||
if code, b := do(t, app, "POST", "/v1/git/repos", "acme", map[string]any{"name": "code"}); code != 201 {
|
||||
t.Fatalf("create repo: %d %s", code, b)
|
||||
}
|
||||
url := base + "/v1/git/acme/code.git"
|
||||
|
||||
work := t.TempDir()
|
||||
gitRun(t, work, "init", "-q", "-b", "main")
|
||||
if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("# org level\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gitRun(t, work, "add", "-A")
|
||||
gitRun(t, work, "commit", "-q", "-m", "first")
|
||||
commit := gitOut(t, work, "rev-parse", "HEAD")
|
||||
gitRun(t, work, "remote", "add", "origin", url)
|
||||
gitRun(t, work, append(orgHeaderArgs("acme"), "push", "origin", "main")...)
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "clone")
|
||||
gitRun(t, "", append(orgHeaderArgs("acme"), "clone", "-q", url, dst)...)
|
||||
if got := gitOut(t, dst, "rev-parse", "HEAD"); got != commit {
|
||||
t.Fatalf("two-segment clone HEAD %s != pushed %s", got, commit)
|
||||
}
|
||||
}
|
||||
|
||||
// A repo created at the org level is NOT reachable under some project, and vice
|
||||
// versa: the middle segment selects a storage path, so a wrong one is a miss
|
||||
// rather than a fallback to the org-level repo.
|
||||
func TestProjectSegmentDoesNotFallBackToOrgLevel(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
base := liveServer(t, app)
|
||||
if code, b := do(t, app, "POST", "/v1/git/repos", "acme", map[string]any{"name": "code"}); code != 201 {
|
||||
t.Fatalf("create repo: %d %s", code, b)
|
||||
}
|
||||
dst := filepath.Join(t.TempDir(), "clone")
|
||||
err := gitTry(t, "", append(scopedHeaderArgs("acme", "nosuch"),
|
||||
"clone", "-q", base+"/v1/git/acme/nosuch/code.git", dst)...)
|
||||
if err == nil {
|
||||
t.Fatal("an org-level repo must not be served under an arbitrary project segment")
|
||||
}
|
||||
}
|
||||
|
||||
// The reported clone URL is the one that works: a project-scoped repo advertises
|
||||
// its three-segment remote, so a caller never has to know the rule.
|
||||
func TestReportedCloneURLCarriesTheProject(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
if code, b := doScoped(t, app, "POST", "/v1/git/repos", "hanzo", "hanzo-apps", map[string]any{"name": "ai"}); code != 201 {
|
||||
t.Fatalf("create: %d %s", code, b)
|
||||
}
|
||||
code, b := doScoped(t, app, "GET", "/v1/git/repos/ai", "hanzo", "hanzo-apps", nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("detail: %d %s", code, b)
|
||||
}
|
||||
var v repoView
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, b)
|
||||
}
|
||||
if v.Project != "hanzo-apps" {
|
||||
t.Fatalf("project = %q, want hanzo-apps", v.Project)
|
||||
}
|
||||
for _, want := range []string{"/v1/git/hanzo/hanzo-apps/ai.git", ":hanzo/hanzo-apps/ai.git"} {
|
||||
if !bytes.Contains([]byte(v.CloneURL+" "+v.SSHURL), []byte(want)) {
|
||||
t.Errorf("advertised URLs %q / %q should contain %q", v.CloneURL, v.SSHURL, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An org-level repo keeps advertising the two-segment remote, so nothing that
|
||||
// already works starts pointing somewhere new.
|
||||
func TestOrgLevelCloneURLIsUnchanged(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
if code, b := do(t, app, "POST", "/v1/git/repos", "acme", map[string]any{"name": "code"}); code != 201 {
|
||||
t.Fatalf("create: %d %s", code, b)
|
||||
}
|
||||
code, b := do(t, app, "GET", "/v1/git/repos/code", "acme", nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("detail: %d %s", code, b)
|
||||
}
|
||||
var v repoView
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, b)
|
||||
}
|
||||
if v.Project != "" {
|
||||
t.Fatalf("project = %q, want empty", v.Project)
|
||||
}
|
||||
if !bytes.HasSuffix([]byte(v.CloneURL), []byte("/v1/git/acme/code.git")) {
|
||||
t.Errorf("clone URL %q must keep the two-segment form", v.CloneURL)
|
||||
}
|
||||
if !bytes.HasSuffix([]byte(v.SSHURL), []byte(":acme/code.git")) {
|
||||
t.Errorf("ssh URL %q must keep the two-segment form", v.SSHURL)
|
||||
}
|
||||
}
|
||||
|
||||
// The middle segment becomes a storage path segment, so it is validated like
|
||||
// every other one rather than trusted.
|
||||
func TestProjectSegmentIsTraversalSafe(t *testing.T) {
|
||||
app := mountApp(t)
|
||||
for _, bad := range []string{"..", ".", "-x", "a/b"} {
|
||||
req := httptest.NewRequest("GET", "/v1/git/acme/"+bad+"/code.git/info/refs?service=git-upload-pack", nil)
|
||||
req.Header.Set("X-Org-Id", "acme")
|
||||
req.Header.Set("X-User-Id", "u_acme")
|
||||
resp, err := app.Fiber().Test(req, testCfg)
|
||||
if err != nil {
|
||||
continue // the router rejected the shape outright, which is also a refusal
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode < 400 {
|
||||
t.Errorf("project segment %q answered %d; it must never be served", bad, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -107,7 +107,7 @@ func (o ops) pushFiles(ctx context.Context, in *pushReq) (*pushResp, error) {
|
||||
}
|
||||
return &pushResp{
|
||||
Commit: commit, Branch: branch,
|
||||
CloneURL: cloneURL(o.s, t.org, in.Name), SSHURL: sshURL(o.s, t.org, in.Name),
|
||||
CloneURL: cloneURL(o.s, t.org, t.project, in.Name), SSHURL: sshURL(o.s, t.org, t.project, in.Name),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
+41
-8
@@ -172,12 +172,45 @@ func packRequestBody(c *zip.Ctx) io.Reader {
|
||||
// the authenticated org (path-vs-identity guard), and confirm the repo exists.
|
||||
// Returns the (org, project, name) the pack driver operates on.
|
||||
//
|
||||
// The project sub-scope comes from the :project path segment when the request
|
||||
// used the three-segment form, and from X-Project-Id otherwise. The path wins
|
||||
// because it is what `git clone` can express: a URL is the only channel a git
|
||||
// client has, and a header cannot reach a remote.
|
||||
//
|
||||
// allowPublic is the READ concession: with no authenticated org, a fetch-side
|
||||
// caller (upload-pack) may still resolve a repo that is (a) addressed by an
|
||||
// explicit, orgRE-safe :org path segment, (b) org-level (no project sub-scope —
|
||||
// anonymous callers have no validated project identity), and (c) marked Public.
|
||||
// A private or missing repo answers the SAME 404, so anonymous probing cannot
|
||||
// distinguish existence. Push (receive-pack) never passes allowPublic.
|
||||
// explicit, orgRE-safe :org path segment and (b) marked Public. A private or
|
||||
// missing repo answers the SAME 404, so anonymous probing cannot distinguish
|
||||
// existence. Push (receive-pack) never passes allowPublic.
|
||||
//
|
||||
// An anonymous caller may name a project in the PATH but never in the header.
|
||||
// The two are not equivalent: the header asserts the caller's own scope and is
|
||||
// unvalidated without a principal, whereas the path segment only addresses a
|
||||
// different repo — one that still has to be Public to be served. Refusing the
|
||||
// path form instead would leave every public project-scoped repo unclonable.
|
||||
// packProject resolves the sub-scope a pack request operates in.
|
||||
//
|
||||
// The :project path segment wins when present, for both authenticated and
|
||||
// anonymous callers: it addresses a repo rather than asserting a scope, and the
|
||||
// repo's own Public flag still decides whether an anonymous caller may read it.
|
||||
// It is validated against projectRE because it becomes a storage path segment.
|
||||
//
|
||||
// With no path segment the header applies, and only with a principal behind it —
|
||||
// an unauthenticated X-Project-Id is unvalidated input, so it degrades to the
|
||||
// org level exactly as before.
|
||||
func packProject(c *zip.Ctx, authed bool) (string, error) {
|
||||
if p := c.Param("project"); p != "" {
|
||||
if len(p) > 128 || !projectRE.MatchString(p) {
|
||||
return "", zip.ErrBadRequest("project path segment is not a valid identifier")
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
if !authed {
|
||||
return "", nil
|
||||
}
|
||||
return projectScope(c), nil
|
||||
}
|
||||
|
||||
func resolvePackRepo(s *cloud.Service[state], c *zip.Ctx, allowPublic bool) (string, string, string, error) {
|
||||
orgID, authed := org(c)
|
||||
if !authed {
|
||||
@@ -193,9 +226,9 @@ func resolvePackRepo(s *cloud.Service[state], c *zip.Ctx, allowPublic bool) (str
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
project := projectScope(c)
|
||||
if !authed {
|
||||
project = "" // anonymous has no validated sub-scope; public repos are org-level
|
||||
project, err := packProject(c, authed)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if p := c.Param("org"); p != "" && p != orgID {
|
||||
return "", "", "", zip.ErrForbidden("org path does not match authenticated org")
|
||||
@@ -239,7 +272,7 @@ func fireBranchBuild(s *cloud.Service[state], ctx context.Context, org, project,
|
||||
branch, before, pusher = strings.Clone(branch), strings.Clone(before), strings.Clone(pusher)
|
||||
if err := cloud.OnGitPush(ctx, cloud.GitPushEvent{
|
||||
Org: org, Project: project, Repo: name,
|
||||
Ref: "refs/heads/" + branch, Commit: after, CloneURL: cloneURL(s, org, name),
|
||||
Ref: "refs/heads/" + branch, Commit: after, CloneURL: cloneURL(s, org, project, name),
|
||||
}); err != nil {
|
||||
s.Log.Warn("git push-to-deploy trigger failed", "org", org, "repo", name, "branch", branch, "err", err)
|
||||
}
|
||||
|
||||
+18
-13
@@ -310,7 +310,11 @@ func (srv *sshServer) runGitCommand(keyOrg, keyUser, command, gitProtocol string
|
||||
}
|
||||
service, path := m[1], m[2]
|
||||
|
||||
pathOrg, name, err := parseRepoPath(path)
|
||||
// The optional middle segment is the project sub-scope: SSH carries no headers
|
||||
// at all, so the path is the only channel it has, exactly as for HTTPS. It
|
||||
// addresses a repo within the key's own org; the org check below is unchanged
|
||||
// and is still what confines the key.
|
||||
pathOrg, project, name, err := parseRepoPath(path)
|
||||
if err != nil {
|
||||
_, _ = io.WriteString(ch.Stderr(), "invalid repo path: "+err.Error()+"\n")
|
||||
return 1
|
||||
@@ -321,8 +325,6 @@ func (srv *sshServer) runGitCommand(keyOrg, keyUser, command, gitProtocol string
|
||||
_, _ = io.WriteString(ch.Stderr(), "access denied: repository is outside your organization\n")
|
||||
return 1
|
||||
}
|
||||
// SSH has no project sub-scope (no X-Project-Id) — org-level repos only.
|
||||
const project = ""
|
||||
|
||||
store, err := storeFor(srv.svc, keyOrg)
|
||||
if err != nil {
|
||||
@@ -356,22 +358,25 @@ func (srv *sshServer) exit(ch ssh.Channel, code int) {
|
||||
_, _ = ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{uint32(code)}))
|
||||
}
|
||||
|
||||
// repoPathRE validates the "<org>/<repo>" tail of a git SSH path. Both segments
|
||||
// are safe identifiers (mirrors nameRE); a leading slash is tolerated (git may
|
||||
// send an absolute-looking path).
|
||||
var repoPathRE = regexp.MustCompile(`^/?([A-Za-z0-9][A-Za-z0-9._-]{0,63})/([A-Za-z0-9][A-Za-z0-9._-]{0,63})$`)
|
||||
// repoPathRE validates the "<org>/<repo>" or "<org>/<project>/<repo>" tail of a
|
||||
// git SSH path. Every segment is a safe identifier (mirrors nameRE); a leading
|
||||
// slash is tolerated (git may send an absolute-looking path). The middle segment
|
||||
// is optional so an org-level path keeps its exact meaning.
|
||||
var repoPathRE = regexp.MustCompile(`^/?([A-Za-z0-9][A-Za-z0-9._-]{0,63})/(?:([A-Za-z0-9][A-Za-z0-9._-]{0,63})/)?([A-Za-z0-9][A-Za-z0-9._-]{0,63})$`)
|
||||
|
||||
// parseRepoPath extracts (org, repo) from a git SSH path like "acme/code.git"
|
||||
// or "/acme/code.git". The trailing ".git" is stripped, and both segments are
|
||||
// validated as safe identifiers so the path can never traverse storage.
|
||||
func parseRepoPath(path string) (org, repo string, err error) {
|
||||
// parseRepoPath extracts (org, project, repo) from a git SSH path like
|
||||
// "acme/code.git", "/acme/code.git" or "acme/site/code.git". The trailing ".git"
|
||||
// is stripped, and every segment is validated as a safe identifier so the path
|
||||
// can never traverse storage. project is empty for an org-level repo, which is
|
||||
// the same value the header path yields.
|
||||
func parseRepoPath(path string) (org, project, repo string, err error) {
|
||||
path = strings.TrimSpace(path)
|
||||
path = strings.TrimSuffix(path, ".git")
|
||||
m := repoPathRE.FindStringSubmatch(path)
|
||||
if m == nil {
|
||||
return "", "", errors.New("path must be <org>/<repo>.git")
|
||||
return "", "", "", errors.New("path must be <org>/<repo>.git or <org>/<project>/<repo>.git")
|
||||
}
|
||||
return m[1], m[2], nil
|
||||
return m[1], m[2], m[3], nil
|
||||
}
|
||||
|
||||
// loadOrCreateHostKey resolves the SSH host key signer: an operator-provided PEM
|
||||
|
||||
+20
-13
@@ -214,27 +214,34 @@ func TestSSHCrossTenantRejected(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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, repo string
|
||||
ok bool
|
||||
in, org, project, repo string
|
||||
ok bool
|
||||
}{
|
||||
{"acme/code.git", "acme", "code", true},
|
||||
{"/acme/code.git", "acme", "code", true},
|
||||
{"acme/code", "acme", "code", true},
|
||||
{"../etc/passwd", "", "", false},
|
||||
{"acme/../beta/x.git", "", "", false},
|
||||
{"acme", "", "", false},
|
||||
{"a/b/c.git", "", "", false},
|
||||
{"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, repo, err := parseRepoPath(tc.in)
|
||||
org, project, repo, err := parseRepoPath(tc.in)
|
||||
if tc.ok {
|
||||
if err != nil || org != tc.org || repo != tc.repo {
|
||||
t.Fatalf("parseRepoPath(%q) = (%q,%q,%v), want (%q,%q,nil)", tc.in, org, repo, err, tc.org, tc.repo)
|
||||
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)", tc.in, org, repo)
|
||||
t.Fatalf("parseRepoPath(%q) should have failed, got (%q,%q,%q)", tc.in, org, project, repo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+32
-11
@@ -54,15 +54,32 @@ var untypedByDesign = map[string]string{
|
||||
"POST /{org}/{repo}/git-receive-pack": "the git-host root form: a pack stream in, pkt-line " +
|
||||
"report-status out, and a c.Next() fall-through on a non-git Host.",
|
||||
|
||||
// The same six one segment deeper, for a project-scoped repo: identical
|
||||
// handlers and identical non-JSON wire, addressed as :org/:project/:repo
|
||||
// because a git client sends no headers and the scope has nowhere else to
|
||||
// ride (git.go, cloneURL).
|
||||
"GET /v1/git/{org}/{project}/{repo}/info/refs": "the project-scoped ref advertisement; " +
|
||||
"application/x-git-*-advertisement bytes, not a JSON value.",
|
||||
"POST /v1/git/{org}/{project}/{repo}/git-upload-pack": "project-scoped: an x-git-upload-pack-request " +
|
||||
"pack stream in, a STREAMED packfile out.",
|
||||
"POST /v1/git/{org}/{project}/{repo}/git-receive-pack": "project-scoped: an x-git-receive-pack-request " +
|
||||
"pack stream in, pkt-line report-status out.",
|
||||
"GET /{org}/{project}/{repo}/info/refs": "the git-host root form, project-scoped; pkt-line bytes and a " +
|
||||
"c.Next() fall-through on a non-git Host.",
|
||||
"POST /{org}/{project}/{repo}/git-upload-pack": "the git-host root form, project-scoped: a pack stream " +
|
||||
"in, a streamed packfile out, and a c.Next() fall-through on a non-git Host.",
|
||||
"POST /{org}/{project}/{repo}/git-receive-pack": "the git-host root form, project-scoped: a pack stream " +
|
||||
"in, pkt-line report-status out, and a c.Next() fall-through on a non-git Host.",
|
||||
|
||||
// 3. The browser UI — Hanzo Git's server-rendered web surface (ui.go),
|
||||
// text/html from html/template. A typed dispatch ends in c.JSON(out). The six
|
||||
// root-level pages carry the same onGitHost c.Next() fall-through as family 2.
|
||||
// The JSON twin of every one of these IS a typed op (/v1/git/repos/{name}/…
|
||||
// refs|tree|blob|commits|readme), so the schema is not missing — it is at the
|
||||
// address that answers JSON.
|
||||
"GET /git": "server-rendered text/html (the repo list page); a typed Out answers JSON.",
|
||||
"GET /git/explore": "server-rendered text/html (the public explore page); a typed Out answers JSON.",
|
||||
"GET /git/{org}/{repo}": "server-rendered text/html (the repo page); a typed Out answers JSON.",
|
||||
"GET /git": "server-rendered text/html (the repo list page); a typed Out answers JSON.",
|
||||
"GET /git/explore": "server-rendered text/html (the public explore page); a typed Out answers JSON.",
|
||||
"GET /git/{org}/{repo}": "server-rendered text/html (the repo page); a typed Out answers JSON.",
|
||||
"GET /git/{org}/{repo}/tree/{wildcard1}": "server-rendered text/html (the tree browser); a typed Out " +
|
||||
"answers JSON.",
|
||||
"GET /git/{org}/{repo}/blob/{wildcard1}": "server-rendered text/html (the blob view); a typed Out " +
|
||||
@@ -200,14 +217,18 @@ func TestEveryTypedOpIsDescribed(t *testing.T) {
|
||||
// usage), the ref advertisement, and the twelve HTML pages. Declaring a body for
|
||||
// one of those would replace an honest silence with a fresh falsehood.
|
||||
var declaredBodies = map[string]string{
|
||||
"POST /v1/git/webhook": "application/json",
|
||||
"POST /v1/git/zap/createRepo": "application/json",
|
||||
"POST /v1/git/zap/getRepo": "application/json",
|
||||
"POST /v1/git/zap/deleteRepo": "application/json",
|
||||
"POST /v1/git/{org}/{repo}/git-upload-pack": "application/octet-stream",
|
||||
"POST /v1/git/{org}/{repo}/git-receive-pack": "application/octet-stream",
|
||||
"POST /{org}/{repo}/git-upload-pack": "application/octet-stream",
|
||||
"POST /{org}/{repo}/git-receive-pack": "application/octet-stream",
|
||||
"POST /v1/git/webhook": "application/json",
|
||||
"POST /v1/git/zap/createRepo": "application/json",
|
||||
"POST /v1/git/zap/getRepo": "application/json",
|
||||
"POST /v1/git/zap/deleteRepo": "application/json",
|
||||
"POST /v1/git/{org}/{repo}/git-upload-pack": "application/octet-stream",
|
||||
"POST /v1/git/{org}/{repo}/git-receive-pack": "application/octet-stream",
|
||||
"POST /{org}/{repo}/git-upload-pack": "application/octet-stream",
|
||||
"POST /{org}/{repo}/git-receive-pack": "application/octet-stream",
|
||||
"POST /v1/git/{org}/{project}/{repo}/git-upload-pack": "application/octet-stream",
|
||||
"POST /v1/git/{org}/{project}/{repo}/git-receive-pack": "application/octet-stream",
|
||||
"POST /{org}/{project}/{repo}/git-upload-pack": "application/octet-stream",
|
||||
"POST /{org}/{project}/{repo}/git-receive-pack": "application/octet-stream",
|
||||
}
|
||||
|
||||
// TestRefusedRoutesDeclareTheBodyTheyRead holds the description of the 24 refusals
|
||||
|
||||
+7
-4
@@ -75,11 +75,14 @@ func uiBase(s *cloud.Service[state], c *zip.Ctx) string {
|
||||
// (https://git.hanzo.ai/<org>/<repo>.git) the root smart-HTTP routes serve, not
|
||||
// the /v1/git-prefixed API form. Falls back to the API cloneURL when no git host
|
||||
// is configured.
|
||||
func uiCloneURL(s *cloud.Service[state], org, name string) string {
|
||||
func uiCloneURL(s *cloud.Service[state], org, project, name string) string {
|
||||
if h := s.State.gitHost; h != "" {
|
||||
return fmt.Sprintf("https://%s/%s/%s.git", h, org, name)
|
||||
if project == "" {
|
||||
return fmt.Sprintf("https://%s/%s/%s.git", h, org, name)
|
||||
}
|
||||
return fmt.Sprintf("https://%s/%s/%s/%s.git", h, org, project, name)
|
||||
}
|
||||
return cloneURL(s, org, name)
|
||||
return cloneURL(s, org, project, name)
|
||||
}
|
||||
|
||||
// uiOrg resolves the caller's validated org and enforces the :org path segment
|
||||
@@ -186,7 +189,7 @@ func uiRepo(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
ref := strings.TrimSpace(c.Query("ref"))
|
||||
base := uiBase(s, c)
|
||||
d := repoData{Base: base, Org: o, Repo: r.Name, Description: r.Description,
|
||||
CloneHTTP: uiCloneURL(s, o, r.Name), CloneSSH: sshURL(s, o, r.Name)}
|
||||
CloneHTTP: uiCloneURL(s, o, r.Project, r.Name), CloneSSH: sshURL(s, o, r.Project, r.Name)}
|
||||
|
||||
repo, err := openRepository(s, r)
|
||||
if err == nil {
|
||||
|
||||
@@ -92,6 +92,13 @@ func init() {
|
||||
openapi.Register("/v1/git/:org/:repo/git-receive-pack", "POST", openapi.Binary{}, nil)
|
||||
openapi.Register("/:org/:repo/git-upload-pack", "POST", openapi.Binary{}, nil)
|
||||
openapi.Register("/:org/:repo/git-receive-pack", "POST", openapi.Binary{}, nil)
|
||||
|
||||
// The same pack streams for a project-scoped repo, which names its project as
|
||||
// a middle segment because a git client has no header to carry it.
|
||||
openapi.Register("/v1/git/:org/:project/:repo/git-upload-pack", "POST", openapi.Binary{}, nil)
|
||||
openapi.Register("/v1/git/:org/:project/:repo/git-receive-pack", "POST", openapi.Binary{}, nil)
|
||||
openapi.Register("/:org/:project/:repo/git-upload-pack", "POST", openapi.Binary{}, nil)
|
||||
openapi.Register("/:org/:project/:repo/git-receive-pack", "POST", openapi.Binary{}, nil)
|
||||
}
|
||||
|
||||
// pushEvent is the subset of the forge's push payload we act on. Owner and pusher
|
||||
|
||||
Reference in New Issue
Block a user