diff --git a/weed/s3api/s3lifecycle/version_time_test.go b/weed/s3api/s3lifecycle/version_time_test.go new file mode 100644 index 000000000..a94bb0c14 --- /dev/null +++ b/weed/s3api/s3lifecycle/version_time_test.go @@ -0,0 +1,168 @@ +package s3lifecycle + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// CompareVersionIds is the version-id tiebreak the router falls back to +// when two versions land at the same mtime second; getting the format / +// null / mixed-format axes wrong silently inverts retention rankings, +// which would resurrect deleted versions or evict live ones. The tests +// below pin every documented branch. + +const ( + // 16-char hex prefix above 0x4000000000000000 → new-format inverted + // timestamp. + newFormatPrefix1 = "4123456789abcdef" + newFormatPrefix2 = "5123456789abcdef" // strictly larger than prefix1 + // 16-char hex prefix at or below the threshold → old-format raw + // timestamp. + oldFormatPrefix1 = "0123456789abcdef" + oldFormatPrefix2 = "0223456789abcdef" // strictly larger than prefix1 +) + +func TestCompareVersionIds_EqualReturnsZero(t *testing.T) { + assert.Equal(t, 0, CompareVersionIds("abc", "abc")) + assert.Equal(t, 0, CompareVersionIds("null", "null")) + assert.Equal(t, 0, CompareVersionIds("", "")) +} + +func TestCompareVersionIds_NullSortsLast(t *testing.T) { + // "null" represents the bare pre-versioning entry; the router treats + // it as older than any real version-id so genuine versions take + // precedence in tiebreaks. + assert.Equal(t, 1, CompareVersionIds("null", newFormatPrefix1)) + assert.Equal(t, 1, CompareVersionIds("null", oldFormatPrefix1)) + assert.Equal(t, -1, CompareVersionIds(newFormatPrefix1, "null")) + assert.Equal(t, -1, CompareVersionIds(oldFormatPrefix1, "null")) +} + +func TestCompareVersionIds_BothNewFormatSmallerIsNewer(t *testing.T) { + // New-format encodes the timestamp inverted, so the lexicographically + // smaller string represents a newer wall-clock time. + assert.Equal(t, -1, CompareVersionIds(newFormatPrefix1, newFormatPrefix2), + "new-format: smaller lex → newer → -1") + assert.Equal(t, 1, CompareVersionIds(newFormatPrefix2, newFormatPrefix1), + "new-format: larger lex → older → 1") +} + +func TestCompareVersionIds_BothOldFormatLargerIsNewer(t *testing.T) { + // Old-format encodes the timestamp raw, so the lexicographically + // larger string is the newer wall-clock time. + assert.Equal(t, 1, CompareVersionIds(oldFormatPrefix1, oldFormatPrefix2), + "old-format: smaller lex → older → 1") + assert.Equal(t, -1, CompareVersionIds(oldFormatPrefix2, oldFormatPrefix1), + "old-format: larger lex → newer → -1") +} + +func TestCompareVersionIds_MixedFormatComparesByTimestamp(t *testing.T) { + // New-format inverted timestamp = MaxInt64 - prefix; old-format raw = + // prefix. With prefix1 = 0x4123… and old prefix1 = 0x0123… the + // inverted new value is small but still positive, while the raw old + // value is also small. Whichever is numerically larger wins. + at := getVersionTimestamp(newFormatPrefix1) + bt := getVersionTimestamp(oldFormatPrefix1) + if at == bt { + assert.Equal(t, 0, CompareVersionIds(newFormatPrefix1, oldFormatPrefix1)) + return + } + if at > bt { + assert.Equal(t, -1, CompareVersionIds(newFormatPrefix1, oldFormatPrefix1)) + assert.Equal(t, 1, CompareVersionIds(oldFormatPrefix1, newFormatPrefix1)) + } else { + assert.Equal(t, 1, CompareVersionIds(newFormatPrefix1, oldFormatPrefix1)) + assert.Equal(t, -1, CompareVersionIds(oldFormatPrefix1, newFormatPrefix1)) + } +} + +func TestCompareVersionIds_MixedFormatEqualTimestampReturnsZero(t *testing.T) { + // If a new-format inverted timestamp equals an old-format raw + // timestamp, neither is newer — should be 0. We can synthesize this: + // pick an old prefix P, the matching new prefix is MaxInt64 - P. + old := uint64(0x0000000000010000) + new_ := uint64(int64(^uint64(0)>>1) - int64(old)) + oldHex := uintToHex16(old) + newHex := uintToHex16(new_) + // Sanity: new_ must be above the threshold, otherwise it'd be parsed + // as old-format and the test wouldn't exercise the mixed branch. + if !isNewFormatVersionId(newHex) { + t.Fatalf("constructed new-format prefix %q didn't classify as new", newHex) + } + if isNewFormatVersionId(oldHex) { + t.Fatalf("constructed old-format prefix %q classified as new", oldHex) + } + assert.Equal(t, 0, CompareVersionIds(newHex, oldHex), "mixed-format equal timestamps must be 0") +} + +func TestIsNewFormatVersionId_TooShortRejected(t *testing.T) { + // Strings shorter than 16 chars can't carry a hex timestamp prefix; + // they must classify as old-format so getVersionTimestamp returns 0 + // rather than panic on slice bounds. + assert.False(t, isNewFormatVersionId("")) + assert.False(t, isNewFormatVersionId("4123")) + assert.False(t, isNewFormatVersionId("4123456789abcde")) // 15 chars +} + +func TestIsNewFormatVersionId_NullRejected(t *testing.T) { + assert.False(t, isNewFormatVersionId("null")) +} + +func TestIsNewFormatVersionId_NonHexRejected(t *testing.T) { + // Parse failure on the 16-char prefix means the comparator falls + // back to old-format treatment. Non-hex characters or surrounding + // whitespace must reject without erroring out. + assert.False(t, isNewFormatVersionId("zzzzzzzzzzzzzzzz")) + assert.False(t, isNewFormatVersionId("0xff000000000000")) // "0x" prefix breaks ParseUint base-16 + assert.False(t, isNewFormatVersionId("ABC GHIJKLMNOPQR")) // space mid-string +} + +func TestIsNewFormatVersionId_ThresholdBoundary(t *testing.T) { + // versionIdFormatThreshold = 0x4000000000000000. The check is + // strictly greater than, so the threshold value itself must + // classify as old-format. + assert.False(t, isNewFormatVersionId("4000000000000000"), "exactly at threshold is old") + assert.True(t, isNewFormatVersionId("4000000000000001"), "one above threshold is new") + assert.False(t, isNewFormatVersionId("3fffffffffffffff"), "one below threshold is old") +} + +func TestGetVersionTimestamp_ReturnsZeroOnMalformed(t *testing.T) { + assert.Equal(t, int64(0), getVersionTimestamp("")) + assert.Equal(t, int64(0), getVersionTimestamp("null")) + assert.Equal(t, int64(0), getVersionTimestamp("4123456789abcde")) // too short + assert.Equal(t, int64(0), getVersionTimestamp("zzzzzzzzzzzzzzzz")) // not hex + assert.Equal(t, int64(0), getVersionTimestamp("zzzzzzzzzzzzzzzzMORE")) // not hex prefix +} + +func TestGetVersionTimestamp_OldFormatReturnsRawValue(t *testing.T) { + // Old-format: prefix below threshold, returned raw. + assert.Equal(t, int64(0x0123456789abcdef), getVersionTimestamp(oldFormatPrefix1)) + assert.Equal(t, int64(0x0223456789abcdef), getVersionTimestamp(oldFormatPrefix2)) + // Trailing characters past the 16-char prefix are ignored. + assert.Equal(t, int64(0x0123456789abcdef), getVersionTimestamp(oldFormatPrefix1+"-suffix")) +} + +func TestGetVersionTimestamp_NewFormatReturnsInvertedValue(t *testing.T) { + // New-format inversion: int64 max - parsed value. So a smaller + // stored prefix yields a larger inverted timestamp (= newer time). + maxI64 := int64(^uint64(0) >> 1) + assert.Equal(t, maxI64-int64(0x4123456789abcdef), getVersionTimestamp(newFormatPrefix1)) + assert.Equal(t, maxI64-int64(0x5123456789abcdef), getVersionTimestamp(newFormatPrefix2)) + // The smaller-prefix-=-newer-timestamp invariant the comparator + // relies on: + assert.Greater(t, getVersionTimestamp(newFormatPrefix1), getVersionTimestamp(newFormatPrefix2)) +} + +// uintToHex16 turns a uint64 into a 16-char lowercase hex string, +// padding with leading zeros. Used for synthesizing mixed-format +// equal-timestamp pairs in TestCompareVersionIds_MixedFormatEqualTimestampReturnsZero. +func uintToHex16(v uint64) string { + const hex = "0123456789abcdef" + out := make([]byte, 16) + for i := 15; i >= 0; i-- { + out[i] = hex[v&0xf] + v >>= 4 + } + return string(out) +}