Add ability to download subpath archive (#36371)

closes: https://github.com/go-gitea/gitea/issues/4478

---------

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
TheFox0x7
2026-01-16 10:31:12 +01:00
committed by GitHub
parent 67e75f30a8
commit 69c5921d71
18 changed files with 230 additions and 134 deletions

View File

@@ -48,6 +48,8 @@ linters:
desc: do not use the ini package, use gitea's config system instead desc: do not use the ini package, use gitea's config system instead
- pkg: gitea.com/go-chi/cache - pkg: gitea.com/go-chi/cache
desc: do not use the go-chi cache package, use gitea's cache system desc: do not use the go-chi cache package, use gitea's cache system
- pkg: github.com/pkg/errors
desc: use builtin errors package instead
nolintlint: nolintlint:
allow-unused: false allow-unused: false
require-explanation: true require-explanation: true

2
go.mod
View File

@@ -95,7 +95,6 @@ require (
github.com/olivere/elastic/v7 v7.0.32 github.com/olivere/elastic/v7 v7.0.32
github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.1 github.com/opencontainers/image-spec v1.1.1
github.com/pkg/errors v0.9.1
github.com/pquerna/otp v1.5.0 github.com/pquerna/otp v1.5.0
github.com/prometheus/client_golang v1.23.0 github.com/prometheus/client_golang v1.23.0
github.com/quasoft/websspi v1.1.2 github.com/quasoft/websspi v1.1.2
@@ -251,6 +250,7 @@ require (
github.com/philhofer/fwd v1.2.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pjbgf/sha1cd v0.4.0 // indirect github.com/pjbgf/sha1cd v0.4.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.65.0 // indirect github.com/prometheus/common v0.65.0 // indirect

View File

@@ -426,9 +426,9 @@ type RunStdError interface {
} }
type runStdError struct { type runStdError struct {
err error err error // usually the low-level error like `*exec.ExitError`
stderr string stderr string // git command's stderr output
errMsg string errMsg string // the cached error message for Error() method
} }
func (r *runStdError) Error() string { func (r *runStdError) Error() string {
@@ -448,6 +448,22 @@ func (r *runStdError) Stderr() string {
return r.stderr return r.stderr
} }
func ErrorAsStderr(err error) (string, bool) {
var runErr RunStdError
if errors.As(err, &runErr) {
return runErr.Stderr(), true
}
return "", false
}
func StderrHasPrefix(err error, prefix string) bool {
stderr, ok := ErrorAsStderr(err)
if !ok {
return false
}
return strings.HasPrefix(stderr, prefix)
}
func IsErrorExitCode(err error, code int) bool { func IsErrorExitCode(err error, code int) bool {
var exitError *exec.ExitError var exitError *exec.ExitError
if errors.As(err, &exitError) { if errors.As(err, &exitError) {

View File

@@ -12,6 +12,7 @@ import (
"code.gitea.io/gitea/modules/tempdir" "code.gitea.io/gitea/modules/tempdir"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
@@ -99,3 +100,14 @@ func TestCommandString(t *testing.T) {
cmd = NewCommand("url: https://a:b@c/", "/root/dir-a/dir-b") cmd = NewCommand("url: https://a:b@c/", "/root/dir-a/dir-b")
assert.Equal(t, cmd.prog+` "url: https://sanitized-credential@c/" .../dir-a/dir-b`, cmd.LogString()) assert.Equal(t, cmd.prog+` "url: https://sanitized-credential@c/" .../dir-a/dir-b`, cmd.LogString())
} }
func TestRunStdError(t *testing.T) {
e := &runStdError{stderr: "some error"}
var err RunStdError = e
var asErr RunStdError
require.ErrorAs(t, err, &asErr)
require.Equal(t, "some error", asErr.Stderr())
require.ErrorAs(t, fmt.Errorf("wrapped %w", err), &asErr)
}

View File

@@ -3,12 +3,18 @@
package gitcmd package gitcmd
import "fmt" import (
"fmt"
// ConcatenateError concatenats an error with stderr string "code.gitea.io/gitea/modules/util"
)
// ConcatenateError concatenates an error with stderr string
// FIXME: use RunStdError instead
func ConcatenateError(err error, stderr string) error { func ConcatenateError(err error, stderr string) error {
if len(stderr) == 0 { if len(stderr) == 0 {
return err return err
} }
return fmt.Errorf("%w - %s", err, stderr) errMsg := fmt.Sprintf("%s - %s", err.Error(), stderr)
return util.ErrorWrap(&runStdError{err: err, stderr: stderr, errMsg: errMsg}, "%s", errMsg)
} }

View File

@@ -8,7 +8,9 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"path"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/git/gitcmd"
@@ -16,7 +18,7 @@ import (
) )
// CreateArchive create archive content to the target path // CreateArchive create archive content to the target path
func CreateArchive(ctx context.Context, repo Repository, format string, target io.Writer, usePrefix bool, commitID string) error { func CreateArchive(ctx context.Context, repo Repository, format string, target io.Writer, usePrefix bool, commitID string, paths []string) error {
if format == "unknown" { if format == "unknown" {
return fmt.Errorf("unknown format: %v", format) return fmt.Errorf("unknown format: %v", format)
} }
@@ -28,6 +30,13 @@ func CreateArchive(ctx context.Context, repo Repository, format string, target i
cmd.AddOptionFormat("--format=%s", format) cmd.AddOptionFormat("--format=%s", format)
cmd.AddDynamicArguments(commitID) cmd.AddDynamicArguments(commitID)
paths = slices.Clone(paths)
for i := range paths {
// although "git archive" already ensures the paths won't go outside the repo, we still clean them here for safety
paths[i] = path.Clean(paths[i])
}
cmd.AddDynamicArguments(paths...)
var stderr strings.Builder var stderr strings.Builder
if err := RunCmd(ctx, repo, cmd.WithStdout(target).WithStderr(&stderr)); err != nil { if err := RunCmd(ctx, repo, cmd.WithStdout(target).WithStderr(&stderr)); err != nil {
return gitcmd.ConcatenateError(err, stderr.String()) return gitcmd.ConcatenateError(err, stderr.String())

View File

@@ -4,6 +4,9 @@
package test package test
import ( import (
"archive/tar"
"compress/gzip"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -71,3 +74,31 @@ func SetupGiteaRoot() string {
_ = os.Setenv("GITEA_ROOT", giteaRoot) _ = os.Setenv("GITEA_ROOT", giteaRoot)
return giteaRoot return giteaRoot
} }
func ReadAllTarGzContent(r io.Reader) (map[string]string, error) {
gzr, err := gzip.NewReader(r)
if err != nil {
return nil, err
}
content := make(map[string]string)
tr := tar.NewReader(gzr)
for {
hd, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
buf, err := io.ReadAll(tr)
if err != nil {
return nil, err
}
content[hd.Name] = string(buf)
}
return content, nil
}

View File

@@ -977,6 +977,7 @@
"repo.fork.blocked_user": "Cannot fork the repository because you are blocked by the repository owner.", "repo.fork.blocked_user": "Cannot fork the repository because you are blocked by the repository owner.",
"repo.use_template": "Use this template", "repo.use_template": "Use this template",
"repo.open_with_editor": "Open with %s", "repo.open_with_editor": "Open with %s",
"repo.download_directory_as": "Download directory as %s",
"repo.download_zip": "Download ZIP", "repo.download_zip": "Download ZIP",
"repo.download_tar": "Download TAR.GZ", "repo.download_tar": "Download TAR.GZ",
"repo.download_bundle": "Download BUNDLE", "repo.download_bundle": "Download BUNDLE",

View File

@@ -8,25 +8,35 @@ import (
"net/http" "net/http"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
archiver_service "code.gitea.io/gitea/services/repository/archiver" archiver_service "code.gitea.io/gitea/services/repository/archiver"
) )
func serveRepoArchive(ctx *context.APIContext, reqFileName string) { func serveRepoArchive(ctx *context.APIContext, reqFileName string, paths []string) {
aReq, err := archiver_service.NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, reqFileName) aReq, err := archiver_service.NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, reqFileName, paths)
if err != nil { if err != nil {
if errors.Is(err, archiver_service.ErrUnknownArchiveFormat{}) { if errors.Is(err, util.ErrInvalidArgument) {
ctx.APIError(http.StatusBadRequest, err) ctx.APIError(http.StatusBadRequest, err)
} else if errors.Is(err, archiver_service.RepoRefNotFoundError{}) { } else if errors.Is(err, util.ErrNotExist) {
ctx.APIError(http.StatusNotFound, err) ctx.APIError(http.StatusNotFound, err)
} else { } else {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
} }
return return
} }
archiver_service.ServeRepoArchive(ctx.Base, aReq) err = archiver_service.ServeRepoArchive(ctx.Base, aReq)
if err != nil {
if errors.Is(err, util.ErrInvalidArgument) {
ctx.APIError(http.StatusBadRequest, err)
} else {
ctx.APIErrorInternal(err)
}
}
} }
// DownloadArchive is the GitHub-compatible endpoint to download repository archives
// TODO: The API document is missing: Add github compatible tarball download API endpoints (#32572)
func DownloadArchive(ctx *context.APIContext) { func DownloadArchive(ctx *context.APIContext) {
var tp repo_model.ArchiveType var tp repo_model.ArchiveType
switch ballType := ctx.PathParam("ball_type"); ballType { switch ballType := ctx.PathParam("ball_type"); ballType {
@@ -40,5 +50,5 @@ func DownloadArchive(ctx *context.APIContext) {
ctx.APIError(http.StatusBadRequest, "Unknown archive type: "+ballType) ctx.APIError(http.StatusBadRequest, "Unknown archive type: "+ballType)
return return
} }
serveRepoArchive(ctx, ctx.PathParam("*")+"."+tp.String()) serveRepoArchive(ctx, ctx.PathParam("*")+"."+tp.String(), ctx.FormStrings("path"))
} }

View File

@@ -273,13 +273,19 @@ func GetArchive(ctx *context.APIContext) {
// description: the git reference for download with attached archive format (e.g. master.zip) // description: the git reference for download with attached archive format (e.g. master.zip)
// type: string // type: string
// required: true // required: true
// - name: path
// in: query
// type: array
// items:
// type: string
// description: subpath of the repository to download
// collectionFormat: multi
// responses: // responses:
// 200: // 200:
// description: success // description: success
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
serveRepoArchive(ctx, ctx.PathParam("*"), ctx.FormStrings("path"))
serveRepoArchive(ctx, ctx.PathParam("*"))
} }
// GetEditorconfig get editor config of a repository // GetEditorconfig get editor config of a repository

View File

@@ -364,31 +364,39 @@ func RedirectDownload(ctx *context.Context) {
// Download an archive of a repository // Download an archive of a repository
func Download(ctx *context.Context) { func Download(ctx *context.Context) {
aReq, err := archiver_service.NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, ctx.PathParam("*")) aReq, err := archiver_service.NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, ctx.PathParam("*"), ctx.FormStrings("path"))
if err != nil { if err != nil {
if errors.Is(err, archiver_service.ErrUnknownArchiveFormat{}) { if errors.Is(err, util.ErrInvalidArgument) {
ctx.HTTPError(http.StatusBadRequest, err.Error()) ctx.HTTPError(http.StatusBadRequest, err.Error())
} else if errors.Is(err, archiver_service.RepoRefNotFoundError{}) { } else if errors.Is(err, util.ErrNotExist) {
ctx.HTTPError(http.StatusNotFound, err.Error()) ctx.HTTPError(http.StatusNotFound, err.Error())
} else { } else {
ctx.ServerError("archiver_service.NewRequest", err) ctx.ServerError("archiver_service.NewRequest", err)
} }
return return
} }
archiver_service.ServeRepoArchive(ctx.Base, aReq) err = archiver_service.ServeRepoArchive(ctx.Base, aReq)
if err != nil {
if errors.Is(err, util.ErrInvalidArgument) {
ctx.HTTPError(http.StatusBadRequest, err.Error())
} else {
ctx.ServerError("archiver_service.ServeRepoArchive", err)
}
}
} }
// InitiateDownload will enqueue an archival request, as needed. It may submit // InitiateDownload will enqueue an archival request, as needed. It may submit
// a request that's already in-progress, but the archiver service will just // a request that's already in-progress, but the archiver service will just
// kind of drop it on the floor if this is the case. // kind of drop it on the floor if this is the case.
func InitiateDownload(ctx *context.Context) { func InitiateDownload(ctx *context.Context) {
if setting.Repository.StreamArchives { paths := ctx.FormStrings("path")
if setting.Repository.StreamArchives || len(paths) > 0 {
ctx.JSON(http.StatusOK, map[string]any{ ctx.JSON(http.StatusOK, map[string]any{
"complete": true, "complete": true,
}) })
return return
} }
aReq, err := archiver_service.NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, ctx.PathParam("*")) aReq, err := archiver_service.NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, ctx.PathParam("*"), paths)
if err != nil { if err != nil {
ctx.HTTPError(http.StatusBadRequest, "invalid archive request") ctx.HTTPError(http.StatusBadRequest, "invalid archive request")
return return

View File

@@ -6,6 +6,8 @@ package pull
import ( import (
"context" "context"
"errors"
"fmt"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git" git_model "code.gitea.io/gitea/models/git"
@@ -14,8 +16,6 @@ import (
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/glob" "code.gitea.io/gitea/modules/glob"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"github.com/pkg/errors"
) )
// MergeRequiredContextsCommitStatus returns a commit status state for given required contexts // MergeRequiredContextsCommitStatus returns a commit status state for given required contexts
@@ -69,7 +69,7 @@ func MergeRequiredContextsCommitStatus(commitStatuses []*git_model.CommitStatus,
func IsPullCommitStatusPass(ctx context.Context, pr *issues_model.PullRequest) (bool, error) { func IsPullCommitStatusPass(ctx context.Context, pr *issues_model.PullRequest) (bool, error) {
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch) pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
if err != nil { if err != nil {
return false, errors.Wrap(err, "GetLatestCommitStatus") return false, fmt.Errorf("GetLatestCommitStatus: %w", err)
} }
if pb == nil || !pb.EnableStatusCheck { if pb == nil || !pb.EnableStatusCheck {
return true, nil return true, nil
@@ -86,19 +86,19 @@ func IsPullCommitStatusPass(ctx context.Context, pr *issues_model.PullRequest) (
func GetPullRequestCommitStatusState(ctx context.Context, pr *issues_model.PullRequest) (commitstatus.CommitStatusState, error) { func GetPullRequestCommitStatusState(ctx context.Context, pr *issues_model.PullRequest) (commitstatus.CommitStatusState, error) {
// Ensure HeadRepo is loaded // Ensure HeadRepo is loaded
if err := pr.LoadHeadRepo(ctx); err != nil { if err := pr.LoadHeadRepo(ctx); err != nil {
return "", errors.Wrap(err, "LoadHeadRepo") return "", fmt.Errorf("LoadHeadRepo: %w", err)
} }
// check if all required status checks are successful // check if all required status checks are successful
headGitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.HeadRepo) headGitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.HeadRepo)
if err != nil { if err != nil {
return "", errors.Wrap(err, "OpenRepository") return "", fmt.Errorf("OpenRepository: %w", err)
} }
defer closer.Close() defer closer.Close()
if pr.Flow == issues_model.PullRequestFlowGithub { if pr.Flow == issues_model.PullRequestFlowGithub {
if exist, err := git_model.IsBranchExist(ctx, pr.HeadRepo.ID, pr.HeadBranch); err != nil { if exist, err := git_model.IsBranchExist(ctx, pr.HeadRepo.ID, pr.HeadBranch); err != nil {
return "", errors.Wrap(err, "IsBranchExist") return "", fmt.Errorf("IsBranchExist: %w", err)
} else if !exist { } else if !exist {
return "", errors.New("Head branch does not exist, can not merge") return "", errors.New("Head branch does not exist, can not merge")
} }
@@ -118,17 +118,17 @@ func GetPullRequestCommitStatusState(ctx context.Context, pr *issues_model.PullR
} }
if err := pr.LoadBaseRepo(ctx); err != nil { if err := pr.LoadBaseRepo(ctx); err != nil {
return "", errors.Wrap(err, "LoadBaseRepo") return "", fmt.Errorf("LoadBaseRepo: %w", err)
} }
commitStatuses, err := git_model.GetLatestCommitStatus(ctx, pr.BaseRepo.ID, sha, db.ListOptionsAll) commitStatuses, err := git_model.GetLatestCommitStatus(ctx, pr.BaseRepo.ID, sha, db.ListOptionsAll)
if err != nil { if err != nil {
return "", errors.Wrap(err, "GetLatestCommitStatus") return "", fmt.Errorf("GetLatestCommitStatus: %w", err)
} }
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch) pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
if err != nil { if err != nil {
return "", errors.Wrap(err, "LoadProtectedBranch") return "", fmt.Errorf("LoadProtectedBranch: %w", err)
} }
var requiredContexts []string var requiredContexts []string
if pb != nil { if pb != nil {

View File

@@ -8,7 +8,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net/http"
"os" "os"
"strings" "strings"
"time" "time"
@@ -16,6 +15,7 @@ import (
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/httplib"
@@ -24,6 +24,7 @@ import (
"code.gitea.io/gitea/modules/queue" "code.gitea.io/gitea/modules/queue"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/util"
gitea_context "code.gitea.io/gitea/services/context" gitea_context "code.gitea.io/gitea/services/context"
) )
@@ -36,58 +37,31 @@ type ArchiveRequest struct {
Repo *repo_model.Repository Repo *repo_model.Repository
Type repo_model.ArchiveType Type repo_model.ArchiveType
CommitID string CommitID string
Paths []string
archiveRefShortName string // the ref short name to download the archive, for example: "master", "v1.0.0", "commit id" archiveRefShortName string // the ref short name to download the archive, for example: "master", "v1.0.0", "commit id"
} }
// ErrUnknownArchiveFormat request archive format is not supported
type ErrUnknownArchiveFormat struct {
RequestNameType string
}
// Error implements error
func (err ErrUnknownArchiveFormat) Error() string {
return "unknown format: " + err.RequestNameType
}
// Is implements error
func (ErrUnknownArchiveFormat) Is(err error) bool {
_, ok := err.(ErrUnknownArchiveFormat)
return ok
}
// RepoRefNotFoundError is returned when a requested reference (commit, tag) was not found.
type RepoRefNotFoundError struct {
RefShortName string
}
// Error implements error.
func (e RepoRefNotFoundError) Error() string {
return "unrecognized repository reference: " + e.RefShortName
}
func (e RepoRefNotFoundError) Is(err error) bool {
_, ok := err.(RepoRefNotFoundError)
return ok
}
// NewRequest creates an archival request, based on the URI. The // NewRequest creates an archival request, based on the URI. The
// resulting ArchiveRequest is suitable for being passed to Await() // resulting ArchiveRequest is suitable for being passed to Await()
// if it's determined that the request still needs to be satisfied. // if it's determined that the request still needs to be satisfied.
func NewRequest(repo *repo_model.Repository, gitRepo *git.Repository, archiveRefExt string) (*ArchiveRequest, error) { func NewRequest(repo *repo_model.Repository, gitRepo *git.Repository, archiveRefExt string, paths []string) (*ArchiveRequest, error) {
// here the archiveRefShortName is not a clear ref, it could be a tag, branch or commit id // here the archiveRefShortName is not a clear ref, it could be a tag, branch or commit id
archiveRefShortName, archiveType := repo_model.SplitArchiveNameType(archiveRefExt) archiveRefShortName, archiveType := repo_model.SplitArchiveNameType(archiveRefExt)
if archiveType == repo_model.ArchiveUnknown { if archiveType == repo_model.ArchiveUnknown {
return nil, ErrUnknownArchiveFormat{archiveRefExt} return nil, util.NewInvalidArgumentErrorf("unknown format: %s", archiveRefExt)
}
if archiveType == repo_model.ArchiveBundle && len(paths) != 0 {
return nil, util.NewInvalidArgumentErrorf("cannot specify paths when requesting a bundle")
} }
// Get corresponding commit. // Get corresponding commit.
commitID, err := gitRepo.ConvertToGitID(archiveRefShortName) commitID, err := gitRepo.ConvertToGitID(archiveRefShortName)
if err != nil { if err != nil {
return nil, RepoRefNotFoundError{RefShortName: archiveRefShortName} return nil, util.NewNotExistErrorf("unrecognized repository reference: %s", archiveRefShortName)
} }
r := &ArchiveRequest{Repo: repo, archiveRefShortName: archiveRefShortName, Type: archiveType} r := &ArchiveRequest{Repo: repo, archiveRefShortName: archiveRefShortName, Type: archiveType, Paths: paths}
r.CommitID = commitID.String() r.CommitID = commitID.String()
return r, nil return r, nil
} }
@@ -159,6 +133,7 @@ func (aReq *ArchiveRequest) Stream(ctx context.Context, w io.Writer) error {
w, w,
setting.Repository.PrefixArchiveFiles, setting.Repository.PrefixArchiveFiles,
aReq.CommitID, aReq.CommitID,
aReq.Paths,
) )
} }
@@ -339,7 +314,7 @@ func DeleteRepositoryArchives(ctx context.Context) error {
return storage.Clean(storage.RepoArchives) return storage.Clean(storage.RepoArchives)
} }
func ServeRepoArchive(ctx *gitea_context.Base, archiveReq *ArchiveRequest) { func ServeRepoArchive(ctx *gitea_context.Base, archiveReq *ArchiveRequest) error {
// Add nix format link header so tarballs lock correctly: // Add nix format link header so tarballs lock correctly:
// https://github.com/nixos/nix/blob/56763ff918eb308db23080e560ed2ea3e00c80a7/doc/manual/src/protocols/tarball-fetcher.md // https://github.com/nixos/nix/blob/56763ff918eb308db23080e560ed2ea3e00c80a7/doc/manual/src/protocols/tarball-fetcher.md
ctx.Resp.Header().Add("Link", fmt.Sprintf(`<%s/archive/%s.%s?rev=%s>; rel="immutable"`, ctx.Resp.Header().Add("Link", fmt.Sprintf(`<%s/archive/%s.%s?rev=%s>; rel="immutable"`,
@@ -350,20 +325,22 @@ func ServeRepoArchive(ctx *gitea_context.Base, archiveReq *ArchiveRequest) {
)) ))
downloadName := archiveReq.Repo.Name + "-" + archiveReq.GetArchiveName() downloadName := archiveReq.Repo.Name + "-" + archiveReq.GetArchiveName()
if setting.Repository.StreamArchives { if setting.Repository.StreamArchives || len(archiveReq.Paths) > 0 {
// the header must be set before starting streaming even an error would occur,
// because errors may happen in git command and such cases aren't in our control.
httplib.ServeSetHeaders(ctx.Resp, &httplib.ServeHeaderOptions{Filename: downloadName}) httplib.ServeSetHeaders(ctx.Resp, &httplib.ServeHeaderOptions{Filename: downloadName})
if err := archiveReq.Stream(ctx, ctx.Resp); err != nil && !ctx.Written() { if err := archiveReq.Stream(ctx, ctx.Resp); err != nil && !ctx.Written() {
log.Error("Archive %v streaming failed: %v", archiveReq, err) if gitcmd.StderrHasPrefix(err, "fatal: pathspec") {
ctx.HTTPError(http.StatusInternalServerError) return util.NewInvalidArgumentErrorf("path doesn't exist or is invalid")
} }
return return fmt.Errorf("archive repo %s: failed to stream: %w", archiveReq.Repo.FullName(), err)
}
return nil
} }
archiver, err := archiveReq.Await(ctx) archiver, err := archiveReq.Await(ctx)
if err != nil { if err != nil {
log.Error("Archive %v await failed: %v", archiveReq, err) return fmt.Errorf("archive repo %s: failed to await: %w", archiveReq.Repo.FullName(), err)
ctx.HTTPError(http.StatusInternalServerError)
return
} }
rPath := archiver.RelativePath() rPath := archiver.RelativePath()
@@ -372,15 +349,13 @@ func ServeRepoArchive(ctx *gitea_context.Base, archiveReq *ArchiveRequest) {
u, err := storage.RepoArchives.URL(rPath, downloadName, ctx.Req.Method, nil) u, err := storage.RepoArchives.URL(rPath, downloadName, ctx.Req.Method, nil)
if u != nil && err == nil { if u != nil && err == nil {
ctx.Redirect(u.String()) ctx.Redirect(u.String())
return return nil
} }
} }
fr, err := storage.RepoArchives.Open(rPath) fr, err := storage.RepoArchives.Open(rPath)
if err != nil { if err != nil {
log.Error("Archive %v open file failed: %v", archiveReq, err) return fmt.Errorf("archive repo %s: failed to open archive file: %w", archiveReq.Repo.FullName(), err)
ctx.HTTPError(http.StatusInternalServerError)
return
} }
defer fr.Close() defer fr.Close()
@@ -388,4 +363,5 @@ func ServeRepoArchive(ctx *gitea_context.Base, archiveReq *ArchiveRequest) {
Filename: downloadName, Filename: downloadName,
LastModified: archiver.CreatedUnix.AsLocalTime(), LastModified: archiver.CreatedUnix.AsLocalTime(),
}) })
return nil
} }

View File

@@ -8,11 +8,13 @@ import (
"time" "time"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/contexttest" "code.gitea.io/gitea/services/contexttest"
_ "code.gitea.io/gitea/models/actions" _ "code.gitea.io/gitea/models/actions"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
@@ -29,47 +31,47 @@ func TestArchive_Basic(t *testing.T) {
contexttest.LoadGitRepo(t, ctx) contexttest.LoadGitRepo(t, ctx)
defer ctx.Repo.GitRepo.Close() defer ctx.Repo.GitRepo.Close()
bogusReq, err := NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, firstCommit+".zip") bogusReq, err := NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, firstCommit+".zip", nil)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, bogusReq) assert.NotNil(t, bogusReq)
assert.Equal(t, firstCommit+".zip", bogusReq.GetArchiveName()) assert.Equal(t, firstCommit+".zip", bogusReq.GetArchiveName())
// Check a series of bogus requests. // Check a series of bogus requests.
// Step 1, valid commit with a bad extension. // Step 1, valid commit with a bad extension.
bogusReq, err = NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, firstCommit+".unknown") bogusReq, err = NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, firstCommit+".unknown", nil)
assert.Error(t, err) assert.Error(t, err)
assert.Nil(t, bogusReq) assert.Nil(t, bogusReq)
// Step 2, missing commit. // Step 2, missing commit.
bogusReq, err = NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, "dbffff.zip") bogusReq, err = NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, "dbffff.zip", nil)
assert.Error(t, err) assert.Error(t, err)
assert.Nil(t, bogusReq) assert.Nil(t, bogusReq)
// Step 3, doesn't look like branch/tag/commit. // Step 3, doesn't look like branch/tag/commit.
bogusReq, err = NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, "db.zip") bogusReq, err = NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, "db.zip", nil)
assert.Error(t, err) assert.Error(t, err)
assert.Nil(t, bogusReq) assert.Nil(t, bogusReq)
bogusReq, err = NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, "master.zip") bogusReq, err = NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, "master.zip", nil)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, bogusReq) assert.NotNil(t, bogusReq)
assert.Equal(t, "master.zip", bogusReq.GetArchiveName()) assert.Equal(t, "master.zip", bogusReq.GetArchiveName())
bogusReq, err = NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, "test/archive.zip") bogusReq, err = NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, "test/archive.zip", nil)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, bogusReq) assert.NotNil(t, bogusReq)
assert.Equal(t, "test-archive.zip", bogusReq.GetArchiveName()) assert.Equal(t, "test-archive.zip", bogusReq.GetArchiveName())
// Now two valid requests, firstCommit with valid extensions. // Now two valid requests, firstCommit with valid extensions.
zipReq, err := NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, firstCommit+".zip") zipReq, err := NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, firstCommit+".zip", nil)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, zipReq) assert.NotNil(t, zipReq)
tgzReq, err := NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, firstCommit+".tar.gz") tgzReq, err := NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, firstCommit+".tar.gz", nil)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, tgzReq) assert.NotNil(t, tgzReq)
secondReq, err := NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, secondCommit+".bundle") secondReq, err := NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, secondCommit+".bundle", nil)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, secondReq) assert.NotNil(t, secondReq)
@@ -89,7 +91,7 @@ func TestArchive_Basic(t *testing.T) {
// Sleep two seconds to make sure the queue doesn't change. // Sleep two seconds to make sure the queue doesn't change.
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
zipReq2, err := NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, firstCommit+".zip") zipReq2, err := NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, firstCommit+".zip", nil)
assert.NoError(t, err) assert.NoError(t, err)
// This zipReq should match what's sitting in the queue, as we haven't // This zipReq should match what's sitting in the queue, as we haven't
// let it release yet. From the consumer's point of view, this looks like // let it release yet. From the consumer's point of view, this looks like
@@ -104,12 +106,12 @@ func TestArchive_Basic(t *testing.T) {
// Now we'll submit a request and TimedWaitForCompletion twice, before and // Now we'll submit a request and TimedWaitForCompletion twice, before and
// after we release it. We should trigger both the timeout and non-timeout // after we release it. We should trigger both the timeout and non-timeout
// cases. // cases.
timedReq, err := NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, secondCommit+".tar.gz") timedReq, err := NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, secondCommit+".tar.gz", nil)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, timedReq) assert.NotNil(t, timedReq)
doArchive(t.Context(), timedReq) doArchive(t.Context(), timedReq)
zipReq2, err = NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, firstCommit+".zip") zipReq2, err = NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, firstCommit+".zip", nil)
assert.NoError(t, err) assert.NoError(t, err)
// Now, we're guaranteed to have released the original zipReq from the queue. // Now, we're guaranteed to have released the original zipReq from the queue.
// Ensure that we don't get handed back the released entry somehow, but they // Ensure that we don't get handed back the released entry somehow, but they
@@ -124,9 +126,13 @@ func TestArchive_Basic(t *testing.T) {
// Ideally, the extension would match what we originally requested. // Ideally, the extension would match what we originally requested.
assert.NotEqual(t, zipReq.GetArchiveName(), tgzReq.GetArchiveName()) assert.NotEqual(t, zipReq.GetArchiveName(), tgzReq.GetArchiveName())
assert.NotEqual(t, zipReq.GetArchiveName(), secondReq.GetArchiveName()) assert.NotEqual(t, zipReq.GetArchiveName(), secondReq.GetArchiveName())
}
func TestErrUnknownArchiveFormat(t *testing.T) { t.Run("BadPath", func(t *testing.T) {
err := ErrUnknownArchiveFormat{RequestNameType: "xxx"} badRequest, err := NewRequest(ctx.Repo.Repository, ctx.Repo.GitRepo, firstCommit+".tar.gz", []string{"not-a-path"})
assert.ErrorIs(t, err, ErrUnknownArchiveFormat{}) require.NoError(t, err)
err = ServeRepoArchive(ctx.Base, badRequest)
require.Error(t, err)
assert.ErrorIs(t, err, util.ErrInvalidArgument)
assert.ErrorContains(t, err, "path doesn't exist or is invalid")
})
} }

View File

@@ -100,6 +100,11 @@
<a class="item" data-clipboard-text="{{.Repository.Link}}/src/commit/{{.CommitID}}/{{PathEscapeSegments .TreePath}}" data-clipboard-text-type="url"> <a class="item" data-clipboard-text="{{.Repository.Link}}/src/commit/{{.CommitID}}/{{PathEscapeSegments .TreePath}}" data-clipboard-text-type="url">
{{svg "octicon-link" 16}}{{ctx.Locale.Tr "repo.file_copy_permalink"}} {{svg "octicon-link" 16}}{{ctx.Locale.Tr "repo.file_copy_permalink"}}
</a> </a>
{{if and (not $.DisableDownloadSourceArchives) $.RefFullName}}
<div class="divider"></div>
<a class="item muted archive-link" href="{{$.RepoLink}}/archive/{{PathEscapeSegments $.RefFullName.ShortName}}.zip?path={{PathEscapeSegments .TreePath}}" rel="nofollow">{{svg "octicon-file-zip"}}{{ctx.Locale.Tr "repo.download_directory_as" "ZIP"}}</a>
<a class="item muted archive-link" href="{{$.RepoLink}}/archive/{{PathEscapeSegments $.RefFullName.ShortName}}.tar.gz?path={{PathEscapeSegments .TreePath}}" rel="nofollow">{{svg "octicon-file-zip"}}{{ctx.Locale.Tr "repo.download_directory_as" "TAR.GZ"}}</a>
{{end}}
{{if and (.Permission.CanWrite ctx.Consts.RepoUnitTypeCode) (not .Repository.IsArchived) (not $isTreePathRoot)}} {{if and (.Permission.CanWrite ctx.Consts.RepoUnitTypeCode) (not .Repository.IsArchived) (not $isTreePathRoot)}}
<div class="divider"></div> <div class="divider"></div>
<a class="item tw-text-danger" href="{{.RepoLink}}/_delete/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}"> <a class="item tw-text-danger" href="{{.RepoLink}}/_delete/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}">

View File

@@ -6256,6 +6256,16 @@
"name": "archive", "name": "archive",
"in": "path", "in": "path",
"required": true "required": true
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "multi",
"description": "subpath of the repository to download",
"name": "path",
"in": "query"
} }
], ],
"responses": { "responses": {

View File

@@ -16,6 +16,7 @@ import (
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
arch_module "code.gitea.io/gitea/modules/packages/arch" arch_module "code.gitea.io/gitea/modules/packages/arch"
"code.gitea.io/gitea/modules/test"
arch_service "code.gitea.io/gitea/services/packages/arch" arch_service "code.gitea.io/gitea/services/packages/arch"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
@@ -78,34 +79,6 @@ license = MIT`)
return buf.Bytes() return buf.Bytes()
} }
readIndexContent := func(r io.Reader) (map[string]string, error) {
gzr, err := gzip.NewReader(r)
if err != nil {
return nil, err
}
content := make(map[string]string)
tr := tar.NewReader(gzr)
for {
hd, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
buf, err := io.ReadAll(tr)
if err != nil {
return nil, err
}
content[hd.Name] = string(buf)
}
return content, nil
}
compressions := []string{"gz", "xz", "zst"} compressions := []string{"gz", "xz", "zst"}
repositories := []string{"main", "testing", "with/slash", ""} repositories := []string{"main", "testing", "with/slash", ""}
@@ -204,7 +177,7 @@ license = MIT`)
req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/aarch64/%s", rootURL, repository, arch_service.IndexArchiveFilename)) req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/aarch64/%s", rootURL, repository, arch_service.IndexArchiveFilename))
resp := MakeRequest(t, req, http.StatusOK) resp := MakeRequest(t, req, http.StatusOK)
content, err := readIndexContent(resp.Body) content, err := test.ReadAllTarGzContent(resp.Body)
assert.NoError(t, err) assert.NoError(t, err)
desc, has := content[fmt.Sprintf("%s-%s/desc", packageName, packageVersion)] desc, has := content[fmt.Sprintf("%s-%s/desc", packageName, packageVersion)]
@@ -256,7 +229,7 @@ license = MIT`)
req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/aarch64/%s", rootURL, repository, arch_service.IndexArchiveFilename)) req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/aarch64/%s", rootURL, repository, arch_service.IndexArchiveFilename))
resp := MakeRequest(t, req, http.StatusOK) resp := MakeRequest(t, req, http.StatusOK)
content, err := readIndexContent(resp.Body) content, err := test.ReadAllTarGzContent(resp.Body)
assert.NoError(t, err) assert.NoError(t, err)
desc, has := content[fmt.Sprintf("%s-%s/desc", packageName, packageVersion)] desc, has := content[fmt.Sprintf("%s-%s/desc", packageName, packageVersion)]
@@ -311,7 +284,7 @@ license = MIT`)
req = NewRequest(t, "GET", fmt.Sprintf("%s/aarch64/%s", rootURL, arch_service.IndexArchiveFilename)) req = NewRequest(t, "GET", fmt.Sprintf("%s/aarch64/%s", rootURL, arch_service.IndexArchiveFilename))
resp := MakeRequest(t, req, http.StatusOK) resp := MakeRequest(t, req, http.StatusOK)
content, err := readIndexContent(resp.Body) content, err := test.ReadAllTarGzContent(resp.Body)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, content, 2) assert.Len(t, content, 2)
@@ -326,7 +299,7 @@ license = MIT`)
req = NewRequest(t, "GET", fmt.Sprintf("%s/aarch64/%s", rootURL, arch_service.IndexArchiveFilename)) req = NewRequest(t, "GET", fmt.Sprintf("%s/aarch64/%s", rootURL, arch_service.IndexArchiveFilename))
resp = MakeRequest(t, req, http.StatusOK) resp = MakeRequest(t, req, http.StatusOK)
content, err = readIndexContent(resp.Body) content, err = test.ReadAllTarGzContent(resp.Body)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, content, 2) assert.Len(t, content, 2)
_, has = content["gitea-test-1.0.0/desc"] _, has = content["gitea-test-1.0.0/desc"]

View File

@@ -15,6 +15,7 @@ import (
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestRepoDownloadArchive(t *testing.T) { func TestRepoDownloadArchive(t *testing.T) {
@@ -23,6 +24,7 @@ func TestRepoDownloadArchive(t *testing.T) {
defer test.MockVariableValue(&web.GzipMinSize, 10)() defer test.MockVariableValue(&web.GzipMinSize, 10)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
t.Run("NoDuplicateCompression", func(t *testing.T) {
req := NewRequest(t, "GET", "/user2/repo1/archive/master.zip") req := NewRequest(t, "GET", "/user2/repo1/archive/master.zip")
req.Header.Set("Accept-Encoding", "gzip") req.Header.Set("Accept-Encoding", "gzip")
resp := MakeRequest(t, req, http.StatusOK) resp := MakeRequest(t, req, http.StatusOK)
@@ -30,4 +32,27 @@ func TestRepoDownloadArchive(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Empty(t, resp.Header().Get("Content-Encoding")) assert.Empty(t, resp.Header().Get("Content-Encoding"))
assert.Len(t, bs, 320) assert.Len(t, bs, 320)
})
t.Run("SubPath", func(t *testing.T) {
// When using "archiving and caching" approach, archiving with paths will always use streaming and never be cached
defer test.MockVariableValue(&setting.Repository.StreamArchives, false) // this can be removed if there is always streaming mode
req := NewRequest(t, "GET", "/user2/glob/archive/master.tar.gz?path=aaa.doc&path=x/y")
resp := MakeRequest(t, req, http.StatusOK)
content, err := test.ReadAllTarGzContent(resp.Body)
require.NoError(t, err)
assert.Empty(t, content["glob/a.txt"])
assert.NotEmpty(t, content["glob/aaa.doc"])
assert.Empty(t, content["glob/x/b.txt"])
assert.NotEmpty(t, content["glob/x/y/a.txt"])
req = NewRequest(t, "GET", "/user2/glob/archive/master.tar.gz")
resp = MakeRequest(t, req, http.StatusOK)
content, err = test.ReadAllTarGzContent(resp.Body)
require.NoError(t, err)
assert.NotEmpty(t, content["glob/a.txt"])
assert.NotEmpty(t, content["glob/aaa.doc"])
assert.NotEmpty(t, content["glob/x/b.txt"])
assert.NotEmpty(t, content["glob/x/y/a.txt"])
})
} }