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