1use 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;
32const MAX_DECODED_BYTES: usize = 512 * 1024 * 1024;
35
36const BUNDLE_FILES: [&str; 2] = ["bm25_index.bin.zst", "embeddings.json"];
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct BundleManifest {
41 pub version: u32,
43 pub project_hash: String,
45 pub created_at: String,
47 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#[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
102pub 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 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
154pub 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
195pub 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 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 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#[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
256pub 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
275pub 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 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 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 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 assert_eq!(derive_key("k"), derive_key("k"));
400 assert_ne!(derive_key("k"), derive_key("k2"));
401 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}