From a1b45d1b01e695328974db79b84c5d5d49d871ed Mon Sep 17 00:00:00 2001 From: Axenos-dev Date: Fri, 10 Jul 2026 19:05:08 -0700 Subject: [PATCH] Added archive prefix parameter --- README.md | 6 ++- internal/archive/transform_test.go | 15 ++++++ internal/domain/archive.go | 35 ++++++++++++- internal/domain/archive_test.go | 40 +++++++++++++++ .../server/control/repositories/archive.go | 15 +++++- .../server/control/repositories/handlers.go | 2 +- .../control/repositories/handlers_test.go | 51 +++++++++++++++++-- internal/services/repositories/errors.go | 5 +- internal/services/repositories/service.go | 19 +++++-- .../services/repositories/service_test.go | 29 +++++++---- 10 files changed, 195 insertions(+), 22 deletions(-) create mode 100644 internal/domain/archive_test.go diff --git a/README.md b/README.md index 7a74d5d..2764f50 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ Every request requires `Authorization: Bearer `. Responses are enve | ------ | ---------------------------------------------- | ----------- | ---------------------------------------------------------------------- | | `GET` | `/repositories/{id}/contents?ref=&path=` | — | List one directory level of the tree at a ref. | | `GET` | `/repositories/{id}/blob?ref=&path=&lfs=` | — | Stream one file's raw content. | -| `GET` | `/repositories/{id}/archive?ref=&format=&lfs=` | — | Stream a `zip` (default) or `tar.gz` archive of the tree. | +| `GET` | `/repositories/{id}/archive?ref=&format=&lfs=&prefix=` | — | Stream a `zip` (default) or `tar.gz` archive of the tree. | | `POST` | `/repositories/{id}/blobs` | _raw bytes_ | Upload content into the repo's object database; returns `{sha, size}`. | | `POST` | `/repositories/{id}/commits` | JSON | Create a commit on a branch from uploaded blobs. | @@ -196,7 +196,9 @@ Every request requires `Authorization: Bearer `. Responses are enve `GET /blob` streams the file bytes with `Content-Length`, a strong `ETag` (the blob sha — content-addressed, so `If-None-Match` caching works perfectly), and `X-HeadlessGit-Commit` carrying the resolved commit. With `lfs=true`, an LFS pointer file is replaced by the real object; a missing object is a `404` rather than silently serving the pointer. -`GET /archive` streams the whole tree as an artifact, named `-.zip` with a matching top-level folder. With `lfs=true`, pointer files are swapped for the real objects **in-flight** — the archive is re-encoded entry by entry, nothing is buffered or written to disk: +`GET /archive` streams the whole tree as an artifact, named `-.zip`. By default its entries are under the matching `-/` directory. Set `prefix=release/source` to choose another directory, or explicitly set `prefix=` to place entries at the archive root. Prefixes are relative directory paths and a trailing `/` is optional. + +With `lfs=true`, pointer files are swapped for the real objects **in-flight** — the archive is re-encoded entry by entry, nothing is buffered or written to disk: ![archive](images/archive.png) diff --git a/internal/archive/transform_test.go b/internal/archive/transform_test.go index 4d567f6..a97e86e 100644 --- a/internal/archive/transform_test.go +++ b/internal/archive/transform_test.go @@ -139,6 +139,21 @@ func TestTransformWithoutSmudge(t *testing.T) { } } +func TestTransformWithoutPrefix(t *testing.T) { + var out bytes.Buffer + if err := Transform(bytes.NewReader(buildTestTar(t, "not a pointer")), "", nil, NewZipEncoder(&out)); err != nil { + t.Fatal(err) + } + + got := readZip(t, out.Bytes()) + if got["README.md"] != "hello\n" { + t.Errorf("README.md = %q", got["README.md"]) + } + if _, ok := got["src/"]; !ok { + t.Errorf("missing root-level directory entry") + } +} + func TestTransformTarGz(t *testing.T) { oid := strings.Repeat("ab", 32) content := "REAL LFS CONTENT" diff --git a/internal/domain/archive.go b/internal/domain/archive.go index 3fbbd94..46c47bb 100644 --- a/internal/domain/archive.go +++ b/internal/domain/archive.go @@ -1,6 +1,13 @@ package domain -import "fmt" +import ( + "fmt" + "strings" + "unicode" + "unicode/utf8" +) + +const archivePrefixMaxLength = 255 type ArchiveFormat string @@ -31,6 +38,32 @@ type ArchiveRequest struct { CommitSHA string Format ArchiveFormat IncludeLFS bool + Prefix string +} + +func NormalizeArchivePrefix(prefix string) (string, bool) { + if prefix == "" { + return "", true + } + if len(prefix) > archivePrefixMaxLength || !utf8.ValidString(prefix) { + return "", false + } + if strings.HasPrefix(prefix, "/") || strings.ContainsAny(prefix, `\:`) { + return "", false + } + for _, r := range prefix { + if unicode.IsControl(r) { + return "", false + } + } + + prefix = strings.TrimSuffix(prefix, "/") + for _, segment := range strings.Split(prefix, "/") { + if segment == "" || segment == "." || segment == ".." { + return "", false + } + } + return prefix + "/", true } // Filename is the suggested artifact name: -. diff --git a/internal/domain/archive_test.go b/internal/domain/archive_test.go new file mode 100644 index 0000000..9b47667 --- /dev/null +++ b/internal/domain/archive_test.go @@ -0,0 +1,40 @@ +package domain + +import ( + "strings" + "testing" +) + +func TestNormalizeArchivePrefix(t *testing.T) { + cases := []struct { + name string + prefix string + want string + ok bool + }{ + {"empty", "", "", true}, + {"directory", "release", "release/", true}, + {"nested", "release/source", "release/source/", true}, + {"trailing slash", "release/source/", "release/source/", true}, + {"unicode", "releases/été", "releases/été/", true}, + {"absolute", "/release", "", false}, + {"dot segment", "release/./source", "", false}, + {"parent segment", "release/../source", "", false}, + {"empty segment", "release//source", "", false}, + {"multiple trailing slashes", "release//", "", false}, + {"backslash", `release\source`, "", false}, + {"windows volume", "C:/release", "", false}, + {"control character", "release\nsource", "", false}, + {"too long", strings.Repeat("a", 256), "", false}, + {"invalid utf8", string([]byte{0xff}), "", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := NormalizeArchivePrefix(tc.prefix) + if ok != tc.ok || got != tc.want { + t.Fatalf("NormalizeArchivePrefix(%q) = %q, %v; want %q, %v", tc.prefix, got, ok, tc.want, tc.ok) + } + }) + } +} diff --git a/internal/server/control/repositories/archive.go b/internal/server/control/repositories/archive.go index ede4e74..a2d89c2 100644 --- a/internal/server/control/repositories/archive.go +++ b/internal/server/control/repositories/archive.go @@ -1,6 +1,7 @@ package repositories import ( + "crypto/sha256" "errors" "fmt" "io" @@ -29,7 +30,15 @@ func (h *handlers) getArchive(w http.ResponseWriter, r *http.Request) error { } } - req, err := h.service.PrepareArchive(r.Context(), id, q.Get("ref"), q.Get("format"), includeLFS) + var prefix *string + if values, ok := q["prefix"]; ok { + if len(values) != 1 { + return response.NewError(http.StatusBadRequest, response.CodeInvalidRequest, "prefix must be specified once") + } + prefix = &values[0] + } + + req, err := h.service.PrepareArchive(r.Context(), id, q.Get("ref"), q.Get("format"), includeLFS, prefix) switch { case errors.Is(err, reposervice.ErrRepositoryNotFound): return response.NewError(http.StatusNotFound, response.CodeRepositoryNotFound, "repository not found") @@ -39,6 +48,8 @@ func (h *handlers) getArchive(w http.ResponseWriter, r *http.Request) error { return response.NewError(http.StatusBadRequest, response.CodeInvalidRequest, "invalid ref") case errors.Is(err, reposervice.ErrUnsupportedFormat): return response.NewError(http.StatusBadRequest, response.CodeInvalidRequest, "unsupported archive format") + case errors.Is(err, reposervice.ErrInvalidArchivePrefix): + return response.NewError(http.StatusBadRequest, response.CodeInvalidRequest, "invalid archive prefix") case errors.Is(err, reposervice.ErrLFSNotEnabled): return response.NewError(http.StatusBadRequest, response.CodeInvalidRequest, "lfs is not enabled") case err != nil: @@ -91,6 +102,8 @@ func archiveETag(req domain.ArchiveRequest) string { if req.IncludeLFS { variant += "-lfs" } + prefixHash := sha256.Sum256([]byte(req.Prefix)) + variant += fmt.Sprintf("-p%x", prefixHash) return fmt.Sprintf(`W/"%s-%s"`, req.CommitSHA, variant) } diff --git a/internal/server/control/repositories/handlers.go b/internal/server/control/repositories/handlers.go index d0b5056..a065c7d 100644 --- a/internal/server/control/repositories/handlers.go +++ b/internal/server/control/repositories/handlers.go @@ -18,7 +18,7 @@ type RepositoryManager interface { SetVisibility(ctx context.Context, repositoryID int64, visibility domain.RepoVisibility) (domain.Repository, error) ListByOwner(ctx context.Context, ownerID int64) ([]domain.Repository, error) Contents(ctx context.Context, repositoryID int64, ref, treePath string) (domain.RepositoryContents, error) - PrepareArchive(ctx context.Context, repositoryID int64, ref, format string, includeLFS bool) (domain.ArchiveRequest, error) + PrepareArchive(ctx context.Context, repositoryID int64, ref, format string, includeLFS bool, prefix *string) (domain.ArchiveRequest, error) StreamArchive(ctx context.Context, req domain.ArchiveRequest, out io.Writer) error PrepareBlob(ctx context.Context, repositoryID int64, ref, treePath string, includeLFS bool) (domain.BlobRequest, error) StreamBlob(ctx context.Context, req domain.BlobRequest, out io.Writer) error diff --git a/internal/server/control/repositories/handlers_test.go b/internal/server/control/repositories/handlers_test.go index b44aecf..4574e1b 100644 --- a/internal/server/control/repositories/handlers_test.go +++ b/internal/server/control/repositories/handlers_test.go @@ -25,6 +25,8 @@ type fakeManager struct { RepositoryManager prepareReq domain.ArchiveRequest prepareErr error + prefix string + prefixSet bool streamBody string streamErr error @@ -79,7 +81,11 @@ func (f *fakeManager) Commit(ctx context.Context, repositoryID int64, req domain return f.commitResult, f.commitErr } -func (f fakeManager) PrepareArchive(ctx context.Context, repositoryID int64, ref, format string, includeLFS bool) (domain.ArchiveRequest, error) { +func (f *fakeManager) PrepareArchive(ctx context.Context, repositoryID int64, ref, format string, includeLFS bool, prefix *string) (domain.ArchiveRequest, error) { + if prefix != nil { + f.prefix = *prefix + f.prefixSet = true + } return f.prepareReq, f.prepareErr } @@ -136,6 +142,7 @@ func testArchiveRequest() domain.ArchiveRequest { Repository: domain.Repository{ID: 7, RepositoryName: "myrepo"}, CommitSHA: testSHA, Format: domain.ArchiveFormatZip, + Prefix: "myrepo-aaaabbbbcccc/", } } @@ -154,7 +161,7 @@ func TestGetArchive(t *testing.T) { if got := rec.Header().Get("Content-Disposition"); got != `attachment; filename="myrepo-aaaabbbbcccc.zip"` { t.Errorf("Content-Disposition = %q", got) } - if got := rec.Header().Get("ETag"); got != `W/"`+testSHA+`-zip"` { + if got := rec.Header().Get("ETag"); got != archiveETag(testArchiveRequest()) { t.Errorf("ETag = %q", got) } if got := rec.Header().Get("X-HeadlessGit-Commit"); got != testSHA { @@ -175,7 +182,7 @@ func TestGetArchiveNotModified(t *testing.T) { router := newTestRouter(&fakeManager{prepareReq: testArchiveRequest(), streamBody: "hello"}) req := httptest.NewRequest(http.MethodGet, "/repositories/7/archive?ref=main", nil) - req.Header.Set("If-None-Match", `W/"`+testSHA+`-zip"`) + req.Header.Set("If-None-Match", archiveETag(testArchiveRequest())) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) @@ -187,6 +194,42 @@ func TestGetArchiveNotModified(t *testing.T) { } } +func TestGetArchivePrefixParameter(t *testing.T) { + cases := []struct { + name string + target string + want string + wantSet bool + }{ + {"omitted", "/repositories/7/archive", "", false}, + {"custom", "/repositories/7/archive?prefix=release%2Fsource", "release/source", true}, + {"empty", "/repositories/7/archive?prefix=", "", true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc := &fakeManager{prepareReq: testArchiveRequest(), streamBody: "hello"} + rec := httptest.NewRecorder() + newTestRouter(svc).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tc.target, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d: %s", rec.Code, rec.Body.String()) + } + if svc.prefixSet != tc.wantSet || svc.prefix != tc.want { + t.Errorf("prefix = %q, set %v; want %q, set %v", svc.prefix, svc.prefixSet, tc.want, tc.wantSet) + } + }) + } +} + +func TestArchiveETagVariesByPrefix(t *testing.T) { + a := testArchiveRequest() + b := testArchiveRequest() + b.Prefix = "release/" + if archiveETag(a) == archiveETag(b) { + t.Fatal("archive ETags must vary by prefix") + } +} + func TestGetArchiveErrors(t *testing.T) { cases := []struct { name string @@ -198,10 +241,12 @@ func TestGetArchiveErrors(t *testing.T) { }{ {"bad id", "/repositories/abc/archive", nil, nil, http.StatusBadRequest, "invalid_request"}, {"bad lfs param", "/repositories/7/archive?lfs=maybe", nil, nil, http.StatusBadRequest, "invalid_request"}, + {"duplicate prefix", "/repositories/7/archive?prefix=a&prefix=b", nil, nil, http.StatusBadRequest, "invalid_request"}, {"repo not found", "/repositories/7/archive", reposervice.ErrRepositoryNotFound, nil, http.StatusNotFound, "repository_not_found"}, {"ref not found", "/repositories/7/archive?ref=nope", reposervice.ErrRefNotFound, nil, http.StatusNotFound, "ref_not_found"}, {"invalid ref", "/repositories/7/archive?ref=--x", reposervice.ErrInvalidRef, nil, http.StatusBadRequest, "invalid_request"}, {"bad format", "/repositories/7/archive?format=rar", reposervice.ErrUnsupportedFormat, nil, http.StatusBadRequest, "invalid_request"}, + {"bad prefix", "/repositories/7/archive?prefix=..", reposervice.ErrInvalidArchivePrefix, nil, http.StatusBadRequest, "invalid_request"}, {"lfs disabled", "/repositories/7/archive?lfs=true", reposervice.ErrLFSNotEnabled, nil, http.StatusBadRequest, "invalid_request"}, {"stream fails before first byte", "/repositories/7/archive", nil, io.ErrUnexpectedEOF, http.StatusInternalServerError, "internal_error"}, } diff --git a/internal/services/repositories/errors.go b/internal/services/repositories/errors.go index 265206f..54c3868 100644 --- a/internal/services/repositories/errors.go +++ b/internal/services/repositories/errors.go @@ -13,8 +13,9 @@ var ( ErrInvalidRef = errors.New("invalid ref") ErrInvalidPath = errors.New("invalid path") - ErrUnsupportedFormat = errors.New("unsupported archive format") - ErrLFSNotEnabled = errors.New("lfs is not enabled") + ErrUnsupportedFormat = errors.New("unsupported archive format") + ErrInvalidArchivePrefix = errors.New("invalid archive prefix") + ErrLFSNotEnabled = errors.New("lfs is not enabled") ErrNotAFile = errors.New("path is not a file") ErrLFSObjectNotFound = errors.New("lfs object not found") diff --git a/internal/services/repositories/service.go b/internal/services/repositories/service.go index c7cc0d7..45c9931 100644 --- a/internal/services/repositories/service.go +++ b/internal/services/repositories/service.go @@ -247,11 +247,20 @@ func (s *Service) GetRepositoryByPath(ctx context.Context, namespace, name strin return toDomain(repo), nil } -func (s *Service) PrepareArchive(ctx context.Context, repositoryID int64, ref, format string, includeLFS bool) (domain.ArchiveRequest, error) { +func (s *Service) PrepareArchive(ctx context.Context, repositoryID int64, ref, format string, includeLFS bool, prefix *string) (domain.ArchiveRequest, error) { f, ok := domain.ParseArchiveFormat(format) if !ok { return domain.ArchiveRequest{}, ErrUnsupportedFormat } + + normalizedPrefix := "" + if prefix != nil { + normalizedPrefix, ok = domain.NormalizeArchivePrefix(*prefix) + if !ok { + return domain.ArchiveRequest{}, ErrInvalidArchivePrefix + } + } + if includeLFS && s.lfs == nil { return domain.ArchiveRequest{}, ErrLFSNotEnabled } @@ -271,11 +280,16 @@ func (s *Service) PrepareArchive(ctx context.Context, repositoryID int64, ref, f return domain.ArchiveRequest{}, err } + if prefix == nil { + normalizedPrefix = fmt.Sprintf("%s-%s/", repo.RepositoryName, domain.ShortSHA(sha)) + } + return domain.ArchiveRequest{ Repository: repo, CommitSHA: sha, Format: f, IncludeLFS: includeLFS, + Prefix: normalizedPrefix, }, nil } @@ -315,8 +329,7 @@ func (s *Service) StreamArchive(ctx context.Context, req domain.ArchiveRequest, } } - prefix := fmt.Sprintf("%s-%s/", req.Repository.RepositoryName, domain.ShortSHA(req.CommitSHA)) - return archive.Transform(pr, prefix, smudge, enc) + return archive.Transform(pr, req.Prefix, smudge, enc) } func (s *Service) PrepareBlob(ctx context.Context, repositoryID int64, ref, treePath string, includeLFS bool) (domain.BlobRequest, error) { diff --git a/internal/services/repositories/service_test.go b/internal/services/repositories/service_test.go index 8476593..5d07083 100644 --- a/internal/services/repositories/service_test.go +++ b/internal/services/repositories/service_test.go @@ -196,6 +196,10 @@ func TestCreateRepository(t *testing.T) { func TestPrepareArchive(t *testing.T) { row := gen.Repository{ID: 7, RepositoryName: "myrepo", StoragePath: "7/myrepo.git", Visibility: "private"} + customPrefix := "release/source" + trailingPrefix := "release/source/" + emptyPrefix := "" + invalidPrefix := "../release" cases := []struct { name string @@ -204,26 +208,32 @@ func TestPrepareArchive(t *testing.T) { lfs LFSObjects format string includeLFS bool + prefix *string + wantPrefix string wantErr error }{ - {"unsupported format", fakeRegistry{repo: row}, fakeStorage{sha: testSHA}, nil, "rar", false, ErrUnsupportedFormat}, - {"lfs disabled", fakeRegistry{repo: row}, fakeStorage{sha: testSHA}, nil, "zip", true, ErrLFSNotEnabled}, - {"lfs enabled ok", fakeRegistry{repo: row}, fakeStorage{sha: testSHA}, fakeLFS{}, "zip", true, nil}, - {"repo not found", fakeRegistry{err: sql.ErrNoRows}, fakeStorage{sha: testSHA}, nil, "zip", false, ErrRepositoryNotFound}, - {"invalid ref", fakeRegistry{repo: row}, fakeStorage{resolveErr: gitbackend.ErrInvalidRev}, nil, "zip", false, ErrInvalidRef}, - {"ref not found", fakeRegistry{repo: row}, fakeStorage{resolveErr: gitbackend.ErrRevNotFound}, nil, "zip", false, ErrRefNotFound}, - {"ok", fakeRegistry{repo: row}, fakeStorage{sha: testSHA}, nil, "zip", false, nil}, + {"unsupported format", fakeRegistry{repo: row}, fakeStorage{sha: testSHA}, nil, "rar", false, nil, "", ErrUnsupportedFormat}, + {"invalid prefix", fakeRegistry{repo: row}, fakeStorage{sha: testSHA}, nil, "zip", false, &invalidPrefix, "", ErrInvalidArchivePrefix}, + {"lfs disabled", fakeRegistry{repo: row}, fakeStorage{sha: testSHA}, nil, "zip", true, nil, "", ErrLFSNotEnabled}, + {"lfs enabled ok", fakeRegistry{repo: row}, fakeStorage{sha: testSHA}, fakeLFS{}, "zip", true, nil, "myrepo-aaaabbbbcccc/", nil}, + {"repo not found", fakeRegistry{err: sql.ErrNoRows}, fakeStorage{sha: testSHA}, nil, "zip", false, nil, "", ErrRepositoryNotFound}, + {"invalid ref", fakeRegistry{repo: row}, fakeStorage{resolveErr: gitbackend.ErrInvalidRev}, nil, "zip", false, nil, "", ErrInvalidRef}, + {"ref not found", fakeRegistry{repo: row}, fakeStorage{resolveErr: gitbackend.ErrRevNotFound}, nil, "zip", false, nil, "", ErrRefNotFound}, + {"default prefix", fakeRegistry{repo: row}, fakeStorage{sha: testSHA}, nil, "zip", false, nil, "myrepo-aaaabbbbcccc/", nil}, + {"custom prefix", fakeRegistry{repo: row}, fakeStorage{sha: testSHA}, nil, "zip", false, &customPrefix, "release/source/", nil}, + {"normalized prefix", fakeRegistry{repo: row}, fakeStorage{sha: testSHA}, nil, "zip", false, &trailingPrefix, "release/source/", nil}, + {"empty prefix", fakeRegistry{repo: row}, fakeStorage{sha: testSHA}, nil, "zip", false, &emptyPrefix, "", nil}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { svc := NewService(zap.NewNop(), tc.registry, tc.storage, tc.lfs, nil) - req, err := svc.PrepareArchive(context.Background(), row.ID, "main", tc.format, tc.includeLFS) + req, err := svc.PrepareArchive(context.Background(), row.ID, "main", tc.format, tc.includeLFS, tc.prefix) if !errors.Is(err, tc.wantErr) { t.Fatalf("PrepareArchive error = %v, want %v", err, tc.wantErr) } if tc.wantErr == nil { - if req.CommitSHA != testSHA || req.Repository.ID != row.ID || req.Format != domain.ArchiveFormatZip { + if req.CommitSHA != testSHA || req.Repository.ID != row.ID || req.Format != domain.ArchiveFormatZip || req.Prefix != tc.wantPrefix { t.Errorf("PrepareArchive = %+v", req) } if want := "myrepo-aaaabbbbcccc.zip"; req.Filename() != want { @@ -269,6 +279,7 @@ func TestStreamArchiveSmudgesLFS(t *testing.T) { CommitSHA: testSHA, Format: domain.ArchiveFormatZip, IncludeLFS: true, + Prefix: "myrepo-aaaabbbbcccc/", } var out bytes.Buffer