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) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-05-09 17:49:23 +09:00
parent e00418537f
commit ebbc3a46ae
7 changed files with 87 additions and 2 deletions

1
Cargo.lock generated
View File

@@ -3528,6 +3528,7 @@ name = "kebab-app"
version = "0.4.0"
dependencies = [
"anyhow",
"base64 0.22.1",
"blake3",
"dirs 5.0.1",
"ignore",

View File

@@ -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

View File

@@ -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 }

View File

@@ -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<usize, ErrorV1> {
let bytes = URL_SAFE_NO_PAD
.decode(s.as_bytes())
.map_err(|_| stale("<malformed>", expected_revision))?;
let payload: Payload = serde_json::from_slice(&bytes)
.map_err(|_| stale("<malformed>", 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,
}
}

View File

@@ -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;

View File

@@ -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");
}

View File

@@ -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