M remoteapi/integration_test.go => remoteapi/integration_test.go +69 -0
@@ 321,6 321,75 @@ func TestRemoteAPIIntegration(t *testing.T) {
}
t.Logf("RO grantee push correctly denied:\n%s", out)
})
+
+ // (8) Push-to-create: alice pushes to a brand-new db that has no repository
+ // row and no store; the server auto-creates a PRIVATE repo + an empty store
+ // and the initial push (a fast-forward from the empty root) succeeds. This is
+ // the end-to-end proof that InitEmptyStore does NOT write an initial commit —
+ // an "Initialize data repository" commit on the remote would make this a
+ // non-fast-forward and the push would be rejected.
+ t.Run("push_to_create", func(t *testing.T) {
+ newURL := fmt.Sprintf("http://%s/~alice/created", addr)
+ env := append(append([]string{}, baseEnv...), "DOLT_REMOTE_PASSWORD="+pat)
+
+ // A fresh local db (dolt init writes its own initial commit) to push from.
+ work := filepath.Join(t.TempDir(), "created")
+ if err := os.MkdirAll(work, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ runDolt(t, env, work, "init")
+ runDolt(t, env, work, "sql", "-q", "create table made(i int primary key); insert into made values (42);")
+ runDolt(t, env, work, "commit", "-Am", "created via push")
+ runDolt(t, env, work, "remote", "add", "origin", newURL)
+ runDolt(t, env, work, "push", "--user", "alice", "origin", "main")
+
+ // The repository row now exists: PRIVATE, owned by alice.
+ created, err := store.GetRepoByOwnerAndName(ctx, "alice", "created")
+ if err != nil {
+ t.Fatalf("auto-created repo row missing after push: %v", err)
+ }
+ if created.Visibility != core.VisibilityPrivate {
+ t.Fatalf("auto-created repo visibility = %s, want PRIVATE", created.Visibility)
+ }
+ if created.OwnerID != 1 {
+ t.Fatalf("auto-created repo owner = %d, want 1 (alice)", created.OwnerID)
+ }
+
+ // It is PRIVATE, so re-clone with alice's PAT and verify the row landed.
+ dir2 := t.TempDir()
+ runDolt(t, env, dir2, "clone", "--user", "alice", newURL)
+ out := runDolt(t, env, filepath.Join(dir2, "created"), "sql", "-q", "select i from made", "-r", "csv")
+ if !strings.Contains(out, "42") {
+ t.Fatalf("pushed row not present after re-clone; got:\n%s", out)
+ }
+ t.Log("push-to-create succeeded and re-clone returned the pushed row")
+ })
+
+ // (9) Push-to-create is refused in another user's namespace: bob (valid RW
+ // PAT) cannot create under ~alice. The push fails and no row is created — the
+ // owner-only guard, which also avoids leaking a stranger's namespace.
+ t.Run("push_to_create_foreign_namespace_denied", func(t *testing.T) {
+ foreignURL := fmt.Sprintf("http://%s/~alice/bobtried", addr)
+ bobPAT := forgePAT("bob", "dolt.sr.ht/repos:RW", time.Now().Add(time.Hour))
+ env := append(append([]string{}, baseEnv...), "DOLT_REMOTE_PASSWORD="+bobPAT)
+
+ work := filepath.Join(t.TempDir(), "bobtried")
+ if err := os.MkdirAll(work, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ runDolt(t, env, work, "init")
+ runDolt(t, env, work, "sql", "-q", "create table t(i int primary key); insert into t values (1);")
+ runDolt(t, env, work, "commit", "-Am", "bob change")
+ runDolt(t, env, work, "remote", "add", "origin", foreignURL)
+ out, err := tryDolt(env, work, "push", "--user", "bob", "origin", "main")
+ if err == nil {
+ t.Fatalf("bob push-to-create under ~alice unexpectedly succeeded:\n%s", out)
+ }
+ if _, err := store.GetRepoByOwnerAndName(ctx, "alice", "bobtried"); err == nil {
+ t.Fatalf("foreign-namespace push must not create a repository row")
+ }
+ t.Logf("foreign-namespace push-to-create correctly denied:\n%s", out)
+ })
}
// --- helpers ---
M remoteapi/interceptors.go => remoteapi/interceptors.go +86 -8
@@ 6,9 6,6 @@ import (
"errors"
"fmt"
- "sourcecraft.dev/bigbes/sr-ht-core/auth"
- "sourcecraft.dev/bigbes/sr-ht-core/config"
- "sourcecraft.dev/bigbes/sr-ht-core/database"
remotesapi "github.com/dolthub/dolt/go/gen/proto/dolt/services/remotesapi/v1alpha1"
"github.com/sirupsen/logrus"
"github.com/vaughan0/go-ini"
@@ 16,10 13,14 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
+ "sourcecraft.dev/bigbes/sr-ht-core/auth"
+ "sourcecraft.dev/bigbes/sr-ht-core/config"
+ "sourcecraft.dev/bigbes/sr-ht-core/database"
"sourcecraft.dev/bigbes/sr-ht-dolt/authn"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
"sourcecraft.dev/bigbes/sr-ht-dolt/db"
+ "sourcecraft.dev/bigbes/sr-ht-dolt/storage"
)
// Method-classification sets, copied verbatim from upstream remotesrv's
@@ 71,6 72,12 @@ type repoRequest interface {
type repoStore interface {
GetRepoByOwnerAndName(ctx context.Context, ownerUsername, name string) (*core.Repo, error)
EffectiveAccess(ctx context.Context, userID, repoID int) (*core.AccessMode, error)
+ // CreateRepo and DeleteRepo back push-to-create: an authenticated caller
+ // touching a not-yet-existing repo in their own namespace has the row
+ // inserted (CreateRepo) and, if the on-disk store then fails to materialize,
+ // rolled back (DeleteRepo).
+ CreateRepo(ctx context.Context, r *core.Repo) (*core.Repo, error)
+ DeleteRepo(ctx context.Context, id int) error
}
// interceptor holds the collaborators the per-RPC auth/authz decision needs. It
@@ 90,18 97,29 @@ type interceptor struct {
// stores returns a repoStore for a request. Production binds a fresh
// db.Store to the shared pool; tests inject a stub.
stores func() repoStore
+
+ // reposRoot is the absolute directory under which bare NBS stores live; it
+ // resolves the on-disk path for a push-to-created repository.
+ reposRoot string
+
+ // createStore materializes the empty on-disk store for a push-to-created
+ // repository. Production uses storage.InitEmptyStore; tests inject a fake.
+ createStore func(ctx context.Context, absPath string) error
}
// newInterceptor builds the interceptor over the shared pool. It binds a fresh
// db.Store per request (the pool owns connection lifetime, ctx bounds each
// query). pool and keys must be non-nil.
-func newInterceptor(conf ini.File, service, expectedAud string, pool *sql.DB, keys authn.KeyStore, logger *logrus.Entry) *interceptor {
+func newInterceptor(conf ini.File, service, expectedAud string, pool *sql.DB, keys authn.KeyStore, reposRoot string, createStore func(ctx context.Context, absPath string) error, logger *logrus.Entry) *interceptor {
if pool == nil {
panic("remoteapi: newInterceptor requires a non-nil *sql.DB")
}
if logger == nil {
logger = logrus.NewEntry(logrus.StandardLogger())
}
+ if createStore == nil {
+ createStore = storage.InitEmptyStore
+ }
i := &interceptor{
conf: conf,
service: service,
@@ 109,6 127,8 @@ func newInterceptor(conf ini.File, service, expectedAud string, pool *sql.DB, ke
keys: keys,
logger: logger,
pool: pool,
+ reposRoot: reposRoot,
+ createStore: createStore,
}
i.stores = func() repoStore { return db.NewStore(pool) }
return i
@@ 218,17 238,32 @@ func (i *interceptor) authorize(ctx context.Context, ac *auth.AuthContext, fullM
return nil, status.Errorf(codes.InvalidArgument, "invalid repository path %q: %v", repoPath, err)
}
+ caller := authn.AsCoreCaller(ac)
+
store := i.stores()
repo, err := store.GetRepoByOwnerAndName(ctx, owner, name)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
- return nil, status.Errorf(codes.NotFound, "repository %s/%s not found", owner, name)
+ // Push-to-create: an authenticated, non-suspended caller touching a
+ // not-yet-existing repo in THEIR OWN namespace (with a valid name)
+ // has it transparently created — a PRIVATE row plus a genuinely
+ // empty on-disk store — then proceeds through the normal ACL check
+ // as the owner. Any other case (anonymous, suspended, another
+ // user's namespace, invalid name) keeps returning NotFound, which
+ // also avoids leaking existence of a stranger's private repos.
+ if caller == nil || caller.Suspended || owner != caller.Username || core.ValidateName(name) != nil {
+ return nil, status.Errorf(codes.NotFound, "repository %s/%s not found", owner, name)
+ }
+ repo, err = i.autoCreate(ctx, store, caller, owner, name)
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ i.logger.Errorf("remotesapi repo lookup %s/%s: %v", owner, name, err)
+ return nil, status.Error(codes.Unavailable, "repository lookup temporarily unavailable")
}
- i.logger.Errorf("remotesapi repo lookup %s/%s: %v", owner, name, err)
- return nil, status.Error(codes.Unavailable, "repository lookup temporarily unavailable")
}
- caller := authn.AsCoreCaller(ac)
var aclMode *core.AccessMode
if caller != nil {
aclMode, err = store.EffectiveAccess(ctx, caller.UserID, repo.ID)
@@ 248,6 283,49 @@ func (i *interceptor) authorize(ctx context.Context, ac *auth.AuthContext, fullM
return authn.WithCaller(ctx, ac), nil
}
+// autoCreate transparently creates a PRIVATE repository owned by caller under
+// owner/name (push-to-create). It inserts the row and materializes a genuinely
+// empty on-disk store. A concurrent first-push that already created the row is
+// tolerated: the caller loses the CreateRepo race, re-fetches the winner's row,
+// and does NOT re-create the store. If the store fails to materialize, the row
+// is rolled back so a later push retries cleanly. Preconditions (authenticated,
+// non-suspended, owns the namespace, valid name) are checked by the caller.
+func (i *interceptor) autoCreate(ctx context.Context, store repoStore, caller *core.Caller, owner, name string) (*core.Repo, error) {
+ newRepo := &core.Repo{
+ Name: name,
+ OwnerID: caller.UserID,
+ OwnerName: owner,
+ Path: storage.RepoDiskPath(i.reposRoot, owner, name),
+ Visibility: core.VisibilityPrivate,
+ }
+ created, cerr := store.CreateRepo(ctx, newRepo)
+ if cerr != nil {
+ if errors.Is(cerr, db.ErrNameTaken) {
+ // Lost the race: another first-push created the row (and its store).
+ // Adopt the winner's row; do not touch disk.
+ repo, gerr := store.GetRepoByOwnerAndName(ctx, owner, name)
+ if gerr != nil {
+ i.logger.Errorf("remotesapi auto-create refetch %s/%s: %v", owner, name, gerr)
+ return nil, status.Error(codes.Unavailable, "repository creation temporarily unavailable")
+ }
+ return repo, nil
+ }
+ i.logger.Errorf("remotesapi auto-create %s/%s: %v", owner, name, cerr)
+ return nil, status.Error(codes.Unavailable, "repository creation temporarily unavailable")
+ }
+
+ if serr := i.createStore(ctx, created.Path); serr != nil {
+ // The row exists but the store does not: roll the row back (best effort)
+ // so the repo does not linger half-created and a retry can succeed.
+ if derr := store.DeleteRepo(ctx, created.ID); derr != nil {
+ i.logger.Errorf("remotesapi auto-create rollback %s/%s (id=%d): %v", owner, name, created.ID, derr)
+ }
+ i.logger.Errorf("remotesapi auto-create store %s/%s: %v", owner, name, serr)
+ return nil, status.Error(codes.Unavailable, "repository creation temporarily unavailable")
+ }
+ return created, nil
+}
+
// repoPathOf extracts the target repo path from a request without panicking on
// an absent path (upstream's getRepoPath panics on empty path + nil repo id).
// It returns "" when neither is present so the caller can treat Root specially.
M remoteapi/interceptors_test.go => remoteapi/interceptors_test.go +198 -25
@@ 2,22 2,28 @@ package remoteapi
import (
"context"
+ "errors"
"testing"
- "sourcecraft.dev/bigbes/sr-ht-core/auth"
- "sourcecraft.dev/bigbes/sr-ht-core/config"
remotesapi "github.com/dolthub/dolt/go/gen/proto/dolt/services/remotesapi/v1alpha1"
"github.com/sirupsen/logrus"
"github.com/vaughan0/go-ini"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
+ "sourcecraft.dev/bigbes/sr-ht-core/auth"
+ "sourcecraft.dev/bigbes/sr-ht-core/config"
"sourcecraft.dev/bigbes/sr-ht-dolt/authn"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
"sourcecraft.dev/bigbes/sr-ht-dolt/db"
+ "sourcecraft.dev/bigbes/sr-ht-dolt/storage"
)
+// errBoom is a sentinel failure injected into createStore to exercise the
+// push-to-create rollback path.
+var errBoom = errors.New("boom")
+
const (
mRoot = "/dolt.services.remotesapi.v1alpha1.ChunkStoreService/Root"
mGetMeta = "/dolt.services.remotesapi.v1alpha1.ChunkStoreService/GetRepoMetadata"
@@ 37,25 43,64 @@ type fakeReq struct {
func (f fakeReq) GetRepoPath() string { return f.path }
func (f fakeReq) GetRepoId() *remotesapi.RepoId { return f.id }
-// stubStore is an in-memory repoStore: no Postgres.
+// stubStore is an in-memory repoStore: no Postgres. It is a pointer receiver so
+// tests can inspect recorded auto-create calls after authorize runs.
type stubStore struct {
repo *core.Repo
repoErr error
acl *core.AccessMode
aclErr error
+
+ // createErr, when set, is returned by CreateRepo (e.g. db.ErrNameTaken to
+ // simulate losing the first-push race).
+ createErr error
+ // refetchRepo is returned by GetRepoByOwnerAndName after a CreateRepo that
+ // failed with ErrNameTaken (the concurrent winner's row). When nil, repo is
+ // returned instead.
+ refetchRepo *core.Repo
+
+ // Recorded calls.
+ created *core.Repo // the repo passed to CreateRepo
+ createCalls int
+ deletedID int
+ deleteCalls int
+ getCalls int
}
-func (s stubStore) GetRepoByOwnerAndName(_ context.Context, _, _ string) (*core.Repo, error) {
+func (s *stubStore) GetRepoByOwnerAndName(_ context.Context, _, _ string) (*core.Repo, error) {
+ s.getCalls++
+ // After a CreateRepo lost the ErrNameTaken race, the re-fetch resolves the
+ // concurrent winner's row.
+ if s.createCalls > 0 && s.refetchRepo != nil {
+ return s.refetchRepo, nil
+ }
if s.repoErr != nil {
return nil, s.repoErr
}
return s.repo, nil
}
-func (s stubStore) EffectiveAccess(_ context.Context, _, _ int) (*core.AccessMode, error) {
+func (s *stubStore) EffectiveAccess(_ context.Context, _, _ int) (*core.AccessMode, error) {
return s.acl, s.aclErr
}
+func (s *stubStore) CreateRepo(_ context.Context, r *core.Repo) (*core.Repo, error) {
+ s.createCalls++
+ s.created = r
+ if s.createErr != nil {
+ return nil, s.createErr
+ }
+ out := *r
+ out.ID = 999
+ return &out, nil
+}
+
+func (s *stubStore) DeleteRepo(_ context.Context, id int) error {
+ s.deleteCalls++
+ s.deletedID = id
+ return nil
+}
+
func testInterceptor(store repoStore) *interceptor {
return &interceptor{
conf: ini.File{},
@@ 63,6 108,8 @@ func testInterceptor(store repoStore) *interceptor {
expectedAud: "dolt.srht.bigb.es",
logger: logrus.NewEntry(logrus.New()),
stores: func() repoStore { return store },
+ reposRoot: "/tmp/repos",
+ createStore: func(context.Context, string) error { return nil },
}
}
@@ 137,77 184,77 @@ func TestAuthorize(t *testing.T) {
req := fakeReq{path: "~alice/db"}
t.Run("anonymous public read allowed", func(t *testing.T) {
- i := testInterceptor(stubStore{repo: repo(1, 100, core.VisibilityPublic)})
+ i := testInterceptor(&stubStore{repo: repo(1, 100, core.VisibilityPublic)})
if _, err := i.authorize(ctx, nil, mGetMeta, req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("anonymous public push denied (visible)", func(t *testing.T) {
- i := testInterceptor(stubStore{repo: repo(1, 100, core.VisibilityPublic)})
+ i := testInterceptor(&stubStore{repo: repo(1, 100, core.VisibilityPublic)})
_, err := i.authorize(ctx, nil, mCommit, req)
wantCode(t, err, codes.PermissionDenied)
})
t.Run("anonymous private read is not found", func(t *testing.T) {
- i := testInterceptor(stubStore{repo: repo(1, 100, core.VisibilityPrivate)})
+ i := testInterceptor(&stubStore{repo: repo(1, 100, core.VisibilityPrivate)})
_, err := i.authorize(ctx, nil, mGetMeta, req)
wantCode(t, err, codes.NotFound)
})
t.Run("owner push allowed", func(t *testing.T) {
- i := testInterceptor(stubStore{repo: repo(1, 42, core.VisibilityPrivate)})
+ i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPrivate)})
if _, err := i.authorize(ctx, cookieCaller(42), mCommit, req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("non-owner private read no acl is not found", func(t *testing.T) {
- i := testInterceptor(stubStore{repo: repo(1, 42, core.VisibilityPrivate)})
+ i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPrivate)})
_, err := i.authorize(ctx, cookieCaller(7), mGetMeta, req)
wantCode(t, err, codes.NotFound)
})
t.Run("acl RO reads private", func(t *testing.T) {
- i := testInterceptor(stubStore{repo: repo(1, 42, core.VisibilityPrivate), acl: acl(core.AccessRO)})
+ i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPrivate), acl: acl(core.AccessRO)})
if _, err := i.authorize(ctx, cookieCaller(7), mGetMeta, req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("acl RO cannot push private (visible ⇒ PermissionDenied)", func(t *testing.T) {
- i := testInterceptor(stubStore{repo: repo(1, 42, core.VisibilityPrivate), acl: acl(core.AccessRO)})
+ i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPrivate), acl: acl(core.AccessRO)})
_, err := i.authorize(ctx, cookieCaller(7), mCommit, req)
wantCode(t, err, codes.PermissionDenied)
})
t.Run("acl RW pushes private", func(t *testing.T) {
- i := testInterceptor(stubStore{repo: repo(1, 42, core.VisibilityPrivate), acl: acl(core.AccessRW)})
+ i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPrivate), acl: acl(core.AccessRW)})
if _, err := i.authorize(ctx, cookieCaller(7), mCommit, req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("unknown method denied", func(t *testing.T) {
- i := testInterceptor(stubStore{repo: repo(1, 100, core.VisibilityPublic)})
+ i := testInterceptor(&stubStore{repo: repo(1, 100, core.VisibilityPublic)})
_, err := i.authorize(ctx, nil, mUnknown, req)
wantCode(t, err, codes.PermissionDenied)
})
t.Run("malformed path is invalid argument", func(t *testing.T) {
- i := testInterceptor(stubStore{repo: repo(1, 100, core.VisibilityPublic)})
+ i := testInterceptor(&stubStore{repo: repo(1, 100, core.VisibilityPublic)})
_, err := i.authorize(ctx, nil, mGetMeta, fakeReq{path: "no-slash"})
wantCode(t, err, codes.InvalidArgument)
})
t.Run("missing repo row is not found", func(t *testing.T) {
- i := testInterceptor(stubStore{repoErr: db.ErrNotFound})
+ i := testInterceptor(&stubStore{repoErr: db.ErrNotFound})
_, err := i.authorize(ctx, nil, mGetMeta, req)
wantCode(t, err, codes.NotFound)
})
t.Run("root ping with no path is allowed", func(t *testing.T) {
- i := testInterceptor(stubStore{})
+ i := testInterceptor(&stubStore{})
got, err := i.authorize(ctx, nil, mRoot, fakeReq{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ 218,13 265,13 @@ func TestAuthorize(t *testing.T) {
})
t.Run("non-root with no path is invalid argument", func(t *testing.T) {
- i := testInterceptor(stubStore{})
+ i := testInterceptor(&stubStore{})
_, err := i.authorize(ctx, nil, mGetMeta, fakeReq{})
wantCode(t, err, codes.InvalidArgument)
})
t.Run("repo id shape resolves path", func(t *testing.T) {
- i := testInterceptor(stubStore{repo: repo(1, 100, core.VisibilityPublic)})
+ i := testInterceptor(&stubStore{repo: repo(1, 100, core.VisibilityPublic)})
r := fakeReq{id: &remotesapi.RepoId{Org: "alice", RepoName: "db"}}
if _, err := i.authorize(ctx, nil, mGetMeta, r); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ 237,39 284,165 @@ func TestAuthorizeGrantGate(t *testing.T) {
req := fakeReq{path: "~alice/db"}
t.Run("PAT without RO grant denied on read", func(t *testing.T) {
- i := testInterceptor(stubStore{repo: repo(1, 42, core.VisibilityPublic)})
+ i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPublic)})
_, err := i.authorize(ctx, patCaller(t, 42, "meta.sr.ht/profile:RO"), mGetMeta, req)
wantCode(t, err, codes.PermissionDenied)
})
t.Run("PAT with RO grant allowed on read of own repo", func(t *testing.T) {
- i := testInterceptor(stubStore{repo: repo(1, 42, core.VisibilityPublic)})
+ i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPublic)})
if _, err := i.authorize(ctx, patCaller(t, 42, "dolt.sr.ht/repos:RO"), mGetMeta, req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("PAT with RO grant cannot push", func(t *testing.T) {
- i := testInterceptor(stubStore{repo: repo(1, 42, core.VisibilityPublic)})
+ i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPublic)})
_, err := i.authorize(ctx, patCaller(t, 42, "dolt.sr.ht/repos:RO"), mCommit, req)
wantCode(t, err, codes.PermissionDenied)
})
t.Run("PAT with RW grant pushes own repo", func(t *testing.T) {
- i := testInterceptor(stubStore{repo: repo(1, 42, core.VisibilityPublic)})
+ i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPublic)})
if _, err := i.authorize(ctx, patCaller(t, 42, "dolt.sr.ht/repos:RW"), mCommit, req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("universal PAT (empty grants) passes gate", func(t *testing.T) {
- i := testInterceptor(stubStore{repo: repo(1, 42, core.VisibilityPublic)})
+ i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPublic)})
if _, err := i.authorize(ctx, patCaller(t, 42, ""), mCommit, req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
+// TestAuthorizePushToCreate covers the push-to-create branch of authorize: an
+// authenticated owner touching a not-yet-existing repo in their own namespace
+// transparently creates it (PRIVATE row + empty on-disk store), while every
+// other case keeps returning NotFound and creates nothing.
+func TestAuthorizePushToCreate(t *testing.T) {
+ ctx := context.Background()
+ req := fakeReq{path: "~alice/newdb"}
+
+ t.Run("owner create on GetRepoMetadata read", func(t *testing.T) {
+ st := &stubStore{repoErr: db.ErrNotFound}
+ var gotPath string
+ i := testInterceptor(st)
+ i.createStore = func(_ context.Context, p string) error { gotPath = p; return nil }
+
+ // The first RPC of a push is GetRepoMetadata (a read); auto-create fires.
+ if _, err := i.authorize(ctx, cookieCaller(42), mGetMeta, req); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if st.createCalls != 1 {
+ t.Fatalf("CreateRepo calls = %d, want 1", st.createCalls)
+ }
+ if st.created == nil || st.created.Visibility != core.VisibilityPrivate {
+ t.Fatalf("created repo visibility = %v, want PRIVATE", st.created)
+ }
+ if st.created.OwnerID != 42 || st.created.Name != "newdb" || st.created.OwnerName != "alice" {
+ t.Fatalf("created repo = %+v, want owner 42 alice/newdb", st.created)
+ }
+ wantPath := storage.RepoDiskPath("/tmp/repos", "alice", "newdb")
+ if st.created.Path != wantPath || gotPath != wantPath {
+ t.Fatalf("store path: created=%q createStore=%q, want %q", st.created.Path, gotPath, wantPath)
+ }
+ if st.deleteCalls != 0 {
+ t.Fatalf("DeleteRepo calls = %d, want 0", st.deleteCalls)
+ }
+ })
+
+ t.Run("owner create then push allowed", func(t *testing.T) {
+ st := &stubStore{repoErr: db.ErrNotFound}
+ i := testInterceptor(st)
+ if _, err := i.authorize(ctx, cookieCaller(42), mCommit, req); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if st.createCalls != 1 {
+ t.Fatalf("CreateRepo calls = %d, want 1", st.createCalls)
+ }
+ })
+
+ t.Run("name-taken race re-fetches, no store creation", func(t *testing.T) {
+ winner := &core.Repo{ID: 7, Name: "newdb", OwnerID: 42, OwnerName: "alice", Visibility: core.VisibilityPrivate}
+ st := &stubStore{repoErr: db.ErrNotFound, createErr: db.ErrNameTaken, refetchRepo: winner}
+ var storeCreated bool
+ i := testInterceptor(st)
+ i.createStore = func(context.Context, string) error { storeCreated = true; return nil }
+
+ if _, err := i.authorize(ctx, cookieCaller(42), mCommit, req); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if st.createCalls != 1 {
+ t.Fatalf("CreateRepo calls = %d, want 1", st.createCalls)
+ }
+ if storeCreated {
+ t.Fatal("createStore must not be called when the row already exists")
+ }
+ if st.deleteCalls != 0 {
+ t.Fatalf("DeleteRepo calls = %d, want 0", st.deleteCalls)
+ }
+ })
+
+ t.Run("store creation failure rolls back row", func(t *testing.T) {
+ st := &stubStore{repoErr: db.ErrNotFound}
+ i := testInterceptor(st)
+ i.createStore = func(context.Context, string) error { return errBoom }
+
+ _, err := i.authorize(ctx, cookieCaller(42), mCommit, req)
+ wantCode(t, err, codes.Unavailable)
+ if st.deleteCalls != 1 || st.deletedID != 999 {
+ t.Fatalf("rollback: deleteCalls=%d deletedID=%d, want 1 and 999", st.deleteCalls, st.deletedID)
+ }
+ })
+
+ t.Run("non-owner namespace is not found, no create", func(t *testing.T) {
+ st := &stubStore{repoErr: db.ErrNotFound}
+ i := testInterceptor(st)
+ // caller bob touching ~alice/newdb.
+ bob := &auth.AuthContext{UserID: 7, Username: "bob", UserType: auth.USER_TYPE_USER, AuthMethod: auth.AUTH_COOKIE}
+ _, err := i.authorize(ctx, bob, mCommit, req)
+ wantCode(t, err, codes.NotFound)
+ if st.createCalls != 0 {
+ t.Fatalf("CreateRepo calls = %d, want 0", st.createCalls)
+ }
+ })
+
+ t.Run("anonymous is not found, no create", func(t *testing.T) {
+ st := &stubStore{repoErr: db.ErrNotFound}
+ i := testInterceptor(st)
+ _, err := i.authorize(ctx, nil, mCommit, req)
+ wantCode(t, err, codes.NotFound)
+ if st.createCalls != 0 {
+ t.Fatalf("CreateRepo calls = %d, want 0", st.createCalls)
+ }
+ })
+
+ t.Run("suspended owner is not found, no create", func(t *testing.T) {
+ st := &stubStore{repoErr: db.ErrNotFound}
+ i := testInterceptor(st)
+ suspended := &auth.AuthContext{UserID: 42, Username: "alice", UserType: auth.USER_TYPE_SUSPENDED, AuthMethod: auth.AUTH_COOKIE}
+ _, err := i.authorize(ctx, suspended, mCommit, req)
+ wantCode(t, err, codes.NotFound)
+ if st.createCalls != 0 {
+ t.Fatalf("CreateRepo calls = %d, want 0", st.createCalls)
+ }
+ })
+
+ t.Run("invalid name is not found, no create", func(t *testing.T) {
+ st := &stubStore{repoErr: db.ErrNotFound}
+ i := testInterceptor(st)
+ // "bad name" passes ParseRepoPath (2 non-traversal segments) but fails
+ // core.ValidateName (space is not an allowed character).
+ _, err := i.authorize(ctx, cookieCaller(42), mCommit, fakeReq{path: "~alice/bad name"})
+ wantCode(t, err, codes.NotFound)
+ if st.createCalls != 0 {
+ t.Fatalf("CreateRepo calls = %d, want 0", st.createCalls)
+ }
+ })
+}
+
// TestUnaryAnonymous exercises the full unary path (authenticate + authorize)
// for an anonymous request: no metadata ⇒ anonymous caller, no crypto/PG.
func TestUnaryAnonymous(t *testing.T) {
@@ 277,7 450,7 @@ func TestUnaryAnonymous(t *testing.T) {
conf: ini.File{},
service: "dolt.sr.ht",
logger: logrus.NewEntry(logrus.New()),
- stores: func() repoStore { return stubStore{repo: repo(1, 100, core.VisibilityPublic)} },
+ stores: func() repoStore { return &stubStore{repo: repo(1, 100, core.VisibilityPublic)} },
}
var gotCaller bool
handler := func(ctx context.Context, _ any) (any, error) {
M remoteapi/server.go => remoteapi/server.go +6 -4
@@ 78,9 78,11 @@ func New(cfg Config) (*Server, error) {
}
// Repo lookup: resolve owner/name to the absolute on-disk store dir via the
- // repository row. v1 has no push-to-create, so a missing row is an error the
- // cache propagates (mapped to gRPC NotFound at the interceptor layer, which
- // runs first anyway).
+ // repository row. By the time Cache.Get runs, the row already exists: the
+ // interceptor runs first on every RPC and, for an authenticated owner
+ // touching a new name in their own namespace, has auto-created the row and
+ // its empty store (push-to-create). A still-missing row here is therefore a
+ // genuine not-found, propagated to the cache's caller.
lookup := func(ctx context.Context, owner, name string) (string, error) {
repo, err := db.NewStore(cfg.DB).GetRepoByOwnerAndName(ctx, owner, name)
if err != nil {
@@ 91,7 93,7 @@ func New(cfg Config) (*Server, error) {
cache := storage.NewCache(lookup)
keys := newKeyStore(cfg.DB)
- icept := newInterceptor(cfg.Conf, serviceName, normalizeAud(cfg.HttpHost), cfg.DB, keys, logger)
+ icept := newInterceptor(cfg.Conf, serviceName, normalizeAud(cfg.HttpHost), cfg.DB, keys, cfg.ReposRoot, storage.InitEmptyStore, logger)
// Load-bearing (see storage/init.go): the FS MUST be rooted at ReposRoot via
// LocalFilesysWithWorkingDir so sealed chunk-download URLs carry clean
M storage/init.go => storage/init.go +39 -0
@@ 31,6 31,7 @@ import (
"github.com/dolthub/dolt/go/libraries/doltcore/doltdb"
"github.com/dolthub/dolt/go/libraries/utils/earl"
"github.com/dolthub/dolt/go/libraries/utils/filesys"
+ "github.com/dolthub/dolt/go/store/nbs"
"github.com/dolthub/dolt/go/store/types"
)
@@ 83,6 84,44 @@ func InitStore(ctx context.Context, absPath, ownerName, ownerEmail string) (err
return nil
}
+// InitEmptyStore creates a genuinely empty bare NBS chunk store at absPath —
+// a directory whose store root is the empty hash, with NO commits and NO
+// working set. Unlike InitStore it deliberately does NOT call WriteEmptyRepo:
+// an initial "Initialize data repository" commit would make the first push to
+// this store a non-fast-forward and be rejected. An empty store lets the
+// client's first push land as the initial history. Used by push-to-create.
+// absPath must be absolute; on any failure the directory is removed.
+func InitEmptyStore(ctx context.Context, absPath string) (err error) {
+ if !filepath.IsAbs(absPath) {
+ return fmt.Errorf("storage: InitEmptyStore requires an absolute path, got %q", absPath)
+ }
+
+ if err := os.MkdirAll(absPath, 0o755); err != nil {
+ return fmt.Errorf("storage: create store dir %q: %w", absPath, err)
+ }
+ // Any error past this point must not leave a half-written store behind.
+ defer func() {
+ if err != nil {
+ os.RemoveAll(absPath)
+ }
+ }()
+
+ // Opening a plain (non-generational) NBS store over the freshly created,
+ // empty directory both validates the directory (checkDir requires it to
+ // exist) and confirms the store is a valid empty store (a missing manifest
+ // is treated lazily as an empty store with the null root). This mirrors the
+ // construction storage.Cache.Get uses to serve pushes; a plain store closes
+ // cleanly, unlike the generational store LoadDoltDB routes through.
+ cs, err := nbs.NewLocalStore(ctx, types.Format_DOLT.VersionString(), absPath, defaultMemTableSize, nbs.NewUnlimitedMemQuotaProvider(), false)
+ if err != nil {
+ return fmt.Errorf("storage: open empty store at %q: %w", absPath, err)
+ }
+ if err = cs.Close(); err != nil {
+ return fmt.Errorf("storage: close empty store at %q: %w", absPath, err)
+ }
+ return nil
+}
+
// DeleteStore removes the store directory at absPath. It refuses to delete
// anything that is not strictly contained within root, guarding against a
// corrupted or attacker-controlled path escaping the configured repos root.
A storage/initempty_test.go => storage/initempty_test.go +48 -0
@@ 0,0 1,48 @@
+package storage
+
+import (
+ "context"
+ "os"
+ "testing"
+
+ "github.com/dolthub/dolt/go/store/hash"
+ "github.com/dolthub/dolt/go/store/nbs"
+ "github.com/dolthub/dolt/go/store/types"
+)
+
+// TestInitEmptyStore verifies that InitEmptyStore creates a directory whose NBS
+// store root is the empty hash (no initial commit), so a client's first push
+// lands as fast-forward-from-empty.
+func TestInitEmptyStore(t *testing.T) {
+ ctx := context.Background()
+ root := t.TempDir()
+ absPath := RepoDiskPath(root, "alice", "empty")
+
+ if err := InitEmptyStore(ctx, absPath); err != nil {
+ t.Fatalf("InitEmptyStore: %v", err)
+ }
+
+ info, err := os.Stat(absPath)
+ if err != nil {
+ t.Fatalf("stat store dir: %v", err)
+ }
+ if !info.IsDir() {
+ t.Fatalf("expected %q to be a directory", absPath)
+ }
+
+ // Reopen via the same construction Cache.Get uses and confirm the store has
+ // no root (the empty hash) — i.e. no commits were written.
+ cs, err := nbs.NewLocalStore(ctx, types.Format_DOLT.VersionString(), absPath, defaultMemTableSize, nbs.NewUnlimitedMemQuotaProvider(), false)
+ if err != nil {
+ t.Fatalf("reopen store: %v", err)
+ }
+ defer cs.Close()
+
+ rootHash, err := cs.Root(ctx)
+ if err != nil {
+ t.Fatalf("Root: %v", err)
+ }
+ if rootHash != (hash.Hash{}) {
+ t.Fatalf("expected empty root hash, got %s", rootHash.String())
+ }
+}