初始提交: Gitea 项目代码
This commit is contained in:
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user