Skip to content
quarry Artifact [4632172eb6]
Login

Artifact [4632172eb6]

Artifact 4632172eb629b403923e15079278d6b47456f9d371b9b86325616e531a648155:


// attachment.go is RFC 0016 Phase 3's forge-local state: the ticket an
// upload holds between begin and commit, the holds on uploads nobody
// trusted has vouched for, and the two policy switches on a binding.
//
// None of it is in the DAG. A ticket that never commits expires here
// and its object is the steward's lifecycle rule's to delete; a hold is
// display state on an A card that fossil clients see as any other; the
// switches decide what the forge does around a removal, not what the
// repository records.
package oltp

import (
	"crypto/rand"
	"database/sql"
	"errors"
	"time"
)

const attachmentSchema = `
CREATE TABLE IF NOT EXISTS upload_ticket (
	id      TEXT PRIMARY KEY,   -- opaque, random
	repo    TEXT NOT NULL,
	target  TEXT NOT NULL,      -- what the A card will name
	name    TEXT NOT NULL,
	size    INTEGER NOT NULL,
	type    TEXT NOT NULL,
	sha256  TEXT NOT NULL,
	who     TEXT NOT NULL,
	created TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS attachment_hold (
	repo     TEXT NOT NULL,
	artifact TEXT NOT NULL,     -- the A card
	target   TEXT NOT NULL,
	name     TEXT NOT NULL,
	who      TEXT NOT NULL,     -- the uploader
	created  TEXT NOT NULL,
	PRIMARY KEY (repo, artifact)
);`

// bucketMigrations widens the binding with Phase 3's two switches.
var bucketMigrations = []string{
	// May an upload declare an archive type. Off: the forge never sees
	// the bytes, so what it holds an upload to is the declared type.
	`ALTER TABLE bucket ADD COLUMN archives INTEGER NOT NULL DEFAULT 0`,
	// Does a removal also DELETE the object. Off: a removal is a
	// decision about the page and deletion one about the bytes, and
	// the steward owns the second (RFC 0016 §Writing).
	`ALTER TABLE bucket ADD COLUMN delete_objects INTEGER NOT NULL DEFAULT 0`,
}

// UploadTicket is what begin was told, kept so commit can check the
// object against it rather than against the caller's second claim.
type UploadTicket struct {
	ID      string
	Repo    string
	Target  string
	Name    string
	Size    int64
	Type    string
	SHA256  string
	Who     string
	Created string
}

// UploadTicketTTL is how long a ticket answers. The presigned PUT it
// pairs with expires in fifteen minutes; a commit an hour later is a
// client that lost its way, and its object is an orphan either way.
const UploadTicketTTL = time.Hour

// PutUploadTicket mints a ticket and returns its id.
func (d *DB) PutUploadTicket(t UploadTicket) (string, error) {
	if t.Repo == "" || t.Target == "" || t.Name == "" || t.SHA256 == "" {
		return "", errors.New("oltp: an upload ticket needs a repository, a target, a name and a hash")
	}
	id := rand.Text()
	_, err := d.db.Exec(`INSERT INTO upload_ticket (id, repo, target, name, size, type, sha256, who, created)
		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, t.Repo, t.Target, t.Name, t.Size, t.Type, t.SHA256, t.Who, now())
	return id, err
}

// UploadTicket reads one ticket by id, false when it is unknown or
// older than UploadTicketTTL. Expired rows are swept on the way.
func (d *DB) UploadTicket(id string) (UploadTicket, bool, error) {
	cutoff := time.Now().UTC().Add(-UploadTicketTTL).Format(time.RFC3339)
	if _, err := d.db.Exec(`DELETE FROM upload_ticket WHERE created < ?`, cutoff); err != nil {
		return UploadTicket{}, false, err
	}
	var t UploadTicket
	err := d.db.QueryRow(`SELECT id, repo, target, name, size, type, sha256, who, created
		FROM upload_ticket WHERE id = ?`, id).
		Scan(&t.ID, &t.Repo, &t.Target, &t.Name, &t.Size, &t.Type, &t.SHA256, &t.Who, &t.Created)
	if errors.Is(err, sql.ErrNoRows) {
		return UploadTicket{}, false, nil
	}
	return t, err == nil, err
}

// DeleteUploadTicket spends a ticket. A commit is once.
func (d *DB) DeleteUploadTicket(id string) error {
	_, err := d.db.Exec(`DELETE FROM upload_ticket WHERE id = ?`, id)
	return err
}

// Hold is one upload waiting for a person to vouch for it.
type Hold struct {
	Repo     string `json:"repo"`
	Artifact string `json:"artifact"`
	Target   string `json:"target"`
	Name     string `json:"name"`
	Who      string `json:"who"`
	Created  string `json:"created"`
}

// HoldAttachment records that artifact's upload is held.
func (d *DB) HoldAttachment(h Hold) error {
	_, err := d.db.Exec(`INSERT OR REPLACE INTO attachment_hold (repo, artifact, target, name, who, created)
		VALUES (?, ?, ?, ?, ?, ?)`, h.Repo, h.Artifact, h.Target, h.Name, h.Who, now())
	return err
}

// ReleaseHold clears the hold on one artifact; a vouch and a reject
// both end here. Releasing an artifact that was never held is fine.
func (d *DB) ReleaseHold(repo, artifact string) error {
	_, err := d.db.Exec(`DELETE FROM attachment_hold WHERE repo = ? AND artifact = ?`, repo, artifact)
	return err
}

// Holds lists a repository's held uploads, oldest first: the review
// queue's order.
func (d *DB) Holds(repo string) ([]Hold, error) {
	rows, err := d.db.Query(`SELECT repo, artifact, target, name, who, created
		FROM attachment_hold WHERE repo = ? ORDER BY created, artifact`, repo)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []Hold
	for rows.Next() {
		var h Hold
		if err := rows.Scan(&h.Repo, &h.Artifact, &h.Target, &h.Name, &h.Who, &h.Created); err != nil {
			return nil, err
		}
		out = append(out, h)
	}
	return out, rows.Err()
}

// HeldArtifacts is the set of held A cards on one repository, for
// decorating a list without a query per row.
func (d *DB) HeldArtifacts(repo string) (map[string]bool, error) {
	holds, err := d.Holds(repo)
	if err != nil {
		return nil, err
	}
	out := make(map[string]bool, len(holds))
	for _, h := range holds {
		out[h.Artifact] = true
	}
	return out, nil
}

// SetBucketPolicy flips the binding's two switches. The row must
// exist: policy on a repository with no bucket is policy on nothing.
func (d *DB) SetBucketPolicy(repo string, archives, deleteObjects bool) error {
	res, err := d.db.Exec(`UPDATE bucket SET archives = ?, delete_objects = ? WHERE repo = ?`,
		boolInt(archives), boolInt(deleteObjects), repo)
	if err != nil {
		return err
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return errors.New("oltp: no bucket is bound to " + repo)
	}
	return nil
}