1use super::*;
3use crate::discovery::{ResourceKind, modified_ns};
4use crate::provider::{FsProvider, ProvidedResource};
5use std::collections::BTreeMap;
6use std::io::{BufRead, Read};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10#[serde(deny_unknown_fields)]
11pub struct CacheImportMapping {
12 pub version: u32,
13 pub old: ResourceId,
14 pub new: ResourceId,
15 pub content_hash: String,
17}
18
19impl CacheImportMapping {
20 pub fn read_jsonl(reader: impl BufRead) -> Result<Vec<Self>> {
22 let mut mappings = Vec::new();
23 for (index, line) in reader.lines().enumerate() {
24 let line = line.map_err(|_| invalid("cannot read mapping file"))?;
25 if line.trim().is_empty() {
26 continue;
27 }
28 let mapping = serde_json::from_str(&line)
29 .map_err(|_| invalid(&format!("invalid mapping on line {}", index + 1)))?;
30 mappings.push(mapping);
31 }
32 validate_mappings(&mappings)?;
33 Ok(mappings)
34 }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct CacheImportReport {
40 pub resources: u64,
41 pub segments: u64,
42 pub vectors: u64,
43 pub fts_rows: u64,
44 pub drops: BTreeMap<String, u64>,
47 pub unmatched_mappings: u64,
48}
49
50impl Default for CacheImportReport {
51 fn default() -> Self {
52 Self {
53 resources: 0,
54 segments: 0,
55 vectors: 0,
56 fts_rows: 0,
57 unmatched_mappings: 0,
58 drops: [
59 "hash_mismatch",
60 "missing_hash",
61 "preparation_changed",
62 "unavailable_or_unstable",
63 "unmapped",
64 ]
65 .into_iter()
66 .map(|reason| (reason.into(), 0))
67 .collect(),
68 }
69 }
70}
71
72impl CacheImportReport {
73 fn drop_resource(&mut self, reason: &str) {
74 *self.drops.entry(reason.into()).or_default() += 1;
75 }
76}
77
78fn invalid(message: &str) -> Error {
79 Error::Config(format!("cache import: {message}"))
80}
81
82fn valid_hash(hash: &str) -> bool {
83 hash.len() == 64
84 && hash
85 .bytes()
86 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
87}
88
89fn validate_mappings(mappings: &[CacheImportMapping]) -> Result<()> {
90 let mut old = HashSet::new();
91 let mut new = HashSet::new();
92 for mapping in mappings {
93 if mapping.version != 1 || !valid_hash(&mapping.content_hash) {
94 return Err(invalid(
95 "mapping requires version 1 and a lowercase BLAKE3 hash",
96 ));
97 }
98 if mapping.old.to_file_path().is_none() || mapping.new.to_file_path().is_none() {
99 return Err(invalid("mapping requires absolute local file URIs"));
100 }
101 if !old.insert(&mapping.old) || !new.insert(&mapping.new) {
102 return Err(invalid("duplicate old or new mapping identity"));
103 }
104 }
105 Ok(())
106}
107
108fn reject_alias(source: &Path, destination: &Path) -> Result<()> {
109 let src = std::fs::canonicalize(source).map_err(|e| Error::io(source, e))?;
110 if destination.exists() {
111 let dst = std::fs::canonicalize(destination).map_err(|e| Error::io(destination, e))?;
112 let alias = src == dst;
113 #[cfg(unix)]
114 let alias = {
115 use std::os::unix::fs::MetadataExt;
116 let a = std::fs::metadata(source).map_err(|e| Error::io(source, e))?;
117 let b = std::fs::metadata(destination).map_err(|e| Error::io(destination, e))?;
118 alias || (a.dev(), a.ino()) == (b.dev(), b.ino())
119 };
120 if alias {
121 return Err(invalid("source and destination identify the same file"));
122 }
123 }
124 Ok(())
125}
126
127fn source_snapshot(source: &Path) -> Result<Connection> {
128 let absolute = std::fs::canonicalize(source).map_err(|e| Error::io(source, e))?;
129 let mut uri = url::Url::from_file_path(absolute).map_err(|_| invalid("invalid source path"))?;
130 uri.set_query(Some("mode=ro"));
131 let conn = Connection::open_with_flags(
132 uri.as_str(),
133 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
134 )?;
135 conn.execute_batch("BEGIN")?;
136 let has_meta: bool = conn.query_row(
137 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name='meta')",
138 [],
139 |r| r.get(0),
140 )?;
141 if !has_meta {
142 return Err(invalid("source requires schema v6 or v7"));
143 }
144 let version: Option<String> = conn
145 .query_row(
146 "SELECT value FROM meta WHERE key='schema_version'",
147 [],
148 |r| r.get(0),
149 )
150 .optional()?;
151 if !matches!(version.as_deref(), Some("6" | "7")) {
152 return Err(invalid(
153 "source requires schema v6 or v7; open older caches once with this seyk first",
154 ));
155 }
156 Ok(conn)
157}
158
159fn read_profile(conn: &Connection, id: &str) -> Result<CacheProfile> {
160 let (json, dimension): (String, i64) = conn
161 .query_row(
162 "SELECT profile,dimension FROM profiles WHERE id=?1",
163 [id],
164 |r| Ok((r.get(0)?, r.get(1)?)),
165 )
166 .optional()?
167 .ok_or_else(|| invalid("profile not found in source"))?;
168 let profile: CacheProfile =
169 serde_json::from_str(&json).map_err(|_| invalid("invalid profile definition"))?;
170 let stored: serde_json::Value =
171 serde_json::from_str(&json).map_err(|_| invalid("invalid profile definition"))?;
172 if stored != serde_json::to_value(&profile).map_err(json_err)?
173 || profile.id() != id
174 || dimension != profile.model.dimension.get() as i64
175 {
176 return Err(invalid("profile identifier or dimension mismatch"));
177 }
178 Ok(profile)
179}
180
181impl SqliteCache {
182 pub fn import_profile(source: &Path, id: &str) -> Result<CacheProfile> {
184 read_profile(&source_snapshot(source)?, id)
185 }
186
187 pub fn import_from(
192 source: &Path,
193 destination: &Path,
194 profile: &CacheProfile,
195 mappings: &[CacheImportMapping],
196 ) -> Result<CacheImportReport> {
197 Self::import_inner(source, destination, profile, mappings, |_| Ok(()))
198 }
199
200 fn import_inner(
201 source: &Path,
202 destination: &Path,
203 profile: &CacheProfile,
204 mappings: &[CacheImportMapping],
205 mut checkpoint: impl FnMut(&str) -> Result<()>,
206 ) -> Result<CacheImportReport> {
207 validate_mappings(mappings)?;
208 reject_alias(source, destination)?;
209 let src = source_snapshot(source)?;
210 let id = profile.id();
211 if read_profile(&src, &id)? != *profile {
212 return Err(invalid("profile definition mismatch"));
213 }
214 let mut dst = Self::open_internal(
215 destination.to_path_buf(),
216 crate::cache_guards::prepare,
217 false,
218 )?;
219 let tx = dst
220 .conn
221 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
222 let has_meta: bool = tx.query_row(
223 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name='meta')",
224 [],
225 |r| r.get(0),
226 )?;
227 if has_meta {
228 let version: Option<String> = tx
229 .query_row(
230 "SELECT value FROM meta WHERE key='schema_version'",
231 [],
232 |r| r.get(0),
233 )
234 .optional()?;
235 if !matches!(version.as_deref(), Some("6" | "7")) {
236 return Err(invalid(
237 "destination must be fresh or empty schema v6 or v7",
238 ));
239 }
240 }
241 let extra: bool = tx.query_row(
242 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name NOT GLOB 'sqlite_*'
243 AND NOT (type='table' AND name IN ('meta','profiles','resources','segments','embeddings','image_codes'))
244 AND NOT (type='index' AND name='segments_by_input' AND tbl_name='segments'))",
245 [], |r| r.get(0))?;
246 if extra {
247 return Err(invalid("destination contains foreign schema objects"));
248 }
249 Self::initialize_schema(&tx)?;
250 for table in [
251 "profiles",
252 "resources",
253 "segments",
254 "embeddings",
255 "image_codes",
256 ] {
257 let count: i64 =
258 tx.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))?;
259 if count != 0 {
260 return Err(invalid("destination is populated"));
261 }
262 }
263 let metadata_count: i64 = tx.query_row(
264 "SELECT count(*) FROM meta WHERE key != 'schema_version'",
265 [],
266 |r| r.get(0),
267 )?;
268 if metadata_count != 0 {
269 return Err(invalid("destination has pending metadata"));
270 }
271 tx.execute(
272 "INSERT INTO profiles VALUES(?1,?2,?3)",
273 params![
274 id,
275 serde_json::to_string(profile).map_err(json_err)?,
276 profile.model.dimension.get() as i64
277 ],
278 )?;
279 create_profile_fts(&tx, &id)?;
280 let mut report = CacheImportReport::default();
281 let source_count: i64 = src.query_row(
282 "SELECT count(*) FROM resources WHERE profile=?1",
283 [&id],
284 |r| r.get(0),
285 )?;
286 let mut matched = 0;
287 for mapping in mappings {
288 let old = resource_key(&mapping.old);
289 let row: Option<(String, Option<String>, String, Option<String>)> = src.query_row(
290 "SELECT content_hash,descriptor_hash,modality,media_type FROM resources WHERE profile=?1 AND path=?2",
291 params![id, old], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).optional()?;
292 let Some((hash, descriptor, modality, media_type)) = row else {
293 report.unmatched_mappings += 1;
294 continue;
295 };
296 matched += 1;
297 if !valid_hash(&hash) {
298 report.drop_resource("missing_hash");
299 continue;
300 }
301 let Some(descriptor) = descriptor else {
302 report.drop_resource("preparation_changed");
303 continue;
304 };
305 validate_resource_type(&modality, media_type.as_deref())?;
306 let Some(new_descriptor) = path_descriptor(&mapping.new) else {
307 report.drop_resource("preparation_changed");
308 continue;
309 };
310 let Some(old_descriptor) = path_descriptor(&mapping.old) else {
311 report.drop_resource("preparation_changed");
312 continue;
313 };
314 if old_descriptor.fingerprint() != new_descriptor.fingerprint()
315 || (matches!(new_descriptor.kind, ResourceKind::Text(_))
316 && descriptor != new_descriptor.fingerprint())
317 || new_descriptor.kind.modality().to_string() != modality
318 {
319 report.drop_resource("preparation_changed");
320 continue;
321 }
322 let verified = match verify_file(&mapping.new) {
323 Ok(value) => value,
324 Err(_) => {
325 report.drop_resource("unavailable_or_unstable");
326 continue;
327 }
328 };
329 if verified.0 != hash || mapping.content_hash != hash {
330 report.drop_resource("hash_mismatch");
331 continue;
332 }
333 let key = resource_key(&mapping.new);
334 let version = ResourceVersion::file(verified.1, verified.2);
335 tx.execute("INSERT INTO resources(profile,path,modality,size_bytes,modified_ns,content_hash,version,descriptor_hash,media_type) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9)",
336 params![id,key,modality,verified.1 as i64,verified.2,hash,serde_json::to_string(&version).map_err(json_err)?,descriptor,media_type])?;
337 checkpoint("resource")?;
338 import_segments(
339 &src,
340 &tx,
341 &id,
342 profile,
343 &old,
344 &mapping.new,
345 verified.1,
346 &mut report,
347 &mut checkpoint,
348 )?;
349 report.resources += 1;
350 }
351 report
352 .drops
353 .insert("unmapped".into(), source_count as u64 - matched);
354 let violations: bool = tx.query_row(
355 "SELECT EXISTS(SELECT 1 FROM pragma_foreign_key_check)",
356 [],
357 |r| r.get(0),
358 )?;
359 let missing: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM segments s LEFT JOIN embeddings e ON s.profile=e.profile AND s.input_hash=e.input_hash WHERE e.input_hash IS NULL)", [], |r| r.get(0))?;
360 let fts_count: i64 = tx.query_row(
361 &format!("SELECT count(*) FROM {}", fts_table(&id)),
362 [],
363 |r| r.get(0),
364 )?;
365 if violations || missing || fts_count as u64 != report.segments {
366 return Err(invalid("destination integrity check failed"));
367 }
368 tx.execute_batch(&format!(
369 "INSERT INTO {0}({0}) VALUES('integrity-check')",
370 fts_table(&id)
371 ))?;
372 checkpoint("commit")?;
373 tx.commit()?;
374 Ok(report)
375 }
376}
377
378fn validate_resource_type(modality: &str, media_type: Option<&str>) -> Result<()> {
379 if matches!(
380 (modality, media_type),
381 ("text" | "image" | "video", None)
382 | ("document", Some("application/pdf"))
383 | (
384 "audio",
385 Some("audio/wav" | "audio/flac" | "audio/mp4" | "audio/mpeg")
386 )
387 ) {
388 Ok(())
389 } else {
390 Err(invalid("invalid modality or media type"))
391 }
392}
393
394fn path_descriptor(id: &ResourceId) -> Option<ProvidedResource> {
395 let path = id.to_file_path()?;
396 Some(ProvidedResource {
397 id: id.clone(),
398 kind: ResourceKind::from_path(&path)?,
399 size_bytes: 0,
400 modified_ns: None,
401 version: ResourceVersion::unknown(),
402 title: path.file_stem().map(|s| s.to_string_lossy().into_owned()),
403 })
404}
405
406fn stable_metadata(a: &std::fs::Metadata, b: &std::fs::Metadata) -> bool {
407 let same =
408 a.is_file() && b.is_file() && a.len() == b.len() && a.modified().ok() == b.modified().ok();
409 #[cfg(unix)]
410 {
411 use std::os::unix::fs::MetadataExt;
412 same && (a.dev(), a.ino(), a.ctime(), a.ctime_nsec())
413 == (b.dev(), b.ino(), b.ctime(), b.ctime_nsec())
414 }
415 #[cfg(not(unix))]
416 {
417 same
418 }
419}
420
421fn verify_file(id: &ResourceId) -> Result<(String, u64, Option<i64>)> {
422 verify_file_after_read(id, || {})
423}
424
425fn verify_file_after_read(
426 id: &ResourceId,
427 after_read: impl FnOnce(),
428) -> Result<(String, u64, Option<i64>)> {
429 let mut file = FsProvider::open_verified_handle(id)?;
430 let path = id.to_file_path().expect("validated local mapping");
431 let before = file.metadata().map_err(|e| Error::io(&path, e))?;
432 let mut hasher = blake3::Hasher::new();
433 let mut buffer = [0u8; 64 * 1024];
434 loop {
435 let n = file.read(&mut buffer).map_err(|e| Error::io(&path, e))?;
436 if n == 0 {
437 break;
438 }
439 hasher.update(&buffer[..n]);
440 }
441 after_read();
442 let after = file.metadata().map_err(|e| Error::io(&path, e))?;
443 let current = FsProvider::open_verified_handle(id)?
444 .metadata()
445 .map_err(|e| Error::io(&path, e))?;
446 if !stable_metadata(&before, &after) || !stable_metadata(&after, ¤t) {
447 return Err(Error::ChangedWhileReading {
448 resource: id.clone(),
449 });
450 }
451 Ok((
452 hasher.finalize().to_hex().to_string(),
453 after.len(),
454 modified_ns(&after),
455 ))
456}
457
458#[allow(clippy::too_many_arguments)]
459fn import_segments(
460 src: &Connection,
461 tx: &rusqlite::Transaction<'_>,
462 id: &str,
463 profile: &CacheProfile,
464 old: &[u8],
465 new: &ResourceId,
466 size: u64,
467 report: &mut CacheImportReport,
468 checkpoint: &mut impl FnMut(&str) -> Result<()>,
469) -> Result<()> {
470 let mut stmt = src.prepare("SELECT id,ordinal,locator,heading,input_hash FROM segments WHERE profile=?1 AND path=?2 ORDER BY ordinal")?;
471 let mut rows = stmt.query(params![id, old])?;
472 let (name, path) = new.labels();
473 let mut expected_ordinal = 0;
474 while let Some(row) = rows.next()? {
475 let rowid: i64 = row.get(0)?;
476 let ordinal: i64 = row.get(1)?;
477 if ordinal != expected_ordinal {
478 return Err(invalid("noncontiguous segment ordinals"));
479 }
480 expected_ordinal += 1;
481 let locator_json: String = row.get(2)?;
482 let locator: ResourceLocator =
483 serde_json::from_str(&locator_json).map_err(|_| invalid("malformed locator"))?;
484 let stored_locator: serde_json::Value =
485 serde_json::from_str(&locator_json).map_err(|_| invalid("malformed locator"))?;
486 if stored_locator != serde_json::to_value(&locator).map_err(json_err)? {
487 return Err(invalid("unexpected locator fields"));
488 }
489 validate_locator(&locator, size)?;
490 let heading: Option<String> = row.get(3)?;
491 let input: String = row.get(4)?;
492 if !valid_hash(&input) {
493 return Err(invalid("invalid input hash"));
494 }
495 let vector: Vec<u8> = src.query_row(
496 "SELECT vector FROM embeddings WHERE profile=?1 AND input_hash=?2",
497 params![id, input],
498 |r| r.get(0),
499 )?;
500 if vector.len() != profile.model.dimension.get() * 4
501 || from_bytes(&vector).map_or(true, |values| values.iter().any(|v| !v.is_finite()))
502 {
503 return Err(invalid("invalid vector encoding or dimension"));
504 }
505 let (fts_heading, body): (String, String) = src.query_row(
506 &format!("SELECT heading,body FROM {} WHERE rowid=?1", fts_table(id)),
507 [rowid],
508 |r| Ok((r.get(0)?, r.get(1)?)),
509 )?;
510 if order_free_text(&fts_heading) != fts_heading
511 || order_free_text(&body) != body
512 || order_free_text(heading.as_deref().unwrap_or("")) != fts_heading
513 {
514 return Err(invalid("inconsistent or non-order-free keyword row"));
515 }
516 report.vectors += tx.execute(
517 "INSERT OR IGNORE INTO embeddings VALUES(?1,?2,?3)",
518 params![id, input, vector],
519 )? as u64;
520 checkpoint("vector")?;
521 tx.execute("INSERT INTO segments(profile,path,ordinal,locator,heading,input_hash) VALUES(?1,?2,?3,?4,?5,?6)",params![id,resource_key(new),ordinal,serde_json::to_string(&locator).map_err(json_err)?,heading,input])?;
522 checkpoint("segment")?;
523 tx.execute(
524 &format!(
525 "INSERT INTO {}(rowid,name,path,heading,body) VALUES(?1,?2,?3,?4,?5)",
526 fts_table(id)
527 ),
528 params![
529 tx.last_insert_rowid(),
530 order_free_text(&name),
531 order_free_text(&path),
532 fts_heading,
533 body
534 ],
535 )?;
536 checkpoint("fts")?;
537 report.segments += 1;
538 report.fts_rows += 1;
539 }
540 Ok(())
541}
542
543fn validate_locator(locator: &ResourceLocator, size: u64) -> Result<()> {
544 let valid = match locator {
545 ResourceLocator::Text {
546 byte_start,
547 byte_end,
548 line_start,
549 line_end,
550 } => {
551 byte_start <= byte_end
552 && *byte_end <= size
553 && match (line_start, line_end) {
554 (Some(a), Some(b)) => *a > 0 && a <= b,
555 (None, None) => true,
556 _ => false,
557 }
558 }
559 ResourceLocator::PageText {
560 page,
561 byte_start,
562 byte_end,
563 text_version,
564 page_text_hash,
565 } => {
566 *page > 0
567 && byte_start <= byte_end
568 && text_version == crate::resource::PDF_TEXT_VERSION
569 && valid_hash(page_text_hash)
570 }
571 ResourceLocator::Page { page } => *page > 0,
572 ResourceLocator::Time { start_ms, end_ms } => start_ms < end_ms,
573 ResourceLocator::ImageRegion { width, height, .. } => *width > 0 && *height > 0,
574 ResourceLocator::WholeResource => true,
575 };
576 if valid {
577 Ok(())
578 } else {
579 Err(invalid("invalid locator range"))
580 }
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586 use crate::{Dimension, ModelIdentity};
587
588 fn fixture(root: &Path) -> (PathBuf, PathBuf, CacheProfile, CacheImportMapping) {
589 let old = root.join("old/note.txt");
590 let new = root.join("new/note.txt");
591 std::fs::create_dir_all(new.parent().unwrap()).unwrap();
592 std::fs::write(&new, b"violet amber violet").unwrap();
593 let source = root.join("source.sqlite");
594 let destination = root.join("destination.sqlite");
595 let profile = CacheProfile {
596 model: ModelIdentity {
597 model_id: "synthetic".into(),
598 revision: "1".into(),
599 variant: "test".into(),
600 dimension: Dimension::D128,
601 normalization: "l2".into(),
602 query_prompt: "q".into(),
603 document_prompt: "d".into(),
604 max_tokens: 512,
605 },
606 segmenter_version: "test".into(),
607 };
608 let mapping = CacheImportMapping {
609 version: 1,
610 old: ResourceId::from_path(old).unwrap(),
611 new: ResourceId::from_path(new).unwrap(),
612 content_hash: blake3::hash(b"violet amber violet").to_hex().to_string(),
613 };
614 let mut cache = SqliteCache::open(&source).unwrap();
615 cache.register_profile(&profile.id(), &profile).unwrap();
616 let input_hash = blake3::hash(b"synthetic model input").to_hex().to_string();
617 cache
618 .put_embeddings(&profile.id(), &[(input_hash.clone(), vec![0.25; 128])])
619 .unwrap();
620 let segment = CachedSegment {
621 locator: ResourceLocator::Text {
622 byte_start: 0,
623 byte_end: 19,
624 line_start: Some(1),
625 line_end: Some(1),
626 },
627 heading: None,
628 input_hash,
629 };
630 let descriptor = path_descriptor(&mapping.old).unwrap().fingerprint();
631 cache
632 .put_resource(
633 &profile.id(),
634 &mapping.old,
635 &ResourceWrite {
636 metadata: &ResourceMetadata {
637 modality: Modality::Text,
638 media_type: None,
639 size_bytes: 19,
640 modified_ns: None,
641 content_hash: mapping.content_hash.clone(),
642 },
643 version: &ResourceVersion::unknown(),
644 descriptor_hash: Some(&descriptor),
645 segments: vec![SegmentWrite {
646 segment: &segment,
647 lexical_body: "violet amber violet",
648 }],
649 },
650 )
651 .unwrap();
652 (source, destination, profile, mapping)
653 }
654
655 #[test]
656 fn every_write_stage_and_precommit_failure_roll_back_schema_and_rows() {
657 for initialized in [false, true] {
658 for stage in ["resource", "vector", "segment", "fts", "commit"] {
659 let temp = tempfile::tempdir().unwrap();
660 let root = temp.path().canonicalize().unwrap();
661 let (source, destination, profile, mapping) = fixture(&root);
662 if initialized {
663 drop(SqliteCache::open(&destination).unwrap());
664 }
665 let before_source = std::fs::read(&source).unwrap();
666 let fail = SqliteCache::import_inner(
667 &source,
668 &destination,
669 &profile,
670 std::slice::from_ref(&mapping),
671 |at| {
672 if at == stage {
673 Err(invalid("injected failure"))
674 } else {
675 Ok(())
676 }
677 },
678 );
679 assert!(fail.is_err(), "{stage}");
680 assert_eq!(std::fs::read(&source).unwrap(), before_source);
681 let conn = Connection::open(&destination).unwrap();
682 let count: i64 = conn.query_row("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",[],|r| r.get(0)).unwrap();
683 assert_eq!(count, if initialized { 6 } else { 0 }, "{stage}");
684 drop(conn);
685 assert_eq!(
686 SqliteCache::import_from(&source, &destination, &profile, &[mapping])
687 .unwrap()
688 .resources,
689 1
690 );
691 }
692 }
693 }
694
695 #[test]
696 fn handle_mutation_replacement_and_disappearance_fail_verification() {
697 for action in ["mutate", "replace", "delete"] {
698 let temp = tempfile::tempdir().unwrap();
699 let root = temp.path().canonicalize().unwrap();
700 let (_, _, _, mapping) = fixture(&root);
701 let path = mapping.new.to_file_path().unwrap();
702 let result = verify_file_after_read(&mapping.new, || match action {
703 "mutate" => std::fs::write(&path, b"changed bytes with different length").unwrap(),
704 "replace" => {
705 let replacement = path.with_extension("replacement");
706 std::fs::write(&replacement, b"violet amber violet").unwrap();
707 std::fs::rename(replacement, &path).unwrap();
708 }
709 _ => std::fs::remove_file(&path).unwrap(),
710 });
711 assert!(result.is_err(), "{action}");
712 }
713 }
714
715 #[test]
716 fn imported_locator_is_reencoded_in_canonical_form() {
717 let temp = tempfile::tempdir().unwrap();
718 let root = temp.path().canonicalize().unwrap();
719 let (source, destination, profile, mapping) = fixture(&root);
720 let conn = Connection::open(&source).unwrap();
721 let original: String = conn
722 .query_row("SELECT locator FROM segments", [], |r| r.get(0))
723 .unwrap();
724 let locator: ResourceLocator = serde_json::from_str(&original).unwrap();
725 let pretty = serde_json::to_string_pretty(&locator).unwrap();
726 assert_ne!(original, pretty);
727 conn.execute("UPDATE segments SET locator=?1", [&pretty])
728 .unwrap();
729 SqliteCache::import_from(&source, &destination, &profile, &[mapping]).unwrap();
730 let stored: String = Connection::open(destination)
731 .unwrap()
732 .query_row("SELECT locator FROM segments", [], |r| r.get(0))
733 .unwrap();
734 assert_eq!(stored, serde_json::to_string(&locator).unwrap());
735 assert_ne!(stored, pretty);
736 }
737
738 #[cfg(unix)]
739 #[test]
740 fn same_length_mutation_with_restored_mtime_fails_verification() {
741 let temp = tempfile::tempdir().unwrap();
742 let root = temp.path().canonicalize().unwrap();
743 let (_, _, _, mapping) = fixture(&root);
744 let path = mapping.new.to_file_path().unwrap();
745 let before = std::fs::metadata(&path).unwrap();
746 let result = verify_file_after_read(&mapping.new, || {
747 std::fs::write(&path, b"amber violet violet").unwrap();
748 std::fs::File::open(&path)
749 .unwrap()
750 .set_times(std::fs::FileTimes::new().set_modified(before.modified().unwrap()))
751 .unwrap();
752 });
753 let after = std::fs::metadata(path).unwrap();
754 assert_eq!(before.len(), after.len());
755 assert_eq!(before.modified().unwrap(), after.modified().unwrap());
756 assert!(result.is_err());
757 }
758
759 #[test]
760 fn source_wal_snapshot_stays_consistent_during_concurrent_commit() {
761 let temp = tempfile::tempdir().unwrap();
762 let root = temp.path().canonicalize().unwrap();
763 let (source, destination, profile, mapping) = fixture(&root);
764 let writer = Connection::open(&source).unwrap();
766 let report = SqliteCache::import_inner(&source, &destination, &profile, &[mapping], |at| {
767 if at == "resource" {
768 writer.execute("UPDATE embeddings SET vector=x'00'", [])?;
769 }
770 Ok(())
771 })
772 .unwrap();
773 assert_eq!(report.resources, 1);
774 let width: i64 = Connection::open(destination)
775 .unwrap()
776 .query_row("SELECT length(vector) FROM embeddings", [], |r| r.get(0))
777 .unwrap();
778 assert_eq!(width, 512);
779 }
780
781 #[test]
782 fn pdf_and_audio_keep_composite_descriptors_for_runtime_validation() {
783 for (extension, modality, locator) in [
784 (
785 "pdf",
786 "document",
787 ResourceLocator::PageText {
788 page: 1,
789 byte_start: 0,
790 byte_end: 3,
791 text_version: crate::resource::PDF_TEXT_VERSION.into(),
792 page_text_hash: blake3::hash(b"synthetic page").to_hex().to_string(),
793 },
794 ),
795 (
796 "wav",
797 "audio",
798 ResourceLocator::Time {
799 start_ms: 0,
800 end_ms: 10,
801 },
802 ),
803 ] {
804 let temp = tempfile::tempdir().unwrap();
805 let root = temp.path().canonicalize().unwrap();
806 let (source, destination, profile, mut mapping) = fixture(&root);
807 let old = mapping
808 .old
809 .to_file_path()
810 .unwrap()
811 .with_extension(extension);
812 let new = mapping
813 .new
814 .to_file_path()
815 .unwrap()
816 .with_extension(extension);
817 std::fs::rename(mapping.new.to_file_path().unwrap(), &new).unwrap();
818 mapping.old = ResourceId::from_path(old).unwrap();
819 mapping.new = ResourceId::from_path(new).unwrap();
820 let conn = Connection::open(&source).unwrap();
821 conn.execute_batch("BEGIN; PRAGMA defer_foreign_keys=ON;")
822 .unwrap();
823 conn.execute("UPDATE resources SET path=?1,modality=?2,media_type=?3,descriptor_hash='composite-runtime-identity'",params![resource_key(&mapping.old),modality,if modality == "document" { "application/pdf" } else { "audio/wav" }]).unwrap();
824 conn.execute(
825 "UPDATE segments SET path=?1,locator=?2",
826 params![
827 resource_key(&mapping.old),
828 serde_json::to_string(&locator).unwrap()
829 ],
830 )
831 .unwrap();
832 conn.execute_batch("COMMIT").unwrap();
833 assert_eq!(
834 SqliteCache::import_from(
835 &source,
836 &destination,
837 &profile,
838 std::slice::from_ref(&mapping)
839 )
840 .unwrap()
841 .resources,
842 1
843 );
844 let resource = SqliteCache::open(destination)
845 .unwrap()
846 .resource(&profile.id(), &mapping.new)
847 .unwrap()
848 .unwrap();
849 assert_eq!(
850 resource.descriptor_hash.as_deref(),
851 Some("composite-runtime-identity")
852 );
853 assert_eq!(resource.segments[0].locator, locator);
854 }
855 }
856
857 #[test]
858 fn verified_metadata_and_provider_token_come_from_opened_file() {
859 let temp = tempfile::tempdir().unwrap();
860 let root = temp.path().canonicalize().unwrap();
861 let (source, destination, profile, mapping) = fixture(&root);
862 SqliteCache::import_from(
863 &source,
864 &destination,
865 &profile,
866 std::slice::from_ref(&mapping),
867 )
868 .unwrap();
869 let cache = SqliteCache::open(destination).unwrap();
870 let resource = cache
871 .resource(&profile.id(), &mapping.new)
872 .unwrap()
873 .unwrap();
874 let metadata = std::fs::metadata(mapping.new.to_file_path().unwrap()).unwrap();
875 assert_eq!(resource.metadata.size_bytes, metadata.len());
876 assert_eq!(resource.metadata.modified_ns, modified_ns(&metadata));
877 assert_eq!(
878 resource.version,
879 ResourceVersion::file(metadata.len(), modified_ns(&metadata))
880 );
881 }
882}