Skip to content
go-libfossil Artifact [3275b54955]
Login

Artifact [3275b54955]

Artifact 3275b549552efb5b13cfa0edb4753a421688e695c1b586118dc0e1ecd025845b:


package uv

import (
	"bytes"
	"compress/zlib"
	"crypto/sha1"
	"database/sql"
	"encoding/hex"
	"fmt"
	"io"
	"sort"

	"github.com/danmestas/go-libfossil/db"
	"github.com/danmestas/go-libfossil/internal/deck"
	"github.com/danmestas/go-libfossil/internal/hash"
)

// Entry represents a row in the unversioned table.
type Entry struct {
	Name  string
	MTime int64  // seconds since 1970
	Hash  string // "" for tombstone (NULL in DB)
	Size  int    // uncompressed size
}

const schemaUV = `CREATE TABLE IF NOT EXISTS unversioned(
  uvid INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT UNIQUE,
  rcvid INTEGER,
  mtime DATETIME,
  hash TEXT,
  sz INTEGER,
  encoding INT,
  content BLOB
);`

func EnsureSchema(d *db.DB) error {
	if d == nil {
		panic("uv.EnsureSchema: d must not be nil")
	}
	_, err := d.Exec(schemaUV)
	return err
}

func Write(d *db.DB, name string, content []byte, mtime int64) error {
	if d == nil {
		panic("uv.Write: d must not be nil")
	}
	if name == "" {
		panic("uv.Write: name must not be empty")
	}
	if content == nil {
		panic("uv.Write: content must not be nil")
	}

	// Name the content by the repo's hash-policy. The old check here read
	// project-code, which is 40 hex characters in every repo whatever the
	// policy, so it could only ever choose SHA1.
	contentHash := hash.NamingFor(d).New.Hash(content)
	if err := storeRow(d, name, mtime, contentHash, content); err != nil {
		return fmt.Errorf("uv.Write: %w", err)
	}
	return InvalidateHash(d)
}

// Receive applies an unversioned file received in a uvfile card, as fossil's
// xfer_accept_unversioned_file does (§7.3). The caller has checked HASH
// against content, which is nil when the card omitted it. Against the local
// row (see Status), status 3 and above change nothing; status 2, the same
// content, takes the card's mtime alone. Otherwise a HASH of "-" makes a
// tombstone, and any other replaces the row under the HASH the card gave,
// not one this repository's policy computes, so the catalogs of both sides
// agree. Omitted content is stored as NULL, with size 0.
func Receive(d *db.DB, name string, mtime int64, fileHash string, content []byte) error {
	if d == nil {
		panic("uv.Receive: d must not be nil")
	}
	if name == "" || fileHash == "" {
		panic("uv.Receive: name and hash must not be empty")
	}
	_, localMtime, localHash, err := Read(d, name)
	if err != nil {
		return fmt.Errorf("uv.Receive: %w", err)
	}
	status := Status(localMtime, localHash, mtime, fileHash)
	switch {
	case status >= 3:
		return nil
	case fileHash == "-":
		// A received deletion only updates a row the repository holds, as
		// fossil's UPDATE does; for an absent name it changes nothing.
		_, err := d.Exec(`UPDATE unversioned SET rcvid=1, mtime=?, hash=NULL, sz=0,
			encoding=0, content=NULL WHERE name=?`, mtime, name)
		if err != nil {
			return fmt.Errorf("uv.Receive: %w", err)
		}
	case status == 2:
		_, err := d.Exec("UPDATE unversioned SET mtime=? WHERE name=?", mtime, name)
		if err != nil {
			return fmt.Errorf("uv.Receive: %w", err)
		}
	default:
		if len(content) == 0 {
			content = nil // fossil stores an empty or omitted payload as NULL
		}
		if err := storeRow(d, name, mtime, fileHash, content); err != nil {
			return fmt.Errorf("uv.Receive: %w", err)
		}
	}
	return InvalidateHash(d)
}

// storeRow writes name's row under fileHash, compressing content when that
// saves a fifth or more, as fossil does. nil content is stored as NULL.
func storeRow(d *db.DB, name string, mtime int64, fileHash string, content []byte) error {
	if d == nil {
		panic("uv.storeRow: d must not be nil")
	}
	if name == "" || fileHash == "" || fileHash == "-" {
		panic("uv.storeRow: a row needs a name and a content hash")
	}
	sz := len(content)
	var encoding int
	stored := content
	if sz > 0 {
		var compressed bytes.Buffer
		w := zlib.NewWriter(&compressed)
		w.Write(content)
		w.Close()
		if compressed.Len() <= sz*4/5 { // <= 80%
			encoding = 1
			stored = compressed.Bytes()
		}
	}
	// A nil []byte binds as an empty blob under some drivers (ncruces), not
	// as NULL, so absent content is bound as an untyped nil.
	var bound any = stored
	if stored == nil {
		bound = nil
	}
	_, err := d.Exec(
		`REPLACE INTO unversioned(name, rcvid, mtime, hash, sz, encoding, content)
		 VALUES(?, 1, ?, ?, ?, ?, ?)`,
		name, mtime, fileHash, sz, encoding, bound,
	)
	return err
}

func Delete(d *db.DB, name string, mtime int64) error {
	if d == nil {
		panic("uv.Delete: d must not be nil")
	}
	if name == "" {
		panic("uv.Delete: name must not be empty")
	}

	// Check if row exists; if not, insert tombstone.
	var exists int
	if err := d.QueryRow("SELECT count(*) FROM unversioned WHERE name=?", name).Scan(&exists); err != nil {
		return fmt.Errorf("uv.Delete: check existence: %w", err)
	}
	if exists == 0 {
		_, err := d.Exec(
			`INSERT INTO unversioned(name, rcvid, mtime, hash, sz, encoding, content)
			 VALUES(?, 1, ?, NULL, 0, 0, NULL)`, name, mtime,
		)
		if err != nil {
			return fmt.Errorf("uv.Delete insert: %w", err)
		}
	} else {
		_, err := d.Exec(
			`UPDATE unversioned SET rcvid=1, mtime=?, hash=NULL, sz=0, encoding=0, content=NULL
			 WHERE name=?`, mtime, name,
		)
		if err != nil {
			return fmt.Errorf("uv.Delete update: %w", err)
		}
	}
	return InvalidateHash(d)
}

func Read(d *db.DB, name string) ([]byte, int64, string, error) {
	if d == nil {
		panic("uv.Read: d must not be nil")
	}

	var mtime int64
	var hashVal sql.NullString
	var encoding int
	var stored []byte

	err := d.QueryRow(
		"SELECT CAST(mtime AS INTEGER), hash, encoding, content FROM unversioned WHERE name=?", name,
	).Scan(&mtime, &hashVal, &encoding, &stored)
	if err == sql.ErrNoRows {
		return nil, 0, "", nil
	}
	if err != nil {
		return nil, 0, "", fmt.Errorf("uv.Read: %w", err)
	}

	h := ""
	if hashVal.Valid {
		h = hashVal.String
	}

	// Tombstone: hash is NULL
	if !hashVal.Valid {
		return nil, mtime, "", nil
	}

	// Decompress if needed.
	if encoding == 1 && stored != nil {
		r, err := zlib.NewReader(bytes.NewReader(stored))
		if err != nil {
			return nil, 0, "", fmt.Errorf("uv.Read: zlib open: %w", err)
		}
		defer r.Close()
		data, err := io.ReadAll(r)
		if err != nil {
			return nil, 0, "", fmt.Errorf("uv.Read: zlib read: %w", err)
		}
		return data, mtime, h, nil
	}

	return stored, mtime, h, nil
}

func List(d *db.DB) ([]Entry, error) {
	if d == nil {
		panic("uv.List: d must not be nil")
	}

	rows, err := d.Query("SELECT name, CAST(mtime AS INTEGER), hash, sz FROM unversioned ORDER BY name")
	if err != nil {
		return nil, fmt.Errorf("uv.List: %w", err)
	}
	defer rows.Close()

	var entries []Entry
	for rows.Next() {
		var e Entry
		var hashVal sql.NullString
		if err := rows.Scan(&e.Name, &e.MTime, &hashVal, &e.Size); err != nil {
			return nil, err
		}
		if hashVal.Valid {
			e.Hash = hashVal.String
		}
		entries = append(entries, e)
	}
	return entries, rows.Err()
}

// ContentHash computes the SHA1 catalog hash matching Fossil's
// unversioned_content_hash(). Always SHA1, even in SHA3 repos.
// Format: "name YYYY-MM-DD HH:MM:SS hash\n" per file, sorted by name.
func ContentHash(d *db.DB) (string, error) {
	if d == nil {
		panic("uv.ContentHash: d must not be nil")
	}

	// Check cache first.
	var cached string
	err := d.QueryRow("SELECT value FROM config WHERE name='uv-hash'").Scan(&cached)
	if err == nil && cached != "" {
		return cached, nil
	}

	// Compute.
	rows, err := d.Query(
		`SELECT name, datetime(mtime,'unixepoch'), hash
		 FROM unversioned WHERE hash IS NOT NULL`,
	)
	if err != nil {
		return "", fmt.Errorf("uv.ContentHash: query: %w", err)
	}
	defer rows.Close()

	// sync-protocol-01 §8.5.1 hashes "stored fossilized name bytes" sorted
	// bytewise. This server stores the decoded name, so it encodes here: a
	// name with a space then hashes as fossil(1) hashes it, from the raw
	// wire bytes it never decodes. The sort must follow the encoded bytes,
	// since SP (0x20) and the backslash of its escape (0x5c) order apart.
	type line struct{ name, rest string }
	var lines []line
	for rows.Next() {
		var name, dt, fileHash string
		if err := rows.Scan(&name, &dt, &fileHash); err != nil {
			return "", err
		}
		lines = append(lines, line{deck.FossilEncode(name), " " + dt + " " + fileHash + "\n"})
	}
	if err := rows.Err(); err != nil {
		return "", err
	}
	sort.Slice(lines, func(i, j int) bool { return lines[i].name < lines[j].name })
	h := sha1.New()
	for _, l := range lines {
		io.WriteString(h, l.name+l.rest)
	}

	result := hex.EncodeToString(h.Sum(nil))

	// Cache. Non-fatal: if this fails we just recompute next time.
	if _, err := d.Exec(
		"INSERT OR REPLACE INTO config(name, value, mtime) VALUES('uv-hash', ?, strftime('%s','now'))",
		result,
	); err != nil {
		return result, fmt.Errorf("uv.ContentHash: cache write: %w", err)
	}

	return result, nil
}

func InvalidateHash(d *db.DB) error {
	if d == nil {
		panic("uv.InvalidateHash: d must not be nil")
	}
	_, err := d.Exec("DELETE FROM config WHERE name='uv-hash'")
	return err
}