Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ jobs:
go-version:
- 1.25.x
runs-on: ${{ matrix.platform }}
permissions:
contents: read
pull-requests: write # required when comment=true so the action can post/update the sticky PR comment
Comment on lines +16 to +18
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

❓ Verification inconclusive

Script executed:

#!/bin/bash
set -euo pipefail

# Expect: no matches for mutable action refs or latest tool versions.
rg -n --glob '.github/workflows/*.yml' --glob '.github/workflows/*.yaml' \
  'uses:\s+[^#]+@(main|master|latest)\b|version:\s*["'\'']?latest["'\'']?\b'

Repository: flanksource/commons


Repository: flanksource/commons
Exit code: 2

stderr:

No files were searched, which means ripgrep probably applied a filter you didn't expect.
Running with --debug will show why files are being skipped.

Script executed:

find . -type f -name "*.yml" -o -name "*.yaml" | head -20

Repository: flanksource/commons


Repository: flanksource/commons
Exit code: 0

stdout:

./.github/dependabot.yml
./.github/workflows/codeql.yml
./.github/workflows/hx-test.yml
./.github/workflows/lint.yml
./.github/workflows/release.yml
./.github/workflows/scorecard.yml
./.github/workflows/test.yml
./.golangci.yml
./certs/fixtures/file.yml
./certs/fixtures/literal.yml
./certs/fixtures/remote.yml
./http/testdata/docker-compose.yml

Script executed:

ls -la .github/workflows/ 2>/dev/null || echo "Directory not found"

Repository: flanksource/commons


Repository: flanksource/commons
Exit code: 0

stdout:

total 28
drwxr-xr-x 2 jailuser jailuser  160 Apr 22 05:15 .
drwxr-xr-x 3 jailuser jailuser   80 Apr 22 05:15 ..
-rw-r--r-- 1 jailuser jailuser 2513 Apr 22 05:15 codeql.yml
-rw-r--r-- 1 jailuser jailuser  830 Apr 22 05:15 hx-test.yml
-rw-r--r-- 1 jailuser jailuser  709 Apr 22 05:15 lint.yml
-rw-r--r-- 1 jailuser jailuser 5568 Apr 22 05:15 release.yml
-rw-r--r-- 1 jailuser jailuser 3172 Apr 22 05:15 scorecard.yml
-rw-r--r-- 1 jailuser jailuser 1250 Apr 22 05:15 test.yml

Script executed:

cat -n .github/workflows/test.yml 2>/dev/null | head -50

Repository: flanksource/commons


Repository: flanksource/commons
Exit code: 0

stdout:

     1	on:
     2	  push:
     3	    branches:
     4	      - master
     5	  pull_request:
     6	    types: [opened, synchronize, reopened]
     7	name: Test
     8	jobs:
     9	  test:
    10	    strategy:
    11	      matrix:
    12	        platform: [ubuntu-latest]
    13	        go-version:
    14	          - 1.25.x
    15	    runs-on: ${{ matrix.platform }}
    16	    permissions:
    17	      contents: read
    18	      pull-requests: write # required when comment=true so the action can post/update the sticky PR comment
    19	    steps:
    20	      - name: Harden the runner (Audit all outbound calls)
    21	        uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
    22	        with:
    23	          egress-policy: audit
    24	
    25	      - name: Install Go
    26	        uses: buildjet/setup-go@555ce355a95ff01018ffcf8fbbd9c44654db8374 # v5.0.2
    27	        with:
    28	          go-version: ${{ matrix.go-version }}
    29	      - name: Checkout code
    30	        uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
    31	
    32	      - uses: flanksource/gavel@main
    33	        with:
    34	          args: test --lint
    35	          version: latest
    36	          json-file: gavel-results.json
    37	          html-file: gavel-results.html
    38	          summary-file: gavel-summary.md
    39	          artifact-name: gavel-results
    40	          comment: "true"
    41	          comment-header: gavel
    42	          fail-on-error: "true"

Script executed:

cd .github/workflows && for f in *.yml; do echo "=== $f ==="; grep -n '@main\|@master\|version:.*latest' "$f" || echo "No mutable refs found"; done

Repository: flanksource/commons


Repository: flanksource/commons
Exit code: 0

stdout:

=== codeql.yml ===
No mutable refs found
=== hx-test.yml ===
No mutable refs found
=== lint.yml ===
No mutable refs found
=== release.yml ===
No mutable refs found
=== scorecard.yml ===
No mutable refs found
=== test.yml ===
32:      - uses: flanksource/gavel@main
35:          version: latest

Pin the Gavel action and tool version before granting write permissions.

This job grants pull-requests: write (line 18) but runs flanksource/gavel@main (line 32) with version: latest (line 35). Pin both to immutable references to prevent supply-chain risk when using a write-scoped token.

🔒 Proposed hardening
-      - uses: flanksource/gavel@main
+      - uses: flanksource/gavel@<commit-sha-or-release-tag>
         with:
           args: test --lint
-          version: latest
+          version: <pinned-version>

Other actions in this workflow are pinned to commit SHAs; apply the same hardening to Gavel.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/test.yml around lines 16 - 18, The workflow currently
grants pull-requests: write while calling flanksource/gavel@main with version:
latest; pin the Gavel action and its tool version to immutable refs (e.g.,
replace flanksource/gavel@main with a specific tag or commit SHA and set
version: to a fixed released semver or digest) so the step that uses the action
and the version field are both immutable; update the action reference and the
version value in the workflow where flanksource/gavel and version: latest are
declared.

steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
Expand All @@ -25,5 +28,15 @@ jobs:
go-version: ${{ matrix.go-version }}
- name: Checkout code
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
- name: Test
run: go test ./...

- uses: flanksource/gavel@main
with:
args: test --lint
version: latest
json-file: gavel-results.json
html-file: gavel-results.html
summary-file: gavel-summary.md
artifact-name: gavel-results
comment: "true"
comment-header: gavel
fail-on-error: "true"
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ y
cliff.toml
.todos/
cmd/hx/fixtures/hx
.ginkgo
.ginkgo/
2 changes: 1 addition & 1 deletion har/pretty.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func headersToDescriptionList(headers []Header) api.DescriptionList {
func (e Entry) Columns() []api.ColumnDef {
return []api.ColumnDef{
api.Column("method").Label("Method").Style("font-bold text-green-500 uppercase").Build(),
api.Column("url").Label("URL").MaxWidth(80).Build(),
api.Column("url").Label("URL").MaxWidth(100).Build(),
api.Column("status").Label("Status").Build(),
api.Column("duration").Label("Duration").Build(),
api.Column("size").Label("Size").Build(),
Expand Down
60 changes: 53 additions & 7 deletions hash/hash.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,22 @@ import (
// hash, err := JSONMD5Hash(config)
// // hash will be consistent for the same config values
func JSONMD5Hash(obj any) (string, error) {
data, err := json.Marshal(obj)
raw, err := jsonMD5Raw(obj)
if err != nil {
return "", err
}
return hex.EncodeToString(raw[:]), nil
}

hash := md5.Sum(data)
// jsonMD5Raw marshals the object into JSON and returns the raw 16-byte MD5
// digest. Internal helper shared by JSONMD5Hash (which hex-encodes it) and
// DeterministicUUID (which uses the raw bytes as UUID bytes).
func jsonMD5Raw(obj any) ([16]byte, error) {
data, err := json.Marshal(obj)
if err != nil {
return "", err
return [16]byte{}, err
}

return hex.EncodeToString(hash[:]), nil
return md5.Sum(data), nil
}

// Sha256Hex computes the SHA256 hash of the input string and returns it
Expand Down Expand Up @@ -113,15 +118,56 @@ func Sha256Hex(in string) string {
// // Generate UUID from string
// id2, err := DeterministicUUID("unique-resource-name")
func DeterministicUUID(seed any) (uuid.UUID, error) {
byteHash, err := JSONMD5Hash(seed)
// If the seed is already a UUID (value, pointer, 16 bytes, or parseable
// string — uuid.Nil included), return it verbatim. Re-hashing a UUID would
// produce a different UUID, defeating the caller's intent.
if id, ok := asUUID(seed); ok {
return id, nil
}

raw, err := jsonMD5Raw(seed)
if err != nil {
return uuid.Nil, err
}

id, err := uuid.FromBytes([]byte(byteHash[0:16]))
// md5.Sum returns exactly 16 bytes, which is the size of a UUID. Use the
// raw digest directly — NOT the hex-encoded representation.
id, err := uuid.FromBytes(raw[:])
if err != nil {
return uuid.Nil, err
}

return id, nil
}

// asUUID reports whether seed is already a UUID in one of its common forms:
// uuid.UUID, *uuid.UUID, [16]byte, []byte of length 16, or a string that
// parses as a UUID. uuid.Nil counts as a valid UUID.
//
// Composite seeds ([]string, pq.StringArray, structs, etc.) are not unwrapped
// — a single-element slice containing a UUID is still a composite and should
// be hashed.
func asUUID(seed any) (uuid.UUID, bool) {
switch v := seed.(type) {
case uuid.UUID:
return v, true
case *uuid.UUID:
if v == nil {
return uuid.Nil, false
}
return *v, true
case [16]byte:
return uuid.UUID(v), true
case []byte:
if len(v) == 16 {
var id uuid.UUID
copy(id[:], v)
return id, true
}
case string:
if id, err := uuid.Parse(v); err == nil {
return id, true
}
Comment on lines +167 to +170
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Expect: existing tests do not cover 32-char compact hex string behavior yet.
rg -n -C3 --glob '**/*_test.go' \
  '32|compact|[[:xdigit:]]{32}|NonUUIDStringStillHashes|DeterministicUUID' hash

Repository: flanksource/commons

Length of output: 6466


🏁 Script executed:

cat -n hash/hash.go | sed -n '150,180p'

Repository: flanksource/commons

Length of output: 664


🏁 Script executed:

rg -n 'uuid.Parse' hash/hash.go -A 2 -B 2

Repository: flanksource/commons

Length of output: 167


🏁 Script executed:

rg -n 'compact\|32.*hex\|[a-f0-9]{32}' hash/hash_test.go

Repository: flanksource/commons

Length of output: 45


🏁 Script executed:

cat > /tmp/test_uuid.go << 'EOF'
package main

import (
	"fmt"
	"github.com/google/uuid"
)

func main() {
	// Test with canonical UUID format
	canonical := "550e8400-e29b-41d4-a716-446655440000"
	id1, err := uuid.Parse(canonical)
	fmt.Printf("Canonical UUID parse: err=%v, String()=%s\n", err, id1.String())
	fmt.Printf("Canonical == input: %v\n", id1.String() == canonical)

	// Test with 32-char compact hex (no hyphens)
	compact := "550e8400e29b41d4a716446655440000"
	id2, err := uuid.Parse(compact)
	fmt.Printf("\nCompact hex parse: err=%v, String()=%s\n", err, id2.String())
	fmt.Printf("Compact == input: %v\n", id2.String() == compact)
	
	// Test with a valid MD5-like hex string
	md5Like := "5d41402abc4b2a76b9719d911017c592"
	id3, err := uuid.Parse(md5Like)
	fmt.Printf("\nMD5-like hex parse: err=%v, String()=%s\n", err, id3.String())
	fmt.Printf("MD5-like == input: %v\n", id3.String() == md5Like)

	// Show that both canonical and compact parse to same UUID
	fmt.Printf("\nCanonical UUID == Compact UUID: %v\n", id1 == id2)
}
EOF
cd /tmp && go run test_uuid.go

Repository: flanksource/commons

Length of output: 230


🏁 Script executed:

cat > /tmp/test_uuid.go << 'EOF'
package main

import (
	"fmt"
	"github.com/google/uuid"
)

func main() {
	// Test with canonical UUID format
	canonical := "550e8400-e29b-41d4-a716-446655440000"
	id1, err := uuid.Parse(canonical)
	fmt.Printf("Canonical UUID parse: err=%v, String()=%s\n", err, id1.String())
	fmt.Printf("Canonical == input: %v\n", id1.String() == canonical)

	// Test with 32-char compact hex (no hyphens)
	compact := "550e8400e29b41d4a716446655440000"
	id2, err := uuid.Parse(compact)
	fmt.Printf("\nCompact hex parse: err=%v, String()=%s\n", err, id2.String())
	fmt.Printf("Compact == input: %v\n", id2.String() == compact)
	
	// Test with a valid MD5-like hex string
	md5Like := "5d41402abc4b2a76b9719d911017c592"
	id3, err := uuid.Parse(md5Like)
	fmt.Printf("\nMD5-like hex parse: err=%v, String()=%s\n", err, id3.String())
	fmt.Printf("MD5-like == input: %v\n", id3.String() == md5Like)

	// Show that both canonical and compact parse to same UUID
	fmt.Printf("\nCanonical UUID == Compact UUID: %v\n", id1 == id2)
	fmt.Printf("Canonical String() == Compact String(): %v\n", id1.String() == id2.String())
}
EOF
cd hash && go run /tmp/test_uuid.go

Repository: flanksource/commons

Length of output: 516


🏁 Script executed:

cat > /tmp/test_impact.go << 'EOF'
package main

import (
	"fmt"
	"github.com/google/uuid"
	"strings"
)

func testAsUUID(seed any, withFix bool) (uuid.UUID, bool) {
	switch v := seed.(type) {
	case string:
		if id, err := uuid.Parse(v); err == nil {
			// Current code (no fix)
			if !withFix {
				return id, true
			}
			// Proposed fix
			if strings.EqualFold(id.String(), v) {
				return id, true
			}
		}
	}
	return uuid.Nil, false
}

func main() {
	// Test with 32-char MD5-like hex (should NOT be treated as UUID)
	md5Like := "5d41402abc4b2a76b9719d911017c592"
	
	// Current behavior (without fix)
	id, ok := testAsUUID(md5Like, false)
	fmt.Printf("Current code with MD5-like hex: ok=%v, id=%s\n", ok, id)
	
	// With proposed fix
	id, ok = testAsUUID(md5Like, true)
	fmt.Printf("Proposed fix with MD5-like hex: ok=%v, id=%s\n", ok, id)
	
	// Test with canonical UUID (should still pass through)
	canonical := "550e8400-e29b-41d4-a716-446655440000"
	
	// Current behavior
	id, ok = testAsUUID(canonical, false)
	fmt.Printf("Current code with canonical UUID: ok=%v, id=%s\n", ok, id)
	
	// With proposed fix
	id, ok = testAsUUID(canonical, true)
	fmt.Printf("Proposed fix with canonical UUID: ok=%v, id=%s\n", ok, id)
	
	// Test with compact hex UUID (should NOT pass through without hyphens)
	compact := "550e8400e29b41d4a716446655440000"
	
	// Current behavior
	id, ok = testAsUUID(compact, false)
	fmt.Printf("Current code with compact hex: ok=%v, id=%s\n", ok, id)
	
	// With proposed fix
	id, ok = testAsUUID(compact, true)
	fmt.Printf("Proposed fix with compact hex: ok=%v, id=%s\n", ok, id)
}
EOF
cd hash && go run /tmp/test_impact.go

Repository: flanksource/commons

Length of output: 552


🏁 Script executed:

# Check if there are any tests that would fail if we reject compact hex
rg -n 'compact\|32.*hex\|without.*hyphen' hash/hash_test.go

Repository: flanksource/commons

Length of output: 45


🏁 Script executed:

# Check if DeterministicUUID currently has a test with MD5-like hex string
rg -n '5d41402abc4b2a76b9719d911017c592|[a-f0-9]{32}' hash/hash_test.go

Repository: flanksource/commons

Length of output: 45


🏁 Script executed:

# Let's see the full context of the TestDeterministicUUID_NonUUIDStringStillHashes test
sed -n '173,184p' hash/hash_test.go

Repository: flanksource/commons

Length of output: 412


Add EqualFold validation to prevent 32-character hex strings from bypassing hashing.

uuid.Parse accepts raw 32-character hex strings without hyphens, causing MD5 hashes and other 32-char hex inputs to be treated as UUID passthroughs instead of being hashed. This allows non-UUID seed data to incorrectly bypass the hashing logic.

Restrict the string-to-UUID passthrough to canonical UUID format by validating that the parsed UUID's string representation matches the input:

Proposed fix
 import (
 	"crypto/md5"
 	"crypto/sha256"
 	"encoding/hex"
 	"encoding/json"
+	"strings"
 
 	"github.com/google/uuid"
 )
 	case string:
-		if id, err := uuid.Parse(v); err == nil {
+		if id, err := uuid.Parse(v); err == nil && strings.EqualFold(id.String(), v) {
 			return id, true
 		}

Add a test case for compact hex strings to prevent regression:

func TestDeterministicUUID_CompactHexStringStillHashes(t *testing.T) {
	// Ensure 32-char hex strings (e.g., MD5 hashes) without hyphens are hashed, not treated as UUIDs
	compactHex := "5d41402abc4b2a76b9719d911017c592"
	got, err := DeterministicUUID(compactHex)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	
	data, _ := json.Marshal(compactHex)
	sum := md5.Sum(data)
	if !bytes.Equal(got[:], sum[:]) {
		t.Fatalf("compact hex strings must hash; want %x, got %x", sum, got[:])
	}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@hash/hash.go` around lines 167 - 170, The string branch currently treats any
32-char hex string as a UUID because uuid.Parse accepts compact hex; update the
passthrough in DeterministicUUID (hash.go) to only return the parsed uuid when
the parsed UUID's canonical string equals the input (use case-insensitive
comparison, e.g., strings.EqualFold(id.String(), v)), otherwise fall through to
hashing; also add the provided TestDeterministicUUID_CompactHexStringStillHashes
unit test to ensure compact 32-char hex strings are hashed, not passed through.

}
return uuid.Nil, false
}
198 changes: 198 additions & 0 deletions hash/hash_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package hash

import (
"bytes"
"crypto/md5"
"encoding/hex"
"encoding/json"
"testing"

"github.com/google/uuid"
)

func TestJSONMD5Hash_ReturnsHexEncoded(t *testing.T) {
// Hex encoding of an MD5 digest is always 32 characters.
h, err := JSONMD5Hash("hello")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(h) != 32 {
t.Fatalf("expected 32-char hex hash, got %d chars: %q", len(h), h)
}
if _, err := hex.DecodeString(h); err != nil {
t.Fatalf("hash is not valid hex: %v", err)
}
}

func TestJSONMD5Hash_Deterministic(t *testing.T) {
a, _ := JSONMD5Hash(map[string]string{"k": "v"})
b, _ := JSONMD5Hash(map[string]string{"k": "v"})
if a != b {
t.Fatalf("expected deterministic hash, got %q vs %q", a, b)
}
Comment on lines +27 to +32
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Check errors in determinism tests before comparing zero values.

These tests can pass falsely if both calls return an error and the zero value. The other tests already fail fast on unexpected errors; do the same here.

✅ Proposed test hardening
 func TestJSONMD5Hash_Deterministic(t *testing.T) {
-	a, _ := JSONMD5Hash(map[string]string{"k": "v"})
-	b, _ := JSONMD5Hash(map[string]string{"k": "v"})
+	a, err := JSONMD5Hash(map[string]string{"k": "v"})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	b, err := JSONMD5Hash(map[string]string{"k": "v"})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
 	if a != b {
 		t.Fatalf("expected deterministic hash, got %q vs %q", a, b)
 	}
 }
 func TestDeterministicUUID_Deterministic(t *testing.T) {
-	a, _ := DeterministicUUID("same-seed")
-	b, _ := DeterministicUUID("same-seed")
+	a, err := DeterministicUUID("same-seed")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	b, err := DeterministicUUID("same-seed")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
 	if a != b {
 		t.Fatalf("expected deterministic UUID, got %q vs %q", a, b)
 	}
 }
 
 func TestDeterministicUUID_DifferentSeedsProduceDifferentUUIDs(t *testing.T) {
-	a, _ := DeterministicUUID("seed-one")
-	b, _ := DeterministicUUID("seed-two")
+	a, err := DeterministicUUID("seed-one")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	b, err := DeterministicUUID("seed-two")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
 	if a == b {
 		t.Fatalf("expected distinct UUIDs for distinct seeds, got %q twice", a)
 	}
 }

Also applies to: 67-80

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@hash/hash_test.go` around lines 27 - 32, The determinism test
TestJSONMD5Hash_Deterministic (and the similar test around lines 67-80)
currently compares returned hashes without checking errors, which can mask
failures when both calls return an error and zero values; update these tests to
check the returned error from JSONMD5Hash and t.Fatalf on non-nil errors before
comparing a and b, i.e., fail fast if err != nil for either call (reference
JSONMD5Hash, TestJSONMD5Hash_Deterministic and the other affected test) so the
comparison only runs when both calls succeeded.

}

func TestDeterministicUUID_UsesRawBytes(t *testing.T) {
// Regression for a bug where DeterministicUUID fed the *hex-encoded*
// JSONMD5Hash string into uuid.FromBytes, producing UUIDs whose bytes
// were the ASCII codes of hex digits (e.g. 30663964-3638-3061-... where
// 0x30='0', 0x66='f', 0x39='9', 0x64='d'). The correct behavior is to
// use the raw 16-byte md5 digest as the UUID bytes.

seed := "test-seed"
got, err := DeterministicUUID(seed)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

data, _ := json.Marshal(seed)
sum := md5.Sum(data)
// The UUID bytes must equal the raw md5 bytes.
for i := 0; i < 16; i++ {
if got[i] != sum[i] {
t.Fatalf("UUID byte %d: want %#x, got %#x", i, sum[i], got[i])
}
}

// Also assert the UUID is NOT the ASCII-hex-encoded variant of the hex
// representation of the md5, which is what the old buggy code produced.
hexStr := hex.EncodeToString(sum[:])
var bogus [16]byte
copy(bogus[:], hexStr[:16])
if got == bogus {
t.Fatal("DeterministicUUID regressed: still uses ASCII hex bytes as UUID bytes")
}
}

func TestDeterministicUUID_Deterministic(t *testing.T) {
a, _ := DeterministicUUID("same-seed")
b, _ := DeterministicUUID("same-seed")
if a != b {
t.Fatalf("expected deterministic UUID, got %q vs %q", a, b)
}
}

func TestDeterministicUUID_DifferentSeedsProduceDifferentUUIDs(t *testing.T) {
a, _ := DeterministicUUID("seed-one")
b, _ := DeterministicUUID("seed-two")
if a == b {
t.Fatalf("expected distinct UUIDs for distinct seeds, got %q twice", a)
}
}

const passthroughUUIDStr = "550e8400-e29b-41d4-a716-446655440000"

func TestDeterministicUUID_PassesThroughValidUUIDString(t *testing.T) {
got, err := DeterministicUUID(passthroughUUIDStr)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.String() != passthroughUUIDStr {
t.Fatalf("expected passthrough %q, got %q", passthroughUUIDStr, got.String())
}
}

func TestDeterministicUUID_PassesThroughUUIDValue(t *testing.T) {
in := uuid.MustParse(passthroughUUIDStr)
got, err := DeterministicUUID(in)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != in {
t.Fatalf("expected passthrough %q, got %q", in, got)
}
}

func TestDeterministicUUID_PassesThroughUUIDPointer(t *testing.T) {
in := uuid.MustParse(passthroughUUIDStr)
got, err := DeterministicUUID(&in)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != in {
t.Fatalf("expected passthrough %q, got %q", in, got)
}
}

func TestDeterministicUUID_PassesThroughUUIDBytes(t *testing.T) {
in := uuid.MustParse(passthroughUUIDStr)

gotArr, err := DeterministicUUID([16]byte(in))
if err != nil {
t.Fatalf("unexpected error ([16]byte): %v", err)
}
if gotArr != in {
t.Fatalf("[16]byte passthrough: want %q, got %q", in, gotArr)
}

gotSlice, err := DeterministicUUID(in[:])
if err != nil {
t.Fatalf("unexpected error ([]byte): %v", err)
}
if gotSlice != in {
t.Fatalf("[]byte passthrough: want %q, got %q", in, gotSlice)
}
}

func TestDeterministicUUID_PassesThroughNilUUID(t *testing.T) {
const nilStr = "00000000-0000-0000-0000-000000000000"

cases := []struct {
name string
in any
}{
{"uuid.Nil value", uuid.Nil},
{"nil string", nilStr},
{"zero [16]byte", [16]byte{}},
}
for _, tc := range cases {
got, err := DeterministicUUID(tc.in)
if err != nil {
t.Fatalf("%s: unexpected error: %v", tc.name, err)
}
if got != uuid.Nil {
t.Fatalf("%s: expected uuid.Nil passthrough, got %q", tc.name, got)
}
}
}

func TestDeterministicUUID_SingleElementSliceIsHashed(t *testing.T) {
in := []string{passthroughUUIDStr}
got, err := DeterministicUUID(in)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.String() == passthroughUUIDStr {
t.Fatal("single-element slice must be treated as a composite and hashed, not unwrapped")
}
if got == uuid.Nil {
t.Fatal("hashed composite should not be uuid.Nil")
}
}

func TestDeterministicUUID_NonUUIDStringStillHashes(t *testing.T) {
got, err := DeterministicUUID("test-seed")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

data, _ := json.Marshal("test-seed")
sum := md5.Sum(data)
if !bytes.Equal(got[:], sum[:]) {
t.Fatalf("non-UUID strings must still hash; want %x, got %x", sum, got[:])
}
}

func TestDeterministicUUID_ShortByteSliceStillHashes(t *testing.T) {
in := []byte{1, 2, 3}
got, err := DeterministicUUID(in)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

data, _ := json.Marshal(in)
sum := md5.Sum(data)
if !bytes.Equal(got[:], sum[:]) {
t.Fatalf("len != 16 []byte must hash via JSON-MD5; want %x, got %x", sum, got[:])
}
}
Loading