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("q", "releases/a.tgz") != "unversioned/q/releases/a.tgz" || TombstoneKey("q", "releases/a.tgz") != "unversioned/q/.tombstones/releases/a.tgz" {
t.Fatalf("FAIL: keys %q %q", Key("q", "releases/a.tgz"), TombstoneKey("q", "releases/a.tgz"))
}
if !IsTombstoneKey("q", "unversioned/q/.tombstones/a") || IsTombstoneKey("q", "unversioned/q/a") || IsTombstoneKey("q", "unversioned/q2/.tombstones/a") {
t.Error("FAIL: IsTombstoneKey")
}
for _, c := range []struct {
key string
name string
tombstone bool
ok bool
}{
{"unversioned/q/releases/a.tgz", "releases/a.tgz", false, true},
{"unversioned/q/.tombstones/releases/a.tgz", "releases/a.tgz", true, true},
{"attachments/abc", "", false, false},
{"unversioned/q/bad name", "", false, false},
// Another repository's, including one whose name starts with q,
// and the unscoped layout's.
{"unversioned/other/a.tgz", "", false, false},
{"unversioned/q2/a.tgz", "", false, false},
{"unversioned/a.tgz", "", false, false},
{"unversioned/.tombstones/a.tgz", "", false, false},
} {
name, tomb, ok := NameOfKey("q", 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, within one repository's prefix")
}
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")
}