Skip to main content

seyk_core/cache/
import.rs

1//! Verified, filtering import into a fresh cache. No extraction or model access.
2use super::*;
3use crate::discovery::{ResourceKind, modified_ns};
4use crate::provider::{FsProvider, ProvidedResource};
5use std::collections::BTreeMap;
6use std::io::{BufRead, Read};
7
8/// One explicit, one-to-one local file mapping in JSONL format version 1.
9#[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    /// Caller-supplied full-file BLAKE3, 64 lowercase hexadecimal characters.
16    pub content_hash: String,
17}
18
19impl CacheImportMapping {
20    /// Parse nonblank JSONL lines. Diagnostics contain line numbers, never input data.
21    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/// Counts only: no source excerpts, terms, paths or vector bytes.
38#[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    /// All five reasons are always listed, including zero counts: hash_mismatch,
45    /// missing_hash, preparation_changed, unavailable_or_unstable and unmapped.
46    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    /// Select a validated profile without opening the source as a writable cache.
183    pub fn import_profile(source: &Path, id: &str) -> Result<CacheProfile> {
184        read_profile(&source_snapshot(source)?, id)
185    }
186
187    /// Seed an absent or completely empty v7 cache from verified local files.
188    /// Schema v6 and v7 sources are supported; derived image codes are ignored. Errors roll back schema and data;
189    /// verification failures drop individual resources and are reported as counts.
190    /// PDF/audio preparation compatibility is also checked by the runtime on reuse.
191    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, &current) {
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        // Keep WAL handles live while the importer and a writer hold distinct connections.
765        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}