Skip to content
quarry Artifact [9eaa727bf9]
Login

Artifact [9eaa727bf9]

Artifact 9eaa727bf95c66a581efa29fe15ac08a519d3fb27229736b1e4ffbe5377d1e2b:


// takedown.go keeps RFC 0023's takedowns: a repository disabled on a
// notice, or put on legal hold, without destroying it. The current
// state is one row per repository; every act is also a row in an
// append-only log that outlives the repository, because it is the
// repeat-infringer record and the evidence that the forge acted.
package oltp

import (
	"database/sql"
	"errors"
	"fmt"
	"slices"
	"time"
)

const takedownSchema = `
CREATE TABLE IF NOT EXISTS takedown (
	repo     TEXT PRIMARY KEY,
	strength TEXT NOT NULL,             -- 'disabled' or 'hold'
	category TEXT NOT NULL,
	notice   TEXT NOT NULL DEFAULT '',  -- the notice's reference
	reason   TEXT NOT NULL DEFAULT '',
	actor    TEXT NOT NULL,
	via      TEXT NOT NULL DEFAULT '',
	at       TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS takedown_log (
	id       INTEGER PRIMARY KEY AUTOINCREMENT,
	repo     TEXT NOT NULL,
	owner    TEXT NOT NULL DEFAULT '',  -- the deed's holder at the time
	act      TEXT NOT NULL,             -- disable, hold, restore, lift
	strength TEXT NOT NULL DEFAULT '',
	category TEXT NOT NULL DEFAULT '',
	notice   TEXT NOT NULL DEFAULT '',
	reason   TEXT NOT NULL DEFAULT '',
	actor    TEXT NOT NULL,
	via      TEXT NOT NULL DEFAULT '',
	at       TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS takedown_log_repo ON takedown_log (repo, id);
CREATE TRIGGER IF NOT EXISTS takedown_log_no_update BEFORE UPDATE ON takedown_log
BEGIN SELECT RAISE(ABORT, 'the takedown log is append-only'); END;
CREATE TRIGGER IF NOT EXISTS takedown_log_no_delete BEFORE DELETE ON takedown_log
BEGIN SELECT RAISE(ABORT, 'the takedown log is append-only'); END;`

// The two strengths.
const (
	// Disabled answers 451 to everybody but the repository's own admins,
	// who keep reading and writing so they can remove what was reported.
	Disabled = "disabled"
	// LegalHold answers 451 to everybody and takes no writes, so what is
	// held stays as it was.
	LegalHold = "hold"
)

// TakedownCategories is what a takedown may be for.
var TakedownCategories = []string{
	"copyright", "host", "unlawful", "terms", "terrorism",
	"child-abuse", "preservation", "law-enforcement",
}

// ErrHeld is a repository under a takedown refusing to be deleted or
// un-served: either would destroy what the takedown keeps.
var ErrHeld = errors.New("the repository is under a takedown")

// ErrTakedownState is an act that does not follow from the current
// state: restoring a repository that is not disabled, disabling one on
// hold, and the like.
var ErrTakedownState = errors.New("no such takedown to change")

// Takedown is a repository's current takedown.
type Takedown struct {
	Repo, Strength, Category, Notice, Reason, Actor, Via, At string
}

// Refuses is the whole rule every surface asks: a hold shuts out
// everybody, a disable everybody but the repository's own admins.
func (t Takedown) Refuses(admin bool) bool {
	return t.Strength == LegalHold || !admin
}

// Sentence is what a refused surface says. A hold says nothing beyond
// the fact: more can warn a person under investigation.
func (t Takedown) Sentence() string {
	if t.Strength == LegalHold {
		return "this repository is unavailable for legal reasons"
	}
	day := t.At
	if len(day) >= 10 {
		day = day[:10]
	}
	return fmt.Sprintf("this repository is unavailable for legal reasons: disabled on %s (%s)", day, t.Category)
}

// TakedownAct is one row of the takedown log.
type TakedownAct struct {
	ID                                                                   int64
	Repo, Owner, Act, Strength, Category, Notice, Reason, Actor, Via, At string
}

func scanTakedown(s interface{ Scan(...any) error }) (Takedown, error) {
	var t Takedown
	err := s.Scan(&t.Repo, &t.Strength, &t.Category, &t.Notice, &t.Reason, &t.Actor, &t.Via, &t.At)
	return t, err
}

const takedownCols = `repo, strength, category, notice, reason, actor, via, at`

// TakedownOf is a repository's current takedown, if it has one.
func (d *DB) TakedownOf(repo string) (Takedown, bool, error) {
	t, err := scanTakedown(d.db.QueryRow(`SELECT `+takedownCols+` FROM takedown WHERE repo = ?`, repo))
	if errors.Is(err, sql.ErrNoRows) {
		return Takedown{}, false, nil
	}
	return t, err == nil, err
}

// Takedowns is every current takedown by repository, for the gates'
// cache: they run on every request and this changes rarely.
func (d *DB) Takedowns() (map[string]Takedown, error) {
	rows, err := d.db.Query(`SELECT ` + takedownCols + ` FROM takedown`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := map[string]Takedown{}
	for rows.Next() {
		t, err := scanTakedown(rows)
		if err != nil {
			return nil, err
		}
		out[t.Repo] = t
	}
	return out, rows.Err()
}

// SetTakedown disables a repository or puts it on hold, and logs it. A
// hold may follow a disable, which is the escalation a notice can turn
// into; nothing else may replace a takedown already in force.
func (d *DB) SetTakedown(t Takedown) error {
	if t.Strength != Disabled && t.Strength != LegalHold {
		return fmt.Errorf("%w: strength %q", ErrTakedownState, t.Strength)
	}
	if !slices.Contains(TakedownCategories, t.Category) {
		return fmt.Errorf("%w: category %q", ErrTakedownState, t.Category)
	}
	if t.Actor == "" {
		return fmt.Errorf("%w: no actor", ErrTakedownState)
	}
	tx, err := d.w.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	var owner string
	if err := tx.QueryRow(`SELECT owner FROM repo WHERE name = ?`, t.Repo).Scan(&owner); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return fmt.Errorf("%w: no repository %q", ErrTakedownState, t.Repo)
		}
		return err
	}
	var have string
	switch err := tx.QueryRow(`SELECT strength FROM takedown WHERE repo = ?`, t.Repo).Scan(&have); {
	case errors.Is(err, sql.ErrNoRows):
	case err != nil:
		return err
	case have == LegalHold:
		return fmt.Errorf("%w: %s is already on legal hold", ErrTakedownState, t.Repo)
	case have == t.Strength:
		return fmt.Errorf("%w: %s is already disabled", ErrTakedownState, t.Repo)
	}
	t.At = time.Now().UTC().Format(time.RFC3339)
	if _, err := tx.Exec(`INSERT OR REPLACE INTO takedown (`+takedownCols+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
		t.Repo, t.Strength, t.Category, t.Notice, t.Reason, t.Actor, t.Via, t.At); err != nil {
		return err
	}
	act := "disable"
	if t.Strength == LegalHold {
		act = "hold"
	}
	if err := logTakedown(tx, TakedownAct{Repo: t.Repo, Owner: owner, Act: act, Strength: t.Strength,
		Category: t.Category, Notice: t.Notice, Reason: t.Reason, Actor: t.Actor, Via: t.Via, At: t.At}); err != nil {
		return err
	}
	return tx.Commit()
}

// EndTakedown restores a disabled repository or lifts a hold, and logs
// it. strength names which one the caller means to end, so restoring
// cannot lift a hold by accident.
func (d *DB) EndTakedown(repo, strength, actor, via, reason string) error {
	if actor == "" {
		return fmt.Errorf("%w: no actor", ErrTakedownState)
	}
	tx, err := d.w.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	t, err := scanTakedown(tx.QueryRow(`SELECT `+takedownCols+` FROM takedown WHERE repo = ?`, repo))
	if errors.Is(err, sql.ErrNoRows) {
		return fmt.Errorf("%w: %s has no takedown", ErrTakedownState, repo)
	}
	if err != nil {
		return err
	}
	if t.Strength != strength {
		if t.Strength == LegalHold {
			return fmt.Errorf("%w: %s is on legal hold; lift the hold instead", ErrTakedownState, repo)
		}
		return fmt.Errorf("%w: %s is disabled, not on hold; restore it instead", ErrTakedownState, repo)
	}
	var owner string
	if err := tx.QueryRow(`SELECT owner FROM repo WHERE name = ?`, repo).Scan(&owner); err != nil && !errors.Is(err, sql.ErrNoRows) {
		return err
	}
	if _, err := tx.Exec(`DELETE FROM takedown WHERE repo = ?`, repo); err != nil {
		return err
	}
	act := "restore"
	if strength == LegalHold {
		act = "lift"
	}
	if err := logTakedown(tx, TakedownAct{Repo: repo, Owner: owner, Act: act, Strength: strength,
		Category: t.Category, Notice: t.Notice, Reason: reason, Actor: actor, Via: via,
		At: time.Now().UTC().Format(time.RFC3339)}); err != nil {
		return err
	}
	return tx.Commit()
}

func logTakedown(tx *sql.Tx, a TakedownAct) error {
	_, err := tx.Exec(`INSERT INTO takedown_log (repo, owner, act, strength, category, notice, reason, actor, via, at)
		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		a.Repo, a.Owner, a.Act, a.Strength, a.Category, a.Notice, a.Reason, a.Actor, a.Via, a.At)
	return err
}

// TakedownLog is the newest acts first, for one repository or, with
// repo empty, the whole forge.
func (d *DB) TakedownLog(repo string, limit int) ([]TakedownAct, error) {
	q := `SELECT id, repo, owner, act, strength, category, notice, reason, actor, via, at FROM takedown_log`
	args := []any{}
	if repo != "" {
		q += ` WHERE repo = ?`
		args = append(args, repo)
	}
	q += ` ORDER BY id DESC LIMIT ?`
	args = append(args, limit)
	rows, err := d.db.Query(q, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []TakedownAct
	for rows.Next() {
		var a TakedownAct
		if err := rows.Scan(&a.ID, &a.Repo, &a.Owner, &a.Act, &a.Strength, &a.Category, &a.Notice,
			&a.Reason, &a.Actor, &a.Via, &a.At); err != nil {
			return nil, err
		}
		out = append(out, a)
	}
	return out, rows.Err()
}

// refuseHeld is the guard under every way a registration is dropped.
func refuseHeld(q interface {
	QueryRow(string, ...any) *sql.Row
}, repo string) error {
	var s string
	switch err := q.QueryRow(`SELECT strength FROM takedown WHERE repo = ?`, repo).Scan(&s); {
	case errors.Is(err, sql.ErrNoRows):
		return nil
	case err != nil:
		return err
	}
	return fmt.Errorf("%w: %s is %s", ErrHeld, repo, map[string]string{Disabled: "disabled", LegalHold: "on legal hold"}[s])
}