初始提交: Gitea 项目代码

This commit is contained in:
root
2026-05-30 22:47:36 +08:00
commit f288f76350
6116 changed files with 776822 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
Copyright (c) 2016 The Gitea Authors
Copyright (c) GitHub, Inc. and LFS Test Server contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+32
View File
@@ -0,0 +1,32 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package lfs
import (
"context"
"io"
"net/http"
"net/url"
)
// DownloadCallback gets called for every requested LFS object to process its content
type DownloadCallback func(p Pointer, content io.ReadCloser, objectError error) error
// UploadCallback gets called for every requested LFS object to provide its content
type UploadCallback func(p Pointer, objectError error) (io.ReadCloser, error)
// Client is used to communicate with a LFS source
type Client interface {
BatchSize() int
Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error
Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error
}
// NewClient creates a LFS client
func NewClient(endpoint *url.URL, httpTransport *http.Transport) Client {
if endpoint.Scheme == "file" {
return newFilesystemClient(endpoint)
}
return newHTTPClient(endpoint, httpTransport)
}
+21
View File
@@ -0,0 +1,21 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package lfs
import (
"net/url"
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewClient(t *testing.T) {
u, _ := url.Parse("file:///test")
c := NewClient(u, nil)
assert.IsType(t, &FilesystemClient{}, c)
u, _ = url.Parse("https://test.com/lfs")
c = NewClient(u, nil)
assert.IsType(t, &HTTPClient{}, c)
}
+163
View File
@@ -0,0 +1,163 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package lfs
import (
"crypto/sha256"
"encoding/hex"
"errors"
"hash"
"io"
"os"
"gitea.dev/modules/log"
"gitea.dev/modules/storage"
)
var (
// ErrHashMismatch occurs if the content has does not match OID
ErrHashMismatch = errors.New("content hash does not match OID")
// ErrSizeMismatch occurs if the content size does not match
ErrSizeMismatch = errors.New("content size does not match")
)
// ContentStore provides a simple file system based storage.
type ContentStore struct {
storage.ObjectStorage
}
// NewContentStore creates the default ContentStore
func NewContentStore() *ContentStore {
contentStore := &ContentStore{ObjectStorage: storage.LFS}
return contentStore
}
// Get takes a Meta object and retrieves the content from the store, returning
// it as an io.ReadSeekCloser.
func (s *ContentStore) Get(pointer Pointer) (storage.Object, error) {
f, err := s.Open(pointer.RelativePath())
if err != nil {
log.Error("Whilst trying to read LFS OID[%s]: Unable to open Error: %v", pointer.Oid, err)
return nil, err
}
return f, err
}
// Put takes a Meta object and an io.Reader and writes the content to the store.
func (s *ContentStore) Put(pointer Pointer, r io.Reader) error {
p := pointer.RelativePath()
// Wrap the provided reader with an inline hashing and size checker
wrappedRd := newHashingReader(pointer.Size, pointer.Oid, r)
// now pass the wrapped reader to Save - if there is a size mismatch or hash mismatch then
// the errors returned by the newHashingReader should percolate up to here
written, err := s.Save(p, wrappedRd, pointer.Size)
if err != nil {
log.Error("Whilst putting LFS OID[%s]: Failed to copy to tmpPath: %s Error: %v", pointer.Oid, p, err)
return err
}
// check again whether there is any error during the Save operation
// because some errors might be ignored by the Reader's caller
if wrappedRd.lastError != nil && !errors.Is(wrappedRd.lastError, io.EOF) {
err = wrappedRd.lastError
} else if written != pointer.Size {
err = ErrSizeMismatch
}
// if the upload failed, try to delete the file
if err != nil {
if errDel := s.Delete(p); errDel != nil {
log.Error("Cleaning the LFS OID[%s] failed: %v", pointer.Oid, errDel)
}
}
return err
}
// Exists returns true if the object exists in the content store.
func (s *ContentStore) Exists(pointer Pointer) (bool, error) {
_, err := s.ObjectStorage.Stat(pointer.RelativePath())
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
return true, nil
}
// Verify returns true if the object exists in the content store and size is correct.
func (s *ContentStore) Verify(pointer Pointer) (bool, error) {
p := pointer.RelativePath()
fi, err := s.ObjectStorage.Stat(p)
if os.IsNotExist(err) || (err == nil && fi.Size() != pointer.Size) {
return false, nil
} else if err != nil {
log.Error("Unable stat file: %s for LFS OID[%s] Error: %v", p, pointer.Oid, err)
return false, err
}
return true, nil
}
// ReadMetaObject will read a git_model.LFSMetaObject and return a reader
func ReadMetaObject(pointer Pointer) (storage.Object, error) {
contentStore := NewContentStore()
return contentStore.Get(pointer)
}
type hashingReader struct {
internal io.Reader
currentSize int64
expectedSize int64
hash hash.Hash
expectedHash string
lastError error
}
// recordError records the last error during the Save operation
// Some callers of the Reader doesn't respect the returned "err"
// For example, MinIO's Put will ignore errors if the written size could equal to expected size
// So we must remember the error by ourselves,
// and later check again whether ErrSizeMismatch or ErrHashMismatch occurs during the Save operation
func (r *hashingReader) recordError(err error) error {
r.lastError = err
return err
}
func (r *hashingReader) Read(b []byte) (int, error) {
n, err := r.internal.Read(b)
if n > 0 {
r.currentSize += int64(n)
wn, werr := r.hash.Write(b[:n])
if wn != n || werr != nil {
return n, r.recordError(werr)
}
}
if errors.Is(err, io.EOF) || r.currentSize >= r.expectedSize {
if r.currentSize != r.expectedSize {
return n, r.recordError(ErrSizeMismatch)
}
shaStr := hex.EncodeToString(r.hash.Sum(nil))
if shaStr != r.expectedHash {
return n, r.recordError(ErrHashMismatch)
}
}
return n, r.recordError(err)
}
func newHashingReader(expectedSize int64, expectedHash string, reader io.Reader) *hashingReader {
return &hashingReader{
internal: reader,
expectedSize: expectedSize,
expectedHash: expectedHash,
hash: sha256.New(),
}
}
+103
View File
@@ -0,0 +1,103 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package lfs
import (
"net/url"
"os"
"path"
"path/filepath"
"strings"
"gitea.dev/modules/log"
"gitea.dev/modules/util"
)
// DetermineEndpoint determines an endpoint from the clone url or uses the specified LFS url.
func DetermineEndpoint(cloneurl, lfsurl string) *url.URL {
if len(lfsurl) > 0 {
return endpointFromURL(lfsurl)
}
return endpointFromCloneURL(cloneurl)
}
func endpointFromCloneURL(rawurl string) *url.URL {
ep := endpointFromURL(rawurl)
if ep == nil {
return ep
}
ep.Path = strings.TrimSuffix(ep.Path, "/")
if ep.Scheme == "file" {
return ep
}
if path.Ext(ep.Path) == ".git" {
ep.Path += "/info/lfs"
} else {
ep.Path += ".git/info/lfs"
}
return ep
}
func endpointFromURL(rawurl string) *url.URL {
if strings.HasPrefix(rawurl, "/") {
return endpointFromLocalPath(rawurl)
}
u, err := url.Parse(rawurl)
if err != nil {
log.Error("lfs.endpointFromUrl: %v", err)
return nil
}
switch u.Scheme {
case "http", "https":
return u
case "git":
u.Scheme = "https"
return u
case "file":
return u
default:
if _, err := os.Stat(rawurl); err == nil {
return endpointFromLocalPath(rawurl)
}
log.Error("lfs.endpointFromUrl: unknown url")
return nil
}
}
func endpointFromLocalPath(path string) *url.URL {
var slash string
if abs, err := filepath.Abs(path); err == nil {
if !strings.HasPrefix(abs, "/") {
slash = "/"
}
path = abs
}
var gitpath string
if filepath.Base(path) == ".git" {
gitpath = path
path = filepath.Dir(path)
} else {
gitpath = filepath.Join(path, ".git")
}
if _, err := os.Stat(gitpath); err == nil {
path = gitpath
} else if _, err := os.Stat(path); err != nil {
return nil
}
path = "file://" + slash + util.PathEscapeSegments(filepath.ToSlash(path))
u, _ := url.Parse(path)
return u
}
+74
View File
@@ -0,0 +1,74 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package lfs
import (
"net/url"
"testing"
"github.com/stretchr/testify/assert"
)
func str2url(raw string) *url.URL {
u, _ := url.Parse(raw)
return u
}
func TestDetermineEndpoint(t *testing.T) {
// Test cases
cases := []struct {
cloneurl string
lfsurl string
expected *url.URL
}{
// case 0
{
cloneurl: "",
lfsurl: "",
expected: nil,
},
// case 1
{
cloneurl: "https://git.com/repo",
lfsurl: "",
expected: str2url("https://git.com/repo.git/info/lfs"),
},
// case 2
{
cloneurl: "https://git.com/repo.git",
lfsurl: "",
expected: str2url("https://git.com/repo.git/info/lfs"),
},
// case 3
{
cloneurl: "",
lfsurl: "https://gitlfs.com/repo",
expected: str2url("https://gitlfs.com/repo"),
},
// case 4
{
cloneurl: "https://git.com/repo.git",
lfsurl: "https://gitlfs.com/repo",
expected: str2url("https://gitlfs.com/repo"),
},
// case 5
{
cloneurl: "git://git.com/repo.git",
lfsurl: "",
expected: str2url("https://git.com/repo.git/info/lfs"),
},
// case 6
{
cloneurl: "",
lfsurl: "git://gitlfs.com/repo",
expected: str2url("https://gitlfs.com/repo"),
},
}
for n, c := range cases {
ep := DetermineEndpoint(c.cloneurl, c.lfsurl)
assert.Equal(t, c.expected, ep, "case %d: error should match", n)
}
}
+88
View File
@@ -0,0 +1,88 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package lfs
import (
"context"
"io"
"net/url"
"os"
"path/filepath"
"gitea.dev/modules/util"
)
// FilesystemClient is used to read LFS data from a filesystem path
type FilesystemClient struct {
lfsDir string
}
// BatchSize returns the preferred size of batchs to process
func (c *FilesystemClient) BatchSize() int {
return 1
}
func newFilesystemClient(endpoint *url.URL) *FilesystemClient {
path, _ := util.FileURLToPath(endpoint)
lfsDir := filepath.Join(path, "lfs", "objects")
return &FilesystemClient{lfsDir}
}
func (c *FilesystemClient) objectPath(oid string) string {
return filepath.Join(c.lfsDir, oid[0:2], oid[2:4], oid)
}
// Download reads the specific LFS object from the target path
func (c *FilesystemClient) Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error {
for _, object := range objects {
p := Pointer{object.Oid, object.Size}
objectPath := c.objectPath(p.Oid)
f, err := os.Open(objectPath)
if err != nil {
return err
}
defer f.Close()
if err := callback(p, f, nil); err != nil {
return err
}
}
return nil
}
// Upload writes the specific LFS object to the target path
func (c *FilesystemClient) Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error {
for _, object := range objects {
p := Pointer{object.Oid, object.Size}
objectPath := c.objectPath(p.Oid)
if err := os.MkdirAll(filepath.Dir(objectPath), os.ModePerm); err != nil {
return err
}
content, err := callback(p, nil)
if err != nil {
return err
}
err = func() error {
defer content.Close()
f, err := os.Create(objectPath)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, content)
return err
}()
if err != nil {
return err
}
}
return nil
}
+288
View File
@@ -0,0 +1,288 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package lfs
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/proxy"
"gitea.dev/modules/setting"
"golang.org/x/sync/errgroup"
)
// HTTPClient is used to communicate with the LFS server
// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md
type HTTPClient struct {
client *http.Client
endpoint string
transfers map[string]TransferAdapter
}
// BatchSize returns the preferred size of batchs to process
func (c *HTTPClient) BatchSize() int {
return setting.LFSClient.BatchSize
}
func newHTTPClient(endpoint *url.URL, httpTransport *http.Transport) *HTTPClient {
if httpTransport == nil {
httpTransport = &http.Transport{
Proxy: proxy.Proxy(),
}
}
hc := &http.Client{
Transport: httpTransport,
}
basic := &BasicTransferAdapter{hc}
client := &HTTPClient{
client: hc,
endpoint: strings.TrimSuffix(endpoint.String(), "/"),
transfers: map[string]TransferAdapter{
basic.Name(): basic,
},
}
return client
}
func (c *HTTPClient) transferNames() []string {
keys := make([]string, len(c.transfers))
i := 0
for k := range c.transfers {
keys[i] = k
i++
}
return keys
}
func (c *HTTPClient) batch(ctx context.Context, operation string, objects []Pointer) (*BatchResponse, error) {
log.Trace("BATCH operation with objects: %v", objects)
url := c.endpoint + "/objects/batch"
// Original: In some lfs server implementations, they require the ref attribute. #32838
// `ref` is an "optional object describing the server ref that the objects belong to"
// but some (incorrect) lfs servers like aliyun require it, so maybe adding an empty ref here doesn't break the correct ones.
// https://github.com/git-lfs/git-lfs/blob/a32a02b44bf8a511aa14f047627c49e1a7fd5021/docs/api/batch.md?plain=1#L37
//
// UPDATE: it can't use "empty ref" here because it breaks others like https://github.com/go-gitea/gitea/issues/33453
request := &BatchRequest{operation, c.transferNames(), nil, objects}
payload := new(bytes.Buffer)
err := json.NewEncoder(payload).Encode(request)
if err != nil {
log.Error("Error encoding json: %v", err)
return nil, err
}
req, err := createRequest(ctx, http.MethodPost, url, map[string]string{"Content-Type": MediaType}, payload)
if err != nil {
return nil, err
}
res, err := performRequest(ctx, c.client, req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var response BatchResponse
err = json.NewDecoder(res.Body).Decode(&response)
if err != nil {
log.Error("Error decoding json: %v", err)
return nil, err
}
if len(response.Transfer) == 0 {
response.Transfer = "basic"
}
return &response, nil
}
// Download reads the specific LFS object from the LFS server
func (c *HTTPClient) Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error {
return c.performOperation(ctx, objects, callback, nil)
}
// Upload sends the specific LFS object to the LFS server
func (c *HTTPClient) Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error {
return c.performOperation(ctx, objects, nil, callback)
}
// performOperation takes a slice of LFS object pointers, batches them, and performs the upload/download operations concurrently in each batch
func (c *HTTPClient) performOperation(ctx context.Context, objects []Pointer, dc DownloadCallback, uc UploadCallback) error {
if len(objects) == 0 {
return nil
}
operation := "download"
if uc != nil {
operation = "upload"
}
result, err := c.batch(ctx, operation, objects)
if err != nil {
return err
}
transferAdapter, ok := c.transfers[result.Transfer]
if !ok {
return fmt.Errorf("TransferAdapter not found: %s", result.Transfer)
}
if setting.LFSClient.BatchOperationConcurrency <= 0 {
panic("BatchOperationConcurrency must be greater than 0, forgot to init?")
}
errGroup, groupCtx := errgroup.WithContext(ctx)
errGroup.SetLimit(setting.LFSClient.BatchOperationConcurrency)
for _, object := range result.Objects {
errGroup.Go(func() error {
return performSingleOperation(groupCtx, object, dc, uc, transferAdapter)
})
}
// only the first error is returned, preserving legacy behavior before concurrency
return errGroup.Wait()
}
// performSingleOperation performs an LFS upload or download operation on a single object
func performSingleOperation(ctx context.Context, object *ObjectResponse, dc DownloadCallback, uc UploadCallback, transferAdapter TransferAdapter) error {
// the response from a lfs batch api request for this specific object id contained an error
if object.Error != nil {
log.Trace("Error on object %v: %v", object.Pointer, object.Error)
// this was an 'upload' request inside the batch request
if uc != nil {
if _, err := uc(object.Pointer, object.Error); err != nil {
return err
}
} else {
// this was NOT an 'upload' request inside the batch request, meaning it must be a 'download' request
if err := dc(object.Pointer, nil, object.Error); err != nil {
return err
}
}
// if the callback returns no err, then the error could be ignored, and the operations should continue
return nil
}
// the response from an lfs batch api request contained necessary upload/download fields to act upon
if uc != nil {
if len(object.Actions) == 0 {
log.Trace("%v already present on server", object.Pointer)
return nil
}
link, ok := object.Actions["upload"]
if !ok {
return errors.New("missing action 'upload'")
}
content, err := uc(object.Pointer, nil)
if err != nil {
return err
}
err = transferAdapter.Upload(ctx, link, object.Pointer, content)
if err != nil {
return err
}
link, ok = object.Actions["verify"]
if ok {
if err := transferAdapter.Verify(ctx, link, object.Pointer); err != nil {
return err
}
}
} else {
link, ok := object.Actions["download"]
if !ok {
// no actions block in response, try legacy response schema
link, ok = object.Links["download"]
}
if !ok {
log.Debug("%+v", object)
return errors.New("missing action 'download'")
}
content, err := transferAdapter.Download(ctx, link)
if err != nil {
return err
}
if err := dc(object.Pointer, content, nil); err != nil {
return err
}
}
return nil
}
// createRequest creates a new request, and sets the headers.
func createRequest(ctx context.Context, method, url string, headers map[string]string, body io.Reader) (*http.Request, error) {
log.Trace("createRequest: %s", url)
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
log.Error("Error creating request: %v", err)
return nil, err
}
for key, value := range headers {
req.Header.Set(key, value)
}
req.Header.Set("Accept", AcceptHeader)
req.Header.Set("User-Agent", UserAgentHeader)
return req, nil
}
// performRequest sends a request, optionally performs a callback on the request and returns the response.
// If the status code is 200, the response is returned, and it will contain a non-nil Body.
// Otherwise, it will return an error, and the Body will be nil or closed.
func performRequest(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) {
log.Trace("performRequest: %s", req.URL)
res, err := client.Do(req)
if err != nil {
select {
case <-ctx.Done():
return res, ctx.Err()
default:
}
log.Error("Error while processing request: %v", err)
return res, err
}
if res.StatusCode != http.StatusOK {
defer res.Body.Close()
return res, handleErrorResponse(res)
}
return res, nil
}
func handleErrorResponse(resp *http.Response) error {
var er ErrorResponse
err := json.NewDecoder(resp.Body).Decode(&er)
if err != nil {
if err == io.EOF {
return io.ErrUnexpectedEOF
}
log.Error("Error decoding json: %v", err)
return err
}
log.Trace("ErrorResponse(%v): %v", resp.Status, er)
return errors.New(er.Message)
}
+369
View File
@@ -0,0 +1,369 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package lfs
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"testing"
"gitea.dev/modules/json"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
type RoundTripFunc func(req *http.Request) *http.Response
func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req), nil
}
type DummyTransferAdapter struct{}
func (a *DummyTransferAdapter) Name() string {
return "dummy"
}
func (a *DummyTransferAdapter) Download(ctx context.Context, l *Link) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("dummy")), nil
}
func (a *DummyTransferAdapter) Upload(ctx context.Context, l *Link, p Pointer, r io.Reader) error {
return nil
}
func (a *DummyTransferAdapter) Verify(ctx context.Context, l *Link, p Pointer) error {
return nil
}
func lfsTestRoundtripHandler(req *http.Request) *http.Response {
var batchResponse *BatchResponse
url := req.URL.String()
if strings.Contains(url, "status-not-ok") {
return &http.Response{StatusCode: http.StatusBadRequest}
} else if strings.Contains(url, "invalid-json-response") {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("invalid json"))}
} else if strings.Contains(url, "valid-batch-request-download") {
batchResponse = &BatchResponse{
Transfer: "dummy",
Objects: []*ObjectResponse{
{
Actions: map[string]*Link{
"download": {},
},
},
},
}
} else if strings.Contains(url, "legacy-batch-request-download") {
batchResponse = &BatchResponse{
Transfer: "dummy",
Objects: []*ObjectResponse{
{
Links: map[string]*Link{
"download": {},
},
},
},
}
} else if strings.Contains(url, "valid-batch-request-upload") {
batchResponse = &BatchResponse{
Transfer: "dummy",
Objects: []*ObjectResponse{
{
Actions: map[string]*Link{
"upload": {},
},
},
},
}
} else if strings.Contains(url, "response-no-objects") {
batchResponse = &BatchResponse{Transfer: "dummy"}
} else if strings.Contains(url, "unknown-transfer-adapter") {
batchResponse = &BatchResponse{Transfer: "unknown_adapter"}
} else if strings.Contains(url, "error-in-response-objects") {
batchResponse = &BatchResponse{
Transfer: "dummy",
Objects: []*ObjectResponse{
{
Error: &ObjectError{
Code: http.StatusNotFound,
Message: "Object not found",
},
},
},
}
} else if strings.Contains(url, "empty-actions-map") {
batchResponse = &BatchResponse{
Transfer: "dummy",
Objects: []*ObjectResponse{
{
Actions: map[string]*Link{},
},
},
}
} else if strings.Contains(url, "download-actions-map") {
batchResponse = &BatchResponse{
Transfer: "dummy",
Objects: []*ObjectResponse{
{
Actions: map[string]*Link{
"download": {},
},
},
},
}
} else if strings.Contains(url, "upload-actions-map") {
batchResponse = &BatchResponse{
Transfer: "dummy",
Objects: []*ObjectResponse{
{
Actions: map[string]*Link{
"upload": {},
},
},
},
}
} else if strings.Contains(url, "verify-actions-map") {
batchResponse = &BatchResponse{
Transfer: "dummy",
Objects: []*ObjectResponse{
{
Actions: map[string]*Link{
"verify": {},
},
},
},
}
} else if strings.Contains(url, "unknown-actions-map") {
batchResponse = &BatchResponse{
Transfer: "dummy",
Objects: []*ObjectResponse{
{
Actions: map[string]*Link{
"unknown": {},
},
},
},
}
} else {
return nil
}
payload := new(bytes.Buffer)
json.NewEncoder(payload).Encode(batchResponse)
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(payload)}
}
func TestHTTPClientDownload(t *testing.T) {
p := Pointer{Oid: "fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab041", Size: 6}
hc := &http.Client{Transport: RoundTripFunc(func(req *http.Request) *http.Response {
assert.Equal(t, "POST", req.Method)
assert.Equal(t, MediaType, req.Header.Get("Content-type"))
assert.Equal(t, AcceptHeader, req.Header.Get("Accept"))
var batchRequest BatchRequest
err := json.NewDecoder(req.Body).Decode(&batchRequest)
assert.NoError(t, err)
assert.Equal(t, "download", batchRequest.Operation)
assert.Len(t, batchRequest.Objects, 1)
assert.Equal(t, p.Oid, batchRequest.Objects[0].Oid)
assert.Equal(t, p.Size, batchRequest.Objects[0].Size)
return lfsTestRoundtripHandler(req)
})}
dummy := &DummyTransferAdapter{}
cases := []struct {
endpoint string
expectedError string
}{
{
endpoint: "https://status-not-ok.io",
expectedError: io.ErrUnexpectedEOF.Error(),
},
{
endpoint: "https://invalid-json-response.io",
expectedError: "/(invalid json|invalid character)/",
},
{
endpoint: "https://valid-batch-request-download.io",
expectedError: "",
},
{
endpoint: "https://response-no-objects.io",
expectedError: "",
},
{
endpoint: "https://unknown-transfer-adapter.io",
expectedError: "TransferAdapter not found: ",
},
{
endpoint: "https://error-in-response-objects.io",
expectedError: "Object not found",
},
{
endpoint: "https://empty-actions-map.io",
expectedError: "missing action 'download'",
},
{
endpoint: "https://download-actions-map.io",
expectedError: "",
},
{
endpoint: "https://upload-actions-map.io",
expectedError: "missing action 'download'",
},
{
endpoint: "https://verify-actions-map.io",
expectedError: "missing action 'download'",
},
{
endpoint: "https://unknown-actions-map.io",
expectedError: "missing action 'download'",
},
{
endpoint: "https://legacy-batch-request-download.io",
expectedError: "",
},
}
defer test.MockVariableValue(&setting.LFSClient.BatchOperationConcurrency, 8)()
for _, c := range cases {
t.Run(c.endpoint, func(t *testing.T) {
client := &HTTPClient{
client: hc,
endpoint: c.endpoint,
transfers: map[string]TransferAdapter{
"dummy": dummy,
},
}
err := client.Download(t.Context(), []Pointer{p}, func(p Pointer, content io.ReadCloser, objectError error) error {
if objectError != nil {
return objectError
}
b, err := io.ReadAll(content)
assert.NoError(t, err)
assert.Equal(t, []byte("dummy"), b)
return nil
})
if c.expectedError != "" {
if strings.HasPrefix(c.expectedError, "/") && strings.HasSuffix(c.expectedError, "/") {
assert.Regexp(t, strings.Trim(c.expectedError, "/"), err.Error())
} else {
assert.ErrorContains(t, err, c.expectedError)
}
} else {
assert.NoError(t, err)
}
})
}
}
func TestHTTPClientUpload(t *testing.T) {
p := Pointer{Oid: "fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab041", Size: 6}
hc := &http.Client{Transport: RoundTripFunc(func(req *http.Request) *http.Response {
assert.Equal(t, "POST", req.Method)
assert.Equal(t, MediaType, req.Header.Get("Content-type"))
assert.Equal(t, AcceptHeader, req.Header.Get("Accept"))
var batchRequest BatchRequest
err := json.NewDecoder(req.Body).Decode(&batchRequest)
assert.NoError(t, err)
assert.Equal(t, "upload", batchRequest.Operation)
assert.Len(t, batchRequest.Objects, 1)
assert.Equal(t, p.Oid, batchRequest.Objects[0].Oid)
assert.Equal(t, p.Size, batchRequest.Objects[0].Size)
return lfsTestRoundtripHandler(req)
})}
dummy := &DummyTransferAdapter{}
cases := []struct {
endpoint string
expectedError string
}{
{
endpoint: "https://status-not-ok.io",
expectedError: io.ErrUnexpectedEOF.Error(),
},
{
endpoint: "https://invalid-json-response.io",
expectedError: "/(invalid json|invalid character)/",
},
{
endpoint: "https://valid-batch-request-upload.io",
expectedError: "",
},
{
endpoint: "https://response-no-objects.io",
expectedError: "",
},
{
endpoint: "https://unknown-transfer-adapter.io",
expectedError: "TransferAdapter not found: ",
},
{
endpoint: "https://error-in-response-objects.io",
expectedError: "Object not found",
},
{
endpoint: "https://empty-actions-map.io",
expectedError: "",
},
{
endpoint: "https://download-actions-map.io",
expectedError: "missing action 'upload'",
},
{
endpoint: "https://upload-actions-map.io",
expectedError: "",
},
{
endpoint: "https://verify-actions-map.io",
expectedError: "missing action 'upload'",
},
{
endpoint: "https://unknown-actions-map.io",
expectedError: "missing action 'upload'",
},
}
defer test.MockVariableValue(&setting.LFSClient.BatchOperationConcurrency, 8)()
for _, c := range cases {
t.Run(c.endpoint, func(t *testing.T) {
client := &HTTPClient{
client: hc,
endpoint: c.endpoint,
transfers: map[string]TransferAdapter{
"dummy": dummy,
},
}
err := client.Upload(t.Context(), []Pointer{p}, func(p Pointer, objectError error) (io.ReadCloser, error) {
return io.NopCloser(new(bytes.Buffer)), objectError
})
if c.expectedError != "" {
if strings.HasPrefix(c.expectedError, "/") && strings.HasSuffix(c.expectedError, "/") {
assert.Regexp(t, strings.Trim(c.expectedError, "/"), err.Error())
} else {
assert.ErrorContains(t, err, c.expectedError)
}
} else {
assert.NoError(t, err)
}
})
}
}
+128
View File
@@ -0,0 +1,128 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package lfs
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"path"
"regexp"
"strconv"
"strings"
)
// spec: https://github.com/git-lfs/git-lfs/blob/master/docs/spec.md
const (
MetaFileMaxSize = 1024 // spec says the maximum size of a pointer file must be smaller than 1024
MetaFileIdentifier = "version https://git-lfs.github.com/spec/v1" // the first line of a pointer file
MetaFileOidPrefix = "oid sha256:" // spec says the only supported hash is sha256 at the moment
)
var (
// ErrMissingPrefix occurs if the content lacks the LFS prefix
ErrMissingPrefix = errors.New("content lacks the LFS prefix")
// ErrInvalidStructure occurs if the content has an invalid structure
ErrInvalidStructure = errors.New("content has an invalid structure")
// ErrInvalidOIDFormat occurs if the oid has an invalid format
ErrInvalidOIDFormat = errors.New("OID has an invalid format")
)
// ReadPointer tries to read LFS pointer data from the reader
func ReadPointer(reader io.Reader) (Pointer, error) {
buf := make([]byte, MetaFileMaxSize)
n, err := io.ReadFull(reader, buf)
if err != nil && err != io.ErrUnexpectedEOF {
return Pointer{}, err
}
buf = buf[:n]
return ReadPointerFromBuffer(buf)
}
var oidPattern = regexp.MustCompile(`^[a-f\d]{64}$`)
// ReadPointerFromBuffer will return a pointer if the provided byte slice is a pointer file or an error otherwise.
func ReadPointerFromBuffer(buf []byte) (Pointer, error) {
var p Pointer
headString := string(buf)
if !strings.HasPrefix(headString, MetaFileIdentifier) {
return p, ErrMissingPrefix
}
splitLines := strings.Split(headString, "\n")
if len(splitLines) < 3 {
return p, ErrInvalidStructure
}
// spec says "key/value pairs MUST be sorted alphabetically in ascending order (version is exception and must be the first)"
oid := strings.TrimPrefix(splitLines[1], MetaFileOidPrefix)
if len(oid) != 64 || !oidPattern.MatchString(oid) {
return p, ErrInvalidOIDFormat
}
size, err := strconv.ParseInt(strings.TrimPrefix(splitLines[2], "size "), 10, 64)
if err != nil {
return p, err
}
p.Oid = oid
p.Size = size
return p, nil
}
// IsValid checks if the pointer has a valid structure.
// It doesn't check if the pointed-to-content exists.
func (p Pointer) IsValid() bool {
if len(p.Oid) != 64 {
return false
}
if !oidPattern.MatchString(p.Oid) {
return false
}
if p.Size < 0 {
return false
}
return true
}
// StringContent returns the string representation of the pointer
// https://github.com/git-lfs/git-lfs/blob/main/docs/spec.md#the-pointer
func (p Pointer) StringContent() string {
return fmt.Sprintf("%s\n%s%s\nsize %d\n", MetaFileIdentifier, MetaFileOidPrefix, p.Oid, p.Size)
}
// RelativePath returns the relative storage path of the pointer
func (p Pointer) RelativePath() string {
if len(p.Oid) < 5 {
return p.Oid
}
return path.Join(p.Oid[0:2], p.Oid[2:4], p.Oid[4:])
}
func (p Pointer) LogString() string {
if p.Oid == "" && p.Size == 0 {
return "<LFSPointer empty>"
}
return fmt.Sprintf("<LFSPointer %s:%d>", p.Oid, p.Size)
}
// GeneratePointer generates a pointer for arbitrary content
func GeneratePointer(content io.Reader) (Pointer, error) {
h := sha256.New()
c, err := io.Copy(h, content)
if err != nil {
return Pointer{}, err
}
sum := h.Sum(nil)
return Pointer{Oid: hex.EncodeToString(sum), Size: c}, nil
}
+55
View File
@@ -0,0 +1,55 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build gogit
package lfs
import (
"context"
"fmt"
"gitea.dev/modules/git"
"github.com/go-git/go-git/v5/plumbing/object"
)
// SearchPointerBlobs scans the whole repository for LFS pointer files
func SearchPointerBlobs(ctx context.Context, repo *git.Repository, pointerChan chan<- PointerBlob) error {
gitRepo := repo.GoGitRepo()
err := func() error {
blobs, err := gitRepo.BlobObjects()
if err != nil {
return fmt.Errorf("lfs.SearchPointerBlobs BlobObjects: %w", err)
}
return blobs.ForEach(func(blob *object.Blob) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if blob.Size > MetaFileMaxSize {
return nil
}
reader, err := blob.Reader()
if err != nil {
return fmt.Errorf("lfs.SearchPointerBlobs blob.Reader: %w", err)
}
defer reader.Close()
pointer, _ := ReadPointer(reader)
if pointer.IsValid() {
pointerChan <- PointerBlob{Hash: blob.Hash.String(), Pointer: pointer}
}
return nil
})
}()
close(pointerChan)
return err
}
+98
View File
@@ -0,0 +1,98 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !gogit
package lfs
import (
"bufio"
"context"
"errors"
"io"
"strconv"
"strings"
"gitea.dev/modules/git"
"gitea.dev/modules/git/gitcmd"
"gitea.dev/modules/git/pipeline"
"gitea.dev/modules/util"
"golang.org/x/sync/errgroup"
)
// SearchPointerBlobs scans the whole repository for LFS pointer files
func SearchPointerBlobs(ctx context.Context, repo *git.Repository, pointerChan chan<- PointerBlob) error {
cmd1AllObjs, cmd3BatchContent := gitcmd.NewCommand(), gitcmd.NewCommand()
cmd1AllObjsStdout, cmd1AllObjsStdoutClose := cmd1AllObjs.MakeStdoutPipe()
defer cmd1AllObjsStdoutClose()
cmd3BatchContentIn, cmd3BatchContentOut, cmd3BatchContentClose := cmd3BatchContent.MakeStdinStdoutPipe()
defer cmd3BatchContentClose()
// Create the go-routines in reverse order (update: the order is not needed any more, the pipes are properly prepared)
wg := errgroup.Group{}
// 4. Take the output of cat-file --batch and check if each file in turn
// to see if they're pointers to files in the LFS store
wg.Go(func() error {
return createPointerResultsFromCatFileBatch(cmd3BatchContentOut, pointerChan)
})
// 3. Take the shas of the blobs and batch read them
wg.Go(func() error {
return pipeline.CatFileBatch(ctx, cmd3BatchContent, repo.Path)
})
// 2. From the provided objects restrict to blobs <=1k
wg.Go(func() error {
return pipeline.BlobsLessThan1024FromCatFileBatchCheck(cmd1AllObjsStdout, cmd3BatchContentIn)
})
// 1. Run batch-check on all objects in the repository
wg.Go(func() error {
return pipeline.CatFileBatchCheckAllObjects(ctx, cmd1AllObjs, repo.Path)
})
err := wg.Wait()
close(pointerChan)
return err
}
func createPointerResultsFromCatFileBatch(catFileBatchReader io.ReadCloser, pointerChan chan<- PointerBlob) error {
defer catFileBatchReader.Close()
bufferedReader := bufio.NewReader(catFileBatchReader)
buf := make([]byte, 1025)
for {
// File descriptor line: sha
sha, err := bufferedReader.ReadString(' ')
if err != nil {
return util.Iif(errors.Is(err, io.EOF), nil, err)
}
sha = strings.TrimSpace(sha)
// Throw away the blob
if _, err := bufferedReader.ReadString(' '); err != nil {
return err
}
sizeStr, err := bufferedReader.ReadString('\n')
if err != nil {
return err
}
size, err := strconv.Atoi(sizeStr[:len(sizeStr)-1])
if err != nil {
return err
}
pointerBuf := buf[:size+1]
if _, err := io.ReadFull(bufferedReader, pointerBuf); err != nil {
return err
}
pointerBuf = pointerBuf[:size]
// Now we need to check if the pointerBuf is an LFS pointer
pointer, _ := ReadPointerFromBuffer(pointerBuf)
if !pointer.IsValid() {
continue
}
pointerChan <- PointerBlob{Hash: sha, Pointer: pointer}
}
}
+102
View File
@@ -0,0 +1,102 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package lfs
import (
"path"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestStringContent(t *testing.T) {
p := Pointer{Oid: "4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393", Size: 1234}
expected := "version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 1234\n"
assert.Equal(t, expected, p.StringContent())
}
func TestRelativePath(t *testing.T) {
p := Pointer{Oid: "4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393"}
expected := path.Join("4d", "7a", "214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393")
assert.Equal(t, expected, p.RelativePath())
p2 := Pointer{Oid: "4d7a"}
assert.Equal(t, "4d7a", p2.RelativePath())
}
func TestIsValid(t *testing.T) {
p := Pointer{}
assert.False(t, p.IsValid())
p = Pointer{Oid: "123"}
assert.False(t, p.IsValid())
p = Pointer{Oid: "z4cb57646c54a297c9807697e80a30946f79a4b82cb079d2606847825b1812cc"}
assert.False(t, p.IsValid())
p = Pointer{Oid: "94cb57646c54a297c9807697e80a30946f79a4b82cb079d2606847825b1812cc"}
assert.True(t, p.IsValid())
p = Pointer{Oid: "94cb57646c54a297c9807697e80a30946f79a4b82cb079d2606847825b1812cc", Size: -1}
assert.False(t, p.IsValid())
}
func TestGeneratePointer(t *testing.T) {
p, err := GeneratePointer(strings.NewReader("Gitea"))
assert.NoError(t, err)
assert.True(t, p.IsValid())
assert.Equal(t, "94cb57646c54a297c9807697e80a30946f79a4b82cb079d2606847825b1812cc", p.Oid)
assert.Equal(t, int64(5), p.Size)
}
func TestReadPointerFromBuffer(t *testing.T) {
p, err := ReadPointerFromBuffer([]byte{})
assert.ErrorIs(t, err, ErrMissingPrefix)
assert.False(t, p.IsValid())
p, err = ReadPointerFromBuffer([]byte("test"))
assert.ErrorIs(t, err, ErrMissingPrefix)
assert.False(t, p.IsValid())
p, err = ReadPointerFromBuffer([]byte("version https://git-lfs.github.com/spec/v1\n"))
assert.ErrorIs(t, err, ErrInvalidStructure)
assert.False(t, p.IsValid())
p, err = ReadPointerFromBuffer([]byte("version https://git-lfs.github.com/spec/v1\noid sha256:4d7a\nsize 1234\n"))
assert.ErrorIs(t, err, ErrInvalidOIDFormat)
assert.False(t, p.IsValid())
p, err = ReadPointerFromBuffer([]byte("version https://git-lfs.github.com/spec/v1\noid sha256:4d7a2146z4ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 1234\n"))
assert.ErrorIs(t, err, ErrInvalidOIDFormat)
assert.False(t, p.IsValid())
p, err = ReadPointerFromBuffer([]byte("version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\ntest 1234\n"))
assert.Error(t, err)
assert.False(t, p.IsValid())
p, err = ReadPointerFromBuffer([]byte("version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize test\n"))
assert.Error(t, err)
assert.False(t, p.IsValid())
p, err = ReadPointerFromBuffer([]byte("version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 1234\n"))
assert.NoError(t, err)
assert.True(t, p.IsValid())
assert.Equal(t, "4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393", p.Oid)
assert.Equal(t, int64(1234), p.Size)
p, err = ReadPointerFromBuffer([]byte("version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 1234\ntest"))
assert.NoError(t, err)
assert.True(t, p.IsValid())
assert.Equal(t, "4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393", p.Oid)
assert.Equal(t, int64(1234), p.Size)
}
func TestReadPointer(t *testing.T) {
p, err := ReadPointer(strings.NewReader("version https://git-lfs.github.com/spec/v1\noid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\nsize 1234\n"))
assert.NoError(t, err)
assert.True(t, p.IsValid())
assert.Equal(t, "4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393", p.Oid)
assert.Equal(t, int64(1234), p.Size)
}
+134
View File
@@ -0,0 +1,134 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package lfs
import (
"errors"
"fmt"
"time"
"gitea.dev/modules/util"
)
const (
// MediaType contains the media type for LFS server requests
MediaType = "application/vnd.git-lfs+json"
// AcceptHeader Some LFS servers offer content with other types, so fallback to '*/*' if application/vnd.git-lfs+json cannot be served
AcceptHeader = "application/vnd.git-lfs+json;q=0.9, */*;q=0.8"
// UserAgentHeader Add User-Agent for gitea's self-implemented lfs client,
// and the version is consistent with the latest version of git lfs can be avoided incompatibilities.
// Some lfs servers will check this
UserAgentHeader = "git-lfs/3.6.0 (Gitea)"
)
// BatchRequest contains multiple requests processed in one batch operation.
// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#requests
type BatchRequest struct {
Operation string `json:"operation"`
Transfers []string `json:"transfers,omitempty"`
Ref *Reference `json:"ref,omitempty"`
Objects []Pointer `json:"objects"`
}
// Reference contains a git reference.
// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#ref-property
type Reference struct {
Name string `json:"name"`
}
// Pointer contains LFS pointer data
type Pointer struct {
Oid string `json:"oid" xorm:"UNIQUE(s) INDEX NOT NULL"`
Size int64 `json:"size" xorm:"NOT NULL"`
}
// BatchResponse contains multiple object metadata Representation structures
// for use with the batch API.
// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#successful-responses
type BatchResponse struct {
Transfer string `json:"transfer,omitempty"`
Objects []*ObjectResponse `json:"objects"`
}
// ObjectResponse is object metadata as seen by clients of the LFS server.
type ObjectResponse struct {
Pointer
Actions map[string]*Link `json:"actions,omitempty"`
Links map[string]*Link `json:"_links,omitempty"`
Error *ObjectError `json:"error,omitempty"`
}
// Link provides a structure with information about how to access a object.
type Link struct {
Href string `json:"href"`
Header map[string]string `json:"header,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
func NewLink(href string) *Link {
return &Link{Href: href}
}
func (l *Link) WithHeader(k, v string) *Link {
if v == "" {
return l
}
if l.Header == nil {
l.Header = make(map[string]string)
}
l.Header[k] = v
return l
}
// ObjectError defines the JSON structure returned to the client in case of an error.
type ObjectError struct {
Code int `json:"code"`
Message string `json:"message"`
}
var (
// See https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#successful-responses
// LFS object error codes should match HTTP status codes where possible:
// 404 - The object does not exist on the server.
// 409 - The specified hash algorithm disagrees with the server's acceptable options.
// 410 - The object was removed by the owner.
// 422 - Validation error.
ErrObjectNotExist = util.ErrNotExist // the object does not exist on the server
ErrObjectHashMismatch = errors.New("the specified hash algorithm disagrees with the server's acceptable options")
ErrObjectRemoved = errors.New("the object was removed by the owner")
ErrObjectValidation = errors.New("validation error")
)
func (e *ObjectError) Error() string {
return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}
func (e *ObjectError) Unwrap() error {
switch e.Code {
case 404:
return ErrObjectNotExist
case 409:
return ErrObjectHashMismatch
case 410:
return ErrObjectRemoved
case 422:
return ErrObjectValidation
default:
return errors.New(e.Message)
}
}
// PointerBlob associates a Git blob with a Pointer.
type PointerBlob struct {
Hash string
Pointer
}
// ErrorResponse describes the error to the client.
type ErrorResponse struct {
Message string
DocumentationURL string `json:"documentation_url,omitempty"`
RequestID string `json:"request_id,omitempty"`
}
+89
View File
@@ -0,0 +1,89 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package lfs
import (
"bytes"
"context"
"io"
"net/http"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
)
// TransferAdapter represents an adapter for downloading/uploading LFS objects.
type TransferAdapter interface {
Name() string
Download(ctx context.Context, l *Link) (io.ReadCloser, error)
Upload(ctx context.Context, l *Link, p Pointer, r io.Reader) error
Verify(ctx context.Context, l *Link, p Pointer) error
}
// BasicTransferAdapter implements the "basic" adapter.
type BasicTransferAdapter struct {
client *http.Client
}
// Name returns the name of the adapter.
func (a *BasicTransferAdapter) Name() string {
return "basic"
}
// Download reads the download location and downloads the data.
func (a *BasicTransferAdapter) Download(ctx context.Context, l *Link) (io.ReadCloser, error) {
req, err := createRequest(ctx, http.MethodGet, l.Href, l.Header, nil)
if err != nil {
return nil, err
}
log.Debug("Download Request: %+v", req)
resp, err := performRequest(ctx, a.client, req)
if err != nil {
return nil, err
}
return resp.Body, nil
}
// Upload sends the content to the LFS server.
func (a *BasicTransferAdapter) Upload(ctx context.Context, l *Link, p Pointer, r io.Reader) error {
req, err := createRequest(ctx, http.MethodPut, l.Href, l.Header, r)
if err != nil {
return err
}
if req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/octet-stream")
}
if req.Header.Get("Transfer-Encoding") == "chunked" {
req.TransferEncoding = []string{"chunked"}
}
req.ContentLength = p.Size
res, err := performRequest(ctx, a.client, req)
if err != nil {
return err
}
defer res.Body.Close()
return nil
}
// Verify calls the verify handler on the LFS server
func (a *BasicTransferAdapter) Verify(ctx context.Context, l *Link, p Pointer) error {
b, err := json.Marshal(p)
if err != nil {
log.Error("Error encoding json: %v", err)
return err
}
req, err := createRequest(ctx, http.MethodPost, l.Href, l.Header, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", MediaType)
res, err := performRequest(ctx, a.client, req)
if err != nil {
return err
}
defer res.Body.Close()
return nil
}
+170
View File
@@ -0,0 +1,170 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package lfs
import (
"bytes"
"io"
"net/http"
"strings"
"testing"
"gitea.dev/modules/json"
"github.com/stretchr/testify/assert"
)
func TestBasicTransferAdapterName(t *testing.T) {
a := &BasicTransferAdapter{}
assert.Equal(t, "basic", a.Name())
}
func TestBasicTransferAdapter(t *testing.T) {
p := Pointer{Oid: "b5a2c96250612366ea272ffac6d9744aaf4b45aacd96aa7cfcb931ee3b558259", Size: 5}
roundTripHandler := func(req *http.Request) *http.Response {
assert.Equal(t, AcceptHeader, req.Header.Get("Accept"))
assert.Equal(t, "test-value", req.Header.Get("test-header"))
url := req.URL.String()
if strings.Contains(url, "download-request") {
assert.Equal(t, "GET", req.Method)
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("dummy"))}
} else if strings.Contains(url, "upload-request") {
assert.Equal(t, "PUT", req.Method)
assert.Equal(t, "application/octet-stream", req.Header.Get("Content-Type"))
b, err := io.ReadAll(req.Body)
assert.NoError(t, err)
assert.Equal(t, "dummy", string(b))
return &http.Response{StatusCode: http.StatusOK}
} else if strings.Contains(url, "verify-request") {
assert.Equal(t, "POST", req.Method)
assert.Equal(t, MediaType, req.Header.Get("Content-Type"))
var vp Pointer
err := json.NewDecoder(req.Body).Decode(&vp)
assert.NoError(t, err)
assert.Equal(t, p.Oid, vp.Oid)
assert.Equal(t, p.Size, vp.Size)
return &http.Response{StatusCode: http.StatusOK}
} else if strings.Contains(url, "error-response") {
er := &ErrorResponse{
Message: "Object not found",
}
payload := new(bytes.Buffer)
json.NewEncoder(payload).Encode(er)
return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(payload)}
}
t.Errorf("Unknown test case: %s", url)
return nil
}
hc := &http.Client{Transport: RoundTripFunc(roundTripHandler)}
a := &BasicTransferAdapter{hc}
t.Run("Download", func(t *testing.T) {
cases := []struct {
link *Link
expectederror string
}{
// case 0
{
link: &Link{
Href: "https://download-request.io",
Header: map[string]string{"test-header": "test-value"},
},
expectederror: "",
},
// case 1
{
link: &Link{
Href: "https://error-response.io",
Header: map[string]string{"test-header": "test-value"},
},
expectederror: "Object not found",
},
}
for n, c := range cases {
_, err := a.Download(t.Context(), c.link)
if len(c.expectederror) > 0 {
assert.Contains(t, err.Error(), c.expectederror, "case %d: '%s' should contain '%s'", n, err.Error(), c.expectederror)
} else {
assert.NoError(t, err, "case %d", n)
}
}
})
t.Run("Upload", func(t *testing.T) {
cases := []struct {
link *Link
expectederror string
}{
// case 0
{
link: &Link{
Href: "https://upload-request.io",
Header: map[string]string{"test-header": "test-value"},
},
expectederror: "",
},
// case 1
{
link: &Link{
Href: "https://error-response.io",
Header: map[string]string{"test-header": "test-value"},
},
expectederror: "Object not found",
},
}
for n, c := range cases {
err := a.Upload(t.Context(), c.link, p, strings.NewReader("dummy"))
if len(c.expectederror) > 0 {
assert.Contains(t, err.Error(), c.expectederror, "case %d: '%s' should contain '%s'", n, err.Error(), c.expectederror)
} else {
assert.NoError(t, err, "case %d", n)
}
}
})
t.Run("Verify", func(t *testing.T) {
cases := []struct {
link *Link
expectederror string
}{
// case 0
{
link: &Link{
Href: "https://verify-request.io",
Header: map[string]string{"test-header": "test-value"},
},
expectederror: "",
},
// case 1
{
link: &Link{
Href: "https://error-response.io",
Header: map[string]string{"test-header": "test-value"},
},
expectederror: "Object not found",
},
}
for n, c := range cases {
err := a.Verify(t.Context(), c.link, p)
if len(c.expectederror) > 0 {
assert.Contains(t, err.Error(), c.expectederror, "case %d: '%s' should contain '%s'", n, err.Error(), c.expectederror)
} else {
assert.NoError(t, err, "case %d", n)
}
}
})
}