mirror of
https://github.com/versity/versitygw.git
synced 2025-12-23 05:05:16 +00:00
This adds a bunch of test cases for non-0 len object, 0 len object, and directory objects to match verified AWS responses for the various range bytes cases. This fixes the posix head/get range responses for these test cases as well.
425 lines
11 KiB
Go
425 lines
11 KiB
Go
// Copyright 2023 Versity Software
|
|
// This file is licensed under the Apache License, Version 2.0
|
|
// (the "License"); you may not use this file except in compliance
|
|
// with the License. You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing,
|
|
// software distributed under the License is distributed on an
|
|
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
// KIND, either express or implied. See the License for the
|
|
// specific language governing permissions and limitations
|
|
// under the License.
|
|
|
|
package backend
|
|
|
|
import (
|
|
"crypto/md5"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"hash"
|
|
"io"
|
|
"io/fs"
|
|
"math"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
|
"github.com/versity/versitygw/s3err"
|
|
"github.com/versity/versitygw/s3response"
|
|
)
|
|
|
|
const (
|
|
// this is the media type for directories in AWS and Nextcloud
|
|
DirContentType = "application/x-directory"
|
|
DefaultContentType = "binary/octet-stream"
|
|
|
|
// this is the minimum allowed size for mp parts
|
|
MinPartSize = 5 * 1024 * 1024
|
|
)
|
|
|
|
func IsValidBucketName(name string) bool { return true }
|
|
|
|
type ByBucketName []s3response.ListAllMyBucketsEntry
|
|
|
|
func (d ByBucketName) Len() int { return len(d) }
|
|
func (d ByBucketName) Swap(i, j int) { d[i], d[j] = d[j], d[i] }
|
|
func (d ByBucketName) Less(i, j int) bool { return d[i].Name < d[j].Name }
|
|
|
|
type ByObjectName []types.Object
|
|
|
|
func (d ByObjectName) Len() int { return len(d) }
|
|
func (d ByObjectName) Swap(i, j int) { d[i], d[j] = d[j], d[i] }
|
|
func (d ByObjectName) Less(i, j int) bool { return *d[i].Key < *d[j].Key }
|
|
|
|
func GetPtrFromString(str string) *string {
|
|
if str == "" {
|
|
return nil
|
|
}
|
|
return &str
|
|
}
|
|
|
|
func GetStringFromPtr(str *string) string {
|
|
if str == nil {
|
|
return ""
|
|
}
|
|
return *str
|
|
}
|
|
|
|
func GetTimePtr(t time.Time) *time.Time {
|
|
return &t
|
|
}
|
|
|
|
func TrimEtag(etag *string) *string {
|
|
if etag == nil {
|
|
return nil
|
|
}
|
|
|
|
return GetPtrFromString(strings.Trim(*etag, "\""))
|
|
}
|
|
|
|
var (
|
|
errInvalidRange = s3err.GetAPIError(s3err.ErrInvalidRange)
|
|
errInvalidCopySourceRange = s3err.GetAPIError(s3err.ErrInvalidCopySourceRange)
|
|
)
|
|
|
|
// ParseObjectRange parses input range header and returns startoffset, length, isValid
|
|
// and error. If no endoffset specified, then length is set to the object size
|
|
// for invalid inputs, it returns no error, but isValid=false
|
|
// `InvalidRange` error is returnd, only if startoffset is greater than the object size
|
|
func ParseObjectRange(size int64, acceptRange string) (int64, int64, bool, error) {
|
|
// Return full object (invalid range, no error) if header empty
|
|
if acceptRange == "" {
|
|
return 0, size, false, nil
|
|
}
|
|
|
|
rangeKv := strings.Split(acceptRange, "=")
|
|
if len(rangeKv) != 2 {
|
|
return 0, size, false, nil
|
|
}
|
|
if rangeKv[0] != "bytes" { // unsupported unit -> ignore
|
|
return 0, size, false, nil
|
|
}
|
|
|
|
bRange := strings.Split(rangeKv[1], "-")
|
|
if len(bRange) != 2 { // malformed / multi-range
|
|
return 0, size, false, nil
|
|
}
|
|
|
|
// Parse start; empty start indicates a suffix-byte-range-spec (e.g. bytes=-100)
|
|
startOffset, err := strconv.ParseInt(bRange[0], 10, strconv.IntSize)
|
|
if startOffset > int64(math.MaxInt) || startOffset < int64(math.MinInt) {
|
|
return 0, size, false, errInvalidRange
|
|
}
|
|
if err != nil && bRange[0] != "" { // invalid numeric start (non-empty) -> ignore range
|
|
return 0, size, false, nil
|
|
}
|
|
|
|
// If end part missing (e.g. bytes=100-)
|
|
if bRange[1] == "" {
|
|
if bRange[0] == "" { // bytes=- (meaningless) -> ignore
|
|
return 0, size, false, nil
|
|
}
|
|
// start beyond or at size is unsatisfiable -> error (RequestedRangeNotSatisfiable)
|
|
if startOffset >= size {
|
|
return 0, 0, false, errInvalidRange
|
|
}
|
|
// bytes=100- => from start to end
|
|
return startOffset, size - startOffset, true, nil
|
|
}
|
|
|
|
endOffset, err := strconv.ParseInt(bRange[1], 10, strconv.IntSize)
|
|
if endOffset > int64(math.MaxInt) {
|
|
return 0, size, false, errInvalidRange
|
|
}
|
|
if err != nil { // invalid numeric end -> ignore range
|
|
return 0, size, false, nil
|
|
}
|
|
|
|
// Suffix range handling (bRange[0] == "")
|
|
if bRange[0] == "" {
|
|
// Disallow -0 (always unsatisfiable)
|
|
if endOffset == 0 {
|
|
return 0, 0, false, errInvalidRange
|
|
}
|
|
// For zero-sized objects any positive suffix is treated as invalid (ignored, no error)
|
|
if size == 0 {
|
|
return 0, size, false, nil
|
|
}
|
|
// Clamp to object size (request more bytes than exist -> entire object)
|
|
endOffset = min(endOffset, size)
|
|
return size - endOffset, endOffset, true, nil
|
|
}
|
|
|
|
// Normal range (start-end)
|
|
if startOffset > endOffset { // start > end -> ignore
|
|
return 0, size, false, nil
|
|
}
|
|
// Start beyond or at end of object -> error
|
|
if startOffset >= size {
|
|
return 0, 0, false, errInvalidRange
|
|
}
|
|
// Adjust end beyond object size (trim)
|
|
if endOffset >= size {
|
|
endOffset = size - 1
|
|
}
|
|
return startOffset, endOffset - startOffset + 1, true, nil
|
|
}
|
|
|
|
// ParseCopySourceRange parses input range header and returns startoffset, length
|
|
// and error. If no endoffset specified, then length is set to the object size
|
|
func ParseCopySourceRange(size int64, acceptRange string) (int64, int64, error) {
|
|
if acceptRange == "" {
|
|
return 0, size, nil
|
|
}
|
|
|
|
rangeKv := strings.Split(acceptRange, "=")
|
|
|
|
if len(rangeKv) != 2 {
|
|
return 0, 0, errInvalidCopySourceRange
|
|
}
|
|
|
|
if rangeKv[0] != "bytes" {
|
|
return 0, 0, errInvalidCopySourceRange
|
|
}
|
|
|
|
bRange := strings.Split(rangeKv[1], "-")
|
|
if len(bRange) != 2 {
|
|
return 0, 0, errInvalidCopySourceRange
|
|
}
|
|
|
|
startOffset, err := strconv.ParseInt(bRange[0], 10, 64)
|
|
if err != nil {
|
|
return 0, 0, errInvalidCopySourceRange
|
|
}
|
|
|
|
if startOffset >= size {
|
|
return 0, 0, s3err.CreateExceedingRangeErr(size)
|
|
}
|
|
|
|
if bRange[1] == "" {
|
|
return startOffset, size - startOffset + 1, nil
|
|
}
|
|
|
|
endOffset, err := strconv.ParseInt(bRange[1], 10, 64)
|
|
if err != nil {
|
|
return 0, 0, errInvalidCopySourceRange
|
|
}
|
|
|
|
if endOffset < startOffset {
|
|
return 0, 0, errInvalidCopySourceRange
|
|
}
|
|
|
|
if endOffset >= size {
|
|
return 0, 0, s3err.CreateExceedingRangeErr(size)
|
|
}
|
|
|
|
return startOffset, endOffset - startOffset + 1, nil
|
|
}
|
|
|
|
// ParseCopySource parses x-amz-copy-source header and returns source bucket,
|
|
// source object, versionId, error respectively
|
|
func ParseCopySource(copySourceHeader string) (string, string, string, error) {
|
|
if copySourceHeader[0] == '/' {
|
|
copySourceHeader = copySourceHeader[1:]
|
|
}
|
|
|
|
var copySource, versionId string
|
|
i := strings.LastIndex(copySourceHeader, "?versionId=")
|
|
if i == -1 {
|
|
copySource = copySourceHeader
|
|
} else {
|
|
copySource = copySourceHeader[:i]
|
|
versionId = copySourceHeader[i+11:]
|
|
}
|
|
|
|
srcBucket, srcObject, ok := strings.Cut(copySource, "/")
|
|
if !ok {
|
|
return "", "", "", s3err.GetAPIError(s3err.ErrInvalidCopySourceBucket)
|
|
}
|
|
|
|
return srcBucket, srcObject, versionId, nil
|
|
}
|
|
|
|
// ParseObjectTags parses the url encoded input string into
|
|
// map[string]string with unescaped key/value pair
|
|
func ParseObjectTags(tagging string) (map[string]string, error) {
|
|
if tagging == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
tagSet := make(map[string]string)
|
|
|
|
for tagging != "" {
|
|
var tag string
|
|
tag, tagging, _ = strings.Cut(tagging, "&")
|
|
// if 'tag' before the first appearance of '&' is empty continue
|
|
if tag == "" {
|
|
continue
|
|
}
|
|
|
|
key, value, found := strings.Cut(tag, "=")
|
|
// if key is empty, but "=" is present, return invalid url ecnoding err
|
|
if found && key == "" {
|
|
return nil, s3err.GetAPIError(s3err.ErrInvalidURLEncodedTagging)
|
|
}
|
|
|
|
// return invalid tag key, if the key is longer than 128
|
|
if len(key) > 128 {
|
|
return nil, s3err.GetAPIError(s3err.ErrInvalidTagKey)
|
|
}
|
|
|
|
// return invalid tag value, if tag value is longer than 256
|
|
if len(value) > 256 {
|
|
return nil, s3err.GetAPIError(s3err.ErrInvalidTagValue)
|
|
}
|
|
|
|
// query unescape tag key
|
|
key, err := url.QueryUnescape(key)
|
|
if err != nil {
|
|
return nil, s3err.GetAPIError(s3err.ErrInvalidURLEncodedTagging)
|
|
}
|
|
|
|
// query unescape tag value
|
|
value, err = url.QueryUnescape(value)
|
|
if err != nil {
|
|
return nil, s3err.GetAPIError(s3err.ErrInvalidURLEncodedTagging)
|
|
}
|
|
|
|
// check tag key to be valid
|
|
if !isValidTagComponent(key) {
|
|
return nil, s3err.GetAPIError(s3err.ErrInvalidTagKey)
|
|
}
|
|
|
|
// check tag value to be valid
|
|
if !isValidTagComponent(value) {
|
|
return nil, s3err.GetAPIError(s3err.ErrInvalidTagValue)
|
|
}
|
|
|
|
// duplicate keys are not allowed: return invalid url encoding err
|
|
_, ok := tagSet[key]
|
|
if ok {
|
|
return nil, s3err.GetAPIError(s3err.ErrInvalidURLEncodedTagging)
|
|
}
|
|
|
|
tagSet[key] = value
|
|
}
|
|
|
|
return tagSet, nil
|
|
}
|
|
|
|
var validTagComponent = regexp.MustCompile(`^[a-zA-Z0-9:/_.\-+ ]+$`)
|
|
|
|
// isValidTagComponent matches strings which contain letters, decimal digits,
|
|
// and special chars: '/', '_', '-', '+', '.', ' ' (space)
|
|
func isValidTagComponent(str string) bool {
|
|
if str == "" {
|
|
return true
|
|
}
|
|
return validTagComponent.Match([]byte(str))
|
|
}
|
|
|
|
func GetMultipartMD5(parts []types.CompletedPart) string {
|
|
var partsEtagBytes []byte
|
|
for _, part := range parts {
|
|
partsEtagBytes = append(partsEtagBytes, getEtagBytes(*part.ETag)...)
|
|
}
|
|
|
|
return fmt.Sprintf("\"%s-%d\"", md5String(partsEtagBytes), len(parts))
|
|
}
|
|
|
|
func getEtagBytes(etag string) []byte {
|
|
decode, err := hex.DecodeString(strings.ReplaceAll(etag, string('"'), ""))
|
|
if err != nil {
|
|
return []byte(etag)
|
|
}
|
|
return decode
|
|
}
|
|
|
|
func md5String(data []byte) string {
|
|
sum := md5.Sum(data)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
type FileSectionReadCloser struct {
|
|
R io.Reader
|
|
F *os.File
|
|
}
|
|
|
|
func (f *FileSectionReadCloser) Read(p []byte) (int, error) {
|
|
return f.R.Read(p)
|
|
}
|
|
|
|
func (f *FileSectionReadCloser) Close() error {
|
|
return f.F.Close()
|
|
}
|
|
|
|
// MoveFile moves a file from source to destination.
|
|
func MoveFile(source, destination string, perm os.FileMode) error {
|
|
// We use Rename as the atomic operation for object puts. The upload is
|
|
// written to a temp file to not conflict with any other simultaneous
|
|
// uploads. The final operation is to move the temp file into place for
|
|
// the object. This ensures the object semantics of last upload completed
|
|
// wins and is not some combination of writes from simultaneous uploads.
|
|
err := os.Rename(source, destination)
|
|
if err == nil || !errors.Is(err, syscall.EXDEV) {
|
|
return err
|
|
}
|
|
|
|
// Rename can fail if the source and destination are not on the same
|
|
// filesystem. The fallback is to copy the file and then remove the source.
|
|
// We need to be careful that the desination does not exist before copying
|
|
// to prevent any other simultaneous writes to the file.
|
|
sourceFile, err := os.Open(source)
|
|
if err != nil {
|
|
return fmt.Errorf("open source: %w", err)
|
|
}
|
|
defer sourceFile.Close()
|
|
|
|
var destFile *os.File
|
|
for {
|
|
destFile, err = os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, perm)
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrExist) {
|
|
if removeErr := os.Remove(destination); removeErr != nil {
|
|
return fmt.Errorf("remove existing destination: %w", removeErr)
|
|
}
|
|
continue
|
|
}
|
|
return fmt.Errorf("create destination: %w", err)
|
|
}
|
|
break
|
|
}
|
|
defer destFile.Close()
|
|
|
|
_, err = io.Copy(destFile, sourceFile)
|
|
if err != nil {
|
|
return fmt.Errorf("copy data: %w", err)
|
|
}
|
|
|
|
err = os.Remove(source)
|
|
if err != nil {
|
|
return fmt.Errorf("remove source: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GenerateEtag generates a new quoted etag from the provided hash.Hash
|
|
func GenerateEtag(h hash.Hash) string {
|
|
dataSum := h.Sum(nil)
|
|
return fmt.Sprintf("\"%s\"", hex.EncodeToString(dataSum[:]))
|
|
}
|
|
|
|
// AreEtagsSame compares 2 etags by ignoring quotes
|
|
func AreEtagsSame(e1, e2 string) bool {
|
|
return strings.Trim(e1, `"`) == strings.Trim(e2, `"`)
|
|
}
|