Files
wehub-resource-sync 5357c39144
Fuzzer / Run Fuzzer (push) Has been cancelled
Race tests / Go race tests (ubuntu-22.04) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:01:40 +08:00

489 lines
15 KiB
Go

// Copyright 2019 Dolthub, Inc.
//
// 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 remotesrv
import (
"bytes"
"context"
"crypto/md5"
"encoding/base64"
"errors"
"fmt"
gohash "hash"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/sirupsen/logrus"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
"github.com/dolthub/dolt/go/libraries/utils/filesys"
"github.com/dolthub/dolt/go/store/hash"
"github.com/dolthub/dolt/go/store/nbs"
"github.com/dolthub/dolt/go/store/types"
)
var (
ErrReadOutOfBounds = errors.New("cannot read file for given length and " +
"offset since the read would exceed the size of the file")
)
type filehandler struct {
dbCache DBCache
fs filesys.Filesys
readOnly bool
lgr *logrus.Entry
sealer Sealer
writeErrBody bool
}
// NewFileHandler returns an http.Handler that serves table file downloads
// and uploads backed by the given DBCache and filesystem. When writeErrBody
// is true, upload errors are written to the response body so clients can
// surface descriptive error messages (used by the SSH transfer path).
func NewFileHandler(lgr *logrus.Entry, dbCache DBCache, fs filesys.Filesys, readOnly bool, sealer Sealer, writeErrBody bool) http.Handler {
fh := newFileHandler(lgr, dbCache, fs, readOnly, sealer)
fh.writeErrBody = writeErrBody
return fh
}
func newFileHandler(lgr *logrus.Entry, dbCache DBCache, fs filesys.Filesys, readOnly bool, sealer Sealer) filehandler {
return filehandler{
dbCache: dbCache,
fs: fs,
readOnly: readOnly,
lgr: lgr.WithFields(logrus.Fields{
"service": "dolt.services.remotesapi.v1alpha1.HttpFileServer",
}),
sealer: sealer,
}
}
func (fh filehandler) ServeHTTP(respWr http.ResponseWriter, req *http.Request) {
logger := getReqLogger(fh.lgr, req.Method+"_"+req.RequestURI)
defer func() { logger.Trace("finished") }()
var err error
req.URL, err = fh.sealer.Unseal(req.URL)
if err != nil {
logger.WithError(err).Warn("could not unseal incoming request URL")
respWr.WriteHeader(http.StatusBadRequest)
return
}
logger = logger.WithField("unsealed_url", req.URL.String())
path := strings.TrimLeft(req.URL.Path, "/")
statusCode := http.StatusMethodNotAllowed
switch req.Method {
case http.MethodGet:
path = filepath.Clean(path)
if strings.HasPrefix(path, "../") || strings.Contains(path, "/../") || strings.HasSuffix(path, "/..") {
logger.Warn("bad request with .. in URL path")
respWr.WriteHeader(http.StatusBadRequest)
return
}
i := strings.LastIndex(path, "/")
if i == -1 {
logger.Warn("bad request with -1 LastIndex of '/' for path")
respWr.WriteHeader(http.StatusBadRequest)
return
}
fileName := path[i+1:]
if strings.HasSuffix(fileName, nbs.ArchiveFileSuffix) {
fileName = fileName[:len(fileName)-len(nbs.ArchiveFileSuffix)]
}
_, ok := hash.MaybeParse(fileName)
if !ok {
logger.WithField("last_path_component", fileName).Warn("bad request with unparseable last path component")
respWr.WriteHeader(http.StatusBadRequest)
return
}
abs, err := fh.fs.Abs(path)
if err != nil {
logger.WithError(err).Error("could not get absolute path")
respWr.WriteHeader(http.StatusInternalServerError)
return
}
respWr.Header().Add("Accept-Ranges", "bytes")
logger, statusCode = readTableFile(logger, abs, respWr, req.Header.Get("Range"))
case http.MethodPost, http.MethodPut:
if fh.readOnly {
respWr.WriteHeader(http.StatusForbidden)
return
}
i := strings.LastIndex(path, "/")
if i < 0 || !validateFileName(path[i+1:]) {
logger = logger.WithField("status", http.StatusNotFound)
respWr.WriteHeader(http.StatusNotFound)
return
}
filepath := path[:i]
file := path[i+1:]
q := req.URL.Query()
ncs := q.Get("num_chunks")
if ncs == "" {
logger = logger.WithField("status", http.StatusBadRequest)
logger.Warn("bad request: num_chunks parameter not provided")
respWr.WriteHeader(http.StatusBadRequest)
return
}
numChunks, err := strconv.Atoi(ncs)
if err != nil {
logger = logger.WithField("status", http.StatusBadRequest)
logger.WithError(err).Warn("bad request: num_chunks parameter did not parse")
respWr.WriteHeader(http.StatusBadRequest)
return
}
cls := q.Get("content_length")
if cls == "" {
logger = logger.WithField("status", http.StatusBadRequest)
logger.Warn("bad request: content_length parameter not provided")
respWr.WriteHeader(http.StatusBadRequest)
return
}
contentLength, err := strconv.Atoi(cls)
if err != nil {
logger = logger.WithField("status", http.StatusBadRequest)
logger.WithError(err).Warn("bad request: content_length parameter did not parse")
respWr.WriteHeader(http.StatusBadRequest)
return
}
// content_hash is optional.
var contentHash []byte
chs := q.Get("content_hash")
if chs != "" {
contentHash, err = base64.RawURLEncoding.DecodeString(chs)
if err != nil {
logger = logger.WithField("status", http.StatusBadRequest)
logger.WithError(err).Warn("bad request: content_hash parameter did not parse")
respWr.WriteHeader(http.StatusBadRequest)
return
}
}
// splitOffset is not required to allow for backwards compatibility with older clients.
splitOffset := uint64(0)
splitQstr := q.Get("split_offset")
if splitQstr != "" {
splitOffset, err = strconv.ParseUint(splitQstr, 10, 64)
if err != nil {
logger = logger.WithField("status", http.StatusBadRequest)
logger.WithError(err).Warn("bad request: split_offset parameter did not parse")
respWr.WriteHeader(http.StatusBadRequest)
return
}
}
var writeErr error
logger, statusCode, writeErr = writeTableFile(req.Context(), logger, fh.dbCache, filepath, file, splitOffset, numChunks, contentHash, uint64(contentLength), req.Body)
if writeErr != nil && fh.writeErrBody {
http.Error(respWr, writeErr.Error(), statusCode)
return
}
}
if statusCode != -1 {
respWr.WriteHeader(statusCode)
}
}
func readTableFile(logger *logrus.Entry, path string, respWr http.ResponseWriter, rangeStr string) (*logrus.Entry, int) {
var r io.ReadCloser
var readSize int64
var fileErr error
{
if rangeStr == "" {
logger = logger.WithField("whole_file", true)
r, readSize, fileErr = getFileReader(path)
} else {
offset, length, headerStr, err := offsetAndLenFromRange(rangeStr)
if err != nil {
logger.Println(err.Error())
return logger, http.StatusBadRequest
}
logger = logger.WithFields(logrus.Fields{
"read_offset": offset,
"read_length": length,
})
readSize = length
var fSize int64
r, fSize, fileErr = getFileReaderAt(path, offset, length)
if fileErr == nil {
respWr.Header().Add("Content-Range", headerStr+strconv.Itoa(int(fSize)))
}
}
}
if fileErr != nil {
logger.Println(fileErr.Error())
if errors.Is(fileErr, os.ErrNotExist) {
logger = logger.WithField("status", http.StatusNotFound)
return logger, http.StatusNotFound
} else if errors.Is(fileErr, ErrReadOutOfBounds) {
logger = logger.WithField("status", http.StatusBadRequest)
logger.Warn("bad request: offset out of bounds for path")
return logger, http.StatusBadRequest
}
logger = logger.WithError(fileErr)
return logger, http.StatusInternalServerError
}
defer func() {
err := r.Close()
if err != nil {
logger.WithError(err).Warn("failed to close file")
}
}()
if rangeStr != "" {
respWr.WriteHeader(http.StatusPartialContent)
} else {
respWr.WriteHeader(http.StatusOK)
}
n, err := io.Copy(respWr, r)
if err != nil {
logger = logger.WithField("status", http.StatusInternalServerError)
logger.WithError(err).Error("error copying data to response writer")
return logger, http.StatusInternalServerError
}
if n != readSize {
logger = logger.WithField("status", http.StatusInternalServerError)
logger.WithField("copied_size", n).Error("failed to copy all bytes to response")
return logger, http.StatusInternalServerError
}
return logger, -1
}
type uploadreader struct {
r io.ReadCloser
totalread int
expectedread uint64
expectedsum []byte
checksum gohash.Hash
}
func (u *uploadreader) Read(p []byte) (n int, err error) {
n, err = u.r.Read(p)
if err == nil || err == io.EOF {
u.totalread += n
u.checksum.Write(p[:n])
}
return n, err
}
var errBodyLengthTFDMismatch = errors.New("body upload length did not match table file details")
var errBodyHashTFDMismatch = errors.New("body upload hash did not match table file details")
func (u *uploadreader) Close() error {
cerr := u.r.Close()
if cerr != nil {
return cerr
}
if u.expectedread != 0 && u.expectedread != uint64(u.totalread) {
return errBodyLengthTFDMismatch
}
sum := u.checksum.Sum(nil)
if u.expectedsum != nil && !bytes.Equal(u.expectedsum, sum[:]) {
return errBodyHashTFDMismatch
}
return nil
}
func writeTableFile(ctx context.Context, logger *logrus.Entry, dbCache DBCache, path, fileId string, splitOffset uint64, numChunks int, contentHash []byte, contentLength uint64, body io.ReadCloser) (*logrus.Entry, int, error) {
if !validateFileName(fileId) {
logger = logger.WithField("status", http.StatusBadRequest)
logger.Warnf("%s is not a valid hash", fileId)
return logger, http.StatusBadRequest, fmt.Errorf("%s is not a valid hash", fileId)
}
cs, err := dbCache.Get(ctx, path, types.Format_DOLT.VersionString())
if err != nil {
logger = logger.WithField("status", http.StatusInternalServerError)
logger.WithError(err).Error("failed to get repository")
return logger, http.StatusInternalServerError, err
}
pending, err := cs.WriteTableFile(ctx, fileId, splitOffset, numChunks, contentHash, func() (io.ReadCloser, uint64, error) {
reader := body
size := contentLength
return &uploadreader{
reader,
0,
contentLength,
contentHash,
md5.New(),
}, size, nil
})
if err != nil {
if errors.Is(err, errBodyLengthTFDMismatch) {
logger = logger.WithField("status", http.StatusBadRequest)
logger.Warn("bad request: body length mismatch")
return logger, http.StatusBadRequest, err
}
if errors.Is(err, errBodyHashTFDMismatch) {
logger = logger.WithField("status", http.StatusBadRequest)
logger.Warn("bad request: body hash mismatch")
return logger, http.StatusBadRequest, err
}
logger = logger.WithField("status", http.StatusInternalServerError)
logger.WithError(err).Error("failed to write upload to table file")
return logger, http.StatusInternalServerError, err
}
// TODO: The pending handle protects the written file from pruning until
// it is added to the manifest. In remotesrv, AddTableFilesToManifest
// happens in a separate gRPC request, so we cannot hold it across
// requests. Close it here for now.
pending.Close()
return logger, http.StatusOK, nil
}
func offsetAndLenFromRange(rngStr string) (int64, int64, string, error) {
if rngStr == "" {
return -1, -1, "", nil
}
if !strings.HasPrefix(rngStr, "bytes=") {
return -1, -1, "", errors.New("range string does not start with 'bytes=")
}
tokens := strings.Split(rngStr[6:], "-")
if len(tokens) != 2 {
return -1, -1, "", errors.New("invalid range format. should be bytes=#-#")
}
start, err := strconv.ParseUint(strings.TrimSpace(tokens[0]), 10, 64)
if err != nil {
return -1, -1, "", errors.New("invalid offset is not a number. should be bytes=#-#")
}
end, err := strconv.ParseUint(strings.TrimSpace(tokens[1]), 10, 64)
if err != nil {
return -1, -1, "", errors.New("invalid length is not a number. should be bytes=#-#")
}
return int64(start), int64(end-start) + 1, "bytes " + tokens[0] + "-" + tokens[1] + "/", nil
}
// getFileReader opens a file at the given path and returns an io.ReadCloser,
// the corresponding file's filesize, and a http status.
func getFileReader(path string) (io.ReadCloser, int64, error) {
return openFile(path)
}
func openFile(path string) (*os.File, int64, error) {
info, err := os.Stat(path)
if err != nil {
return nil, 0, fmt.Errorf("failed to get stats for file at path %s: %w", path, err)
}
f, err := os.Open(path)
if err != nil {
return nil, 0, fmt.Errorf("failed to open file at path %s: %w", path, err)
}
return f, info.Size(), nil
}
type closerReaderWrapper struct {
io.Reader
io.Closer
}
func getFileReaderAt(path string, offset int64, length int64) (io.ReadCloser, int64, error) {
f, fSize, err := openFile(path)
if err != nil {
return nil, 0, err
}
if fSize < int64(offset+length) {
return nil, 0, fmt.Errorf("failed to read file %s at offset %d, length %d: %w", path, offset, length, ErrReadOutOfBounds)
}
_, err = f.Seek(int64(offset), 0)
if err != nil {
return nil, 0, fmt.Errorf("failed to seek file at path %s to offset %d: %w", path, offset, err)
}
r := closerReaderWrapper{io.LimitReader(f, length), f}
return r, fSize, nil
}
// ExtractBasicAuthCreds extracts the username and password from the incoming request. It returns RequestCredentials
// populated with necessary information to authenticate the request. nil and an error will be returned if any error
// occurs.
func ExtractBasicAuthCreds(ctx context.Context) (*RequestCredentials, error) {
if md, ok := metadata.FromIncomingContext(ctx); !ok {
return nil, errors.New("no metadata in context")
} else {
var username string
var password string
auths := md.Get("authorization")
if len(auths) != 1 {
username = "root"
password = ""
} else {
auth := auths[0]
if !strings.HasPrefix(auth, "Basic ") {
return nil, fmt.Errorf("bad request: authorization header did not start with 'Basic '")
}
authTrim := strings.TrimPrefix(auth, "Basic ")
uDec, err := base64.URLEncoding.DecodeString(authTrim)
if err != nil {
return nil, fmt.Errorf("incoming request authorization header failed to decode: %v", err)
}
userPass := strings.Split(string(uDec), ":")
username = userPass[0]
password = userPass[1]
}
addr, ok := peer.FromContext(ctx)
if !ok {
return nil, errors.New("incoming request had no peer")
}
return &RequestCredentials{Username: username, Password: password, Address: addr.Addr.String()}, nil
}
}
func validateFileName(fileName string) bool {
if len(fileName) == 32 {
_, ok := hash.MaybeParse(fileName)
return ok
}
if len(fileName) == 32+len(nbs.ArchiveFileSuffix) && strings.HasSuffix(fileName, nbs.ArchiveFileSuffix) {
_, ok := hash.MaybeParse(fileName[:32])
return ok
}
return false
}