Skip to content
quarry Artifact [a81d930dbb]
Login

Artifact [a81d930dbb]

Artifact a81d930dbb479027688c73310f44de2a6690184fb09b95baa492dc5360e6f231:


package api

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"testing"

	"craftdesign.group/quarry/internal/artifact"
	"craftdesign.group/quarry/internal/attach"
	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
	"craftdesign.group/quarry/internal/zitcli"
)

// memAttachments is an AttachmentStore in memory. Policy writes reach
// the bucket store it is paired with, as the OLTP's do.
type memAttachments struct {
	mu      sync.Mutex
	tickets map[string]oltp.UploadTicket
	holds   map[string]oltp.Hold
	buckets *memBuckets
	n       int
}

func newMemAttachments(b *memBuckets) *memAttachments {
	return &memAttachments{tickets: map[string]oltp.UploadTicket{}, holds: map[string]oltp.Hold{}, buckets: b}
}

func (m *memAttachments) PutUploadTicket(t oltp.UploadTicket) (string, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.n++
	t.ID = "tk" + strconv.Itoa(m.n)
	m.tickets[t.ID] = t
	return t.ID, nil
}

func (m *memAttachments) UploadTicket(id string) (oltp.UploadTicket, bool, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	t, ok := m.tickets[id]
	return t, ok, nil
}

func (m *memAttachments) DeleteUploadTicket(id string) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	delete(m.tickets, id)
	return nil
}

func (m *memAttachments) HoldAttachment(h oltp.Hold) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.holds[h.Repo+"/"+h.Artifact] = h
	return nil
}

func (m *memAttachments) ReleaseHold(repo, artifact string) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	delete(m.holds, repo+"/"+artifact)
	return nil
}

func (m *memAttachments) Holds(repo string) ([]oltp.Hold, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	var out []oltp.Hold
	for k, h := range m.holds {
		if strings.HasPrefix(k, repo+"/") {
			out = append(out, h)
		}
	}
	return out, nil
}

func (m *memAttachments) HeldArtifacts(repo string) (map[string]bool, error) {
	holds, _ := m.Holds(repo)
	out := map[string]bool{}
	for _, h := range holds {
		out[h.Artifact] = true
	}
	return out, nil
}

func (m *memAttachments) SetBucketPolicy(repo string, archives, deleteObjects bool) error {
	m.buckets.mu.Lock()
	defer m.buckets.mu.Unlock()
	b, ok := m.buckets.rows[repo]
	if !ok {
		return errors.New("no bucket is bound")
	}
	b.Archives, b.DeleteObjects = archives, deleteObjects
	m.buckets.rows[repo] = b
	return nil
}

// objectS3 is a bucket that holds whatever the test says it holds:
// HEAD answers the size or 404, DELETE forgets, PUT stores the length.
type objectS3 struct {
	mu      sync.Mutex
	objects map[string]int64
	deleted []string
}

func newObjectS3(t *testing.T) (*httptest.Server, *objectS3) {
	t.Helper()
	o := &objectS3{objects: map[string]int64{}}
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Query().Get("X-Amz-Signature") == "" {
			w.WriteHeader(http.StatusForbidden)
			return
		}
		key := strings.TrimPrefix(r.URL.Path, "/files/")
		o.mu.Lock()
		defer o.mu.Unlock()
		switch r.Method {
		case http.MethodHead:
			n, ok := o.objects[key]
			if !ok {
				w.WriteHeader(http.StatusNotFound)
				return
			}
			w.Header().Set("Content-Length", fmt.Sprint(n))
			w.WriteHeader(http.StatusOK)
		case http.MethodPut:
			o.objects[key] = r.ContentLength
			w.WriteHeader(http.StatusOK)
		case http.MethodDelete:
			delete(o.objects, key)
			o.deleted = append(o.deleted, key)
			w.WriteHeader(http.StatusNoContent)
		}
	}))
	t.Cleanup(srv.Close)
	return srv, o
}

// attachStore is a zit store with one technote and an index that
// folds it, so the ops have a target that exists in both. Skips
// without zit on PATH, like the zitcli tests.
func attachStore(t *testing.T) (*index.Index, *zitcli.Client, string) {
	t.Helper()
	if _, err := exec.LookPath("zit"); err != nil {
		t.Skip("zit not on PATH")
	}
	dir := t.TempDir()
	if err := zitcli.Init(context.Background(), dir); err != nil {
		t.Fatal(err)
	}
	z := zitcli.New(dir)
	event, hash, err := z.TechnoteAdd("dan", "", "text/x-markdown", "a note", "", []byte("body\n"))
	if err != nil {
		t.Fatal(err)
	}
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	if err := ix.FoldNow([]index.Parsed{{Hash: hash, Art: &artifact.Artifact{Type: artifact.Technote,
		EventID: event, EventTime: "2026-01-01T00:00:00", Comment: "a note",
		Date: "2026-01-01T00:00:00", User: "dan", Mimetype: "text/x-markdown", Body: []byte("body\n")}}}); err != nil {
		t.Fatal(err)
	}
	return ix, z, event
}

func as(user, letters string) context.Context {
	return ops.WithCaps(ops.WithUser(context.Background(), user), oltp.CapSet(letters))
}

const sha = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"

// begin refuses on each of its gates and, past them, answers a
// presigned PUT whose signed headers are the length and the type and
// a ticket that remembers what it was told.
func TestAttachmentBeginGatesThenPresigns(t *testing.T) {
	srv, _ := newObjectS3(t)
	ix := attachedIndex(t)
	buckets := newMemBuckets()
	store := newMemAttachments(buckets)
	d := Deps{Version: "v", Repos: NewRepos(map[string]*Repo{"test": {Index: ix, Zit: &zitcli.Client{}}}),
		Buckets: buckets, Attachments: store}
	r := ops.NewRegistry()
	if err := RegisterAll(r, d); err != nil {
		t.Fatal(err)
	}
	begin := func(ctx context.Context, in attachmentBeginIn) (attachmentBeginOut, error) {
		out, err := r.Invoke(ctx, "attachment.begin", in)
		if err != nil {
			return attachmentBeginOut{}, err
		}
		return out.(attachmentBeginOut), nil
	}
	good := attachmentBeginIn{Repo: "test", Target: "Design", Name: "shot.png", Size: 1200, Type: "image/png", SHA256: sha}
	if _, err := begin(as("lou", "b"), good); !errors.Is(err, ops.ErrInvalid) {
		t.Errorf("FAIL: unbound bucket: %v", err)
	}
	buckets.rows["test"] = oltp.BucketBinding{Endpoint: srv.URL, Bucket: "files", Region: "auto", AccessKey: "AK", Secret: "SK", LimitBytes: 4096}
	unknown := good
	unknown.Target = "nothing-here"
	if _, err := begin(as("lou", "b"), unknown); !errors.Is(err, ops.ErrNotFound) {
		t.Errorf("FAIL: unknown target: %v", err)
	}
	if _, err := begin(as("lou", "B"), good); !errors.Is(err, ops.ErrForbidden) {
		t.Errorf("FAIL: the forum letter reached a wiki page: %v", err)
	}
	post := good
	post.Target = "f1"
	if _, err := begin(as("lou", "b"), post); !errors.Is(err, ops.ErrForbidden) {
		t.Errorf("FAIL: the ticket letter reached a post: %v", err)
	}
	big := good
	big.Size = 4097
	if _, err := begin(as("lou", "b"), big); !errors.Is(err, ops.ErrInvalid) {
		t.Errorf("FAIL: over the limit: %v", err)
	}
	exe := good
	exe.Type = "application/x-msdownload"
	if _, err := begin(as("lou", "b"), exe); !errors.Is(err, ops.ErrInvalid) {
		t.Errorf("FAIL: an executable: %v", err)
	}
	zipped := good
	zipped.Type = "application/zip"
	if _, err := begin(as("lou", "b"), zipped); !errors.Is(err, ops.ErrInvalid) {
		t.Errorf("FAIL: an archive with the policy closed: %v", err)
	}
	if _, err := r.Invoke(as("dan", "a"), "bucket.policy", bucketPolicyIn{Repo: "test", Archives: true}); err != nil {
		t.Fatal(err)
	}
	if _, err := begin(as("lou", "b"), zipped); err != nil {
		t.Errorf("FAIL: an archive with the policy open: %v", err)
	}
	out, err := begin(as("lou", "b"), attachmentBeginIn{Repo: "test", Target: "eeee1111", Name: "../../shot.png", Size: 1200, Type: "image/png; charset=binary", SHA256: strings.ToUpper(sha)})
	if err != nil {
		t.Fatalf("FAIL: %v", err)
	}
	if out.Method != "PUT" || out.Headers["Content-Length"] != "1200" || out.Headers["Content-Type"] != "image/png" || out.Ticket == "" {
		t.Errorf("FAIL: out = %+v", out)
	}
	if !strings.Contains(out.URL, "/files/attachments/"+sha+"?") || !strings.Contains(out.URL, "X-Amz-SignedHeaders=content-length%3Bcontent-type%3Bhost") {
		t.Errorf("FAIL: url = %s", out.URL)
	}
	tk := store.tickets[out.Ticket]
	if tk.Name != "shot.png" || tk.Target != "eeee111122223333444455556666777788889999" || tk.SHA256 != sha || tk.Who != "lou" || tk.Type != "image/png" {
		t.Errorf("FAIL: ticket = %+v", tk)
	}
	t.Log("ok: begin refuses on every gate and presigns with the two headers")
}

// The whole flow against a real store: commit checks the object, writes
// the stub, holds it for an untrusted uploader; the queue lists it; a
// vouch clears it; the head and link ops answer from the row; remove
// writes the sourceless card and deletes the object when told to.
func TestAttachmentCommitHoldVouchAndRemove(t *testing.T) {
	ix, z, event := attachStore(t)
	srv, objects := newObjectS3(t)
	buckets := newMemBuckets()
	buckets.rows["test"] = oltp.BucketBinding{Endpoint: srv.URL, Bucket: "files", Region: "auto", AccessKey: "AK", Secret: "SK", LimitBytes: 1 << 20}
	store := newMemAttachments(buckets)
	d := Deps{Version: "v", Repos: NewRepos(map[string]*Repo{"test": {Index: ix, Zit: z}}),
		Buckets: buckets, Attachments: store}
	r := ops.NewRegistry()
	if err := RegisterAll(r, d); err != nil {
		t.Fatal(err)
	}
	lou := as("lou", "b")
	out, err := r.Invoke(lou, "attachment.begin", attachmentBeginIn{Repo: "test", Target: event[:16], Name: "clip.mp4", Size: 500, Type: "video/mp4", SHA256: sha})
	if err != nil {
		t.Fatal(err)
	}
	ticket := out.(attachmentBeginOut).Ticket
	if _, err := r.Invoke(lou, "attachment.commit", attachmentCommitIn{Repo: "test", Ticket: ticket}); !errors.Is(err, ops.ErrInvalid) {
		t.Errorf("FAIL: commit with no object: %v", err)
	}
	objects.objects[attach.Key(sha)] = 499
	if _, err := r.Invoke(lou, "attachment.commit", attachmentCommitIn{Repo: "test", Ticket: ticket}); !errors.Is(err, ops.ErrInvalid) {
		t.Errorf("FAIL: commit with the wrong length: %v", err)
	}
	objects.objects[attach.Key(sha)] = 500
	if _, err := r.Invoke(as("mallory", "b"), "attachment.commit", attachmentCommitIn{Repo: "test", Ticket: ticket}); !errors.Is(err, ops.ErrForbidden) {
		t.Errorf("FAIL: someone else's ticket: %v", err)
	}
	got, err := r.Invoke(lou, "attachment.commit", attachmentCommitIn{Repo: "test", Ticket: ticket})
	if err != nil {
		t.Fatalf("FAIL: commit: %v", err)
	}
	c := got.(attachmentCommitOut)
	if len(c.Artifact) != 64 || len(c.Blob) != 64 || !c.Held || c.Name != "clip.mp4" || c.Size != 500 || c.Type != "video/mp4" {
		t.Fatalf("FAIL: commit = %+v", c)
	}
	if _, ok, _ := store.UploadTicket(ticket); ok {
		t.Errorf("FAIL: the ticket was not spent")
	}
	raw, err := z.ArtifactRaw(context.Background(), c.Blob, 4096)
	if err != nil {
		t.Fatal(err)
	}
	stub, err := attach.Parse(raw)
	if err != nil || stub.SHA256 != sha || stub.Size != 500 || stub.Type != "video/mp4" || stub.Name != "clip.mp4" {
		t.Errorf("FAIL: the blob is not the stub: %q %v", raw, err)
	}
	// The ingest folds the A card in production; here the test does,
	// with the stub the ingest would have read.
	if err := ix.FoldNow([]index.Parsed{{Hash: c.Artifact, Stub: &stub, Art: &artifact.Artifact{
		Type: artifact.Attachment, Date: "2026-01-02T00:00:00", User: "lou", Mimetype: "video/mp4",
		Attachment: &artifact.Attach{Name: "clip.mp4", Target: event, Source: c.Blob}}}}); err != nil {
		t.Fatal(err)
	}
	held, _ := r.Invoke(as("mod", "l"), "attachment.held", attachmentHeldIn{Repo: "test"})
	if hs := held.(attachmentHeldOut).Holds; len(hs) != 1 || hs[0].Artifact != c.Artifact || hs[0].Who != "lou" {
		t.Errorf("FAIL: held = %+v", hs)
	}
	list, _ := r.Invoke(lou, "attachment.list", attachmentListIn{Repo: "test", Target: event})
	if rows := list.(attachmentListOut).Attachments; len(rows) != 1 || !rows[0].Held || !rows[0].External || rows[0].SHA256 != sha || rows[0].Size != 500 {
		t.Errorf("FAIL: list = %+v", rows)
	}
	hd, err := r.Invoke(lou, "attachment.head", attachmentHeadIn{Repo: "test", Hash: c.Blob})
	if err != nil {
		t.Fatal(err)
	}
	if h := hd.(attachmentHeadOut); !h.External || !h.Held || h.Kind != "media" || h.Size != 500 || h.Mimetype != "video/mp4" || h.SHA256 != sha {
		t.Errorf("FAIL: head = %+v", h)
	}
	ln, err := r.Invoke(context.Background(), "attachment.link", attachmentLinkIn{Repo: "test", Hash: c.Blob})
	if err != nil {
		t.Fatal(err)
	}
	if l := ln.(attachmentLinkOut); !l.External || !l.Held || l.Inline != "" || !strings.Contains(l.Download, "/files/attachments/"+sha+"?") || !strings.Contains(l.Download, "response-content-disposition=attachment") {
		t.Errorf("FAIL: held link = %+v", l)
	}
	if _, err := r.Invoke(as("lou", "b"), "attachment.vouch", attachmentVouchIn{Repo: "test", Artifact: c.Artifact}); !errors.Is(err, ops.ErrForbidden) {
		t.Errorf("FAIL: the uploader vouched for themselves: %v", err)
	}
	if _, err := r.Invoke(as("mod", "l"), "attachment.vouch", attachmentVouchIn{Repo: "test", Artifact: c.Artifact}); err != nil {
		t.Fatalf("FAIL: vouch: %v", err)
	}
	ln, _ = r.Invoke(context.Background(), "attachment.link", attachmentLinkIn{Repo: "test", Hash: c.Blob})
	if l := ln.(attachmentLinkOut); l.Held || l.Inline == "" || !strings.Contains(l.Inline, "response-content-disposition=inline") {
		t.Errorf("FAIL: vouched link = %+v", l)
	}
	// A public base is linked directly, and never for a private repository.
	buckets.rows["test"] = func() oltp.BucketBinding {
		b := buckets.rows["test"]
		b.PublicBase = "https://files.example"
		return b
	}()
	ln, _ = r.Invoke(context.Background(), "attachment.link", attachmentLinkIn{Repo: "test", Hash: c.Blob})
	if l := ln.(attachmentLinkOut); l.Download != "https://files.example/attachments/"+sha || l.Inline != l.Download {
		t.Errorf("FAIL: public link = %+v", l)
	}
	d.Private = func() map[string]bool { return map[string]bool{"test": true} }
	r2 := ops.NewRegistry()
	if err := RegisterAll(r2, d); err != nil {
		t.Fatal(err)
	}
	ln, _ = r2.Invoke(context.Background(), "attachment.link", attachmentLinkIn{Repo: "test", Hash: c.Blob})
	if l := ln.(attachmentLinkOut); strings.HasPrefix(l.Download, "https://files.example") {
		t.Errorf("FAIL: a private repository linked its public base: %+v", l)
	}

	// Removal: the sourceless card, and the object only when the switch says so.
	rm, err := r.Invoke(lou, "attachment.remove", attachmentRemoveIn{Repo: "test", Target: event, Name: "clip.mp4"})
	if err != nil {
		t.Fatalf("FAIL: remove: %v", err)
	}
	if o := rm.(attachmentRemoveOut); !o.Removed || o.Deleted || len(o.Artifact) != 64 {
		t.Errorf("FAIL: remove = %+v", o)
	}
	card, _ := z.ArtifactRaw(context.Background(), rm.(attachmentRemoveOut).Artifact, 4096)
	if !strings.Contains(string(card), "A clip.mp4 "+event+"\n") {
		t.Errorf("FAIL: not a removal card: %q", card)
	}
	if len(objects.deleted) != 0 {
		t.Errorf("FAIL: the object was deleted with the switch off")
	}
	if _, err := r.Invoke(as("dan", "a"), "bucket.policy", bucketPolicyIn{Repo: "test", DeleteObjects: true}); err != nil {
		t.Fatal(err)
	}
	rm, err = r.Invoke(lou, "attachment.remove", attachmentRemoveIn{Repo: "test", Target: event, Name: "clip.mp4"})
	if err != nil {
		t.Fatalf("FAIL: second remove: %v", err)
	}
	if o := rm.(attachmentRemoveOut); !o.Deleted || len(objects.deleted) != 1 || objects.deleted[0] != attach.Key(sha) {
		t.Errorf("FAIL: remove with the switch on = %+v, deleted %v", o, objects.deleted)
	}
	t.Log("ok: commit checks the object and holds; vouch releases; link and head answer from the row; remove deletes only when told")
}

// The no-script form: the file is counted and hashed on the way, sent
// to the bucket by the forge, and committed; one byte over the limit
// is refused before the rest is read.
func TestUploadFormCarriesTheFileToTheBucket(t *testing.T) {
	ix, z, event := attachStore(t)
	srv, objects := newObjectS3(t)
	buckets := newMemBuckets()
	buckets.rows["test"] = oltp.BucketBinding{Endpoint: srv.URL, Bucket: "files", Region: "auto", AccessKey: "AK", Secret: "SK", LimitBytes: 64}
	store := newMemAttachments(buckets)
	d := Deps{Version: "v", Repos: NewRepos(map[string]*Repo{"test": {Index: ix, Zit: z}}), Buckets: buckets, Attachments: store}
	r := ops.NewRegistry()
	if err := RegisterAll(r, d); err != nil {
		t.Fatal(err)
	}
	form := func(name string, body []byte) *http.Request {
		var buf strings.Builder
		buf.WriteString("--b\r\nContent-Disposition: form-data; name=\"file\"; filename=\"" + name + "\"\r\nContent-Type: text/plain\r\n\r\n")
		buf.Write(body)
		buf.WriteString("\r\n--b--\r\n")
		req := httptest.NewRequest("POST", "/", strings.NewReader(buf.String()))
		req.Header.Set("Content-Type", "multipart/form-data; boundary=b")
		return req
	}
	out, err := UploadForm(as("dan", "bl"), d, r, "test", event, form("notes.txt", []byte("hello, forge\n")))
	if err != nil {
		t.Fatalf("FAIL: %v", err)
	}
	if out.Held || out.Name != "notes.txt" || out.Size != 13 || out.Type != "text/plain" || len(out.Artifact) != 64 {
		t.Errorf("FAIL: out = %+v", out)
	}
	if n, ok := objects.objects[attach.Key(out.SHA256)]; !ok || n != 13 {
		t.Errorf("FAIL: the bucket holds %v", objects.objects)
	}
	if _, err := UploadForm(as("dan", "bl"), d, r, "test", event, form("big.txt", []byte(strings.Repeat("x", 65)))); !errors.Is(err, ops.ErrInvalid) {
		t.Errorf("FAIL: over the limit: %v", err)
	}
	if len(objects.objects) != 1 {
		t.Errorf("FAIL: the refused file reached the bucket")
	}
	t.Log("ok: the form path hashes, sends and commits; the limit is kept")
}