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:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -3528,6 +3528,7 @@ name = "kebab-app"
|
|||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"base64 0.22.1",
|
||||||
"blake3",
|
"blake3",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"ignore",
|
"ignore",
|
||||||
|
|||||||
@@ -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
|
# a tokio runtime to host its mock server (the runtime adapter crate stays
|
||||||
# sync via reqwest::blocking — wiremock is dev-only there).
|
# sync via reqwest::blocking — wiremock is dev-only there).
|
||||||
wiremock = "0.6"
|
wiremock = "0.6"
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
# Disk-footprint trim for dev / test builds. Codegen, opt-level, and
|
# Disk-footprint trim for dev / test builds. Codegen, opt-level, and
|
||||||
# behavior are unchanged — only DWARF debug info is reduced (line
|
# behavior are unchanged — only DWARF debug info is reduced (line
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ unicode-normalization = "0.1"
|
|||||||
# p9-fb-31: GitignoreBuilder for .kebabignore matching in ingest_file_with_config.
|
# 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.
|
# Same version as kebab-source-fs (0.4) to avoid duplicate dep versions.
|
||||||
ignore = "0.4"
|
ignore = "0.4"
|
||||||
|
# p9-fb-34: opaque pagination cursor encodes payload as base64.
|
||||||
|
base64 = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
rusqlite = { workspace = true }
|
rusqlite = { workspace = true }
|
||||||
|
|||||||
56
crates/kebab-app/src/cursor.rs
Normal file
56
crates/kebab-app/src/cursor.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ use kebab_parse_md::{BodyHints, parse_blocks, parse_frontmatter};
|
|||||||
use kebab_source_fs::FsSourceConnector;
|
use kebab_source_fs::FsSourceConnector;
|
||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
|
pub mod cursor;
|
||||||
pub mod doctor_signal;
|
pub mod doctor_signal;
|
||||||
pub mod error_signal;
|
pub mod error_signal;
|
||||||
pub mod error_wire;
|
pub mod error_wire;
|
||||||
|
|||||||
24
crates/kebab-app/tests/cursor.rs
Normal file
24
crates/kebab-app/tests/cursor.rs
Normal 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");
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ kamadak-exif = "0.6"
|
|||||||
# rustls-tls) so both crates share the same TLS backend and the
|
# rustls-tls) so both crates share the same TLS backend and the
|
||||||
# transitive tokio runtime is brought in once.
|
# transitive tokio runtime is brought in once.
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||||
base64 = "0.22"
|
base64 = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
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
|
# fixture. Only loaded for tests; the production crate doesn't need
|
||||||
# font rendering.
|
# font rendering.
|
||||||
ab_glyph = "0.2"
|
ab_glyph = "0.2"
|
||||||
base64 = "0.22"
|
base64 = { workspace = true }
|
||||||
# `kebab-llm/mock` exposes `MockLanguageModel` for hermetic caption
|
# `kebab-llm/mock` exposes `MockLanguageModel` for hermetic caption
|
||||||
# tests. Real adapters (Ollama) live in `kebab-llm-local`, which is
|
# tests. Real adapters (Ollama) live in `kebab-llm-local`, which is
|
||||||
# only allowed at the dev-dep level here — the runtime crate stays
|
# only allowed at the dev-dep level here — the runtime crate stays
|
||||||
|
|||||||
Reference in New Issue
Block a user