Skip to main content

graphforge_storage/
search_manifest.rs

1//! Canonical identity, manifests, and freshness for M19 search artifacts.
2//!
3//! Text indexes and caller-supplied vector stores share this metadata contract.
4//! Raw selectors never become path components: normalized UTF-8 bytes are
5//! length-framed, hexadecimal encoded, and split into bounded safe segments.
6
7use std::path::{Path, PathBuf};
8
9use graphforge_core::GfError;
10
11/// Search manifest format implemented by this release.
12pub const SEARCH_MANIFEST_VERSION: u32 = 1;
13/// Maximum accepted JSON manifest size.
14pub const MAX_SEARCH_MANIFEST_BYTES: usize = 64 * 1024;
15/// Maximum bytes in one normalized label, property, or vector-space selector.
16pub const MAX_SEARCH_SELECTOR_BYTES: usize = 128;
17/// Maximum combined normalized bytes represented by one artifact key.
18pub const MAX_SEARCH_ARTIFACT_KEY_BYTES: usize = 384;
19
20/// Errors produced by shared search storage and publication.
21#[derive(Debug, thiserror::Error)]
22pub enum SearchArtifactError {
23    /// A caller selector cannot be normalized into the v0.5 contract.
24    #[error("invalid search {field}: {reason}")]
25    InvalidSelector {
26        /// Selector field.
27        field: &'static str,
28        /// Stable validation reason.
29        reason: String,
30    },
31    /// No published artifact exists for the requested key.
32    #[error("search artifact is missing at {}", path.display())]
33    Missing {
34        /// Expected path.
35        path: PathBuf,
36    },
37    /// A JSON manifest or publication pointer is malformed.
38    #[error("corrupt search manifest at {}: {reason}", path.display())]
39    CorruptManifest {
40        /// Corrupt metadata path.
41        path: PathBuf,
42        /// Parse or contract failure.
43        reason: String,
44    },
45    /// Backend files for a rebuildable derived text index failed validation.
46    #[error("corrupt derived search index at {}: {reason}", path.display())]
47    CorruptDerivedIndex {
48        /// Corrupt derived artifact.
49        path: PathBuf,
50        /// Backend validation failure.
51        reason: String,
52    },
53    /// A manifest uses a version this binary cannot consume.
54    #[error(
55        "incompatible search manifest at {}: version {found}, supported {supported}",
56        path.display()
57    )]
58    IncompatibleManifest {
59        /// Manifest path.
60        path: PathBuf,
61        /// Version found on disk.
62        found: u64,
63        /// Version supported by this binary.
64        supported: u32,
65    },
66    /// A verified manifest does not describe the current source snapshot.
67    #[error("stale search artifact: {reason}")]
68    Stale {
69        /// Deterministic mismatch reason.
70        reason: String,
71    },
72    /// Caller-supplied vector data is corrupt and must not be discarded.
73    #[error("corrupt primary vector data at {}: {reason}", path.display())]
74    CorruptPrimaryVectors {
75        /// Corrupt vector artifact.
76        path: PathBuf,
77        /// Validation failure.
78        reason: String,
79    },
80    /// The graph changed twice while the bounded build retry ran.
81    #[error("graph changed during both search publication attempts")]
82    ConcurrentMutation,
83    /// The per-artifact writer lock could not be acquired.
84    #[error("search writer lock failed at {}: {reason}", path.display())]
85    Lock {
86        /// Lock-file path.
87        path: PathBuf,
88        /// I/O or timeout reason.
89        reason: String,
90    },
91    /// Cooperative cancellation stopped work before publication.
92    #[error("search operation cancelled")]
93    Cancelled,
94    /// A named search resource limit was exceeded.
95    #[error("search resource limit exceeded for {resource}: limit {limit}")]
96    ResourceExhausted {
97        /// Bounded resource.
98        resource: &'static str,
99        /// Configured maximum.
100        limit: u64,
101    },
102    /// Backend construction failed without publishing partial state.
103    #[error("search artifact build failed: {0}")]
104    Build(String),
105    /// Capturing the committed graph source snapshot failed.
106    #[error("search source snapshot failed: {reason}")]
107    SourceSnapshot {
108        /// Storage or fingerprint failure.
109        reason: String,
110    },
111    /// Filesystem work failed.
112    #[error("search storage {operation} failed at {}: {source}", path.display())]
113    Io {
114        /// Stable operation name.
115        operation: &'static str,
116        /// Affected path.
117        path: PathBuf,
118        /// Operating-system failure.
119        #[source]
120        source: std::io::Error,
121    },
122}
123
124impl From<SearchArtifactError> for GfError {
125    fn from(error: SearchArtifactError) -> Self {
126        let message = error.to_string();
127        match error {
128            SearchArtifactError::InvalidSelector { .. } => Self::Validation(message),
129            SearchArtifactError::Cancelled
130            | SearchArtifactError::ResourceExhausted { .. }
131            | SearchArtifactError::Build(_) => Self::Execution(message),
132            SearchArtifactError::ConcurrentMutation => Self::Lifecycle(message),
133            SearchArtifactError::Missing { .. }
134            | SearchArtifactError::CorruptManifest { .. }
135            | SearchArtifactError::CorruptDerivedIndex { .. }
136            | SearchArtifactError::IncompatibleManifest { .. }
137            | SearchArtifactError::Stale { .. }
138            | SearchArtifactError::CorruptPrimaryVectors { .. }
139            | SearchArtifactError::SourceSnapshot { .. }
140            | SearchArtifactError::Lock { .. }
141            | SearchArtifactError::Io { .. } => Self::Storage(message),
142        }
143    }
144}
145
146/// Physical search backend represented by an artifact.
147#[derive(Clone, Copy, Debug, PartialEq, Eq)]
148pub enum SearchIndexKind {
149    /// Rebuildable Tantivy text index.
150    Text,
151    /// Caller-supplied primary vector data.
152    Vector,
153}
154
155impl SearchIndexKind {
156    fn as_str(self) -> &'static str {
157        match self {
158            Self::Text => "text",
159            Self::Vector => "vector",
160        }
161    }
162
163    fn parse(value: &str, path: &Path) -> Result<Self, SearchArtifactError> {
164        match value {
165            "text" => Ok(Self::Text),
166            "vector" => Ok(Self::Vector),
167            other => Err(corrupt(path, format!("unknown index_kind {other:?}"))),
168        }
169    }
170}
171
172/// Canonical normalized identity of one search artifact.
173#[derive(Clone, Debug, PartialEq, Eq)]
174pub struct SearchArtifactKey {
175    kind: SearchIndexKind,
176    label: String,
177    properties: Option<Vec<String>>,
178    space: Option<String>,
179}
180
181impl SearchArtifactKey {
182    /// Construct a text key. Property names are trimmed, sorted, and deduplicated.
183    ///
184    /// # Errors
185    /// Returns [`SearchArtifactError::InvalidSelector`] for empty, control-
186    /// containing, overlong, or collectively oversized selectors.
187    pub fn text<I, S>(label: &str, properties: I) -> Result<Self, SearchArtifactError>
188    where
189        I: IntoIterator<Item = S>,
190        S: AsRef<str>,
191    {
192        let label = normalize_selector("label", label)?;
193        let mut properties = properties
194            .into_iter()
195            .map(|property| normalize_selector("property", property.as_ref()))
196            .collect::<Result<Vec<_>, _>>()?;
197        properties.sort_unstable();
198        properties.dedup();
199        if properties.is_empty() {
200            return Err(invalid("properties", "at least one property is required"));
201        }
202        let key = Self {
203            kind: SearchIndexKind::Text,
204            label,
205            properties: Some(properties),
206            space: None,
207        };
208        key.validate_total_size()?;
209        Ok(key)
210    }
211
212    /// Construct a vector key for one required label and normalized space.
213    ///
214    /// # Errors
215    /// Returns [`SearchArtifactError::InvalidSelector`] for an invalid selector.
216    pub fn vector(label: &str, space: &str) -> Result<Self, SearchArtifactError> {
217        let key = Self {
218            kind: SearchIndexKind::Vector,
219            label: normalize_selector("label", label)?,
220            properties: None,
221            space: Some(normalize_selector("space", space)?),
222        };
223        key.validate_total_size()?;
224        Ok(key)
225    }
226
227    /// Backend kind.
228    #[must_use]
229    pub const fn kind(&self) -> SearchIndexKind {
230        self.kind
231    }
232
233    /// Normalized graph label.
234    #[must_use]
235    pub fn label(&self) -> &str {
236        &self.label
237    }
238
239    /// Canonical sorted text properties.
240    #[must_use]
241    pub fn properties(&self) -> Option<&[String]> {
242        self.properties.as_deref()
243    }
244
245    /// Normalized vector space.
246    #[must_use]
247    pub fn space(&self) -> Option<&str> {
248        self.space.as_deref()
249    }
250
251    /// Collision-free, filesystem-safe root for this key.
252    #[must_use]
253    pub fn artifact_root(&self, project_dir: &Path) -> PathBuf {
254        match self.kind {
255            SearchIndexKind::Text => {
256                let mut path = project_dir.join("indexes").join("search").join("text");
257                push_encoded(&mut path, "label", self.label.as_bytes());
258                let properties = self
259                    .properties
260                    .as_deref()
261                    .expect("text keys always carry properties");
262                push_encoded(&mut path, "properties", &encode_sequence(properties));
263                path
264            }
265            SearchIndexKind::Vector => {
266                let mut path = project_dir.join("embeddings");
267                push_encoded(
268                    &mut path,
269                    "space",
270                    self.space
271                        .as_deref()
272                        .expect("vector keys always carry a space")
273                        .as_bytes(),
274                );
275                push_encoded(&mut path, "label", self.label.as_bytes());
276                path
277            }
278        }
279    }
280
281    fn validate_total_size(&self) -> Result<(), SearchArtifactError> {
282        let property_bytes = self
283            .properties
284            .as_deref()
285            .unwrap_or_default()
286            .iter()
287            .map(String::len)
288            .sum::<usize>();
289        let bytes = self.label.len() + property_bytes + self.space.as_deref().map_or(0, str::len);
290        if bytes > MAX_SEARCH_ARTIFACT_KEY_BYTES {
291            return Err(invalid(
292                "artifact key",
293                format!("{bytes} normalized bytes exceeds {MAX_SEARCH_ARTIFACT_KEY_BYTES}"),
294            ));
295        }
296        Ok(())
297    }
298}
299
300/// Stable graph snapshot identity stored in a search manifest.
301#[derive(Clone, Debug, PartialEq, Eq)]
302pub struct SearchSourceSnapshot {
303    /// Committed search-source generation.
304    pub generation: u64,
305    /// Canonical content fingerprint.
306    pub fingerprint: String,
307}
308
309impl SearchSourceSnapshot {
310    /// Capture the current search generation and fingerprint the supplied
311    /// logical source parts.
312    ///
313    /// # Errors
314    /// Returns a storage error for an unreadable generation or invalid source
315    /// part list.
316    pub fn capture(
317        project_dir: &Path,
318        parts: &[SearchSourcePart<'_>],
319    ) -> Result<Self, SearchArtifactError> {
320        let generation =
321            crate::generation::read_search_generation(project_dir).map_err(|error| {
322                SearchArtifactError::SourceSnapshot {
323                    reason: error.to_string(),
324                }
325            })?;
326        let fingerprint = canonical_source_fingerprint(parts)?;
327        Ok(Self {
328            generation,
329            fingerprint,
330        })
331    }
332}
333
334/// One deterministically named byte source in a committed search snapshot.
335#[derive(Clone, Copy, Debug)]
336pub struct SearchSourcePart<'a> {
337    /// Stable logical name, not an absolute machine path.
338    pub name: &'a str,
339    /// Exact committed bytes.
340    pub bytes: &'a [u8],
341}
342
343/// Produce a deterministic 256-bit content fingerprint.
344///
345/// The four-domain FNV-1a construction is a change detector, not a security
346/// primitive. Supported GraphForge mutations are protected by the generation
347/// counter; this fingerprint additionally detects observable external file
348/// changes without claiming adversarial tamper resistance.
349///
350/// # Errors
351/// Rejects empty, control-containing, or duplicate logical part names.
352pub fn canonical_source_fingerprint(
353    parts: &[SearchSourcePart<'_>],
354) -> Result<String, SearchArtifactError> {
355    let mut ordered = parts.to_vec();
356    ordered.sort_unstable_by(|left, right| left.name.as_bytes().cmp(right.name.as_bytes()));
357    for pair in ordered.windows(2) {
358        if pair[0].name == pair[1].name {
359            return Err(invalid(
360                "source fingerprint",
361                format!("duplicate source part {:?}", pair[0].name),
362            ));
363        }
364    }
365
366    let mut states = [
367        0xcbf2_9ce4_8422_2325_u64,
368        0x8422_2325_cbf2_9ce4_u64,
369        0x9e37_79b9_7f4a_7c15_u64,
370        0xd6e8_feb8_6659_fd93_u64,
371    ];
372    for part in ordered {
373        let name = normalize_source_name(part.name)?;
374        hash_frame(&mut states, name.as_bytes());
375        hash_frame(&mut states, part.bytes);
376    }
377    Ok(format!(
378        "gf-fnv1a256:{:016x}{:016x}{:016x}{:016x}",
379        states[0], states[1], states[2], states[3]
380    ))
381}
382
383/// Versioned JSON metadata for one complete search artifact.
384#[derive(Clone, Debug, PartialEq, Eq)]
385pub struct SearchManifest {
386    /// Search-manifest version.
387    pub manifest_version: u32,
388    /// Text or vector backend.
389    pub index_kind: SearchIndexKind,
390    /// Pinned backend/semantic version.
391    pub backend_version: String,
392    /// Normalized graph label.
393    pub label: String,
394    /// Sorted text properties; absent for vector artifacts.
395    pub properties: Option<Vec<String>>,
396    /// Normalized vector space; absent for text artifacts.
397    pub space: Option<String>,
398    /// Fixed vector dimension; absent for text artifacts.
399    pub dimension: Option<u32>,
400    /// Committed graph mutation generation.
401    pub source_generation: u64,
402    /// Canonical committed-source fingerprint.
403    pub source_fingerprint: String,
404    /// Scoring/tokenization/vector contract version.
405    pub contract_version: String,
406    /// True only in the atomically published manifest.
407    pub completed: bool,
408}
409
410impl SearchManifest {
411    /// Build a manifest for a normalized artifact key and source snapshot.
412    ///
413    /// # Errors
414    /// Rejects invalid versions or a missing/unexpected vector dimension.
415    pub fn for_key(
416        key: &SearchArtifactKey,
417        backend_version: &str,
418        contract_version: &str,
419        dimension: Option<u32>,
420        source: &SearchSourceSnapshot,
421        completed: bool,
422    ) -> Result<Self, SearchArtifactError> {
423        let backend_version = normalize_version("backend_version", backend_version)?;
424        let contract_version = normalize_version("contract_version", contract_version)?;
425        if !canonical_fingerprint(&source.fingerprint) {
426            return Err(invalid(
427                "source_fingerprint",
428                "must be gf-fnv1a256 followed by 64 lowercase hexadecimal digits",
429            ));
430        }
431        match (key.kind, dimension) {
432            (SearchIndexKind::Text, None) => {}
433            (SearchIndexKind::Vector, Some(value)) if value > 0 => {}
434            (SearchIndexKind::Text, Some(_)) => {
435                return Err(invalid("dimension", "text manifests omit dimension"));
436            }
437            (SearchIndexKind::Vector, _) => {
438                return Err(invalid(
439                    "dimension",
440                    "vector manifests require a non-zero dimension",
441                ));
442            }
443        }
444        Ok(Self {
445            manifest_version: SEARCH_MANIFEST_VERSION,
446            index_kind: key.kind,
447            backend_version,
448            label: key.label.clone(),
449            properties: key.properties.clone(),
450            space: key.space.clone(),
451            dimension,
452            source_generation: source.generation,
453            source_fingerprint: source.fingerprint.clone(),
454            contract_version,
455            completed,
456        })
457    }
458
459    /// Serialize canonical compact JSON. Optional backend-specific fields are
460    /// omitted rather than encoded as null.
461    ///
462    /// # Errors
463    /// Returns a corruption error only if JSON serialization unexpectedly
464    /// fails.
465    pub fn to_canonical_json(&self) -> Result<Vec<u8>, SearchArtifactError> {
466        let mut value = serde_json::Map::new();
467        value.insert(
468            "manifest_version".to_owned(),
469            serde_json::Value::from(self.manifest_version),
470        );
471        value.insert(
472            "index_kind".to_owned(),
473            serde_json::Value::from(self.index_kind.as_str()),
474        );
475        value.insert(
476            "backend_version".to_owned(),
477            serde_json::Value::from(self.backend_version.clone()),
478        );
479        value.insert(
480            "label".to_owned(),
481            serde_json::Value::from(self.label.clone()),
482        );
483        if let Some(properties) = &self.properties {
484            value.insert(
485                "properties".to_owned(),
486                serde_json::Value::Array(
487                    properties
488                        .iter()
489                        .cloned()
490                        .map(serde_json::Value::from)
491                        .collect(),
492                ),
493            );
494        }
495        if let Some(space) = &self.space {
496            value.insert("space".to_owned(), serde_json::Value::from(space.clone()));
497        }
498        if let Some(dimension) = self.dimension {
499            value.insert("dimension".to_owned(), serde_json::Value::from(dimension));
500        }
501        value.insert(
502            "source_generation".to_owned(),
503            serde_json::Value::from(self.source_generation),
504        );
505        value.insert(
506            "source_fingerprint".to_owned(),
507            serde_json::Value::from(self.source_fingerprint.clone()),
508        );
509        value.insert(
510            "contract_version".to_owned(),
511            serde_json::Value::from(self.contract_version.clone()),
512        );
513        value.insert(
514            "completed".to_owned(),
515            serde_json::Value::from(self.completed),
516        );
517        serde_json::to_vec(&serde_json::Value::Object(value)).map_err(|error| {
518            SearchArtifactError::CorruptManifest {
519                path: PathBuf::from("<memory>"),
520                reason: error.to_string(),
521            }
522        })
523    }
524
525    /// Parse and validate a persisted manifest.
526    ///
527    /// # Errors
528    /// Distinguishes oversized, corrupt, and incompatible manifests.
529    pub fn from_json(path: &Path, bytes: &[u8]) -> Result<Self, SearchArtifactError> {
530        if bytes.len() > MAX_SEARCH_MANIFEST_BYTES {
531            return Err(SearchArtifactError::ResourceExhausted {
532                resource: "manifest_bytes",
533                limit: MAX_SEARCH_MANIFEST_BYTES as u64,
534            });
535        }
536        let value: serde_json::Value =
537            serde_json::from_slice(bytes).map_err(|error| corrupt(path, error.to_string()))?;
538        let object = value
539            .as_object()
540            .ok_or_else(|| corrupt(path, "expected a JSON object"))?;
541        let found = required_u64(object, "manifest_version", path)?;
542        if found != u64::from(SEARCH_MANIFEST_VERSION) {
543            return Err(SearchArtifactError::IncompatibleManifest {
544                path: path.to_path_buf(),
545                found,
546                supported: SEARCH_MANIFEST_VERSION,
547            });
548        }
549        let kind = SearchIndexKind::parse(required_str(object, "index_kind", path)?, path)?;
550        let label = required_str(object, "label", path)?;
551        let properties = optional_strings(object, "properties", path)?;
552        let space = optional_str(object, "space", path)?;
553        let key = match kind {
554            SearchIndexKind::Text => SearchArtifactKey::text(
555                label,
556                properties
557                    .as_deref()
558                    .ok_or_else(|| corrupt(path, "text manifest omits properties"))?,
559            )
560            .map_err(|error| corrupt(path, error.to_string()))?,
561            SearchIndexKind::Vector => SearchArtifactKey::vector(
562                label,
563                space.ok_or_else(|| corrupt(path, "vector manifest omits space"))?,
564            )
565            .map_err(|error| corrupt(path, error.to_string()))?,
566        };
567        if key.label() != label || key.properties() != properties.as_deref() || key.space() != space
568        {
569            return Err(corrupt(
570                path,
571                "artifact selectors are not in canonical normalized order",
572            ));
573        }
574        let backend_version = required_str(object, "backend_version", path)?;
575        let contract_version = required_str(object, "contract_version", path)?;
576        let dimension = optional_u64(object, "dimension", path)?
577            .map(|value| {
578                u32::try_from(value).map_err(|_| corrupt(path, "dimension exceeds u32 range"))
579            })
580            .transpose()?;
581        let source = SearchSourceSnapshot {
582            generation: required_u64(object, "source_generation", path)?,
583            fingerprint: required_str(object, "source_fingerprint", path)?.to_owned(),
584        };
585        let manifest = Self::for_key(
586            &key,
587            backend_version,
588            contract_version,
589            dimension,
590            &source,
591            required_bool(object, "completed", path)?,
592        )
593        .map_err(|error| corrupt(path, error.to_string()))?;
594        if manifest.backend_version != backend_version
595            || manifest.contract_version != contract_version
596        {
597            return Err(corrupt(path, "version fields are not normalized"));
598        }
599        Ok(manifest)
600    }
601
602    /// Verify exact selector, version, completion, generation, and fingerprint
603    /// equality against the current committed snapshot.
604    ///
605    /// # Errors
606    /// Returns [`SearchArtifactError::Stale`] with the first deterministic
607    /// mismatch. Derived text callers rebuild; vector callers surface primary
608    /// corruption or staleness according to their backend contract.
609    pub fn verify_fresh(
610        &self,
611        key: &SearchArtifactKey,
612        backend_version: &str,
613        contract_version: &str,
614        dimension: Option<u32>,
615        source: &SearchSourceSnapshot,
616    ) -> Result<(), SearchArtifactError> {
617        let expected = Self::for_key(
618            key,
619            backend_version,
620            contract_version,
621            dimension,
622            source,
623            true,
624        )?;
625        let reason = if !self.completed {
626            Some("manifest is incomplete".to_owned())
627        } else if self.index_kind != expected.index_kind
628            || self.label != expected.label
629            || self.properties != expected.properties
630            || self.space != expected.space
631        {
632            Some("normalized artifact key changed".to_owned())
633        } else if self.backend_version != expected.backend_version {
634            Some("backend version changed".to_owned())
635        } else if self.contract_version != expected.contract_version {
636            Some("search contract version changed".to_owned())
637        } else if self.dimension != expected.dimension {
638            Some("vector dimension changed".to_owned())
639        } else if self.source_generation != expected.source_generation {
640            Some(format!(
641                "source generation changed from {} to {}",
642                self.source_generation, expected.source_generation
643            ))
644        } else if self.source_fingerprint != expected.source_fingerprint {
645            Some("source fingerprint changed".to_owned())
646        } else {
647            None
648        };
649        match reason {
650            Some(reason) => Err(SearchArtifactError::Stale { reason }),
651            None => Ok(()),
652        }
653    }
654}
655
656fn invalid(field: &'static str, reason: impl Into<String>) -> SearchArtifactError {
657    SearchArtifactError::InvalidSelector {
658        field,
659        reason: reason.into(),
660    }
661}
662
663fn corrupt(path: &Path, reason: impl Into<String>) -> SearchArtifactError {
664    SearchArtifactError::CorruptManifest {
665        path: path.to_path_buf(),
666        reason: reason.into(),
667    }
668}
669
670fn normalize_selector(field: &'static str, value: &str) -> Result<String, SearchArtifactError> {
671    let value = value.trim();
672    if value.is_empty() {
673        return Err(invalid(field, "must not be empty"));
674    }
675    if value.chars().any(char::is_control) {
676        return Err(invalid(field, "must not contain control characters"));
677    }
678    if value.len() > MAX_SEARCH_SELECTOR_BYTES {
679        return Err(invalid(
680            field,
681            format!(
682                "{} UTF-8 bytes exceeds {MAX_SEARCH_SELECTOR_BYTES}",
683                value.len()
684            ),
685        ));
686    }
687    Ok(value.to_owned())
688}
689
690fn normalize_source_name(value: &str) -> Result<&str, SearchArtifactError> {
691    if value.is_empty() || value.chars().any(char::is_control) {
692        return Err(invalid(
693            "source fingerprint",
694            "logical part names must be non-empty and control-free",
695        ));
696    }
697    Ok(value)
698}
699
700fn normalize_version(field: &'static str, value: &str) -> Result<String, SearchArtifactError> {
701    normalize_selector(field, value)
702}
703
704fn canonical_fingerprint(value: &str) -> bool {
705    value.strip_prefix("gf-fnv1a256:").is_some_and(|digest| {
706        digest.len() == 64
707            && digest
708                .bytes()
709                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
710    })
711}
712
713fn encode_sequence(values: &[String]) -> Vec<u8> {
714    let mut bytes = Vec::new();
715    let value_count =
716        u32::try_from(values.len()).expect("validated search keys contain a bounded item count");
717    bytes.extend_from_slice(&value_count.to_be_bytes());
718    for value in values {
719        let value_len =
720            u32::try_from(value.len()).expect("validated search selectors have bounded lengths");
721        bytes.extend_from_slice(&value_len.to_be_bytes());
722        bytes.extend_from_slice(value.as_bytes());
723    }
724    bytes
725}
726
727fn push_encoded(path: &mut PathBuf, field: &str, bytes: &[u8]) {
728    path.push(field);
729    let encoded = hex(bytes);
730    for (index, chunk) in encoded.as_bytes().chunks(120).enumerate() {
731        let chunk = std::str::from_utf8(chunk).expect("hex is always UTF-8");
732        path.push(format!("{index:04}-{chunk}"));
733    }
734}
735
736fn hex(bytes: &[u8]) -> String {
737    const DIGITS: &[u8; 16] = b"0123456789abcdef";
738    let mut encoded = String::with_capacity(bytes.len() * 2);
739    for byte in bytes {
740        encoded.push(char::from(DIGITS[usize::from(byte >> 4)]));
741        encoded.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
742    }
743    encoded
744}
745
746fn hash_frame(states: &mut [u64; 4], bytes: &[u8]) {
747    for state in &mut *states {
748        for byte in (bytes.len() as u64).to_be_bytes().iter().chain(bytes) {
749            *state ^= u64::from(*byte);
750            *state = state.wrapping_mul(0x0000_0100_0000_01b3);
751        }
752    }
753    for (index, state) in states.iter_mut().enumerate() {
754        *state ^= (index as u64 + 1) * 0x9e37_79b9;
755    }
756}
757
758fn required_str<'a>(
759    object: &'a serde_json::Map<String, serde_json::Value>,
760    field: &str,
761    path: &Path,
762) -> Result<&'a str, SearchArtifactError> {
763    object
764        .get(field)
765        .and_then(serde_json::Value::as_str)
766        .ok_or_else(|| corrupt(path, format!("missing or non-string {field}")))
767}
768
769fn optional_str<'a>(
770    object: &'a serde_json::Map<String, serde_json::Value>,
771    field: &str,
772    path: &Path,
773) -> Result<Option<&'a str>, SearchArtifactError> {
774    object
775        .get(field)
776        .map(|value| {
777            value
778                .as_str()
779                .ok_or_else(|| corrupt(path, format!("{field} must be a string")))
780        })
781        .transpose()
782}
783
784fn required_u64(
785    object: &serde_json::Map<String, serde_json::Value>,
786    field: &str,
787    path: &Path,
788) -> Result<u64, SearchArtifactError> {
789    object
790        .get(field)
791        .and_then(serde_json::Value::as_u64)
792        .ok_or_else(|| corrupt(path, format!("missing or non-u64 {field}")))
793}
794
795fn optional_u64(
796    object: &serde_json::Map<String, serde_json::Value>,
797    field: &str,
798    path: &Path,
799) -> Result<Option<u64>, SearchArtifactError> {
800    object
801        .get(field)
802        .map(|value| {
803            value
804                .as_u64()
805                .ok_or_else(|| corrupt(path, format!("{field} must be a u64")))
806        })
807        .transpose()
808}
809
810fn required_bool(
811    object: &serde_json::Map<String, serde_json::Value>,
812    field: &str,
813    path: &Path,
814) -> Result<bool, SearchArtifactError> {
815    object
816        .get(field)
817        .and_then(serde_json::Value::as_bool)
818        .ok_or_else(|| corrupt(path, format!("missing or non-boolean {field}")))
819}
820
821fn optional_strings(
822    object: &serde_json::Map<String, serde_json::Value>,
823    field: &str,
824    path: &Path,
825) -> Result<Option<Vec<String>>, SearchArtifactError> {
826    object
827        .get(field)
828        .map(|value| {
829            let values = value
830                .as_array()
831                .ok_or_else(|| corrupt(path, format!("{field} must be an array")))?;
832            values
833                .iter()
834                .map(|value| {
835                    value
836                        .as_str()
837                        .map(str::to_owned)
838                        .ok_or_else(|| corrupt(path, format!("{field} must contain strings")))
839                })
840                .collect()
841        })
842        .transpose()
843}
844
845#[cfg(test)]
846mod tests {
847    use super::*;
848
849    fn source() -> SearchSourceSnapshot {
850        SearchSourceSnapshot {
851            generation: 7,
852            fingerprint: format!("gf-fnv1a256:{:064x}", 7),
853        }
854    }
855
856    #[test]
857    fn key_normalization_and_paths_are_stable_and_safe() {
858        let key = SearchArtifactKey::text(" Person ", [" bio ", "name", "bio"]).unwrap();
859        assert_eq!(key.label(), "Person");
860        assert_eq!(
861            key.properties().unwrap(),
862            &["bio".to_owned(), "name".to_owned()]
863        );
864        let path = key.artifact_root(Path::new("/project"));
865        let rendered = path.to_string_lossy();
866        assert!(rendered.starts_with("/project/indexes/search/text/label/"));
867        assert!(!rendered.contains("Person"));
868        assert!(!rendered.contains("bio"));
869
870        let vector = SearchArtifactKey::vector("Person", " model/v1 ").unwrap();
871        let rendered = vector
872            .artifact_root(Path::new("/project"))
873            .to_string_lossy()
874            .into_owned();
875        assert!(rendered.starts_with("/project/embeddings/space/"));
876        assert!(!rendered.contains("model/v1"));
877    }
878
879    #[test]
880    fn key_encoding_is_collision_free_for_framed_properties() {
881        let left = SearchArtifactKey::text("L", ["ab", "c"]).unwrap();
882        let right = SearchArtifactKey::text("L", ["a", "bc"]).unwrap();
883        assert_ne!(
884            left.artifact_root(Path::new("/p")),
885            right.artifact_root(Path::new("/p"))
886        );
887    }
888
889    #[test]
890    fn invalid_selectors_and_key_budget_are_structured() {
891        assert!(matches!(
892            SearchArtifactKey::vector("", "space"),
893            Err(SearchArtifactError::InvalidSelector { field: "label", .. })
894        ));
895        assert!(matches!(
896            SearchArtifactKey::vector("Person", "bad\nspace"),
897            Err(SearchArtifactError::InvalidSelector { field: "space", .. })
898        ));
899        let long = "x".repeat(MAX_SEARCH_SELECTOR_BYTES + 1);
900        assert!(SearchArtifactKey::text("Person", [&long]).is_err());
901    }
902
903    #[test]
904    fn fingerprint_is_order_independent_and_content_sensitive() {
905        let a = SearchSourcePart {
906            name: "properties/Person",
907            bytes: b"alice",
908        };
909        let b = SearchSourcePart {
910            name: "topology/nodes",
911            bytes: b"uuid",
912        };
913        assert_eq!(
914            canonical_source_fingerprint(&[a, b]).unwrap(),
915            canonical_source_fingerprint(&[b, a]).unwrap()
916        );
917        assert_ne!(
918            canonical_source_fingerprint(&[a]).unwrap(),
919            canonical_source_fingerprint(&[SearchSourcePart {
920                name: a.name,
921                bytes: b"bob",
922            }])
923            .unwrap()
924        );
925        assert!(canonical_source_fingerprint(&[a, a]).is_err());
926    }
927
928    #[test]
929    fn source_snapshot_rejects_corrupt_generation_and_noncanonical_fingerprint() {
930        let dir = tempfile::TempDir::new().unwrap();
931        let generation = crate::generation::generation_path(dir.path());
932        std::fs::create_dir_all(generation.parent().unwrap()).unwrap();
933        std::fs::write(&generation, b"corrupt").unwrap();
934        assert!(matches!(
935            SearchSourceSnapshot::capture(dir.path(), &[]),
936            Err(SearchArtifactError::SourceSnapshot { .. })
937        ));
938
939        let key = SearchArtifactKey::text("Person", ["name"]).unwrap();
940        let source = SearchSourceSnapshot {
941            generation: 1,
942            fingerprint: "not-canonical".to_owned(),
943        };
944        assert!(matches!(
945            SearchManifest::for_key(&key, "tantivy-0.25", "text-v1", None, &source, true),
946            Err(SearchArtifactError::InvalidSelector {
947                field: "source_fingerprint",
948                ..
949            })
950        ));
951    }
952
953    #[test]
954    fn text_manifest_round_trips_canonical_json() {
955        let key = SearchArtifactKey::text("Person", ["name", "bio"]).unwrap();
956        let manifest = SearchManifest::for_key(
957            &key,
958            "tantivy-0.25",
959            "graphforge_text_v1",
960            None,
961            &source(),
962            true,
963        )
964        .unwrap();
965        let bytes = manifest.to_canonical_json().unwrap();
966        let parsed = SearchManifest::from_json(Path::new("manifest.json"), &bytes).unwrap();
967        assert_eq!(parsed, manifest);
968        let json = String::from_utf8(bytes).unwrap();
969        assert!(json.contains(r#""properties":["bio","name"]"#));
970        assert!(!json.contains(r#""space""#));
971        assert!(!json.contains(r#""dimension""#));
972        let noncanonical = json.replace(r#""label":"Person""#, r#""label":" Person ""#);
973        assert!(matches!(
974            SearchManifest::from_json(Path::new("manifest.json"), noncanonical.as_bytes()),
975            Err(SearchArtifactError::CorruptManifest { .. })
976        ));
977    }
978
979    #[test]
980    fn vector_manifest_requires_dimension_and_omits_properties() {
981        let key = SearchArtifactKey::vector("Person", "semantic").unwrap();
982        assert!(
983            SearchManifest::for_key(&key, "exact-cosine-v1", "vector-v1", None, &source(), true)
984                .is_err()
985        );
986        let manifest = SearchManifest::for_key(
987            &key,
988            "exact-cosine-v1",
989            "vector-v1",
990            Some(3),
991            &source(),
992            true,
993        )
994        .unwrap();
995        let json = String::from_utf8(manifest.to_canonical_json().unwrap()).unwrap();
996        assert!(json.contains(r#""dimension":3"#));
997        assert!(json.contains(r#""space":"semantic""#));
998        assert!(!json.contains(r#""properties""#));
999        assert!(matches!(
1000            manifest.verify_fresh(&key, "exact-cosine-v1", "vector-v1", Some(4), &source(),),
1001            Err(SearchArtifactError::Stale { .. })
1002        ));
1003    }
1004
1005    #[test]
1006    fn parsing_distinguishes_corrupt_incompatible_and_oversized() {
1007        let path = Path::new("manifest.json");
1008        assert!(matches!(
1009            SearchManifest::from_json(path, b"no"),
1010            Err(SearchArtifactError::CorruptManifest { .. })
1011        ));
1012        assert!(matches!(
1013            SearchManifest::from_json(path, br#"{"manifest_version":99}"#),
1014            Err(SearchArtifactError::IncompatibleManifest { found: 99, .. })
1015        ));
1016        assert!(matches!(
1017            SearchManifest::from_json(path, &vec![b' '; MAX_SEARCH_MANIFEST_BYTES + 1]),
1018            Err(SearchArtifactError::ResourceExhausted {
1019                resource: "manifest_bytes",
1020                ..
1021            })
1022        ));
1023    }
1024
1025    #[test]
1026    fn freshness_checks_completion_versions_generation_and_fingerprint() {
1027        let key = SearchArtifactKey::text("Person", ["name"]).unwrap();
1028        let mut manifest =
1029            SearchManifest::for_key(&key, "tantivy-0.25", "text-v1", None, &source(), true)
1030                .unwrap();
1031        manifest
1032            .verify_fresh(&key, "tantivy-0.25", "text-v1", None, &source())
1033            .unwrap();
1034
1035        manifest.completed = false;
1036        assert!(matches!(
1037            manifest.verify_fresh(&key, "tantivy-0.25", "text-v1", None, &source()),
1038            Err(SearchArtifactError::Stale { .. })
1039        ));
1040        manifest.completed = true;
1041        let changed = SearchSourceSnapshot {
1042            generation: 8,
1043            fingerprint: source().fingerprint,
1044        };
1045        assert!(
1046            manifest
1047                .verify_fresh(&key, "tantivy-0.25", "text-v1", None, &changed)
1048                .is_err()
1049        );
1050    }
1051}