diff --git a/verifier_tools/verify/README.md b/verifier_tools/verify/README.md index 7edd594..3937a24 100644 --- a/verifier_tools/verify/README.md +++ b/verifier_tools/verify/README.md @@ -29,7 +29,9 @@ The verifier uses the associated checkpoint (depending on the target log) and th * `https://developers.google.com/android/binary_transparency/google1p/tile/` (old log) * `https://gstatic.com/android/binary_transparency/google1p/jwt/2026/01/tile/` (latest sharded log) * Google Product Application Transparency Log - * `https://gstatic.com/android/binary_transparency/google1p/apk/2026/01/tile/` + * `https://gstatic.com/android/binary_transparency/google1p/apk/2026/01/tile/` (old log) + * `https://gstatic.com/android/binary_transparency/google1p/apk/2026/02/tile/` (latest Tessera-backed sharded log) + * `https://gstatic.com/android/binary_transparency/google1p/apk/2026/02/tile/entries/` (for data leaves) To run the verifier after you have built it in the previous section: ``` diff --git a/verifier_tools/verify/cmd/verifier/verifier.go b/verifier_tools/verify/cmd/verifier/verifier.go index dbda76a..0755fe7 100644 --- a/verifier_tools/verify/cmd/verifier/verifier.go +++ b/verifier_tools/verify/cmd/verifier/verifier.go @@ -24,10 +24,10 @@ import ( "flag" "log/slog" "os" - "path/filepath" "github.com/android/android-binary-transparency/verifier_tools/verify/internal/checkpoint" "github.com/android/android-binary-transparency/verifier_tools/verify/internal/tiles" + "golang.org/x/mod/sumdb/note" "golang.org/x/mod/sumdb/tlog" _ "embed" @@ -43,7 +43,9 @@ const ( KeyNameForVerifierMainlineModule = "gstatic.com/android/binary_transparency/mainline/modules/2026/0" LogBaseURLPixel = "https://developers.google.com/android/binary_transparency" LogBaseURLG1PJWT = "https://developers.google.com/android/binary_transparency/google1p" - LogBaseURLG1PAPK = "https://www.gstatic.com/android/binary_transparency/google1p/apk/2026/01" + LogBaseURLG1PAPK202601 = "https://www.gstatic.com/android/binary_transparency/google1p/apk/2026/01" + LogBaseURLG1PAPK202602 = "https://www.gstatic.com/android/binary_transparency/google1p/apk/2026/02" + NoteVerifierG1PAPK202602 = "android.transparency.goog/google1p/apk/2026/1+fc654374+ATr9NQE0gvOtVfj5cCStUzdlflEp3oZoNHD8pImzPj5O" LogBaseURLMainlineModule = "https://www.gstatic.com/android/binary_transparency/mainline/2026/01" ImageInfoFilename = "image_info.txt" PackageInfoFilename = "package_info.txt" @@ -75,6 +77,16 @@ var ( logType = flag.String("log_type", "", "Which log: 'pixel' or 'google_1p_code' or 'google_1p_apk' or 'mainline_module'.") ) +type logTarget struct { + name string + baseURL string + checkpointPath string + verifier note.Verifier + tileHeight int + isTessera bool + binaryInfoFilename string +} + func main() { flag.Parse() @@ -94,89 +106,163 @@ func main() { slog.Info("Reformatted payload content", "from", b, "to", payloadBytes) } - var logPubKey []byte - var logBaseURL string - var keyNameForVerifier string - var binaryInfoFilename string - var tileHeight int + var targets []logTarget switch *logType { case "": slog.Error("must specify which log to verify against using '--log_type' flag: {pixel, google_1p_code, google_1p_apk, mainline_module}") os.Exit(1) case "pixel": - logPubKey = pixelLogPubKey - logBaseURL = LogBaseURLPixel - keyNameForVerifier = KeyNameForVerifierPixel - binaryInfoFilename = ImageInfoFilename - tileHeight = 1 + v, err := checkpoint.NewVerifier(pixelLogPubKey, KeyNameForVerifierPixel) + if err != nil { + slog.Error("error creating verifier", "log", "pixel", "error", err) + os.Exit(1) + } + targets = append(targets, logTarget{ + name: "pixel", + baseURL: LogBaseURLPixel, + checkpointPath: "checkpoint.txt", + verifier: v, + tileHeight: 1, + isTessera: false, + binaryInfoFilename: ImageInfoFilename, + }) case "google_1p_code": - logPubKey = googleSystemAppLogPubKey - logBaseURL = LogBaseURLG1PJWT - keyNameForVerifier = KeyNameForVerifierG1PJWT - binaryInfoFilename = PackageInfoFilename - tileHeight = 1 + v, err := checkpoint.NewVerifier(googleSystemAppLogPubKey, KeyNameForVerifierG1PJWT) + if err != nil { + slog.Error("error creating verifier", "log", "google_1p_code", "error", err) + os.Exit(1) + } + targets = append(targets, logTarget{ + name: "google_1p_code", + baseURL: LogBaseURLG1PJWT, + checkpointPath: "checkpoint.txt", + verifier: v, + tileHeight: 1, + isTessera: false, + binaryInfoFilename: PackageInfoFilename, + }) case "google_1p_apk": - logPubKey = googleAPKLogPubKey - logBaseURL = LogBaseURLG1PAPK - keyNameForVerifier = KeyNameForVerifierG1PAPK - binaryInfoFilename = PackageInfoFilename - tileHeight = 8 + // Shard 2026/02: Tessera log + v2, err := note.NewVerifier(NoteVerifierG1PAPK202602) + if err != nil { + slog.Error("error creating verifier for 2026/02 Tessera log", "error", err) + os.Exit(1) + } + targets = append(targets, logTarget{ + name: "google_1p_apk (2026/02 Tessera)", + baseURL: LogBaseURLG1PAPK202602, + checkpointPath: "checkpoint", + verifier: v2, + tileHeight: 8, + isTessera: true, + }) + + // Shard 2026/01: Legacy log continuation fallback + v1, err := checkpoint.NewVerifier(googleAPKLogPubKey, KeyNameForVerifierG1PAPK) + if err != nil { + slog.Error("error creating verifier for 2026/01 log", "error", err) + os.Exit(1) + } + targets = append(targets, logTarget{ + name: "google_1p_apk (2026/01)", + baseURL: LogBaseURLG1PAPK202601, + checkpointPath: "checkpoint.txt", + verifier: v1, + tileHeight: 8, + isTessera: false, + binaryInfoFilename: PackageInfoFilename, + }) case "mainline_module": - logPubKey = mainlineModuleLogPubKey - logBaseURL = LogBaseURLMainlineModule - keyNameForVerifier = KeyNameForVerifierMainlineModule - binaryInfoFilename = ModuleInfoFilename - tileHeight = 8 + v, err := checkpoint.NewVerifier(mainlineModuleLogPubKey, KeyNameForVerifierMainlineModule) + if err != nil { + slog.Error("error creating verifier", "log", "mainline_module", "error", err) + os.Exit(1) + } + targets = append(targets, logTarget{ + name: "mainline_module", + baseURL: LogBaseURLMainlineModule, + checkpointPath: "checkpoint.txt", + verifier: v, + tileHeight: 8, + isTessera: false, + binaryInfoFilename: ModuleInfoFilename, + }) default: slog.Error("unsupported log type") os.Exit(1) } - v, err := checkpoint.NewVerifier(logPubKey, keyNameForVerifier) - if err != nil { - slog.Error("error creating verifier", "error", err) - os.Exit(1) - } - root, err := checkpoint.FromURL(logBaseURL, v) - if err != nil { - slog.Error("error reading checkpoint", "log", logBaseURL, "error", err) - os.Exit(1) - } + var verified bool + for _, target := range targets { + slog.Info("Checking log", "log", target.name, "url", target.baseURL) + root, err := checkpoint.FromURLWithPath(target.baseURL, target.checkpointPath, target.verifier) + if err != nil { + slog.Warn("Failed to read checkpoint", "log", target.name, "error", err) + continue + } - logSize := int64(root.Size) + logSize := int64(root.Size) + var binaryInfoIndex int64 + var found bool - m, err := tiles.BinaryInfosIndex(logBaseURL, binaryInfoFilename, logSize) - if err != nil { - slog.Error("failed to load binary info map to find log index", "error", err) - os.Exit(1) - } - binaryInfoIndex, ok := m[string(payloadBytes)] - if !ok { - slog.Error("failed to find payload", "payload", string(payloadBytes), "file", filepath.Join(logBaseURL, binaryInfoFilename)) - os.Exit(1) - } + if target.isTessera { + idx, ok, err := tiles.TesseraFindPayloadIndex(target.baseURL, logSize, payloadBytes) + if err != nil { + slog.Warn("Failed to search Tessera entry tiles", "log", target.name, "error", err) + continue + } + binaryInfoIndex = idx + found = ok + } else { + m, err := tiles.BinaryInfosIndex(target.baseURL, target.binaryInfoFilename, logSize) + if err != nil { + slog.Warn("Failed to load binary info map", "log", target.name, "error", err) + continue + } + idx, ok := m[string(payloadBytes)] + binaryInfoIndex = idx + found = ok + } - var th tlog.Hash - copy(th[:], root.Hash) + if !found { + slog.Info("Payload not found in log", "log", target.name) + continue + } - r := tiles.HashReader{URL: logBaseURL, TileHeight: tileHeight, TreeSize: logSize} - slog.Debug("tlog.ProveRecord", "logSize", logSize, "binaryInfoIndex", binaryInfoIndex) - rp, err := tlog.ProveRecord(logSize, binaryInfoIndex, r) - if err != nil { - slog.Error("error in tlog.ProveRecord", "error", err) - os.Exit(1) - } + var th tlog.Hash + copy(th[:], root.Hash) - leafHash, err := tiles.PayloadHash(payloadBytes) - if err != nil { - slog.Error("error hashing payload", "error", err) - os.Exit(1) + r := tiles.HashReader{ + URL: target.baseURL, + TileHeight: target.tileHeight, + TreeSize: logSize, + IsTessera: target.isTessera, + } + slog.Debug("tlog.ProveRecord", "log", target.name, "logSize", logSize, "binaryInfoIndex", binaryInfoIndex) + rp, err := tlog.ProveRecord(logSize, binaryInfoIndex, r) + if err != nil { + slog.Error("error in tlog.ProveRecord", "log", target.name, "error", err) + os.Exit(1) + } + + leafHash, err := tiles.PayloadHash(payloadBytes) + if err != nil { + slog.Error("error hashing payload", "error", err) + os.Exit(1) + } + + if err := tlog.CheckRecord(rp, logSize, th, binaryInfoIndex, leafHash); err != nil { + slog.Error("FAILURE: inclusion check error in tlog.CheckRecord", "log", target.name, "error", err) + os.Exit(1) + } + + slog.Info("OK. inclusion check success!", "log", target.name) + verified = true + break } - if err := tlog.CheckRecord(rp, logSize, th, binaryInfoIndex, leafHash); err != nil { - slog.Error("FAILURE: inclusion check error in tlog.CheckRecord", "error", err) + if !verified { + slog.Error("FAILURE: payload not verified in any log") os.Exit(1) - } else { - slog.Info("OK. inclusion check success!") } } diff --git a/verifier_tools/verify/internal/checkpoint/checkpoint.go b/verifier_tools/verify/internal/checkpoint/checkpoint.go index 6090b66..0fe89af 100644 --- a/verifier_tools/verify/internal/checkpoint/checkpoint.go +++ b/verifier_tools/verify/internal/checkpoint/checkpoint.go @@ -43,6 +43,8 @@ const ( originIDG1P = "developers.google.com/android/binary_transparency/google1p/0\n" // originIDG1PAPK identifies a checkpoint for the Google 1P APK Transparency Log. originIDG1PAPK = "gstatic.com/android/binary_transparency/google1p/apk/2026/0\n" + // originIDG1PAPKTessera identifies a checkpoint for the Google 1P APK Transparency Log (Tessera shard). + originIDG1PAPKTessera = "android.transparency.goog/google1p/apk/2026/1\n" // originIDMainlineModule identifies a checkpoint for the Android Mainline Module Transparency Log. originIDMainlineModule = "gstatic.com/android/binary_transparency/mainline/modules/2026/0\n" ) @@ -121,36 +123,47 @@ func parseCheckpoint(ckpt string) (Root, error) { body = ckpt[len(originIDG1P):] case strings.HasPrefix(ckpt, originIDG1PAPK): body = ckpt[len(originIDG1PAPK):] + case strings.HasPrefix(ckpt, originIDG1PAPKTessera): + body = ckpt[len(originIDG1PAPKTessera):] case strings.HasPrefix(ckpt, originIDMainlineModule): body = ckpt[len(originIDMainlineModule):] default: - return Root{}, fmt.Errorf("invalid checkpoint - unknown origin, must be either %s, %s, or %s", originIDPixel, originIDG1P, originIDG1PAPK) + return Root{}, fmt.Errorf("invalid checkpoint - unknown origin, must be either %s, %s, %s, %s, or %s", + strings.TrimSpace(originIDPixel), + strings.TrimSpace(originIDG1P), + strings.TrimSpace(originIDG1PAPK), + strings.TrimSpace(originIDG1PAPKTessera), + strings.TrimSpace(originIDMainlineModule)) } - // body must contain exactly 2 lines, size and the root hash. - l := strings.SplitN(body, "\n", 3) - if len(l) != 3 || len(l[2]) != 0 { + if !strings.HasSuffix(ckpt, "\n") { + return Root{}, fmt.Errorf("invalid checkpoint - bad format: must terminate with newline") + } + + // body must contain at least 2 lines: size and root hash. + lines := strings.Split(body, "\n") + if len(lines) < 3 || lines[0] == "" || lines[1] == "" { return Root{}, fmt.Errorf("invalid checkpoint - bad format: must have origin id, size and root hash each followed by newline") } - size, err := strconv.ParseUint(l[0], 10, 64) + size, err := strconv.ParseUint(lines[0], 10, 64) if err != nil { return Root{}, fmt.Errorf("invalid checkpoint - cannot read size: %w", err) } - rh, err := base64.StdEncoding.DecodeString(l[1]) + rh, err := base64.StdEncoding.DecodeString(lines[1]) if err != nil { return Root{}, fmt.Errorf("invalid checkpoint - invalid roothash: %w", err) } return Root{Size: size, Hash: rh}, nil } -func getSignedCheckpoint(logURL string) ([]byte, error) { +func getSignedCheckpoint(logURL, checkpointPath string) ([]byte, error) { // Sanity check the input url. u, err := url.Parse(logURL) if err != nil { return []byte{}, fmt.Errorf("invalid URL %s: %v", u, err) } - u.Path = path.Join(u.Path, "checkpoint.txt") + u.Path = path.Join(u.Path, checkpointPath) resp, err := http.Get(u.String()) if err != nil { @@ -164,27 +177,30 @@ func getSignedCheckpoint(logURL string) ([]byte, error) { return io.ReadAll(resp.Body) } +// FromURLWithPath verifies the signature and unpacks and returns a Root from the given checkpoint path. +func FromURLWithPath(logURL, checkpointPath string, v note.Verifier) (Root, error) { + b, err := getSignedCheckpoint(logURL, checkpointPath) + if err != nil { + return Root{}, fmt.Errorf("failed to get signed checkpoint: %v", err) + } + + n, err := note.Open(b, note.VerifierList(v)) + if err != nil { + return Root{}, fmt.Errorf("failed to verify note signatures: %v", err) + } + return parseCheckpoint(n.Text) +} + // FromURL verifies the signature and unpacks and returns a Root. // // Validates signature before reading data, using a provided verifier. // Data at `logURL` is the checkpoint and must be in the note format // (golang.org/x/mod/sumdb/note). // -// The checkpoint must be for the Pixel Binary Transparency Log origin. -// // Returns error if the signature fails to verify or if the checkpoint // does not conform to the following format: // // []byte("[origin]\n[size]\n[hash]"). -func FromURL(logURL string, v verifier) (Root, error) { - b, err := getSignedCheckpoint(logURL) - if err != nil { - return Root{}, fmt.Errorf("failed to get signed checkpoint: %v", err) - } - - n, err := note.Open(b, note.VerifierList(v)) - if err != nil { - return Root{}, fmt.Errorf("failed to verify note signatures: %v", err) - } - return parseCheckpoint(n.Text) +func FromURL(logURL string, v note.Verifier) (Root, error) { + return FromURLWithPath(logURL, "checkpoint.txt", v) } diff --git a/verifier_tools/verify/internal/checkpoint/checkpoint_test.go b/verifier_tools/verify/internal/checkpoint/checkpoint_test.go index 8f6ec4b..6c82416 100644 --- a/verifier_tools/verify/internal/checkpoint/checkpoint_test.go +++ b/verifier_tools/verify/internal/checkpoint/checkpoint_test.go @@ -12,7 +12,9 @@ import ( func TestInvalidCheckpointFormat(t *testing.T) { tests := []struct { - desc string + desc string + // m represents the checkpoint message text (n.Text). A valid signed checkpoint body + // contains 3 newline-separated lines: \n\n\n. m string wantErr bool }{ @@ -106,7 +108,7 @@ func TestGetSignedCheckpoint(t *testing.T) { } { t.Run(tt.desc, func(t *testing.T) { u.Path = path.Join(u.Path, tt.path) - b, gotErr := getSignedCheckpoint(u.String()) + b, gotErr := getSignedCheckpoint(u.String(), "checkpoint.txt") got := string(b) if diff := cmp.Diff(got, tt.want); diff != "" { t.Errorf("bad response body: got %v, want %v", got, tt.want) @@ -117,3 +119,36 @@ func TestGetSignedCheckpoint(t *testing.T) { }) } } + +func TestValidCheckpointFormat(t *testing.T) { + tests := []struct { + desc string + // m represents the checkpoint message text (n.Text). A valid signed checkpoint body + // contains 3 newline-separated lines: \n\n\n. + m string + wantSize uint64 + }{ + { + desc: "pixel origin", + m: "developers.google.com/android/binary_transparency/0\n10\ndGhlIHZpZXcgZnJvbSB0aGUgdHJlZSB0b3BzIGlzIGdyZWF0IQ==\n", + wantSize: 10, + }, + { + desc: "google 1p apk tessera origin", + m: "android.transparency.goog/google1p/apk/2026/1\n42\ndGhlIHZpZXcgZnJvbSB0aGUgdHJlZSB0b3BzIGlzIGdyZWF0IQ==\n", + wantSize: 42, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + root, err := parseCheckpoint(tt.m) + if err != nil { + t.Fatalf("parseCheckpoint(%q) returned error: %v", tt.m, err) + } + if root.Size != tt.wantSize { + t.Errorf("got size %d, want %d", root.Size, tt.wantSize) + } + }) + } +} diff --git a/verifier_tools/verify/internal/tiles/reader.go b/verifier_tools/verify/internal/tiles/reader.go index 8ab4ec1..6251081 100644 --- a/verifier_tools/verify/internal/tiles/reader.go +++ b/verifier_tools/verify/internal/tiles/reader.go @@ -2,7 +2,9 @@ package tiles import ( + "bytes" "crypto/sha256" + "encoding/binary" "fmt" "io" "log/slog" @@ -24,6 +26,7 @@ type HashReader struct { URL string TileHeight int TreeSize int64 + IsTessera bool } // Domain separation prefix for Merkle tree hashing with second preimage @@ -74,6 +77,10 @@ func (h HashReader) ReadHashes(indices []int64) ([]tlog.Hash, error) { } pathForLookup := tile.Path() + if h.IsTessera { + // Tessera / c2sp tile path format is tile//[.p/], omitting the height . + pathForLookup = "tile/" + strings.TrimPrefix(pathForLookup, fmt.Sprintf("tile/%d/", h.TileHeight)) + } content, exists := tiles[pathForLookup] var err error @@ -268,3 +275,147 @@ func PayloadHash(p []byte) (tlog.Hash, error) { copy(hash[:], h[:]) return hash, nil } + +// EntryTilePath returns the relative path for an entry tile given its index N and width W. +func EntryTilePath(tileN int64, w int) string { + t := tlog.Tile{H: 8, L: 0, N: tileN, W: w} + p := t.Path() + return "tile/entries/" + strings.TrimPrefix(p, "tile/8/0/") +} + +// ParseEntryBundle parses an entry bundle encoded according to the tlog-tiles spec: +// a sequence of 2-byte big-endian length-prefixed entries. +func ParseEntryBundle(data []byte) ([][]byte, error) { + var entries [][]byte + r := bytes.NewReader(data) + for r.Len() > 0 { + var length uint16 + if err := binary.Read(r, binary.BigEndian, &length); err != nil { + return nil, fmt.Errorf("failed to read entry length: %w", err) + } + if int(length) > r.Len() { + return nil, fmt.Errorf("entry length %d exceeds remaining data length %d", length, r.Len()) + } + entry := make([]byte, length) + if _, err := io.ReadFull(r, entry); err != nil { + return nil, fmt.Errorf("failed to read entry data: %w", err) + } + entries = append(entries, entry) + } + return entries, nil +} + +func readCachedEntryTile(logBaseURL string, tileN int64, w int) ([]byte, error) { + entryPath := EntryTilePath(tileN, w) + + cacheDir, err := os.UserCacheDir() + if err != nil { + slog.Warn("Failed to get user cache dir, falling back to direct download", "error", err) + b, err := readFromURL(logBaseURL, entryPath) + if err != nil { + // Fallback to tiles/entries/ if tile/entries/ fails + altPath := "tiles/entries/" + strings.TrimPrefix(entryPath, "tile/entries/") + return readFromURL(logBaseURL, altPath) + } + return b, nil + } + + abtCacheDir := filepath.Join(cacheDir, "android-binary-transparency") + if err := os.MkdirAll(abtCacheDir, 0755); err != nil { + slog.Warn("Failed to create cache dir, falling back to direct download", "error", err) + b, err := readFromURL(logBaseURL, entryPath) + if err != nil { + altPath := "tiles/entries/" + strings.TrimPrefix(entryPath, "tile/entries/") + return readFromURL(logBaseURL, altPath) + } + return b, nil + } + + urlHash := sha256.Sum256([]byte(logBaseURL)) + // TODO: Consider implementing cache cleanup / TTL eviction for older partial entry tiles (w < 256) as the tree grows. + cacheFilename := fmt.Sprintf("%x_entry_tile_%d_%d", urlHash[:8], tileN, w) + cachePath := filepath.Join(abtCacheDir, cacheFilename) + + // Try reading from cache + if b, err := os.ReadFile(cachePath); err == nil { + slog.Debug("Loaded entry tile from local cache", "path", cachePath) + return b, nil + } + + // Cache miss, download from URL + slog.Debug("Downloading entry tile", "url", logBaseURL+"/"+entryPath) + b, err := readFromURL(logBaseURL, entryPath) + if err != nil { + altPath := "tiles/entries/" + strings.TrimPrefix(entryPath, "tile/entries/") + slog.Debug("Trying alternative entry tile path", "url", logBaseURL+"/"+altPath) + b, err = readFromURL(logBaseURL, altPath) + if err != nil { + return nil, err + } + } + + // Save to cache atomically + tmpFile, err := os.CreateTemp(abtCacheDir, cacheFilename+".*.tmp") + if err != nil { + slog.Warn("Failed to create cache tmp file", "error", err) + return b, nil + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + + if _, err := tmpFile.Write(b); err != nil { + slog.Warn("Failed to write to cache tmp file", "error", err) + tmpFile.Close() + return b, nil + } + if err := tmpFile.Close(); err != nil { + slog.Warn("Failed to close cache tmp file", "error", err) + return b, nil + } + + if err := os.Rename(tmpPath, cachePath); err != nil { + slog.Warn("Failed to move cache file to final destination", "error", err) + return b, nil + } + + return b, nil +} + +// TesseraFindPayloadIndex searches the Tessera entry tiles for targetPayload +// and returns its 0-based sequence index in the log. +// Returns (index, true, nil) if found, (-1, false, nil) if not found. +func TesseraFindPayloadIndex(logBaseURL string, treeSize int64, targetPayload []byte) (int64, bool, error) { + if treeSize <= 0 { + return -1, false, nil + } + + numTiles := (treeSize + 255) / 256 + target := bytes.TrimSpace(targetPayload) + + // TODO: Consider supporting reverse scanning (from last tile to first) as recent packages tend to be + // more commonly verified. + for tileN := int64(0); tileN < numTiles; tileN++ { + w := 256 + if (tileN+1)*256 > treeSize { + w = int(treeSize - tileN*256) + } + + b, err := readCachedEntryTile(logBaseURL, tileN, w) + if err != nil { + return -1, false, fmt.Errorf("failed to fetch entry tile %d (width %d): %w", tileN, w, err) + } + + entries, err := ParseEntryBundle(b) + if err != nil { + return -1, false, fmt.Errorf("failed to parse entry tile %d: %w", tileN, err) + } + + for idx, entry := range entries { + if bytes.Equal(bytes.TrimSpace(entry), target) { + return tileN*256 + int64(idx), true, nil + } + } + } + + return -1, false, nil +} diff --git a/verifier_tools/verify/internal/tiles/reader_test.go b/verifier_tools/verify/internal/tiles/reader_test.go index 2445d7a..8d73d0c 100644 --- a/verifier_tools/verify/internal/tiles/reader_test.go +++ b/verifier_tools/verify/internal/tiles/reader_test.go @@ -219,3 +219,108 @@ func TestParsePackageInfosIndex(t *testing.T) { }) } } + +func TestEntryTilePath(t *testing.T) { + tests := []struct { + tileN int64 + w int + want string + }{ + { + tileN: 0, + w: 256, + want: "tile/entries/000", + }, + { + tileN: 0, + w: 15, + want: "tile/entries/000.p/15", + }, + { + tileN: 1, + w: 256, + want: "tile/entries/001", + }, + { + tileN: 1234067, + w: 8, + want: "tile/entries/x001/x234/067.p/8", + }, + } + + for _, tt := range tests { + got := EntryTilePath(tt.tileN, tt.w) + if got != tt.want { + t.Errorf("EntryTilePath(%d, %d) = %q, want %q", tt.tileN, tt.w, got, tt.want) + } + } +} + +func TestParseEntryBundle(t *testing.T) { + entry1 := []byte("entry one content\n") + entry2 := []byte("entry two content\n") + + var buf bytes.Buffer + buf.Write([]byte{0x00, byte(len(entry1))}) + buf.Write(entry1) + buf.Write([]byte{0x00, byte(len(entry2))}) + buf.Write(entry2) + + entries, err := ParseEntryBundle(buf.Bytes()) + if err != nil { + t.Fatalf("ParseEntryBundle failed: %v", err) + } + + if len(entries) != 2 { + t.Fatalf("got %d entries, want 2", len(entries)) + } + if !bytes.Equal(entries[0], entry1) { + t.Errorf("entry[0] = %q, want %q", entries[0], entry1) + } + if !bytes.Equal(entries[1], entry2) { + t.Errorf("entry[1] = %q, want %q", entries[1], entry2) + } + + // Test invalid / truncated bundle + _, err = ParseEntryBundle([]byte{0x00, 0x10, 'a'}) + if err == nil { + t.Errorf("ParseEntryBundle on truncated data expected error, got nil") + } +} + +func TestTesseraFindPayloadIndex(t *testing.T) { + entry0 := []byte("hash0\nhash_desc0\npackage_name0\n100\n") + entry1 := []byte("hash1\nhash_desc1\npackage_name1\n101\n") + + var buf bytes.Buffer + buf.Write([]byte{0x00, byte(len(entry0))}) + buf.Write(entry0) + buf.Write([]byte{0x00, byte(len(entry1))}) + buf.Write(entry1) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/tile/entries/000.p/2" { + w.Write(buf.Bytes()) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + idx, found, err := TesseraFindPayloadIndex(server.URL, 2, entry1) + if err != nil { + t.Fatalf("TesseraFindPayloadIndex error: %v", err) + } + if !found || idx != 1 { + t.Errorf("got (%d, %v), want (1, true)", idx, found) + } + + // Search for non-existent payload + idx, found, err = TesseraFindPayloadIndex(server.URL, 2, []byte("non_existent")) + if err != nil { + t.Fatalf("TesseraFindPayloadIndex error: %v", err) + } + if found { + t.Errorf("expected found=false for non-existent payload, got %d", idx) + } +}