From ebbc3a46ae7d2db25b70803a0105a6e6217b6d0a Mon Sep 17 00:00:00 2001
From: th-kim0823
Date: Sat, 9 May 2026 17:49:23 +0900
Subject: [PATCH] feat(app): cursor encode/decode for paginated search (fb-34)
Opaque base64(JSON{offset, corpus_revision}). Mismatch or
malformed input returns ErrorV1 with code = stale_cursor.
base64 promoted to workspace dep.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
Cargo.lock | 1 +
Cargo.toml | 1 +
crates/kebab-app/Cargo.toml | 2 ++
crates/kebab-app/src/cursor.rs | 56 +++++++++++++++++++++++++++++
crates/kebab-app/src/lib.rs | 1 +
crates/kebab-app/tests/cursor.rs | 24 +++++++++++++
crates/kebab-parse-image/Cargo.toml | 4 +--
7 files changed, 87 insertions(+), 2 deletions(-)
create mode 100644 crates/kebab-app/src/cursor.rs
create mode 100644 crates/kebab-app/tests/cursor.rs
diff --git a/Cargo.lock b/Cargo.lock
index db4e4d3..12804d3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3528,6 +3528,7 @@ name = "kebab-app"
version = "0.4.0"
dependencies = [
"anyhow",
+ "base64 0.22.1",
"blake3",
"dirs 5.0.1",
"ignore",
diff --git a/Cargo.toml b/Cargo.toml
index aa549fc..661925c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -80,6 +80,7 @@ rmcp = { version = "1.6", default-features = false, features = ["server"
# a tokio runtime to host its mock server (the runtime adapter crate stays
# sync via reqwest::blocking — wiremock is dev-only there).
wiremock = "0.6"
+base64 = "0.22"
# Disk-footprint trim for dev / test builds. Codegen, opt-level, and
# behavior are unchanged — only DWARF debug info is reduced (line
diff --git a/crates/kebab-app/Cargo.toml b/crates/kebab-app/Cargo.toml
index cc35d07..a3ec230 100644
--- a/crates/kebab-app/Cargo.toml
+++ b/crates/kebab-app/Cargo.toml
@@ -52,6 +52,8 @@ unicode-normalization = "0.1"
# p9-fb-31: GitignoreBuilder for .kebabignore matching in ingest_file_with_config.
# Same version as kebab-source-fs (0.4) to avoid duplicate dep versions.
ignore = "0.4"
+# p9-fb-34: opaque pagination cursor encodes payload as base64.
+base64 = { workspace = true }
[dev-dependencies]
rusqlite = { workspace = true }
diff --git a/crates/kebab-app/src/cursor.rs b/crates/kebab-app/src/cursor.rs
new file mode 100644
index 0000000..176cb28
--- /dev/null
+++ b/crates/kebab-app/src/cursor.rs
@@ -0,0 +1,56 @@
+//! p9-fb-34 opaque pagination cursor.
+//!
+//! Format: base64(JSON({offset: usize, corpus_revision: string})).
+//! Opaque to callers — they MUST NOT decode the contents themselves;
+//! the schema is internal and may change without notice.
+
+use base64::Engine;
+use base64::engine::general_purpose::URL_SAFE_NO_PAD;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use crate::error_wire::ErrorV1;
+
+#[derive(Serialize, Deserialize)]
+struct Payload {
+ offset: usize,
+ corpus_revision: String,
+}
+
+/// Encode `(offset, corpus_revision)` as an opaque base64 string.
+pub fn encode(offset: usize, corpus_revision: &str) -> String {
+ let payload = Payload {
+ offset,
+ corpus_revision: corpus_revision.to_string(),
+ };
+ let json = serde_json::to_vec(&payload).expect("Payload serializes");
+ URL_SAFE_NO_PAD.encode(&json)
+}
+
+/// Decode an opaque cursor against the expected `corpus_revision`.
+/// Mismatch or malformed input returns an `ErrorV1` with
+/// `code = "stale_cursor"`.
+pub fn decode(s: &str, expected_revision: &str) -> Result {
+ let bytes = URL_SAFE_NO_PAD
+ .decode(s.as_bytes())
+ .map_err(|_| stale("", expected_revision))?;
+ let payload: Payload = serde_json::from_slice(&bytes)
+ .map_err(|_| stale("", expected_revision))?;
+ if payload.corpus_revision != expected_revision {
+ return Err(stale(&payload.corpus_revision, expected_revision));
+ }
+ Ok(payload.offset)
+}
+
+fn stale(found: &str, expected: &str) -> ErrorV1 {
+ ErrorV1 {
+ schema_version: "error.v1".to_string(),
+ code: "stale_cursor".to_string(),
+ message: format!(
+ "cursor was issued against corpus_revision '{found}'; current revision is \
+ '{expected}'. Re-issue search to obtain a fresh cursor."
+ ),
+ details: Value::Null,
+ hint: None,
+ }
+}
diff --git a/crates/kebab-app/src/lib.rs b/crates/kebab-app/src/lib.rs
index 960442b..8bbc5d2 100644
--- a/crates/kebab-app/src/lib.rs
+++ b/crates/kebab-app/src/lib.rs
@@ -55,6 +55,7 @@ use kebab_parse_md::{BodyHints, parse_blocks, parse_frontmatter};
use kebab_source_fs::FsSourceConnector;
mod app;
+pub mod cursor;
pub mod doctor_signal;
pub mod error_signal;
pub mod error_wire;
diff --git a/crates/kebab-app/tests/cursor.rs b/crates/kebab-app/tests/cursor.rs
new file mode 100644
index 0000000..74fd45b
--- /dev/null
+++ b/crates/kebab-app/tests/cursor.rs
@@ -0,0 +1,24 @@
+//! p9-fb-34: cursor encode/decode round-trip + corpus_revision mismatch.
+
+use kebab_app::cursor;
+
+#[test]
+fn cursor_roundtrip_preserves_offset() {
+ let encoded = cursor::encode(5, "rev-abc");
+ let offset = cursor::decode(&encoded, "rev-abc").unwrap();
+ assert_eq!(offset, 5);
+}
+
+#[test]
+fn cursor_decode_rejects_mismatched_revision() {
+ let encoded = cursor::encode(7, "rev-old");
+ let err = cursor::decode(&encoded, "rev-new").unwrap_err();
+ assert_eq!(err.code, "stale_cursor");
+ assert!(err.message.contains("rev-old") || err.message.contains("rev-new"));
+}
+
+#[test]
+fn cursor_decode_rejects_garbage_input() {
+ let err = cursor::decode("not-base64!!!", "any").unwrap_err();
+ assert_eq!(err.code, "stale_cursor");
+}
diff --git a/crates/kebab-parse-image/Cargo.toml b/crates/kebab-parse-image/Cargo.toml
index 4e78465..46b56bc 100644
--- a/crates/kebab-parse-image/Cargo.toml
+++ b/crates/kebab-parse-image/Cargo.toml
@@ -34,7 +34,7 @@ kamadak-exif = "0.6"
# rustls-tls) so both crates share the same TLS backend and the
# transitive tokio runtime is brought in once.
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
-base64 = "0.22"
+base64 = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
@@ -47,7 +47,7 @@ tokio = { workspace = true, features = ["rt-multi-thread"] }
# fixture. Only loaded for tests; the production crate doesn't need
# font rendering.
ab_glyph = "0.2"
-base64 = "0.22"
+base64 = { workspace = true }
# `kebab-llm/mock` exposes `MockLanguageModel` for hermetic caption
# tests. Real adapters (Ollama) live in `kebab-llm-local`, which is
# only allowed at the dev-dep level here — the runtime crate stays