Skip to content
quarry Artifact [8d25fea1ef]
Login

Artifact [8d25fea1ef]

Artifact 8d25fea1ef4ee0b63a738e644496d2d77121e5688d252dc70ce5bb714faa914a:


package uv

import (
	"crypto/sha1"
	"crypto/sha3"
	"encoding/hex"
	"strings"
	"testing"
	"time"
)

// Fossil's own rules for a name, one case per clause of
// file_is_simple_pathname and the checks `uv add` makes around it.
func TestCheckNameFollowsFossil(t *testing.T) {
	good := []string{"download.html", "releases/1.2.3/zit-linux-amd64.tar.gz", ".hidden", "a..b", "ünïcode.txt"}
	for _, n := range good {
		if err := CheckName(n); err != nil {
			t.Errorf("FAIL: %q refused: %v", n, err)
		}
	}
	bad := []string{"", "/abs", "a//b", "a/", "./a", "a/./b", "../a", "a/../b", "a/..", "a\\b",
		"has space", "tab\there", "nl\nhere", strings.Repeat("x", 501), "\xff\xfe", "\U0001F600.png",
		".tombstones/x", "nul\x00here", "esc\x1b[0m", "del\x7f", "bell\a"}
	for _, n := range bad {
		if err := CheckName(n); err == nil {
			t.Errorf("FAIL: %q accepted", n)
		}
	}
	if err := CheckName(strings.Repeat("x", 500)); err != nil {
		t.Errorf("FAIL: 500 bytes refused: %v", err)
	}
	t.Log("ok: names follow fossil's rules, and the tombstone prefix is reserved")
}

func TestKeysRoundTrip(t *testing.T) {
	if Key("releases/a.tgz") != "unversioned/releases/a.tgz" || TombstoneKey("releases/a.tgz") != "unversioned/.tombstones/releases/a.tgz" {
		t.Fatalf("FAIL: keys %q %q", Key("releases/a.tgz"), TombstoneKey("releases/a.tgz"))
	}
	for _, c := range []struct {
		key       string
		name      string
		tombstone bool
		ok        bool
	}{
		{"unversioned/releases/a.tgz", "releases/a.tgz", false, true},
		{"unversioned/.tombstones/releases/a.tgz", "releases/a.tgz", true, true},
		{"attachments/abc", "", false, false},
		{"unversioned/bad name", "", false, false},
	} {
		name, tomb, ok := NameOfKey(c.key)
		if name != c.name || tomb != c.tombstone || ok != c.ok {
			t.Errorf("FAIL: NameOfKey(%q) = %q %v %v", c.key, name, tomb, ok)
		}
	}
	t.Log("ok: object keys and names round-trip")
}

func TestHashIsSHA3AndVerifyTakesEither(t *testing.T) {
	body := []byte("hello\n")
	s3sum := sha3.Sum256(body)
	s1sum := sha1.Sum(body)
	if Hash(body) != hex.EncodeToString(s3sum[:]) {
		t.Errorf("FAIL: Hash = %s", Hash(body))
	}
	if !Verify(hex.EncodeToString(s3sum[:]), body) || !Verify(hex.EncodeToString(s1sum[:]), body) {
		t.Error("FAIL: a correct hash did not verify")
	}
	if Verify(strings.ToUpper(hex.EncodeToString(s1sum[:])), body) {
		t.Error("FAIL: fossil hashes are lower case; upper case verified")
	}
	if Verify(hex.EncodeToString(s3sum[:]), []byte("other")) || Verify("abc", body) {
		t.Error("FAIL: a wrong hash verified")
	}
	t.Log("ok: new content is SHA3-256, either policy verifies")
}

// unversioned_status in fossil's src/unversioned.c, as the receiver of
// a uvfile card applies it (xfer.c: accept unless status >= 3).
func TestReplacesIsFossilsStatusRule(t *testing.T) {
	have := File{Name: "a", Mtime: 100, Hash: "bb"}
	for _, c := range []struct {
		in   File
		want bool
		why  string
	}{
		{File{Name: "a", Mtime: 101, Hash: "aa"}, true, "newer mtime wins"},
		{File{Name: "a", Mtime: 99, Hash: "cc"}, false, "older mtime loses"},
		{File{Name: "a", Mtime: 100, Hash: "cc"}, true, "same mtime, larger hash wins"},
		{File{Name: "a", Mtime: 100, Hash: "aa"}, false, "same mtime, smaller hash loses"},
		{File{Name: "a", Mtime: 100, Hash: "bb"}, false, "identical is not a change"},
		{File{Name: "a", Mtime: 101, Hash: "bb"}, true, "same content, newer mtime updates"},
		{File{Name: "a", Mtime: 101, Deleted: true}, true, "a newer tombstone wins"},
		{File{Name: "a", Mtime: 100, Deleted: true}, false, "a tombstone at the same mtime loses to content"},
	} {
		if got := Replaces(c.in, have, true); got != c.want {
			t.Errorf("FAIL: %s: got %v", c.why, got)
		}
	}
	if !Replaces(File{Name: "a", Mtime: 1, Hash: "aa"}, File{}, false) {
		t.Error("FAIL: content for an absent name was refused")
	}
	if Replaces(File{Name: "a", Mtime: 1, Deleted: true}, File{}, false) {
		t.Error("FAIL: a tombstone for an absent name was taken; fossil's UPDATE finds no row")
	}
	t.Log("ok: replacement follows unversioned_status")
}

// CatalogHash against the definition written out by hand: SHA1 over
// "NAME YYYY-MM-DD HH:MM:SS HASH\n" for every row whose hash is not
// NULL, ordered by name.
func TestCatalogHashMatchesFossilsDefinition(t *testing.T) {
	files := []File{
		{Name: "releases/z.tgz", Mtime: 1727740800, Hash: "c0ffee"},
		{Name: "download.html", Mtime: 0, Hash: "abc123"},
		{Name: "gone.txt", Mtime: 1727740801, Deleted: true},
		{Name: "B-upper", Mtime: 86399, Hash: "0a0a"},
	}
	text := "B-upper 1970-01-01 23:59:59 0a0a\n" +
		"download.html 1970-01-01 00:00:00 abc123\n" +
		"releases/z.tgz " + time.Unix(1727740800, 0).UTC().Format("2006-01-02 15:04:05") + " c0ffee\n"
	sum := sha1.Sum([]byte(text))
	if got, want := CatalogHash(files), hex.EncodeToString(sum[:]); got != want {
		t.Errorf("FAIL: CatalogHash = %s, want %s", got, want)
	}
	if got := CatalogHash(nil); got != "da39a3ee5e6b4b0d3255bfef95601890afd80709" {
		t.Errorf("FAIL: the empty catalog hashes to %s, not fossil's constant", got)
	}
	t.Log("ok: the uv-hash is fossil's")
}

// The same catalog given to fossil 2.28 itself (/opt/homebrew/bin/fossil)
// in a scratch repository, sha3 policy:
//
//	fossil init repo.fossil
//	fossil uv add a.txt  --as download.html      --mtime '2024-10-01 00:00:00'  # "hello\n"
//	fossil uv add d.html --as releases/1.0/a.txt --mtime '1970-01-01 23:59:59'  # "<p>x</p>\n"
//	fossil uv add g.txt  --as gone.txt --mtime '2024-01-01 00:00:00' && fossil uv rm gone.txt
//	fossil uv add f.txt  --as far.txt                                           # "far\n"
//	fossil sql "UPDATE unversioned SET mtime=253402300800 WHERE name='far.txt';
//	            DELETE FROM config WHERE name='uv-hash'"
//	fossil uv hash   # 372f3da766e6e0587dddafc95f1062b34c02cca0
//
// far.txt's mtime is one second past SQLite's last date, so fossil
// hashes it with an empty datetime; gone.txt is a tombstone with its
// own mtime and is left out.
func TestCatalogHashMatchesRealFossil(t *testing.T) {
	files := []File{
		{Name: "download.html", Mtime: 1727740800, Hash: Hash([]byte("hello\n"))},
		{Name: "far.txt", Mtime: 253402300800, Hash: Hash([]byte("far\n"))},
		{Name: "gone.txt", Mtime: 1790852752, Deleted: true},
		{Name: "releases/1.0/a.txt", Mtime: 86399, Hash: Hash([]byte("<p>x</p>\n"))},
	}
	if files[0].Hash != "b314e28493eae9dab57ac4f0c6d887bddbbeb810e900d818395ace558e96516d" {
		t.Fatalf("FAIL: content hash %s is not the one fossil stored", files[0].Hash)
	}
	if got := CatalogHash(files); got != "372f3da766e6e0587dddafc95f1062b34c02cca0" {
		t.Errorf("FAIL: CatalogHash = %s, fossil said 372f3da766e6e0587dddafc95f1062b34c02cca0", got)
	}
	t.Log("ok: the uv-hash is the one fossil 2.28 computes, out-of-range mtime included")
}

func TestContentTypeForcesActiveTypesToDownload(t *testing.T) {
	for _, c := range []struct {
		name, ctype string
		inline      bool
	}{
		{"download.html", "text/html; charset=utf-8", false},
		{"logo.svg", "image/svg+xml", false},
		{"download.js", "text/javascript; charset=utf-8", false},
		{"page.XHTML", "application/xhtml+xml", false},
		{"notes.txt", "text/plain; charset=utf-8", true},
		{"shot.PNG", "image/png", true},
		{"zit-linux-amd64.tar.gz", "application/gzip", false},
		{"SHA256SUMS", "application/octet-stream", false},
	} {
		ctype, inline := ContentType(c.name)
		if ctype != c.ctype || inline != c.inline {
			t.Errorf("FAIL: %s: %q inline=%v", c.name, ctype, inline)
		}
	}
	t.Log("ok: the type comes from a fixed table and nothing active renders")
}