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() == "" || strings.HasPrefix(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
}