Skip to main content

lean_ctx/core/
index_bundle.rs

1//! Encrypted index bundles for the hosted Personal Index (GL #392).
2//!
3//! Packs the locally built retrieval artifacts (`bm25_index.bin.zst`,
4//! `embeddings.json` from the project's vector namespace) into one container,
5//! encrypts it client-side, and unpacks pulled bundles back into the
6//! namespace — so a fresh device gets working `ctx_semantic_search` without a
7//! local re-index.
8//!
9//! Contract: `docs/contracts/hosted-personal-index-v1.md`.
10//!
11//! ## Container format (`LCIB1`)
12//!
13//! ```text
14//! "LCIB1\n" | u32 LE manifest_len | manifest JSON | zstd(files payload)
15//! ```
16//!
17//! ## Encryption
18//!
19//! XChaCha20-Poly1305 with a 24-byte random nonce prepended to the
20//! ciphertext. The key is HKDF-SHA256-derived from the account API key —
21//! the backend stores that key only as a SHA-256 hash, so the server can
22//! never decrypt a bundle (true E2E for the operator threat model). Every
23//! logged-in device derives the same key with zero extra setup.
24
25use serde::{Deserialize, Serialize};
26use sha2::{Digest, Sha256};
27use std::path::Path;
28
29const MAGIC: &[u8; 6] = b"LCIB1\n";
30const ZSTD_LEVEL: i32 = 3;
31const NONCE_LEN: usize = 24;
32/// Hard ceiling for a decoded payload (defense against decompression bombs
33/// on pull; the server enforces its own per-bundle upload cap).
34const MAX_DECODED_BYTES: usize = 512 * 1024 * 1024;
35
36/// The two retrieval artifacts a v1 bundle carries.
37const BUNDLE_FILES: [&str; 2] = ["bm25_index.bin.zst", "embeddings.json"];
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct BundleManifest {
41    /// Format version of the container ("1").
42    pub version: u32,
43    /// Project namespace hash this bundle belongs to.
44    pub project_hash: String,
45    /// RFC 3339 creation timestamp.
46    pub created_at: String,
47    /// Engine version that produced the bundle.
48    pub engine_version: String,
49    pub files: Vec<BundleFileEntry>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct BundleFileEntry {
54    pub name: String,
55    pub size: u64,
56    pub sha256: String,
57}
58
59#[derive(Debug, thiserror::Error)]
60pub enum BundleError {
61    #[error(
62        "no index artifacts found in {0} — run a search once (or `lean-ctx index`) to build the local index first"
63    )]
64    NothingToBundle(String),
65    #[error("not a lean-ctx index bundle (bad magic)")]
66    BadMagic,
67    #[error("corrupt bundle: {0}")]
68    Corrupt(String),
69    #[error(
70        "decryption failed — bundle was encrypted with a different account key (re-push from a logged-in device)"
71    )]
72    Decrypt,
73    #[error("io: {0}")]
74    Io(#[from] std::io::Error),
75}
76
77fn sha256_hex(data: &[u8]) -> String {
78    let mut h = Sha256::new();
79    h.update(data);
80    let digest = h.finalize();
81    let mut out = String::with_capacity(digest.len() * 2);
82    for b in digest {
83        use std::fmt::Write;
84        let _ = write!(out, "{b:02x}");
85    }
86    out
87}
88
89// ---------------------------------------------------------------------------
90// Pack / unpack (plaintext container)
91// ---------------------------------------------------------------------------
92
93/// Whether this project has any bundleable index artifacts on disk — the
94/// cheap pre-check the background auto-push (GL #392) uses to skip silently
95/// instead of erroring through [`pack`].
96#[must_use]
97pub fn local_index_present(project_root: &Path) -> bool {
98    let dir = crate::core::index_namespace::vectors_dir(project_root);
99    BUNDLE_FILES.iter().any(|name| dir.join(name).is_file())
100}
101
102/// Pack the project's index artifacts into a plaintext `LCIB1` container.
103/// Returns the container bytes and its manifest.
104pub fn pack(project_root: &Path) -> Result<(Vec<u8>, BundleManifest), BundleError> {
105    let dir = crate::core::index_namespace::vectors_dir(project_root);
106    let mut files = Vec::new();
107    let mut payload = Vec::new();
108
109    for name in BUNDLE_FILES {
110        let path = dir.join(name);
111        let Ok(data) = std::fs::read(&path) else {
112            continue;
113        };
114        files.push(BundleFileEntry {
115            name: name.to_string(),
116            size: data.len() as u64,
117            sha256: sha256_hex(&data),
118        });
119        payload.extend_from_slice(&data);
120    }
121
122    if files.is_empty() {
123        return Err(BundleError::NothingToBundle(dir.display().to_string()));
124    }
125
126    let manifest = BundleManifest {
127        version: 1,
128        // The vector-namespace hash: derived from the project *identity*
129        // (git remote / manifest name), so the same repo cloned on another
130        // device maps to the same hosted bucket.
131        project_hash: crate::core::index_namespace::namespace_hash(project_root),
132        created_at: chrono::Utc::now().to_rfc3339(),
133        engine_version: env!("CARGO_PKG_VERSION").to_string(),
134        files,
135    };
136
137    let manifest_json =
138        serde_json::to_vec(&manifest).map_err(|e| BundleError::Corrupt(e.to_string()))?;
139    let compressed = zstd::encode_all(payload.as_slice(), ZSTD_LEVEL)
140        .map_err(|e| BundleError::Corrupt(format!("zstd: {e}")))?;
141
142    let mut out = Vec::with_capacity(MAGIC.len() + 4 + manifest_json.len() + compressed.len());
143    out.extend_from_slice(MAGIC);
144    out.extend_from_slice(
145        &u32::try_from(manifest_json.len())
146            .map_err(|_| BundleError::Corrupt("manifest too large".into()))?
147            .to_le_bytes(),
148    );
149    out.extend_from_slice(&manifest_json);
150    out.extend_from_slice(&compressed);
151    Ok((out, manifest))
152}
153
154/// Parse a plaintext container without writing anything (manifest preview).
155pub fn read_manifest(container: &[u8]) -> Result<BundleManifest, BundleError> {
156    let (manifest, _payload) = split_container(container)?;
157    Ok(manifest)
158}
159
160fn split_container(container: &[u8]) -> Result<(BundleManifest, Vec<u8>), BundleError> {
161    if container.len() < MAGIC.len() + 4 || &container[..MAGIC.len()] != MAGIC {
162        return Err(BundleError::BadMagic);
163    }
164    let len_start = MAGIC.len();
165    let manifest_len = u32::from_le_bytes(
166        container[len_start..len_start + 4]
167            .try_into()
168            .map_err(|_| BundleError::Corrupt("truncated header".into()))?,
169    ) as usize;
170    let manifest_end = len_start + 4 + manifest_len;
171    if container.len() < manifest_end {
172        return Err(BundleError::Corrupt("truncated manifest".into()));
173    }
174    let manifest: BundleManifest = serde_json::from_slice(&container[len_start + 4..manifest_end])
175        .map_err(|e| BundleError::Corrupt(format!("manifest: {e}")))?;
176
177    let declared: u64 = manifest.files.iter().map(|f| f.size).sum();
178    if declared > MAX_DECODED_BYTES as u64 {
179        return Err(BundleError::Corrupt(format!(
180            "declared payload {declared} bytes exceeds the {MAX_DECODED_BYTES} byte ceiling"
181        )));
182    }
183
184    let payload = zstd::decode_all(&container[manifest_end..])
185        .map_err(|e| BundleError::Corrupt(format!("zstd: {e}")))?;
186    if payload.len() as u64 != declared {
187        return Err(BundleError::Corrupt(format!(
188            "payload size mismatch: got {}, manifest declares {declared}",
189            payload.len()
190        )));
191    }
192    Ok((manifest, payload))
193}
194
195/// Unpack a plaintext container into the project's vector namespace. Every
196/// file's SHA-256 is verified before anything is written; writes are atomic
197/// (tmp + rename) so a torn pull can never corrupt a working local index.
198pub fn unpack(project_root: &Path, container: &[u8]) -> Result<BundleManifest, BundleError> {
199    let (manifest, payload) = split_container(container)?;
200
201    let dir = crate::core::index_namespace::vectors_dir(project_root);
202    std::fs::create_dir_all(&dir)?;
203
204    // Verify all hashes first — only then start writing.
205    let mut offset = 0usize;
206    let mut verified: Vec<(&BundleFileEntry, &[u8])> = Vec::with_capacity(manifest.files.len());
207    for entry in &manifest.files {
208        let size = usize::try_from(entry.size)
209            .map_err(|_| BundleError::Corrupt("file size overflow".into()))?;
210        let end = offset
211            .checked_add(size)
212            .filter(|&e| e <= payload.len())
213            .ok_or_else(|| BundleError::Corrupt("file extends past payload".into()))?;
214        let data = &payload[offset..end];
215        if sha256_hex(data) != entry.sha256 {
216            return Err(BundleError::Corrupt(format!(
217                "sha256 mismatch for {}",
218                entry.name
219            )));
220        }
221        // File names are fixed by the format — never trust path components.
222        if !BUNDLE_FILES.contains(&entry.name.as_str()) {
223            return Err(BundleError::Corrupt(format!(
224                "unexpected file in bundle: {}",
225                entry.name
226            )));
227        }
228        verified.push((entry, data));
229        offset = end;
230    }
231
232    for (entry, data) in verified {
233        let target = dir.join(&entry.name);
234        let tmp = dir.join(format!(".{}.pull.tmp", entry.name));
235        std::fs::write(&tmp, data)?;
236        std::fs::rename(&tmp, &target)?;
237    }
238    Ok(manifest)
239}
240
241// ---------------------------------------------------------------------------
242// Encryption (XChaCha20-Poly1305, HKDF-SHA256 account key)
243// ---------------------------------------------------------------------------
244
245/// Derive the per-account bundle key from the API key. The server only ever
246/// stores `sha256(api_key)`, so this key is unknowable server-side.
247#[must_use]
248pub fn derive_key(api_key: &str) -> [u8; 32] {
249    let hk = hkdf::Hkdf::<Sha256>::new(Some(b"leanctx"), api_key.as_bytes());
250    let mut okm = [0u8; 32];
251    hk.expand(b"index-bundle-v1", &mut okm)
252        .expect("32 bytes is a valid HKDF-SHA256 output length");
253    okm
254}
255
256/// Encrypt a plaintext container. Output: `nonce (24B) || ciphertext`.
257pub fn encrypt(container: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, BundleError> {
258    use chacha20poly1305::aead::{Aead, KeyInit};
259    use chacha20poly1305::{XChaCha20Poly1305, XNonce};
260
261    let mut nonce_bytes = [0u8; NONCE_LEN];
262    getrandom::fill(&mut nonce_bytes)
263        .map_err(|e| BundleError::Corrupt(format!("nonce generation: {e}")))?;
264    let cipher = XChaCha20Poly1305::new(key.into());
265    let ciphertext = cipher
266        .encrypt(XNonce::from_slice(&nonce_bytes), container)
267        .map_err(|_| BundleError::Corrupt("encryption failed".into()))?;
268
269    let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len());
270    out.extend_from_slice(&nonce_bytes);
271    out.extend_from_slice(&ciphertext);
272    Ok(out)
273}
274
275/// Decrypt `nonce || ciphertext` back into the plaintext container.
276pub fn decrypt(blob: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, BundleError> {
277    use chacha20poly1305::aead::{Aead, KeyInit};
278    use chacha20poly1305::{XChaCha20Poly1305, XNonce};
279
280    if blob.len() <= NONCE_LEN {
281        return Err(BundleError::Corrupt("blob shorter than nonce".into()));
282    }
283    let (nonce, ciphertext) = blob.split_at(NONCE_LEN);
284    let cipher = XChaCha20Poly1305::new(key.into());
285    cipher
286        .decrypt(XNonce::from_slice(nonce), ciphertext)
287        .map_err(|_| BundleError::Decrypt)
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    /// Build a fake project with index artifacts and return (dir, root).
295    fn project_with_index() -> (tempfile::TempDir, std::path::PathBuf) {
296        let data_dir = tempfile::tempdir().unwrap();
297        let root = data_dir.path().join("proj");
298        std::fs::create_dir_all(root.join(".git")).unwrap();
299        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
300
301        let vectors = crate::core::index_namespace::vectors_dir(&root);
302        std::fs::create_dir_all(&vectors).unwrap();
303        std::fs::write(vectors.join("bm25_index.bin.zst"), b"fake-bm25-bytes").unwrap();
304        std::fs::write(vectors.join("embeddings.json"), br#"{"entries":[]}"#).unwrap();
305        (data_dir, root)
306    }
307
308    #[test]
309    fn pack_unpack_roundtrip_preserves_artifacts() {
310        let _env = crate::core::data_dir::test_env_lock();
311        let (data_dir, root) = project_with_index();
312
313        let (container, manifest) = pack(&root).unwrap();
314        assert_eq!(manifest.version, 1);
315        assert_eq!(manifest.files.len(), 2);
316
317        // Wipe the local artifacts, then restore from the bundle.
318        let vectors = crate::core::index_namespace::vectors_dir(&root);
319        std::fs::remove_file(vectors.join("bm25_index.bin.zst")).unwrap();
320        std::fs::remove_file(vectors.join("embeddings.json")).unwrap();
321
322        let restored = unpack(&root, &container).unwrap();
323        assert_eq!(restored.project_hash, manifest.project_hash);
324        assert_eq!(
325            std::fs::read(vectors.join("bm25_index.bin.zst")).unwrap(),
326            b"fake-bm25-bytes"
327        );
328        assert_eq!(
329            std::fs::read(vectors.join("embeddings.json")).unwrap(),
330            br#"{"entries":[]}"#
331        );
332        drop(data_dir);
333    }
334
335    #[test]
336    fn pack_without_artifacts_is_a_clear_error() {
337        let _env = crate::core::data_dir::test_env_lock();
338        let data_dir = tempfile::tempdir().unwrap();
339        let root = data_dir.path().join("empty");
340        std::fs::create_dir_all(root.join(".git")).unwrap();
341        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
342
343        match pack(&root) {
344            Err(BundleError::NothingToBundle(_)) => {}
345            other => panic!("expected NothingToBundle, got {other:?}"),
346        }
347    }
348
349    #[test]
350    fn tampered_payload_is_rejected() {
351        let _env = crate::core::data_dir::test_env_lock();
352        let (_data_dir, root) = project_with_index();
353        let (container, _) = pack(&root).unwrap();
354
355        // Re-compress a tampered payload behind the original manifest: the
356        // per-file sha256 must catch it.
357        let (manifest, mut payload) = split_container(&container).unwrap();
358        payload[0] ^= 0xFF;
359        let manifest_json = serde_json::to_vec(&manifest).unwrap();
360        let mut forged = Vec::new();
361        forged.extend_from_slice(MAGIC);
362        forged.extend_from_slice(&(u32::try_from(manifest_json.len()).unwrap()).to_le_bytes());
363        forged.extend_from_slice(&manifest_json);
364        forged.extend_from_slice(&zstd::encode_all(payload.as_slice(), 3).unwrap());
365
366        match unpack(&root, &forged) {
367            Err(BundleError::Corrupt(msg)) => assert!(msg.contains("sha256"), "{msg}"),
368            other => panic!("expected Corrupt(sha256), got {other:?}"),
369        }
370    }
371
372    #[test]
373    fn bad_magic_is_rejected() {
374        match read_manifest(b"not-a-bundle") {
375            Err(BundleError::BadMagic) => {}
376            other => panic!("expected BadMagic, got {other:?}"),
377        }
378    }
379
380    #[test]
381    fn encrypt_decrypt_roundtrip_and_wrong_key_fails() {
382        let key = derive_key("test-api-key-1");
383        let plaintext = b"LCIB1\n-fake-container-bytes".to_vec();
384
385        let blob = encrypt(&plaintext, &key).unwrap();
386        assert_ne!(&blob[NONCE_LEN..], plaintext.as_slice());
387        assert_eq!(decrypt(&blob, &key).unwrap(), plaintext);
388
389        let wrong = derive_key("test-api-key-2");
390        match decrypt(&blob, &wrong) {
391            Err(BundleError::Decrypt) => {}
392            other => panic!("expected Decrypt error, got {other:?}"),
393        }
394    }
395
396    #[test]
397    fn key_derivation_is_stable_and_key_separated() {
398        // Same input ⇒ same key (multi-device); different input ⇒ different key.
399        assert_eq!(derive_key("k"), derive_key("k"));
400        assert_ne!(derive_key("k"), derive_key("k2"));
401        // And never the raw key material itself.
402        assert_ne!(derive_key("k").as_slice(), b"k".as_slice());
403    }
404
405    #[test]
406    fn nonces_are_unique_per_encryption() {
407        let key = derive_key("k");
408        let a = encrypt(b"same", &key).unwrap();
409        let b = encrypt(b"same", &key).unwrap();
410        assert_ne!(a[..NONCE_LEN], b[..NONCE_LEN]);
411    }
412}