Skip to content

Commit 1fb164c

Browse files
committed
tile: drop nonstandard /log/v1/entry endpoint; read entries from data tiles
The browser UI and cactus-cli both used a cactus-specific /log/v1/entry/<index> endpoint to fetch a single entry. Remove it so the read path is standard tlog-tiles only and the UI works against any log. - tile UI: clicking an entry in a fetched data tile now opens it in the inspector straight from the bytes already in hand (entryCache), no extra fetch; the by-index box and cactus-cli both resolve an entry by fetching its standard tile/entries/<N>[.p/<W>] data tile and splitting it. - Remove the server handler/route and update MTC.md. - Add a live integration test (TestCLIEntry) covering the CLI entry read and asserting the removed endpoint now 404s.
1 parent 2e5c29f commit 1fb164c

8 files changed

Lines changed: 166 additions & 109 deletions

File tree

MTC.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,9 @@ that means the read-path (HTTP) serves files at:
140140

141141
- `/checkpoint` — a c2sp signed-note with the latest size + root.
142142
- `/tile/<L>/<NNN>[.p/<W>]` — Merkle hash tiles.
143-
- `/tile/entries/<NNN>[.p/<W>]` — entry blobs (the "data" tiles).
144-
- `/log/v1/entry/<index>` — fetch one entry by index.
143+
- `/tile/entries/<NNN>[.p/<W>]` — entry blobs (the "data" tiles). A single
144+
entry is read by fetching its data tile and splitting out the entry at its
145+
position within the tile; there is no per-entry endpoint.
145146
- `/subtree/<name>` — cached signed subtree blob (the name is the
146147
`start-end` storage key).
147148

cmd/cactus-cli/main.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"strings"
2626

2727
"github.com/letsencrypt/cactus/cert"
28+
"github.com/letsencrypt/cactus/log/tilewriter"
2829
"github.com/letsencrypt/cactus/tlogx"
2930
)
3031

@@ -121,12 +122,42 @@ func treeShow(logURL string) {
121122
fmt.Printf("root: %x\n", root[:])
122123
}
123124

124-
// entryShow fetches an entry blob and prints a brief decode.
125+
// entryShow fetches an entry blob and prints a brief decode. It reads the
126+
// entry the way any tlog-tiles client would — there is no per-entry endpoint.
127+
// It fetches the standard data tile that contains the index (sized from the
128+
// checkpoint so the rightmost partial tile resolves to its .p/<width> path)
129+
// and splits out the entry at its position within that tile.
125130
func entryShow(logURL string, idx uint64) {
126-
body, err := httpGet(fmt.Sprintf("%s/log/v1/entry/%d", logURL, idx))
131+
cp, err := httpGet(logURL + "/checkpoint")
127132
if err != nil {
128-
die("fetch entry: %v", err)
133+
die("fetch checkpoint: %v", err)
134+
}
135+
size, _, _, err := parseSignedNoteFlat(cp)
136+
if err != nil {
137+
die("parse checkpoint: %v", err)
138+
}
139+
if idx >= size {
140+
die("entry %d out of range (tree size %d)", idx, size)
141+
}
142+
tileN := int64(idx) / int64(tilewriter.EntriesPerDataTile)
143+
posInTile := int(int64(idx) % int64(tilewriter.EntriesPerDataTile))
144+
width := tilewriter.EntriesPerDataTile
145+
if w := int(size - uint64(tileN)*tilewriter.EntriesPerDataTile); w < width {
146+
width = w // rightmost (partial) data tile
147+
}
148+
tilePath := logURL + "/" + tilewriter.DataTilePath(tileN, width)
149+
tile, err := httpGet(tilePath)
150+
if err != nil {
151+
die("fetch data tile: %v", err)
152+
}
153+
entries, err := tilewriter.SplitDataTile(tile)
154+
if err != nil {
155+
die("parse data tile: %v", err)
156+
}
157+
if posInTile >= len(entries) {
158+
die("entry %d not present in %s", idx, tilePath)
129159
}
160+
body := entries[posInTile]
130161
// MerkleTreeCertEntry (§5.2.1): extensions<0..2^16-1> then uint16 type
131162
// then the type-specific data. The leading uint16 is the extensions
132163
// vector length, NOT the type.

integration/cli_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"encoding/pem"
66
"fmt"
7+
"net/http"
78
"os"
89
"os/exec"
910
"path/filepath"
@@ -147,6 +148,48 @@ func TestCLIProve(t *testing.T) {
147148
}
148149
}
149150

151+
// TestCLIEntry issues a cert, then runs `cactus-cli entry` to confirm the
152+
// CLI reads the entry through the standard data tile (there is no per-entry
153+
// endpoint), and checks the read path directly: the data tile serves, while
154+
// the old /log/v1/entry/<index> endpoint is gone (404).
155+
func TestCLIEntry(t *testing.T) {
156+
s := bringUp(t, t.TempDir())
157+
defer s.close()
158+
if _, err := acmeIssueOne(s.acmeBase, "entry.test"); err != nil {
159+
t.Fatal(err)
160+
}
161+
162+
bin := buildCLI(t)
163+
out, err := exec.Command(bin, "entry", s.tileBase, "0").CombinedOutput()
164+
if err != nil {
165+
t.Fatalf("cactus-cli entry: %v\nout=%s", err, out)
166+
}
167+
for _, want := range []string{"entry 0: tbs_cert_entry", "subject:", "spki hash:"} {
168+
if !strings.Contains(string(out), want) {
169+
t.Errorf("output missing %q:\n%s", want, out)
170+
}
171+
}
172+
173+
// The first (and only) entry lives in the partial data tile 0 at width 1.
174+
if r, err := http.Get(s.tileBase + "/tile/entries/000.p/1"); err != nil {
175+
t.Fatalf("GET data tile: %v", err)
176+
} else {
177+
r.Body.Close()
178+
if r.StatusCode != http.StatusOK {
179+
t.Errorf("standard data tile: status = %d, want 200", r.StatusCode)
180+
}
181+
}
182+
// The nonstandard per-entry endpoint must no longer exist.
183+
if r, err := http.Get(s.tileBase + "/log/v1/entry/0"); err != nil {
184+
t.Fatalf("GET removed endpoint: %v", err)
185+
} else {
186+
r.Body.Close()
187+
if r.StatusCode != http.StatusNotFound {
188+
t.Errorf("removed /log/v1/entry/0: status = %d, want 404", r.StatusCode)
189+
}
190+
}
191+
}
192+
150193
// mustHex decodes hex string s into dst. dst is assumed to be the
151194
// right length.
152195
func mustHex(t *testing.T, s string, dst []byte) {

tile/app.js

Lines changed: 63 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -185,10 +185,14 @@ function drillDown(level, index, i) {
185185
else selectAndFetch("entries", index);
186186
}
187187

188-
// Open one entry (global index) in the entry viewer below.
188+
// Open one entry (global index) in the entry viewer below, reusing the bytes
189+
// already fetched as part of its data tile. Falls back to fetching the data
190+
// tile (a standard request) if this entry hasn't been loaded yet.
189191
function openEntry(globalIndex) {
190192
$("eindex").value = globalIndex;
191-
fetchEntry();
193+
const e = entryCache[globalIndex];
194+
if (!e) { fetchEntry(); return; }
195+
renderEntry(e.bytes, e.label);
192196
$("inspector").scrollIntoView({ behavior: "smooth", block: "nearest" });
193197
}
194198

@@ -231,26 +235,42 @@ function renderHashTile(out, url, buf, level, index) {
231235
out.innerHTML = html;
232236
}
233237

234-
// Entry (data) tiles are uint16-big-endian length-prefixed entries.
238+
// Split an entry (data) tile into its entries. Data tiles are a sequence of
239+
// uint16-big-endian length-prefixed blobs; subarray() clamps a final
240+
// length that overruns a truncated/partial tile, so this never throws.
241+
function splitDataTile(buf) {
242+
const entries = [];
243+
let pos = 0;
244+
while (pos + 2 <= buf.length) {
245+
const len = (buf[pos] << 8) | buf[pos + 1];
246+
pos += 2;
247+
entries.push(buf.subarray(pos, pos + len));
248+
pos += len;
249+
}
250+
return entries;
251+
}
252+
253+
// Bytes of entries we've already pulled out of fetched data tiles, keyed by
254+
// global entry index, so clicking an entry link opens it in the viewer with
255+
// no extra fetch. Each value is { bytes, label } (label is the source line
256+
// shown in the inspector header).
257+
const entryCache = {};
258+
259+
// Render a fetched entry (data) tile, one clickable row per entry. Clicking a
260+
// row opens that entry in the inspector below straight from the bytes we
261+
// already have — no per-entry request, so this stays standard-API-only.
235262
function renderEntriesTile(out, url, buf, tileIndex) {
236-
let html = '<span class="ok">GET ' + esc(url) + "</span>\n" + buf.length + " bytes\n" +
237-
'<span class="hint">click an entry to open it in the entry viewer</span>\n\n';
238-
let pos = 0, i = 0;
239-
try {
240-
while (pos + 2 <= buf.length) {
241-
const len = (buf[pos] << 8) | buf[pos + 1];
242-
pos += 2;
243-
const body = buf.subarray(pos, pos + len);
244-
pos += len;
245-
const preview = hex(body.subarray(0, 32));
246-
const gidx = tileIndex * TILE_W + i;
247-
html += '<a href="#" onclick="openEntry(' + gidx + ');return false">entry ' + i + "</a>" +
248-
" (#" + gidx + "): " + len + " bytes " + preview + (len > 32 ? "…" : "") + "\n";
249-
i++;
250-
}
251-
html = html.replace("\n\n", "\n" + i + " entr" + (i === 1 ? "y" : "ies") + "\n\n");
252-
} catch (e) {
253-
html += '<span class="err">decode error: ' + esc(String(e.message)) + "</span>";
263+
const entries = splitDataTile(buf);
264+
let html = '<span class="ok">GET ' + esc(url) + "</span>\n" + buf.length + " bytes · " +
265+
entries.length + " entr" + (entries.length === 1 ? "y" : "ies") + "\n" +
266+
'<span class="hint">click an entry to open it in the entry viewer below</span>\n\n';
267+
for (let i = 0; i < entries.length; i++) {
268+
const body = entries[i];
269+
const gidx = tileIndex * TILE_W + i;
270+
entryCache[gidx] = { bytes: body, label: url + " · entry " + i + " (#" + gidx + ")" };
271+
const preview = hex(body.subarray(0, 32));
272+
html += '<a href="#" onclick="openEntry(' + gidx + ');return false">entry ' + i + "</a>" +
273+
" (#" + gidx + "): " + body.length + " bytes " + preview + (body.length > 32 ? "…" : "") + "\n";
254274
}
255275
out.innerHTML = html;
256276
}
@@ -677,12 +697,14 @@ function renderAnnotations(container, ann) {
677697
container.innerHTML = ann.length ? html : '<div class="hint">(no annotations)</div>';
678698
}
679699

680-
// Render the full three-column inspector for one entry blob.
681-
function renderEntry(buf, url) {
700+
// Render the full three-column inspector for one entry blob. `label` is the
701+
// source line shown in the header (e.g. the data-tile path the entry came
702+
// from).
703+
function renderEntry(buf, label) {
682704
const res = parseEntry(buf);
683705
const meta = $("entrymeta");
684706
meta.style.display = "block";
685-
let m = '<span class="ok">GET ' + esc(url) + "</span> · " + buf.length + " bytes (MerkleTreeCertEntry)";
707+
let m = '<span class="ok">' + esc(label) + "</span> · " + buf.length + " bytes (MerkleTreeCertEntry)";
686708
if (res.typeLabel) m += " · type " + esc(res.typeLabel);
687709
if (res.error) m += ' · <span class="err">' + esc(res.error) + "</span>";
688710
meta.innerHTML = m;
@@ -694,9 +716,15 @@ function renderEntry(buf, url) {
694716
return res;
695717
}
696718

719+
// Look up an entry by global index using only the standard tile API: fetch
720+
// the data tile that contains it (auto-sizing the partial width from the
721+
// checkpoint) and pull out the entry at its position within that tile.
697722
async function fetchEntry() {
698723
const idx = parseInt($("eindex").value, 10) || 0;
699-
const url = "log/v1/entry/" + idx;
724+
const tileIndex = Math.floor(idx / TILE_W);
725+
const posInTile = idx % TILE_W;
726+
const width = tileWidth("entries", tileIndex);
727+
const url = tileURL("entries", tileIndex, width || null);
700728
const meta = $("entrymeta");
701729
meta.style.display = "block";
702730
meta.textContent = "GET " + url + " …";
@@ -705,7 +733,15 @@ async function fetchEntry() {
705733
const r = await fetch(url, { cache: "no-store" });
706734
if (!r.ok) { meta.innerHTML = '<span class="err">GET ' + esc(url) + " → HTTP " + r.status + "</span>"; return; }
707735
const buf = new Uint8Array(await r.arrayBuffer());
708-
renderEntry(buf, url);
736+
const entries = splitDataTile(buf);
737+
if (posInTile >= entries.length) {
738+
meta.innerHTML = '<span class="err">entry #' + idx + " not in " + esc(url) +
739+
" (tile holds " + entries.length + " entr" + (entries.length === 1 ? "y" : "ies") + ")</span>";
740+
return;
741+
}
742+
const body = entries[posInTile];
743+
entryCache[idx] = { bytes: body, label: url + " · entry " + posInTile + " (#" + idx + ")" };
744+
renderEntry(body, entryCache[idx].label);
709745
} catch (e) {
710746
meta.innerHTML = '<span class="err">' + esc(String(e.message)) + "</span>";
711747
}
@@ -789,7 +825,7 @@ if (typeof module !== "undefined" && module.exports) {
789825
module.exports = {
790826
TILE_W, formatTileIndex, tileURL, treeHeight, tileLevels, tileWidth,
791827
loadCheckpoint, renderTileLists, populateLevels, selectAndFetch, drillDown,
792-
openEntry, fetchTile, renderHashTile, renderEntriesTile, fetchEntry,
828+
openEntry, fetchTile, renderHashTile, splitDataTile, renderEntriesTile, fetchEntry,
793829
DER, tagName, decodeOID, oidName, decodeDN, decodeSAN, hexPreview,
794830
previewInteger, formatTime, previewPrimitive, derNode, derForest,
795831
parseEntry, renderHexDump, renderStructure, renderAnnotations, renderEntry,

tile/app.test.js

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,11 +125,25 @@ test("drillDown from level 0 descends to the aligned entry (data) tile", () => {
125125
expect(fetches.at(-1)).toBe("tile/entries/001");
126126
});
127127

128-
test("openEntry fetches the single entry by global index", () => {
128+
test("openEntry opens a tile-loaded entry without any extra fetch", () => {
129129
const { els, fetches } = makeUI();
130-
app.openEntry(1283);
131-
expect(els.eindex.value).toBe("1283");
132-
expect(fetches.at(-1)).toBe("log/v1/entry/1283");
130+
app.treeSize = 600;
131+
// Render a data tile so its entries are cached, then click one.
132+
const out = new El("#tileout");
133+
const buf = new Uint8Array([0x00, 0x04, 0x00, 0x00, 0x00, 0x00]); // one 4-byte null_entry blob
134+
app.renderEntriesTile(out, "tile/entries/000", buf, 0);
135+
app.openEntry(0);
136+
expect(els.eindex.value).toBe("0");
137+
expect(els.inspector.style.display).toBe("grid"); // inspector populated
138+
expect(fetches.length).toBe(0); // no network call
139+
});
140+
141+
test("openEntry falls back to the standard data tile for an uncached entry", () => {
142+
const { els, fetches } = makeUI();
143+
app.treeSize = 600;
144+
app.openEntry(257); // not loaded from a tile yet → fetch its data tile
145+
expect(els.eindex.value).toBe("257");
146+
expect(fetches.at(-1)).toBe("tile/entries/001"); // tile 1 (full), standard path
133147
});
134148

135149
// =========================================================================
@@ -309,7 +323,7 @@ test("renderAnnotations: each row carries its structure's byte range", () => {
309323
test("renderEntry: fills all three columns and reports byte count", () => {
310324
const { els } = makeUI();
311325
const buf = fixtureEntry();
312-
const res = app.renderEntry(buf, "log/v1/entry/0");
326+
const res = app.renderEntry(buf, "tile/entries/000 · entry 0 (#0)");
313327
expect(res.type).toBe(1);
314328
expect(els.entrymeta.innerHTML).toContain(buf.length + " bytes");
315329
expect(els.entrymeta.innerHTML).toContain("tbs_cert_entry");

tile/index.html

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,11 @@ <h2>Entry viewer</h2>
157157
<div class="row">
158158
<label>Index</label><input type="number" id="eindex" min="0" value="0">
159159
<button id="efetch">Fetch entry</button>
160-
<span class="hint">GET <code>log/v1/entry/&lt;index&gt;</code> — one MerkleTreeCertEntry blob</span>
160+
<span class="hint">extracted from its standard <code>tile/entries/&lt;N&gt;</code> data tile</span>
161161
</div>
162+
<p class="hint">Click an entry in a data tile above to open it here with no extra fetch, or
163+
look one up by index. Either way the entry is read straight from the standard
164+
tlog-tiles data tile — no log-specific API.</p>
162165
<p class="hint">A §5.2.1 <code>MerkleTreeCertEntry</code>: TLS-presentation framing
163166
(<code>extensions</code><code>entry_type</code>) wrapping the DER contents octets of a
164167
<code>TBSCertificateLogEntry</code>. Hover any column to light up the matching bytes.</p>

tile/server.go

Lines changed: 0 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,10 @@ import (
1212
"errors"
1313
"io/fs"
1414
"net/http"
15-
"strconv"
1615
"strings"
1716

1817
"github.com/letsencrypt/cactus/landmark"
1918
"github.com/letsencrypt/cactus/log"
20-
"github.com/letsencrypt/cactus/log/tilewriter"
2119
"github.com/letsencrypt/cactus/storage"
2220
)
2321

@@ -58,7 +56,6 @@ func (s *Server) WithLandmarks(seq *landmark.Sequence) *Server {
5856
// GET /checkpoint — latest signed note
5957
// GET /tile/<L>/<NNN..> — hash tiles (c2sp tlog-tiles)
6058
// GET /tile/entries/<NNN..> — entry (data) tiles (c2sp tlog-tiles)
61-
// GET /log/v1/entry/<index> — single entry blob (the §5.2.1 MerkleTreeCertEntry)
6259
// GET /subtree/<start>-<end> — cached signed subtree signature
6360
// GET /landmarks — §6.3.1 landmark list (only if WithLandmarks)
6461
func (s *Server) Handler() http.Handler {
@@ -67,7 +64,6 @@ func (s *Server) Handler() http.Handler {
6764
mux.HandleFunc("GET /app.js", s.handleAppJS)
6865
mux.HandleFunc("GET /checkpoint", s.handleCheckpoint)
6966
mux.HandleFunc("GET /tile/", s.handleTile)
70-
mux.HandleFunc("GET /log/v1/entry/{index}", s.handleEntry)
7167
mux.HandleFunc("GET /subtree/{name}", s.handleSubtree)
7268
if s.landmarks != nil {
7369
mux.Handle("GET /landmarks", s.landmarks.Handler())
@@ -128,45 +124,6 @@ func (s *Server) handleTile(w http.ResponseWriter, r *http.Request) {
128124
w.Write(data)
129125
}
130126

131-
func (s *Server) handleEntry(w http.ResponseWriter, r *http.Request) {
132-
idxStr := r.PathValue("index")
133-
idx, err := strconv.ParseUint(idxStr, 10, 64)
134-
if err != nil {
135-
http.Error(w, "bad index", http.StatusBadRequest)
136-
return
137-
}
138-
139-
// Locate the data tile and the position of the requested entry within it.
140-
tileN := int64(idx) / int64(tilewriter.EntriesPerDataTile)
141-
posInTile := int(int64(idx) - tileN*int64(tilewriter.EntriesPerDataTile))
142-
143-
// Find any persisted data tile at width >= posInTile+1, preferring
144-
// the widest (most up-to-date) one.
145-
for width := tilewriter.EntriesPerDataTile; width >= posInTile+1; width-- {
146-
data, err := s.fs.Get("log/" + tilewriter.DataTilePath(tileN, width))
147-
if errors.Is(err, fs.ErrNotExist) {
148-
continue
149-
}
150-
if err != nil {
151-
http.Error(w, "entry read failed", http.StatusInternalServerError)
152-
return
153-
}
154-
entries, err := tilewriter.SplitDataTile(data)
155-
if err != nil {
156-
http.Error(w, "data tile parse failed", http.StatusInternalServerError)
157-
return
158-
}
159-
if posInTile >= len(entries) {
160-
continue
161-
}
162-
w.Header().Set("Content-Type", "application/octet-stream")
163-
w.Header().Set("Cache-Control", "public, max-age=86400, immutable")
164-
w.Write(entries[posInTile])
165-
return
166-
}
167-
http.NotFound(w, r)
168-
}
169-
170127
func (s *Server) handleSubtree(w http.ResponseWriter, r *http.Request) {
171128
name := r.PathValue("name") // e.g. "8-13"
172129
data, err := s.fs.Get("log/subtrees/" + name)

0 commit comments

Comments
 (0)