Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions cmd/objgitd/example_hook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import (
"time"

"github.com/go-git/go-billy/v6/memfs"
"github.com/go-git/go-git/v6/plumbing/transport"
"github.com/tigrisdata/objgit/internal/auth"
"github.com/tigrisdata/objgit/internal/repofs"
)

// TestExampleHookRuns pushes the repository's own example hook
Expand All @@ -37,8 +37,8 @@ func TestExampleHookRuns(t *testing.T) {

fs := memfs.New()
d := &daemon{
fs: fs,
loader: transport.NewFilesystemLoader(fs, false),
sysFS: fs,
resolver: repofs.BucketResolver{Base: fs},
authz: auth.AllowAnonymous{AllowWrite: true},
allowHooks: true,
hookTimeout: 30 * time.Second,
Expand All @@ -51,7 +51,7 @@ func TestExampleHookRuns(t *testing.T) {
}
go func() { _ = d.ServeGitProtocol(ctx, ln) }()

remote := "git://" + ln.Addr().String() + "/example.git"
remote := "git://" + ln.Addr().String() + "/acme/example.git"
work := t.TempDir()
runGit(t, work, "init", "-b", "main")
runGit(t, work, "config", "user.email", "test@example.com")
Expand Down
58 changes: 38 additions & 20 deletions cmd/objgitd/git_protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/go-git/go-git/v6/storage/filesystem"
"github.com/tigrisdata/objgit/internal/auth"
"github.com/tigrisdata/objgit/internal/metrics"
"github.com/tigrisdata/objgit/internal/repofs"
)

// handshakeTimeout bounds how long a client has to send its git-proto-request.
Expand All @@ -43,28 +44,42 @@ func (d *daemon) authorize(ctx context.Context, req auth.Request) auth.Decision
return dec
}

// daemon serves the git:// (TCP) protocol out of a billy filesystem.
// daemon serves the git protocols out of billy filesystems resolved per repo.
type daemon struct {
fs billy.Filesystem
loader transport.Loader
authz auth.Authorizer
// sysFS holds daemon-level state that is not scoped to a repository (the SSH
// host key); repository storage is resolved per request via resolver.
sysFS billy.Filesystem
resolver repofs.Resolver
authz auth.Authorizer

// allowHooks gates running .objgit/hooks/receive-pack after a push.
allowHooks bool
hookTimeout time.Duration
}

// load opens the storer for repoPath and heals a dangling HEAD before returning
// it (see ensureHEAD). It preserves the loader's error verbatim — notably
// storerFor returns the bare-repository storer rooted at fs, or
// transport.ErrRepositoryNotFound when no repository exists there. It reuses
// go-git's own bare-repo detection (a "config" file at the root) by loading the
// repository at the filesystem root.
func storerFor(fs billy.Filesystem) (storage.Storer, error) {
return transport.NewFilesystemLoader(fs, false).Load(&url.URL{Path: "/"})
}

// load resolves the storer for ref and heals a dangling HEAD before returning it
// (see ensureHEAD). It preserves storerFor's error verbatim — notably
// transport.ErrRepositoryNotFound, which callers map to a 404 — and treats a
// heal failure as non-fatal so a clone is never broken by a transient HEAD write.
func (d *daemon) load(repoPath string) (storage.Storer, error) {
st, err := d.loader.Load(&url.URL{Path: repoPath})
func (d *daemon) load(ctx context.Context, ref repofs.RepoRef, cred repofs.Credential) (storage.Storer, error) {
fs, err := d.resolver.Resolve(ctx, ref, cred, false)
if err != nil {
return nil, err
}
st, err := storerFor(fs)
if err != nil {
return nil, err
}
if err := ensureHEAD(st); err != nil {
slog.Warn("could not repoint dangling HEAD", "path", repoPath, "err", err)
slog.Warn("could not repoint dangling HEAD", "repo", ref.Path(), "err", err)
}
return st, nil
}
Expand Down Expand Up @@ -140,29 +155,32 @@ func pickDefaultBranch(st storage.Storer) (plumbing.ReferenceName, error) {
return first, nil
}

// loadOrInit returns the storer for repoPath, creating an empty bare repository
// on demand. Git's daemon never auto-creates; objgitd does, so a first push to
// a new path just works.
func (d *daemon) loadOrInit(repoPath string) (storage.Storer, error) {
st, err := d.load(repoPath)
// loadOrInit returns the storer for ref, creating an empty bare repository on
// demand. Git's daemon never auto-creates; objgitd does, so a first push to a
// new path just works.
func (d *daemon) loadOrInit(ctx context.Context, ref repofs.RepoRef, cred repofs.Credential) (storage.Storer, error) {
fs, err := d.resolver.Resolve(ctx, ref, cred, true)
if err != nil {
return nil, err
}

st, err := storerFor(fs)
if err == nil {
if err := ensureHEAD(st); err != nil {
slog.Warn("could not repoint dangling HEAD", "repo", ref.Path(), "err", err)
}
return st, nil
}
if !errors.Is(err, transport.ErrRepositoryNotFound) {
return nil, err
}

fs, err := d.fs.Chroot(repoPath)
if err != nil {
return nil, fmt.Errorf("chroot %q: %w", repoPath, err)
}

st = filesystem.NewStorage(fs, cache.NewObjectLRUDefault())
if _, err := git.Init(st, git.WithDefaultBranch(plumbing.NewBranchReferenceName("main"))); err != nil {
return nil, fmt.Errorf("init bare repo: %w", err)
}

metrics.ReposCreated()
slog.Info("created repository", "path", repoPath)
slog.Info("created repository", "repo", ref.Path())
return st, nil
}
53 changes: 30 additions & 23 deletions cmd/objgitd/git_protocol_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/go-git/go-git/v6/plumbing/transport"
"github.com/tigrisdata/objgit/internal/auth"
"github.com/tigrisdata/objgit/internal/metrics"
"github.com/tigrisdata/objgit/internal/repofs"
)

// ServeGitProtocol accepts connections on l until ctx is cancelled or Accept fails.
Expand Down Expand Up @@ -71,6 +72,12 @@ func (d *daemon) handleGitProtocol(ctx context.Context, conn net.Conn) error {
"remote", conn.RemoteAddr().String(),
)

ref, err := repofs.Parse(req.Pathname)
if err != nil {
_, _ = pktline.WriteError(conn, err)
return fmt.Errorf("invalid repo path %q: %w", req.Pathname, err)
}

// ExtraParams carries e.g. "version=2"; transport.ProtocolVersion splits on ":".
gitProtocol := strings.Join(req.ExtraParams, ":")

Expand All @@ -83,7 +90,7 @@ func (d *daemon) handleGitProtocol(ctx context.Context, conn net.Conn) error {
start := time.Now()

if d.authorize(ctx, auth.Request{
Repo: req.Pathname,
Repo: ref.Path(),
Operation: operationFor(req.RequestCommand),
Cred: auth.Anonymous{},
Transport: "git",
Expand All @@ -93,7 +100,7 @@ func (d *daemon) handleGitProtocol(ctx context.Context, conn net.Conn) error {
return fmt.Errorf("access denied for %q (%s)", req.Pathname, req.RequestCommand)
}

err := d.serveGit(ctx, conn, r, req, gitProtocol)
err = d.serveGit(ctx, conn, r, req, ref, gitProtocol)
status := "ok"
if err != nil {
status = "error"
Expand All @@ -104,10 +111,10 @@ func (d *daemon) handleGitProtocol(ctx context.Context, conn net.Conn) error {

// serveGit dispatches a parsed, authorized git:// request to the matching
// go-git transport command.
func (d *daemon) serveGit(ctx context.Context, conn net.Conn, r io.ReadCloser, req packp.GitProtoRequest, gitProtocol string) error {
func (d *daemon) serveGit(ctx context.Context, conn net.Conn, r io.ReadCloser, req packp.GitProtoRequest, ref repofs.RepoRef, gitProtocol string) error {
switch req.RequestCommand {
case transport.UploadPackService:
st, err := d.load(req.Pathname)
st, err := d.load(ctx, ref, repofs.Credential{})
if err != nil {
_, _ = pktline.WriteError(conn, fmt.Errorf("repository %q not found", req.Pathname))
return fmt.Errorf("loading %q: %w", req.Pathname, err)
Expand All @@ -117,20 +124,20 @@ func (d *daemon) serveGit(ctx context.Context, conn net.Conn, r io.ReadCloser, r
})

case transport.UploadArchiveService:
st, err := d.load(req.Pathname)
st, err := d.load(ctx, ref, repofs.Credential{})
if err != nil {
_, _ = pktline.WriteError(conn, fmt.Errorf("repository %q not found", req.Pathname))
return fmt.Errorf("loading %q: %w", req.Pathname, err)
}
return transport.UploadArchive(ctx, st, r, conn, &transport.UploadArchiveRequest{})

case transport.ReceivePackService:
st, err := d.loadOrInit(req.Pathname)
st, err := d.loadOrInit(ctx, ref, repofs.Credential{})
if err != nil {
_, _ = pktline.WriteError(conn, fmt.Errorf("cannot open repository %q", req.Pathname))
return fmt.Errorf("opening %q for push: %w", req.Pathname, err)
}
return d.receivePack(ctx, st, req.Pathname, r, conn, &transport.ReceivePackRequest{
return d.receivePack(ctx, st, ref.Path(), r, conn, &transport.ReceivePackRequest{
GitProtocol: gitProtocol,
})

Expand All @@ -150,9 +157,9 @@ func TestDaemonPushCreatesRepo(t *testing.T) {

fs := memfs.New()
d := &daemon{
fs: fs,
loader: transport.NewFilesystemLoader(fs, false),
authz: auth.AllowAnonymous{AllowWrite: true},
sysFS: fs,
resolver: repofs.BucketResolver{Base: fs},
authz: auth.AllowAnonymous{AllowWrite: true},
}

ctx, cancel := context.WithCancel(context.Background())
Expand All @@ -166,7 +173,7 @@ func TestDaemonPushCreatesRepo(t *testing.T) {
srvErr := make(chan error, 1)
go func() { srvErr <- d.ServeGitProtocol(ctx, ln) }()

remote := "git://" + ln.Addr().String() + "/test.git"
remote := "git://" + ln.Addr().String() + "/acme/test.git"

work := t.TempDir()
runGit(t, work, "init", "-b", "main")
Expand All @@ -177,8 +184,8 @@ func TestDaemonPushCreatesRepo(t *testing.T) {
// The repository does not exist yet; the push must create it.
runGit(t, work, "push", remote, "main")

if _, err := fs.Stat("/test.git/config"); err != nil {
t.Fatalf("expected bare repo to be created on push, but %q is missing: %v", "/test.git/config", err)
if _, err := fs.Stat("/acme/test/config"); err != nil {
t.Fatalf("expected bare repo to be created on push, but %q is missing: %v", "/acme/test/config", err)
}

// Round-trip: a clone must recover the pushed commit.
Expand Down Expand Up @@ -207,9 +214,9 @@ func TestDaemonPushDisabled(t *testing.T) {

fs := memfs.New()
d := &daemon{
fs: fs,
loader: transport.NewFilesystemLoader(fs, false),
authz: auth.AllowAnonymous{AllowWrite: false},
sysFS: fs,
resolver: repofs.BucketResolver{Base: fs},
authz: auth.AllowAnonymous{AllowWrite: false},
}

ctx, cancel := context.WithCancel(context.Background())
Expand All @@ -221,7 +228,7 @@ func TestDaemonPushDisabled(t *testing.T) {
}
go func() { _ = d.ServeGitProtocol(ctx, ln) }()

remote := "git://" + ln.Addr().String() + "/test.git"
remote := "git://" + ln.Addr().String() + "/acme/test.git"

work := t.TempDir()
runGit(t, work, "init", "-b", "main")
Expand All @@ -233,7 +240,7 @@ func TestDaemonPushDisabled(t *testing.T) {
t.Fatalf("expected push to be rejected when allowPush is false, got success:\n%s", out)
}

if _, err := fs.Stat("/test.git/config"); err == nil {
if _, err := fs.Stat("/acme/test/config"); err == nil {
t.Fatal("repository must not be created when push is disabled")
}
}
Expand All @@ -251,9 +258,9 @@ func TestDaemonPushKeepsPack(t *testing.T) {

fs := memfs.New()
d := &daemon{
fs: fs,
loader: transport.NewFilesystemLoader(fs, false),
authz: auth.AllowAnonymous{AllowWrite: true},
sysFS: fs,
resolver: repofs.BucketResolver{Base: fs},
authz: auth.AllowAnonymous{AllowWrite: true},
}

ctx, cancel := context.WithCancel(context.Background())
Expand All @@ -265,7 +272,7 @@ func TestDaemonPushKeepsPack(t *testing.T) {
}
go func() { _ = d.ServeGitProtocol(ctx, ln) }()

remote := "git://" + ln.Addr().String() + "/test.git"
remote := "git://" + ln.Addr().String() + "/acme/test.git"

work := t.TempDir()
runGit(t, work, "init", "-b", "main")
Expand All @@ -276,7 +283,7 @@ func TestDaemonPushKeepsPack(t *testing.T) {
runGit(t, work, "commit", "-m", "initial") // blob + tree + commit
runGit(t, work, "push", remote, "main")

assertPackedRepo(t, fs, "/test.git")
assertPackedRepo(t, fs, "/acme/test")
}

// assertPackedRepo fails unless repoPath holds at least one packfile and no loose
Expand Down
20 changes: 10 additions & 10 deletions cmd/objgitd/head_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import (
"github.com/go-git/go-billy/v6/memfs"
"github.com/go-git/go-git/v6/plumbing"
"github.com/go-git/go-git/v6/plumbing/cache"
"github.com/go-git/go-git/v6/plumbing/transport"
"github.com/go-git/go-git/v6/storage/filesystem"
"github.com/tigrisdata/objgit/internal/auth"
"github.com/tigrisdata/objgit/internal/repofs"
)

// dummyHash is a stand-in object id for branch refs in unit tests; ensureHEAD
Expand Down Expand Up @@ -148,11 +148,11 @@ func TestSmartHTTPHealsDanglingHEAD(t *testing.T) {
}

fs := memfs.New()
ts := httptest.NewServer(&daemon{
fs: fs,
loader: transport.NewFilesystemLoader(fs, false),
authz: auth.AllowAnonymous{AllowWrite: true},
})
ts := httptest.NewServer((&daemon{
sysFS: fs,
resolver: repofs.BucketResolver{Base: fs},
authz: auth.AllowAnonymous{AllowWrite: true},
}).httpHandler())
t.Cleanup(ts.Close)

// Push a single "master" branch (no "main"), like a project whose default
Expand All @@ -164,18 +164,18 @@ func TestSmartHTTPHealsDanglingHEAD(t *testing.T) {
writeFile(t, filepath.Join(work, "README.md"), "hello\n")
runGit(t, work, "add", ".")
runGit(t, work, "commit", "-m", "initial")
if out, err := tryGit(work, "push", ts.URL+"/go.git", "master"); err != nil {
if out, err := tryGit(work, "push", ts.URL+"/acme/go.git", "master"); err != nil {
t.Fatalf("push failed: %v\n%s", err, out)
}

// Re-break HEAD to simulate a repo created before this fix (post-push heal
// would otherwise have already fixed it): point HEAD back at the dangling
// refs/heads/main directly in the backing store. The very next load (this
// clone) must heal it on the way to serving the advertisement.
breakHEAD(t, fs, "/go.git")
breakHEAD(t, fs, "/acme/go")

dst := t.TempDir()
out, err := tryGit(dst, "clone", ts.URL+"/go.git", "cloned")
out, err := tryGit(dst, "clone", ts.URL+"/acme/go.git", "cloned")
if err != nil {
t.Fatalf("clone failed: %v\n%s", err, out)
}
Expand All @@ -195,7 +195,7 @@ func TestSmartHTTPHealsDanglingHEAD(t *testing.T) {
}

// After a load-healed clone, the advertisement now carries the symref.
if body := getInfoRefs(t, ts.URL+"/go.git"); !strings.Contains(body, "symref=HEAD:refs/heads/master") {
if body := getInfoRefs(t, ts.URL+"/acme/go.git"); !strings.Contains(body, "symref=HEAD:refs/heads/master") {
t.Errorf("expected symref=HEAD:refs/heads/master after heal; advertisement:\n%q", body)
}
}
Expand Down
Loading
Loading