Skip to content
quarry Artifact [2c4d28afa0]
Login

Artifact [2c4d28afa0]

Artifact 2c4d28afa0cc3f1f39d22bbbf9acfbe5285fd62eb9594531e7f14be97562dad5:


package oltp

import (
	"database/sql"
	"errors"
	"fmt"
	"net/url"
	"strconv"
	"strings"
	"time"

	"craftdesign.group/quarry/internal/outbound"
)

// A repository's mirror to a Fossil server (RFC 0022, Fossil remote):
// where the repository is pushed over Fossil's sync protocol, and the
// login the push authenticates as.
//
// The row exists only while the mirror is on. Turning it off deletes
// the row, and the password with it.
const fossilMirrorSchema = `
CREATE TABLE IF NOT EXISTS fossil_mirror (
	repo        TEXT PRIMARY KEY,
	remote      TEXT NOT NULL,            -- an https sync address, never with userinfo
	user        TEXT NOT NULL,            -- the login the push authenticates as
	pass_sealed TEXT NOT NULL,            -- that login's password, sealed (secret.go)
	pushed      TEXT NOT NULL DEFAULT '', -- trunk's tip at the last success, for display
	pushed_at   TEXT NOT NULL DEFAULT '',
	err         TEXT NOT NULL DEFAULT '', -- the last failure, in words
	err_at      TEXT NOT NULL DEFAULT '',
	fails       INTEGER NOT NULL DEFAULT 0,
	created     TEXT NOT NULL,
	created_by  TEXT NOT NULL
);`

// fossilMirrorMigrations: gen names one configuration of the mirror (the
// remote, the user and the password together), so a push that went out
// for one cannot read the password of, or report its result on,
// another.
var fossilMirrorMigrations = []string{
	`ALTER TABLE fossil_mirror ADD COLUMN gen INTEGER NOT NULL DEFAULT 1`,
}

// ErrFossilMirrorChanged says the mirror was changed, or turned off and
// on again, since the caller read it: what it holds is stale.
var ErrFossilMirrorChanged = errors.New("the Fossil mirror changed")

// FossilMirror is the setting as a page or an op shows it. The password
// is not on it: nothing that renders a mirror has a reason to hold it.
type FossilMirror struct {
	Repo      string
	Remote    string
	User      string
	Pushed    string
	PushedAt  string
	Err       string
	ErrAt     string
	Created   string
	CreatedBy string
	// Fails is how many pushes have failed since the last success.
	Fails int
	// Gen is the configuration this row holds: it changes whenever the
	// remote, the user or the password does, and a mirror turned off
	// and on again starts a new one. A push carries it from the moment
	// it reads the row (see FossilMirrorPassword).
	Gen int64
}

// FossilMirrorOf reads a repository's Fossil mirror; false when it has none.
func (d *DB) FossilMirrorOf(repo string) (FossilMirror, bool, error) {
	m := FossilMirror{Repo: repo}
	err := d.db.QueryRow(`SELECT remote, user, pushed, pushed_at, err, err_at, created, created_by, fails, gen
		FROM fossil_mirror WHERE repo = ?`, repo).
		Scan(&m.Remote, &m.User, &m.Pushed, &m.PushedAt, &m.Err, &m.ErrAt, &m.Created, &m.CreatedBy, &m.Fails, &m.Gen)
	if errors.Is(err, sql.ErrNoRows) {
		return FossilMirror{}, false, nil
	}
	if err != nil {
		return FossilMirror{}, false, err
	}
	return m, true, nil
}

// SetFossilMirror turns a repository's Fossil mirror on, or changes
// where it pushes, and records each field that changed in the settings
// log, in one transaction. The password is never logged.
//
// A password is required when the mirror is first set and when the
// remote or the user changes: a stored password was given for another
// destination. Otherwise an empty one keeps the stored password. A
// change of destination clears the push status, which described the old
// one.
//
// Refused for a private repository and while a takedown is in force,
// read inside the transaction, as for the Git mirror.
func (d *DB) SetFossilMirror(repo, remote, user, password, by, via string) (FossilMirror, error) {
	remote, user = strings.TrimSpace(remote), strings.TrimSpace(user)
	if err := CheckFossilRemote(remote); err != nil {
		return FossilMirror{}, err
	}
	if err := checkFossilUser(user); err != nil {
		return FossilMirror{}, err
	}
	if len(password) > 200 || strings.ContainsAny(password, "\r\n\x00") {
		return FossilMirror{}, fmt.Errorf("%w: the password is at most 200 characters, on one line", ErrMirrorRefused)
	}
	tx, err := d.w.Begin()
	if err != nil {
		return FossilMirror{}, err
	}
	defer tx.Rollback()

	var visibility string
	switch err := tx.QueryRow(`SELECT visibility FROM repo WHERE name = ?`, repo).Scan(&visibility); {
	case errors.Is(err, sql.ErrNoRows):
		return FossilMirror{}, fmt.Errorf("no repository named %q", repo)
	case err != nil:
		return FossilMirror{}, err
	}
	if visibility == Private {
		return FossilMirror{}, fmt.Errorf("%w: a private repository cannot be mirrored", ErrMirrorRefused)
	}
	if err := refuseHeld(tx, repo); err != nil {
		return FossilMirror{}, err
	}

	stamp := now()
	var oldRemote, oldUser string
	err = tx.QueryRow(`SELECT remote, user FROM fossil_mirror WHERE repo = ?`, repo).Scan(&oldRemote, &oldUser)
	fresh := errors.Is(err, sql.ErrNoRows)
	if err != nil && !fresh {
		return FossilMirror{}, err
	}
	moved := fresh || remote != oldRemote || user != oldUser
	if moved && password == "" {
		return FossilMirror{}, fmt.Errorf("%w: give the login's password: it is needed when the mirror is first set and when its remote or user changes", ErrMirrorRefused)
	}
	var changes []SettingsChange
	switch {
	case fresh:
		sealed, err := d.seal.seal(password)
		if err != nil {
			return FossilMirror{}, err
		}
		// Not 1: a push still running for the mirror this one replaces
		// must not match it, so a new row starts where no old one was.
		if _, err := tx.Exec(`INSERT INTO fossil_mirror (repo, remote, user, pass_sealed, created, created_by, gen)
			VALUES (?, ?, ?, ?, ?, ?, ?)`, repo, remote, user, sealed, stamp, by, time.Now().UnixNano()); err != nil {
			return FossilMirror{}, err
		}
		changes = append(changes, SettingsChange{Field: "fossil-mirror", Was: "off", Now: "on"})
	case moved || password != "":
		sealed := ""
		if password != "" {
			if sealed, err = d.seal.seal(password); err != nil {
				return FossilMirror{}, err
			}
		}
		// A new password is a new chance: a failure under the old one
		// must not hold the retry back.
		if _, err := tx.Exec(`UPDATE fossil_mirror SET remote = ?, user = ?,
			pass_sealed = CASE WHEN ? = '' THEN pass_sealed ELSE ? END,
			pushed = CASE WHEN ? THEN '' ELSE pushed END, pushed_at = CASE WHEN ? THEN '' ELSE pushed_at END,
			err = '', err_at = '', fails = 0, gen = gen + 1
			WHERE repo = ?`, remote, user, sealed, sealed, moved, moved, repo); err != nil {
			return FossilMirror{}, err
		}
		if !moved {
			changes = append(changes, SettingsChange{Field: "fossil-mirror-password", Was: "set", Now: "changed"})
		}
	}
	if !fresh && remote != oldRemote {
		changes = append(changes, SettingsChange{Field: "fossil-mirror-remote", Was: oldRemote, Now: remote})
	} else if fresh {
		changes = append(changes, SettingsChange{Field: "fossil-mirror-remote", Was: "", Now: remote})
	}
	if !fresh && user != oldUser {
		changes = append(changes, SettingsChange{Field: "fossil-mirror-user", Was: oldUser, Now: user})
	} else if fresh {
		changes = append(changes, SettingsChange{Field: "fossil-mirror-user", Was: "", Now: user})
	}
	if err := logSettings(tx, repo, stamp, by, via, changes); err != nil {
		return FossilMirror{}, err
	}
	if err := tx.Commit(); err != nil {
		return FossilMirror{}, err
	}
	m, _, err := d.FossilMirrorOf(repo)
	return m, err
}

// DisableFossilMirror turns a repository's Fossil mirror off and
// discards its password. Never refused. False when there was no mirror
// to stop, which records nothing.
func (d *DB) DisableFossilMirror(repo, by, via string) (bool, error) {
	tx, err := d.w.Begin()
	if err != nil {
		return false, err
	}
	defer tx.Rollback()
	res, err := tx.Exec(`DELETE FROM fossil_mirror WHERE repo = ?`, repo)
	if err != nil {
		return false, err
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return false, nil
	}
	if err := logSettings(tx, repo, now(), by, via,
		[]SettingsChange{{Field: "fossil-mirror", Was: "on", Now: "off"}}); err != nil {
		return false, err
	}
	return true, tx.Commit()
}

// FossilMirrorRepos lists the repositories that have a Fossil mirror on.
func (d *DB) FossilMirrorRepos() ([]string, error) {
	rows, err := d.db.Query(`SELECT repo FROM fossil_mirror ORDER BY repo`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []string
	for rows.Next() {
		var r string
		if err := rows.Scan(&r); err != nil {
			return nil, err
		}
		out = append(out, r)
	}
	return out, rows.Err()
}

// FossilMirrorPassword opens a mirror's password, for the length of one
// push, if the mirror is still the configuration gen names;
// ErrFossilMirrorChanged otherwise, so a push never sends a password
// entered for one remote to another. The only reader of the sealed
// column: nothing that renders a mirror calls it.
func (d *DB) FossilMirrorPassword(repo string, gen int64) (string, error) {
	var sealed string
	err := d.db.QueryRow(`SELECT pass_sealed FROM fossil_mirror WHERE repo = ? AND gen = ?`, repo, gen).Scan(&sealed)
	if errors.Is(err, sql.ErrNoRows) {
		return "", ErrFossilMirrorChanged
	}
	if err != nil {
		return "", err
	}
	return d.seal.open(sealed)
}

// RecordFossilMirrorPush and RecordFossilMirrorFailure write a push's
// outcome, but only while the mirror is still the configuration gen
// names. An admin who changed the remote, the user or the password
// mid-push, or turned the mirror off and on, must not see the old
// push's result reported as the new mirror's.
func (d *DB) RecordFossilMirrorPush(repo string, gen int64, pushed string) error {
	_, err := d.w.Exec(`UPDATE fossil_mirror SET pushed = ?, pushed_at = ?, err = '', err_at = '', fails = 0
		WHERE repo = ? AND gen = ?`, pushed, now(), repo, gen)
	return err
}

func (d *DB) RecordFossilMirrorFailure(repo string, gen int64, why string) error {
	_, err := d.w.Exec(`UPDATE fossil_mirror SET err = ?, err_at = ?, fails = fails + 1
		WHERE repo = ? AND gen = ?`, clipValue(why), now(), repo, gen)
	return err
}

// CheckFossilRemote accepts the sync address of a Fossil server: https,
// or http to this machine alone (the forge's one outbound rule,
// outbound.Check), a host, no user name or password in it (the login is its own
// field), no query and no fragment. Whether the host is one the forge
// may reach is the caller's to check, because that needs the network.
func CheckFossilRemote(remote string) error {
	refuse := func(why string) error { return fmt.Errorf("%w: %s", ErrMirrorRefused, why) }
	if remote == "" {
		return refuse("give the remote's address, such as https://fossil.example.org/repo")
	}
	if len(remote) > 300 {
		return refuse("the remote address is at most 300 characters")
	}
	if strings.ContainsAny(remote, " \t\r\n\\") {
		return refuse("the remote address has characters a Fossil server does not use")
	}
	u, err := url.Parse(remote)
	if err != nil || u.Opaque != "" {
		return refuse("the remote address is not a URL")
	}
	if _, err := outbound.Check(remote); err != nil {
		return refuse("only an https address can be pushed to, so the password is not sent in the clear")
	}
	if u.User != nil || strings.Contains(u.Host, "@") {
		return refuse("the remote address carries a user name or password; they are their own fields")
	}
	if u.Hostname() == "" {
		return refuse("the remote address names no host")
	}
	if u.RawQuery != "" || u.Fragment != "" || strings.Contains(remote, "?") || strings.Contains(remote, "#") {
		return refuse("the remote address has a query or a fragment")
	}
	if p := u.Port(); p != "" {
		if n, err := strconv.Atoi(p); err != nil || n < 1 || n > 65535 {
			return refuse("the remote's port is not a port")
		}
	}
	if strings.HasPrefix(remote, "-") {
		return refuse("the remote address is not a URL")
	}
	return nil
}

// checkFossilUser accepts a Fossil login name. Handed to zit as the
// value of --user, so one starting with a dash is refused; a colon or a
// space would not survive the login card.
func checkFossilUser(user string) error {
	if user == "" {
		return fmt.Errorf("%w: give the login the push authenticates as", ErrMirrorRefused)
	}
	if len(user) > 64 || strings.HasPrefix(user, "-") || strings.ContainsAny(user, " \t\r\n\x00:/\\") {
		return fmt.Errorf("%w: the login name has characters a Fossil login does not", ErrMirrorRefused)
	}
	return nil
}