Skip to content
quarry Artifact [186417193f]
Login

Artifact [186417193f]

Artifact 186417193f67ad31175bf755861da81af2b089e7fe0f200ed705c1fc842e2e13:


// uvsync.go is what RFC 0020's sync front needs from the catalog and
// the bucket beyond rung 2's ops: whether a write can land at all, a
// file's bytes, and fossil's mtime-only update. The front itself is
// internal/xfer/unversioned.go; SyncUV is the seam it is handed.
package api

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"strings"
	"time"

	"craftdesign.group/quarry/internal/ops"
	"craftdesign.group/quarry/internal/uv"
	"craftdesign.group/quarry/internal/xfer"
)

// SyncUV answers the sync front's unversioned questions
// (xfer.Unversioned) from these Deps. Its refusals are sentences for a
// fossil error card, without the ops layer's sentinel on the end.
type SyncUV struct{ Deps }

var _ xfer.Unversioned = SyncUV{}

// UVWrite is Deps.UVWrite with its refusal in plain words.
func (s SyncUV) UVWrite(ctx context.Context, repo string, f uv.File, content []byte) (uv.File, bool, error) {
	out, applied, err := s.Deps.UVWrite(ctx, repo, f, content)
	return out, applied, plainErr(err)
}

// UVWritable is the binding's per-file limit, or why repo can take no
// unversioned write at all.
func (s SyncUV) UVWritable(ctx context.Context, repo string) (int64, error) {
	_, b, _, ok, err := s.uvBinding(repo)
	if err != nil {
		return 0, err
	}
	if !ok {
		return 0, fmt.Errorf("%s has no bucket of its own: %s", repo, NoUVBucket)
	}
	if err := s.uvPublicRefusal(repo, b); err != nil {
		return 0, plainErr(err)
	}
	return b.LimitBytes, nil
}

// UVContent is Deps.UVContent.
func (s SyncUV) UVContent(ctx context.Context, repo string, f uv.File) ([]byte, error) {
	return s.Deps.UVContent(ctx, repo, f)
}

// UVTouch is Deps.UVTouch with its refusal in plain words.
func (s SyncUV) UVTouch(ctx context.Context, repo string, f uv.File) (uv.File, bool, error) {
	out, applied, err := s.Deps.UVTouch(ctx, repo, f)
	return out, applied, plainErr(err)
}

// MayPublish reports whether user holds y on repo. A forge with no
// accounts plane has nobody to hold it, so the answer is no: unlike an
// op's own gate, the sync front has no other floor to fall back on.
func (s SyncUV) MayPublish(repo, user string) bool {
	return s.CapsFor != nil && user != "" && s.CapsFor(user, repo).HasAny("y")
}

// plainErr drops the ops sentinel an error was wrapped with for HTTP,
// which a fossil client would print as part of the sentence.
func plainErr(err error) error {
	for _, sentinel := range []error{ops.ErrInvalid, ops.ErrForbidden, ops.ErrNotFound} {
		if err != nil && errors.Is(err, sentinel) {
			return errors.New(strings.TrimSuffix(err.Error(), ": "+sentinel.Error()))
		}
	}
	return err
}

// uvTransferTimeout bounds one bucket transfer of size bytes: 30
// seconds, plus 2 seconds for every MiB begun, which is half a MiB a
// second at worst. A file runs up to the binding's limit (100 MiB by
// default, more if its admin says so), and attachments' fixed 20
// seconds would refuse a large release on an ordinary link while a
// fixed generous one would leave a stalled small write hanging.
func uvTransferTimeout(size int64) time.Duration {
	const mib = 1 << 20
	return 30*time.Second + time.Duration((size+mib-1)/mib)*2*time.Second
}

// uvClient is the client for a transfer of size bytes.
func uvClient(size int64) *http.Client {
	return &http.Client{Timeout: uvTransferTimeout(size)}
}

// UVContent is one file's bytes from the bucket, read whole, bounded
// by the binding's per-file limit: the sync front puts them in a
// uvfile card, and a reply is one buffer.
func (d Deps) UVContent(ctx context.Context, repo string, f uv.File) ([]byte, error) {
	bucket, b, _, ok, err := d.uvBinding(repo)
	if err != nil {
		return nil, err
	}
	if !ok {
		return nil, fmt.Errorf("%s has no bucket of its own", repo)
	}
	if f.Size > b.LimitBytes {
		return nil, fmt.Errorf("%s is %d bytes, over the bucket's limit of %d, lowered since it was published: %w", f.Name, f.Size, b.LimitBytes, xfer.ErrUVTooLarge)
	}
	return bucket.GetMax(ctx, uvClient(min(f.Size, b.LimitBytes)), uv.Key(repo, f.Name), b.LimitBytes, time.Now())
}

// UVTouch is fossil's mtime-only update: a uvfile card with flag 0x0004
// names a hash the catalog already holds with an older mtime, and
// fossil's receiver then changes the mtime alone (unversioned_status
// 2 in xfer_accept_unversioned_file). The object's metadata is
// rewritten in place by a copy onto itself, so the bytes do not move.
// Anything else is not applied: without content there is nothing to
// put in the bucket, and fossil's other cases would leave a row with no
// bytes.
func (d Deps) UVTouch(ctx context.Context, repo string, f uv.File) (uv.File, bool, error) {
	if err := uv.CheckName(f.Name); err != nil {
		return uv.File{}, false, fmt.Errorf("%v: %w", err, ops.ErrInvalid)
	}
	bucket, b, _, ok, err := d.uvBinding(repo)
	if err != nil {
		return uv.File{}, false, err
	}
	if !ok {
		return uv.File{}, false, fmt.Errorf("%s has no bucket of its own: %s: %w", repo, NoUVBucket, ops.ErrInvalid)
	}
	if err := d.uvPublicRefusal(repo, b); err != nil {
		return uv.File{}, false, err
	}
	unlock := uvLock(repo)
	defer unlock()
	if err := d.uvEnsure(ctx, repo, bucket, b); err != nil {
		return uv.File{}, false, err
	}
	have, exists, err := d.UV.UVFile(repo, f.Name)
	if err != nil {
		return uv.File{}, false, err
	}
	if !exists || have.Deleted || have.Hash != f.Hash || have.Mtime >= f.Mtime {
		return have, false, nil
	}
	f.Size, f.Deleted = have.Size, false
	now := time.Now()
	key := uv.Key(repo, f.Name)
	if err := bucket.Copy(ctx, uvClient(f.Size), key, key, uvPutOptions(f), now); err != nil {
		return uv.File{}, false, fmt.Errorf("updating %s in the bucket: %w", key, err)
	}
	if err := d.uvRecord(repo, &f, now); err != nil {
		return uv.File{}, false, err
	}
	d.raiseUVChanged(ctx, repo, f)
	return f, true, nil
}