Skip to main content

fdu_core/
stored_state.rs

1//! Stored-state identity: which requests a stored tier may answer.
2//!
3//! A **tier** is a unit of identity: entries and their roll-ups, `.gitignore` control
4//! state, and content records. A **store** holds one or more tiers: a metadata snapshot
5//! holds the entry and control tiers, a content sidecar holds the content tier, and a
6//! retained [`Index`](crate::Index) holds all three. A store records the identity of every
7//! tier it holds, and a stored tier answers a request only when the request's identity
8//! for that tier is one it serves.
9//!
10//! Each identity is the engine fingerprint plus exactly the request parts that change the
11//! tier's values. Operational settings such as worker counts, batch sizes, and scan order
12//! never appear here, so they can never invalidate a store, and a request part that
13//! changes a tier's values always does.
14
15use crate::content::{
16    AnalysisSet, AnalyzerId, AnalyzerVersion, ContentProvenance, OptionsFingerprint,
17};
18use crate::control::ControlLimits;
19use crate::engine_contract::ScanScope;
20use crate::query::IgnoredEntries;
21
22/// Version of the fixed `.gitignore` control semantics.
23///
24/// Two identities carry it, for different reasons. The engine fingerprint
25/// ([`crate::snapshot::engine_fingerprint`]) mixes it, and that alone keeps a store written
26/// under other semantics from being served: a stored control tier records only its
27/// observation and limits, and an Include entry tier records no governing control policy,
28/// so a snapshot of the default population decodes to exactly the identity this build
29/// wants whichever semantics wrote it. [`ControlTierIdentity::ignore_rules_fingerprint`]
30/// hashes it first as well, so a scope's ignore-rules identity, and the governing control
31/// policy a narrowed entry tier records, also differ between semantics.
32///
33/// 3: a directory's control is what a lookup of `<dir>/.gitignore` resolves to, so a
34/// `.GITIGNORE` governs on a case-insensitive volume (fdu-0w1b). A snapshot taken under 2
35/// recorded no rules for such a directory, and serving it would answer as the old rule
36/// did; the engine fingerprint differs, so it is not served.
37///
38/// 4: a UTF-8 byte-order mark at the start of a `.gitignore` is skipped, and a pattern
39/// ends at the first NUL byte in its line, as git reads both (fdu-ifci). A snapshot taken
40/// under 3 classified entries with the first rule of such a file never matching, or with
41/// the bytes after the NUL as part of the rule.
42pub(crate) const IGNORE_RULES_VERSION: u64 = 4;
43
44/// Which entries a scan retains: its depth, symlink, filesystem-boundary, hidden-entry,
45/// and special-object settings.
46///
47/// This is [`ScanScope`] without its type-rules, reducer-set, and ignore-rules
48/// fingerprints: the filesystem-admission identity of a validated scan configuration. A
49/// store's entry tier records it, and an opened root binds it in
50/// [`EngineVersion::scope`](crate::EngineVersion::scope). Root binding and execution policy
51/// are deliberately absent, so two roots may share it without claiming to be the same live
52/// session.
53#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
54pub struct EntryScope {
55    /// Maximum retained relative depth, or unlimited when absent.
56    pub max_depth: Option<usize>,
57    /// Whether directory symlinks are followed.
58    pub follow_symlinks: bool,
59    /// Whether traversal stays on the root filesystem.
60    pub one_filesystem: bool,
61    /// Identity of leading-dot component admission and its exact-name allowlist.
62    pub hidden_fingerprint: u64,
63    /// Whether filesystem objects outside files, directories, and symlinks are excluded.
64    pub exclude_special: bool,
65    /// Ignored population that shaped retained entries and content candidates.
66    pub population: IgnoredEntries,
67    /// Governing control policy when population narrows retained facts; zero for Include.
68    pub control_fingerprint: u64,
69}
70
71/// Identity of an entry tier: the entries a store holds and the roll-ups derived from
72/// them.
73///
74/// Include populations have equal entry tiers with or without `.gitignore`
75/// observation, since classification alone does not change retained entries. Narrowed
76/// populations store the governing control policy in `scope`, because those rules
77/// determine which entries exist in this tier.
78#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
79pub struct EntryTierIdentity {
80    /// The engine fingerprint of the build that produced the tier
81    /// ([`crate::snapshot::engine_fingerprint`]).
82    pub engine: u64,
83    /// Which entries a scan retains.
84    pub scope: EntryScope,
85    /// Identity of the type-classification rules roll-ups were tallied under.
86    pub type_rules_fingerprint: u64,
87    /// Identity of the enabled reducer set.
88    pub reducers_fingerprint: u64,
89}
90
91impl EntryTierIdentity {
92    /// The entry tier identity this build gives an index of `scope`.
93    pub fn of_scope(scope: ScanScope) -> Self {
94        Self {
95            engine: crate::snapshot::engine_fingerprint(),
96            scope: scope.entry_scope(),
97            type_rules_fingerprint: scope.type_rules_fingerprint,
98            reducers_fingerprint: scope.reducers_fingerprint,
99        }
100    }
101}
102
103/// Identity of a `.gitignore` control tier.
104#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
105pub enum ControlTierIdentity {
106    /// No control file was read and no entry was classified, so the tier cannot say
107    /// whether any entry is ignored.
108    NotObserved,
109    /// Control files were read and admitted under `limits`.
110    Observed {
111        /// The budget and line limit that decided which control files apply.
112        limits: ControlLimits,
113    },
114}
115
116impl ControlTierIdentity {
117    /// Whether the tier observed control state.
118    pub const fn is_observed(self) -> bool {
119        matches!(self, Self::Observed { .. })
120    }
121
122    /// The ignore-rules fingerprint a [`ScanScope`] with this control tier carries.
123    ///
124    /// Zero is reserved for [`Self::NotObserved`], which is what
125    /// [`ScanScope::observes_controls`] tests. An observed tier hashes the control
126    /// semantics version and each limit in turn with FNV-1a and is never zero, so a scope
127    /// taken under one budget or line limit never matches one taken under another.
128    pub fn ignore_rules_fingerprint(self) -> u64 {
129        const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
130        const FNV_PRIME: u64 = 0x100_0000_01b3;
131        const UNBOUNDED: u8 = 0;
132        const BOUNDED: u8 = 1;
133
134        let Self::Observed { limits } = self else {
135            return 0;
136        };
137        let mut fingerprint = FNV_OFFSET_BASIS;
138        let mut mix = |bytes: &[u8]| {
139            for byte in bytes {
140                fingerprint ^= u64::from(*byte);
141                fingerprint = fingerprint.wrapping_mul(FNV_PRIME);
142            }
143        };
144        mix(&IGNORE_RULES_VERSION.to_le_bytes());
145        for limit in [limits.budget, limits.line_limit] {
146            match limit {
147                None => mix(&[UNBOUNDED]),
148                Some(limit) => {
149                    mix(&[BOUNDED]);
150                    mix(&u64::try_from(limit).unwrap_or(u64::MAX).to_le_bytes());
151                }
152            }
153        }
154        fingerprint.max(1)
155    }
156}
157
158/// The identity of every tier a metadata snapshot holds.
159#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
160pub struct SnapshotIdentity {
161    /// The entry tier.
162    pub entries: EntryTierIdentity,
163    /// The `.gitignore` control tier.
164    pub controls: ControlTierIdentity,
165}
166
167impl SnapshotIdentity {
168    /// The scope an index holding these tiers records.
169    pub fn scan_scope(self) -> ScanScope {
170        let EntryScope {
171            max_depth,
172            follow_symlinks,
173            one_filesystem,
174            hidden_fingerprint,
175            exclude_special,
176            population,
177            control_fingerprint: _,
178        } = self.entries.scope;
179        ScanScope {
180            max_depth,
181            follow_symlinks,
182            one_filesystem,
183            hidden_fingerprint,
184            exclude_special,
185            population,
186            ignore_rules_fingerprint: self.controls.ignore_rules_fingerprint(),
187            type_rules_fingerprint: self.entries.type_rules_fingerprint,
188            reducers_fingerprint: self.entries.reducers_fingerprint,
189        }
190    }
191}
192
193/// Identity of a content tier: the per-file analysis records a sidecar holds.
194///
195/// The entry tier's identity rather than the whole snapshot's: Include observes
196/// controls only for selection, while narrowed entry tiers carry the governing
197/// control policy in their own identity. Then the analyzer set the records were
198/// produced for, and the analyzers' identities, versions, and options.
199///
200/// The type rules the records were classified under are the entry tier's, and stated only
201/// there: a record's [`ContentProvenance`] is this identity's entry-tier type rules and its
202/// [`AnalyzerProvenance`].
203#[derive(Clone, PartialEq, Eq, Debug)]
204pub struct ContentTierIdentity {
205    /// The entry tier the records were analyzed over, including their type rules.
206    pub entries: EntryTierIdentity,
207    /// The analyzer set the tier holds records for.
208    pub analysis: AnalysisSet,
209    /// The options and analyzer versions the records were produced under.
210    pub provenance: AnalyzerProvenance,
211}
212
213/// The analyzers a content tier's records were produced by, and the options they ran with.
214///
215/// A record's [`ContentProvenance`] without its type-rules fingerprint, which the content
216/// tier's [`EntryTierIdentity`] holds.
217#[derive(Clone, PartialEq, Eq, Debug, Hash)]
218pub struct AnalyzerProvenance {
219    /// Identity of the semantic analyzer options.
220    pub options_fingerprint: OptionsFingerprint,
221    /// Each analyzer the records ran, with its version, in the order the set enables them.
222    pub analyzers: Vec<(AnalyzerId, AnalyzerVersion)>,
223}
224
225impl ContentTierIdentity {
226    /// The provenance each record of this tier carries.
227    pub(crate) fn record_provenance(&self) -> ContentProvenance {
228        ContentProvenance {
229            type_rules_fingerprint: self.entries.type_rules_fingerprint,
230            options_fingerprint: self.provenance.options_fingerprint,
231            analyzers: self.provenance.analyzers.clone(),
232        }
233    }
234
235    /// Identity produced by this build for the requested entry tier and analyzers.
236    pub fn for_request(entries: EntryTierIdentity, analysis: AnalysisSet) -> Self {
237        let provenance = ContentProvenance::for_request(
238            crate::content::AnalysisRequest { profile: analysis, ..Default::default() },
239            entries.type_rules_fingerprint,
240        );
241        Self {
242            entries,
243            analysis,
244            provenance: AnalyzerProvenance {
245                options_fingerprint: provenance.options_fingerprint,
246                analyzers: provenance.analyzers,
247            },
248        }
249    }
250
251    /// Admit a stored content identity and return the projection that may consume it.
252    ///
253    /// Equality is the only lawful content projection today. Adding another relation
254    /// requires implementing its record and tier projection here, not widening a reader.
255    #[must_use]
256    pub fn admit(&self, stored: &Self) -> Option<ContentAdmission<'_>> {
257        self.admit_parts(
258            stored.entries,
259            stored.analysis,
260            stored.provenance.options_fingerprint,
261            &stored.provenance.analyzers,
262        )
263    }
264
265    fn admit_parts(
266        &self,
267        entries: EntryTierIdentity,
268        analysis: AnalysisSet,
269        options: OptionsFingerprint,
270        analyzers: &[(AnalyzerId, AnalyzerVersion)],
271    ) -> Option<ContentAdmission<'_>> {
272        (entries == self.entries
273            && analysis == self.analysis
274            && options == self.provenance.options_fingerprint
275            && analyzers == self.provenance.analyzers)
276            .then_some(ContentAdmission { identity: self })
277    }
278
279    /// Admission for an observation already tied to this entry tier by its candidate.
280    /// This compares borrowed components, without allocating provenance per file.
281    pub(crate) fn admit_record(
282        &self,
283        analysis: AnalysisSet,
284        provenance: &ContentProvenance,
285    ) -> Option<ContentAdmission<'_>> {
286        self.admit_parts(
287            EntryTierIdentity {
288                type_rules_fingerprint: provenance.type_rules_fingerprint,
289                ..self.entries
290            },
291            analysis,
292            provenance.options_fingerprint,
293            &provenance.analyzers,
294        )
295    }
296}
297
298/// Proof that stored content can be projected to one requested identity.
299///
300/// The private constructor is the shared admission relation. Consumers apply this proof
301/// to records or a borrowed record set; the proof never widens the requested analyzer set.
302#[must_use = "admission must be applied before consuming stored content"]
303#[derive(Clone, Copy, Debug)]
304pub struct ContentAdmission<'a> {
305    identity: &'a ContentTierIdentity,
306}
307
308impl<'a> ContentAdmission<'a> {
309    /// The identity the projected content answers.
310    pub const fn identity(self) -> &'a ContentTierIdentity {
311        self.identity
312    }
313
314    pub(crate) fn record(self, record: crate::content::FileAnalysis) -> Option<AdmittedRecord<'a>> {
315        record
316            .matches_profile(self.identity.analysis)
317            .then_some(AdmittedRecord { identity: self.identity, record })
318    }
319
320    pub(crate) fn project(
321        self,
322        content: &crate::content::ContentIndex,
323    ) -> Option<ContentProjection<'_>> {
324        let _admission = self.identity.admit(content.identity()?)?;
325        Some(ContentProjection { content })
326    }
327}
328
329/// A record set projected through the content serving relation for one request.
330#[derive(Clone, Copy)]
331pub(crate) struct ContentProjection<'a> {
332    content: &'a crate::content::ContentIndex,
333}
334
335impl<'a> ContentProjection<'a> {
336    pub(crate) fn identity(self) -> &'a ContentTierIdentity {
337        self.content.identity().expect("admitted tier has an identity")
338    }
339    pub(crate) fn len(self) -> usize {
340        self.content.len()
341    }
342    pub(crate) fn state(self) -> Option<crate::content::ContentTierState> {
343        self.content.state()
344    }
345    pub(crate) fn file(self, path: &std::path::Path) -> Option<&'a crate::content::FileAnalysis> {
346        self.content.file(path)
347    }
348    pub(crate) fn records(
349        self,
350    ) -> impl Iterator<Item = (&'a std::path::Path, &'a crate::content::FileAnalysis)> {
351        self.content.records()
352    }
353}
354
355/// A decoded record whose identity and analyzer slots have passed admission.
356#[must_use]
357pub(crate) struct AdmittedRecord<'a> {
358    identity: &'a ContentTierIdentity,
359    record: crate::content::FileAnalysis,
360}
361
362impl AdmittedRecord<'_> {
363    pub(crate) fn value(&self) -> &crate::content::FileAnalysis {
364        &self.record
365    }
366
367    pub(crate) fn into_record(self) -> crate::content::FileAnalysis {
368        self.record
369    }
370
371    pub(crate) fn for_tier(
372        self,
373        wanted: &ContentTierIdentity,
374    ) -> Option<crate::content::FileAnalysis> {
375        wanted.admit(self.identity)?.record(self.record).map(AdmittedRecord::into_record)
376    }
377}
378
379/// How a stored tier answers a request.
380#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
381pub enum Serves {
382    /// The stored identity equals the requested one, so the stored tier holds what a cold
383    /// run of the request would build.
384    Exact,
385    /// The stored entry tier equals the requested one, and an observed control tier can
386    /// be discarded to produce the controls-off index a cold run would build.
387    ProjectControlsOff,
388    /// The stored tier cannot answer the request, which is a miss.
389    Refuse,
390}
391
392/// Whether a snapshot of the `stored` identity answers a request for `wanted`.
393///
394/// Equality: a snapshot serves exactly the request whose identity for every tier equals
395/// its own. Any relation beyond equality arrives with a projection that yields what a cold
396/// run of `wanted` would, and is proven by its own test.
397pub fn serves_snapshot(stored: SnapshotIdentity, wanted: SnapshotIdentity) -> Serves {
398    if stored == wanted {
399        Serves::Exact
400    } else if stored.entries == wanted.entries
401        && stored.controls.is_observed()
402        && wanted.controls == ControlTierIdentity::NotObserved
403    {
404        Serves::ProjectControlsOff
405    } else {
406        Serves::Refuse
407    }
408}
409
410// ---- write rules ----
411//
412// Each tier is written by what an absent item in it means. An absent entry changes every
413// roll-up above it, so the entry tier is written only when the pass verified all of it.
414// An absent content record is a miss that reads the file again, so records are written one
415// at a time, each when the pass verified it.
416
417/// Whether `index`'s entry tier, and the control tier stored with it, may be written.
418///
419/// Only a complete, fresh index: a snapshot missing an entry would be served as the tree's
420/// totals on the next run, and an older complete snapshot is better than that.
421pub(crate) fn entries_writable(index: &crate::Index) -> bool {
422    index.freshness() == crate::Freshness::Fresh
423        && index.state().coverage == crate::engine_contract::Coverage::Complete
424}
425
426/// Whether the content record `record` for the file at `path` may be written.
427///
428/// A record is written when it describes a file this pass verified: the index holds a
429/// regular file there whose fingerprint is the record's, the entry was scanned or
430/// revalidated by the pass rather than retained from a snapshot, and reading it did not
431/// fail. A file the pass verified was listed by its parent, so its subtree was verified
432/// down to it. A record under a subtree the pass could not verify describes a retained
433/// file nobody checked, so it is left out, and the next run that verifies the file reads
434/// it again.
435pub(crate) fn content_record_writable(
436    index: &crate::Index,
437    path: &std::path::Path,
438    record: &crate::content::FileAnalysis,
439) -> bool {
440    if !record.is_reusable() {
441        return false;
442    }
443    // A complete, fresh pass verified every entry, and the content tier holds only records
444    // that match their live entry, because a metadata change invalidates a file's record
445    // and a commit checks the entry it lands on. So only a partial pass asks per file, and
446    // the common write pays no lookup per record.
447    if entries_writable(index) {
448        return true;
449    }
450    let crate::PathState::Present { kind: crate::EntryKind::File, attrs } = index.path_state(path)
451    else {
452        return false;
453    };
454    attrs.fingerprint() == record.fingerprint
455        && index.provenance(path).is_some_and(crate::Provenance::is_verified)
456}
457
458/// Whether `index`'s content tier may be written beside the store that holds
459/// `stored_entries`, the entry tier of the snapshot already stored for its root, if any.
460///
461/// After a complete pass, always: the snapshot is written with it. After a partial pass,
462/// only beside a snapshot of the same entry tier, which the sidecar pairs with, because a partial
463/// pass under another identity writes no snapshot, so replacing the sidecar would evict the
464/// records that pair with the snapshot that stays.
465pub(crate) fn content_tier_writable(
466    index: &crate::Index,
467    stored_entries: impl FnOnce() -> Option<EntryTierIdentity>,
468) -> bool {
469    entries_writable(index)
470        || stored_entries().is_some_and(|stored| stored == index.snapshot_identity().entries)
471}
472
473// ---- fixed-width codecs ----
474//
475// Every store writes its tier identities in these encodings. Each is fixed-width and
476// canonical: one identity has exactly one encoding, and a decoder refuses any byte that no
477// encoder writes, so two stores hold equal identities exactly when their encoded bytes are
478// equal. The engine fingerprint is not encoded here: a store writes it once, in its
479// prologue beside the magic and format version, and every tier identity it holds shares
480// it.
481
482/// Encoded width of an optional bound: a tag, then eight value bytes.
483pub(crate) const BOUND_BYTES: usize = 1 + 8;
484
485/// Encoded width of an [`EntryTierIdentity`] after its engine fingerprint: the maximum
486/// depth as a bound, scope flags, and the hidden-entry, governing-control, type-rules,
487/// and reducer-set fingerprints.
488pub(crate) const ENTRY_TIER_BYTES: usize = BOUND_BYTES + 1 + 8 + 8 + 8 + 8;
489
490/// Encoded width of a [`ControlTierIdentity`]: the observation tag, then the budget and the
491/// line limit, each a bound.
492pub(crate) const CONTROL_TIER_BYTES: usize = 1 + 2 * BOUND_BYTES;
493
494/// Encoded width of a [`SnapshotIdentity`] after its engine fingerprint.
495pub(crate) const SNAPSHOT_IDENTITY_BYTES: usize = ENTRY_TIER_BYTES + CONTROL_TIER_BYTES;
496
497/// Scope flag for symlink-following traversal.
498const SCOPE_FOLLOW_SYMLINKS: u8 = 1 << 0;
499/// Scope flag for staying on the root filesystem.
500const SCOPE_ONE_FILESYSTEM: u8 = 1 << 1;
501/// Scope flag for excluding native special objects.
502const SCOPE_EXCLUDE_SPECIAL: u8 = 1 << 2;
503const SCOPE_POPULATION_EXCLUDE: u8 = 1 << 3;
504const SCOPE_POPULATION_ONLY: u8 = 1 << 4;
505/// Every scope flag this encoding defines.
506const SCOPE_KNOWN_FLAGS: u8 = SCOPE_FOLLOW_SYMLINKS
507    | SCOPE_ONE_FILESYSTEM
508    | SCOPE_EXCLUDE_SPECIAL
509    | SCOPE_POPULATION_EXCLUDE
510    | SCOPE_POPULATION_ONLY;
511
512/// Control tier tag for a tier that observed nothing, whose limit fields are all zero.
513const CONTROLS_NOT_OBSERVED: u8 = 0;
514/// Control tier tag for an observed tier, whose limit fields follow.
515const CONTROLS_OBSERVED: u8 = 1;
516
517/// Bound tag for no bound, whose eight value bytes are zero.
518const UNBOUNDED: u8 = 0;
519/// Bound tag for a bound, whose value is the eight bytes that follow.
520const BOUNDED: u8 = 1;
521
522// Every bound is a `usize`, which fits the eight bytes that encode it on every target, so a
523// bound is never refused or aliased at encode.
524const _: () = assert!(usize::BITS <= u64::BITS, "a bound fits its eight encoded bytes");
525
526/// Fills a fixed-width encoding field by field.
527struct FixedWriter<const N: usize> {
528    bytes: [u8; N],
529    at: usize,
530}
531
532impl<const N: usize> FixedWriter<N> {
533    const fn new() -> Self {
534        Self { bytes: [0; N], at: 0 }
535    }
536
537    fn put(&mut self, field: &[u8]) {
538        let end = self.at + field.len();
539        self.bytes[self.at..end].copy_from_slice(field);
540        self.at = end;
541    }
542
543    /// Write an optional bound as its tag and eight value bytes, zero when unbounded.
544    ///
545    /// A tag rather than a reserved value, so no bound can be mistaken for a sentinel.
546    fn put_bound(&mut self, bound: Option<usize>) {
547        let (tag, value) = match bound {
548            None => (UNBOUNDED, 0),
549            // Lossless: the width assertion above holds on every target this compiles for.
550            Some(bound) => (BOUNDED, u64::try_from(bound).unwrap_or(u64::MAX)),
551        };
552        self.put(&[tag]);
553        self.put(&value.to_le_bytes());
554    }
555
556    fn finish(self) -> [u8; N] {
557        debug_assert_eq!(self.at, N, "every field of a fixed-width encoding is written");
558        self.bytes
559    }
560}
561
562/// A field holds bytes no encoder writes.
563struct NotEncoded;
564
565/// Reads a fixed-width encoding field by field.
566struct FixedReader<'a> {
567    rest: &'a [u8],
568}
569
570impl FixedReader<'_> {
571    fn take<const W: usize>(&mut self) -> [u8; W] {
572        let (field, rest) =
573            self.rest.split_first_chunk::<W>().expect("a fixed-width encoding holds every field");
574        self.rest = rest;
575        *field
576    }
577
578    fn u8(&mut self) -> u8 {
579        self.take::<1>()[0]
580    }
581
582    fn u64(&mut self) -> u64 {
583        u64::from_le_bytes(self.take())
584    }
585
586    /// Read [`FixedWriter::put_bound`], refusing a tag or value no encoder writes.
587    fn bound(&mut self) -> Result<Option<usize>, NotEncoded> {
588        match (self.u8(), self.u64()) {
589            (UNBOUNDED, 0) => Ok(None),
590            (BOUNDED, value) => usize::try_from(value).map(Some).map_err(|_| NotEncoded),
591            _ => Err(NotEncoded),
592        }
593    }
594}
595
596impl EntryTierIdentity {
597    /// Encode every field but the engine fingerprint, which the store's prologue carries.
598    pub(crate) fn encode(self) -> [u8; ENTRY_TIER_BYTES] {
599        let scope = self.scope;
600        let mut flags = 0u8;
601        if scope.follow_symlinks {
602            flags |= SCOPE_FOLLOW_SYMLINKS;
603        }
604        if scope.one_filesystem {
605            flags |= SCOPE_ONE_FILESYSTEM;
606        }
607        if scope.exclude_special {
608            flags |= SCOPE_EXCLUDE_SPECIAL;
609        }
610        flags |= match scope.population {
611            IgnoredEntries::Include => 0,
612            IgnoredEntries::Exclude => SCOPE_POPULATION_EXCLUDE,
613            IgnoredEntries::Only => SCOPE_POPULATION_ONLY,
614        };
615        let mut out = FixedWriter::new();
616        out.put_bound(scope.max_depth);
617        out.put(&[flags]);
618        out.put(&scope.hidden_fingerprint.to_le_bytes());
619        out.put(&scope.control_fingerprint.to_le_bytes());
620        out.put(&self.type_rules_fingerprint.to_le_bytes());
621        out.put(&self.reducers_fingerprint.to_le_bytes());
622        out.finish()
623    }
624
625    /// Decode [`Self::encode`] under the engine fingerprint of the store that holds it, or
626    /// `None` for a field no encoder writes.
627    pub(crate) fn decode(engine: u64, bytes: &[u8; ENTRY_TIER_BYTES]) -> Option<Self> {
628        let mut fields = FixedReader { rest: bytes };
629        let max_depth = fields.bound().ok()?;
630        let flags = fields.u8();
631        if flags & !SCOPE_KNOWN_FLAGS != 0 {
632            return None;
633        }
634        let population = match flags & (SCOPE_POPULATION_EXCLUDE | SCOPE_POPULATION_ONLY) {
635            0 => IgnoredEntries::Include,
636            SCOPE_POPULATION_EXCLUDE => IgnoredEntries::Exclude,
637            SCOPE_POPULATION_ONLY => IgnoredEntries::Only,
638            _ => return None,
639        };
640        let hidden_fingerprint = fields.u64();
641        let control_fingerprint = fields.u64();
642        if (population == IgnoredEntries::Include && control_fingerprint != 0)
643            || (population != IgnoredEntries::Include && control_fingerprint == 0)
644        {
645            return None;
646        }
647        let scope = EntryScope {
648            max_depth,
649            follow_symlinks: flags & SCOPE_FOLLOW_SYMLINKS != 0,
650            one_filesystem: flags & SCOPE_ONE_FILESYSTEM != 0,
651            hidden_fingerprint,
652            exclude_special: flags & SCOPE_EXCLUDE_SPECIAL != 0,
653            population,
654            control_fingerprint,
655        };
656        Some(Self {
657            engine,
658            scope,
659            type_rules_fingerprint: fields.u64(),
660            reducers_fingerprint: fields.u64(),
661        })
662    }
663}
664
665impl ControlTierIdentity {
666    /// Encode the observation and, when observed, both limits.
667    pub(crate) fn encode(self) -> [u8; CONTROL_TIER_BYTES] {
668        let mut out = FixedWriter::new();
669        match self {
670            Self::NotObserved => out.put(&[CONTROLS_NOT_OBSERVED; CONTROL_TIER_BYTES]),
671            Self::Observed { limits } => {
672                out.put(&[CONTROLS_OBSERVED]);
673                out.put_bound(limits.budget);
674                out.put_bound(limits.line_limit);
675            }
676        }
677        out.finish()
678    }
679
680    /// Decode [`Self::encode`], or `None` for a tag or value no encoder writes.
681    pub(crate) fn decode(bytes: &[u8; CONTROL_TIER_BYTES]) -> Option<Self> {
682        let mut fields = FixedReader { rest: bytes };
683        match fields.u8() {
684            CONTROLS_NOT_OBSERVED => {
685                bytes[1..].iter().all(|byte| *byte == 0).then_some(Self::NotObserved)
686            }
687            CONTROLS_OBSERVED => {
688                let budget = fields.bound().ok()?;
689                let line_limit = fields.bound().ok()?;
690                Some(Self::Observed { limits: ControlLimits { budget, line_limit } })
691            }
692            _ => None,
693        }
694    }
695}
696
697impl SnapshotIdentity {
698    /// Encode the entry tier's fields, then the control tier.
699    pub(crate) fn encode(self) -> [u8; SNAPSHOT_IDENTITY_BYTES] {
700        let mut out = FixedWriter::new();
701        out.put(&self.entries.encode());
702        out.put(&self.controls.encode());
703        out.finish()
704    }
705
706    /// Decode [`Self::encode`] under the engine fingerprint of the snapshot that holds it.
707    pub(crate) fn decode(engine: u64, bytes: &[u8; SNAPSHOT_IDENTITY_BYTES]) -> Option<Self> {
708        let mut fields = FixedReader { rest: bytes };
709        let entries = EntryTierIdentity::decode(engine, &fields.take())?;
710        let controls = ControlTierIdentity::decode(&fields.take())?;
711        if entries.scope.population != IgnoredEntries::Include
712            && entries.scope.control_fingerprint != controls.ignore_rules_fingerprint()
713        {
714            return None;
715        }
716        Some(Self { entries, controls })
717    }
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723    use crate::ScanConfig;
724
725    fn limits(budget: Option<usize>, line_limit: Option<usize>) -> ControlLimits {
726        ControlLimits { budget, line_limit }
727    }
728
729    #[test]
730    fn snapshot_serving_is_equality_plus_observation_on_to_off() {
731        let base = ScanConfig::default().snapshot_identity();
732        assert_eq!(serves_snapshot(base, base), Serves::Exact);
733
734        let entries = base.entries;
735        let mut refused = vec![
736            SnapshotIdentity {
737                entries: EntryTierIdentity { engine: entries.engine ^ 1, ..entries },
738                ..base
739            },
740            SnapshotIdentity {
741                entries: EntryTierIdentity {
742                    type_rules_fingerprint: entries.type_rules_fingerprint ^ 1,
743                    ..entries
744                },
745                ..base
746            },
747            SnapshotIdentity {
748                entries: EntryTierIdentity {
749                    reducers_fingerprint: entries.reducers_fingerprint ^ 1,
750                    ..entries
751                },
752                ..base
753            },
754            SnapshotIdentity {
755                controls: ControlTierIdentity::Observed { limits: limits(None, None) },
756                ..base
757            },
758        ];
759        for config in [
760            ScanConfig { max_depth: Some(1), ..ScanConfig::default() },
761            ScanConfig { one_filesystem: true, ..ScanConfig::default() },
762            ScanConfig { exclude_special: true, ..ScanConfig::default() },
763            ScanConfig { population: IgnoredEntries::Exclude, ..ScanConfig::default() },
764            ScanConfig { population: IgnoredEntries::Only, ..ScanConfig::default() },
765            ScanConfig {
766                hidden: Some(std::sync::Arc::new(crate::HiddenPolicy::prune_hidden(
767                    std::iter::empty::<std::ffi::OsString>(),
768                ))),
769                ..ScanConfig::default()
770            },
771        ] {
772            refused.push(config.snapshot_identity());
773        }
774        for wanted in refused {
775            assert_eq!(serves_snapshot(base, wanted), Serves::Refuse, "{wanted:?}");
776            assert_eq!(serves_snapshot(wanted, base), Serves::Refuse, "{wanted:?}");
777        }
778        let blind = SnapshotIdentity { controls: ControlTierIdentity::NotObserved, ..base };
779        assert_eq!(serves_snapshot(base, blind), Serves::ProjectControlsOff);
780        assert_eq!(serves_snapshot(blind, base), Serves::Refuse);
781    }
782
783    #[test]
784    fn control_settings_change_only_the_control_tier() {
785        let base = ScanConfig::default();
786        for config in [
787            ScanConfig { read_controls: false, ..base.clone() },
788            ScanConfig { control_limits: limits(None, Some(1)), ..base.clone() },
789            ScanConfig {
790                read_controls: false,
791                control_limits: limits(Some(1), None),
792                ..base.clone()
793            },
794        ] {
795            assert_eq!(config.snapshot_identity().entries, base.snapshot_identity().entries);
796            assert_ne!(config.snapshot_identity().controls, base.snapshot_identity().controls);
797        }
798        // Limits decide nothing when nothing is observed, so they leave no trace.
799        let blind = ScanConfig { read_controls: false, ..base.clone() };
800        let blind_other_limits = ScanConfig { control_limits: limits(None, None), ..blind.clone() };
801        assert_eq!(blind.snapshot_identity(), blind_other_limits.snapshot_identity());
802        assert_eq!(blind.control_identity(), ControlTierIdentity::NotObserved);
803    }
804
805    #[test]
806    fn the_ignore_rules_fingerprint_reserves_zero_for_an_unobserved_tier() {
807        assert_eq!(ControlTierIdentity::NotObserved.ignore_rules_fingerprint(), 0);
808        let defaults = ControlLimits::default();
809        let observed = [
810            defaults,
811            limits(None, defaults.line_limit),
812            limits(defaults.budget, None),
813            limits(None, None),
814            // The same values in each other's places are a different tier.
815            limits(defaults.line_limit, defaults.budget),
816        ]
817        .map(|limits| ControlTierIdentity::Observed { limits }.ignore_rules_fingerprint());
818        for (index, fingerprint) in observed.iter().enumerate() {
819            assert_ne!(*fingerprint, 0);
820            assert!(!observed[index + 1..].contains(fingerprint), "{observed:?}");
821        }
822    }
823
824    #[test]
825    fn the_scope_an_identity_composes_is_the_one_a_scan_records() {
826        for config in [
827            ScanConfig::default(),
828            ScanConfig { read_controls: false, ..ScanConfig::default() },
829            ScanConfig { control_limits: limits(None, None), ..ScanConfig::default() },
830            ScanConfig { max_depth: Some(3), exclude_special: true, ..ScanConfig::default() },
831            ScanConfig { population: IgnoredEntries::Exclude, ..ScanConfig::default() },
832            ScanConfig { population: IgnoredEntries::Only, ..ScanConfig::default() },
833        ] {
834            let identity = config.snapshot_identity();
835            let scope = identity.scan_scope();
836            assert_eq!(scope, config.scope());
837            assert_eq!(EntryTierIdentity::of_scope(scope), identity.entries);
838            assert_eq!(scope.observes_controls(), identity.controls.is_observed());
839
840            let index = crate::Index::new_with_config("/root", &config);
841            assert_eq!(index.snapshot_identity(), identity);
842            assert_eq!(index.control_identity(), config.control_identity());
843        }
844    }
845
846    #[test]
847    fn each_tier_is_writable_by_its_own_rule() {
848        let config = ScanConfig::default();
849        let entries = config.snapshot_identity().entries;
850        let other =
851            ScanConfig { max_depth: Some(2), ..ScanConfig::default() }.snapshot_identity().entries;
852
853        let mut complete = crate::Index::new_with_config("/root", &config);
854        complete.set_initial_freshness(true);
855        assert!(entries_writable(&complete));
856        for stored in [None, Some(entries), Some(other)] {
857            assert!(
858                content_tier_writable(&complete, || stored),
859                "a complete pass writes: {stored:?}"
860            );
861        }
862
863        let mut partial = crate::Index::new_with_config("/root", &config);
864        partial.set_initial_freshness(false);
865        assert!(!entries_writable(&partial), "an absent entry would change totals");
866        assert!(content_tier_writable(&partial, || Some(entries)));
867        for stored in [None, Some(other)] {
868            assert!(!content_tier_writable(&partial, || stored), "mismatched pair: {stored:?}");
869        }
870
871        let mut unverified = complete.clone();
872        unverified.mark_unverified();
873        assert!(!entries_writable(&unverified), "a cache-only index verified nothing");
874    }
875
876    /// Identities that each differ from the default in one encoded field, including the
877    /// edges of each range, so a codec that dropped any one field would alias two of them.
878    fn identities() -> Vec<SnapshotIdentity> {
879        let base = ScanConfig::default().snapshot_identity();
880        let entries = base.entries;
881        let defaults = ControlLimits::default();
882        assert_eq!(entries.scope.max_depth, None);
883        assert!(defaults.budget.is_some() && defaults.line_limit.is_some());
884        let mut all = vec![
885            base,
886            ScanConfig { population: IgnoredEntries::Exclude, ..ScanConfig::default() }
887                .snapshot_identity(),
888            ScanConfig { population: IgnoredEntries::Only, ..ScanConfig::default() }
889                .snapshot_identity(),
890            ScanConfig {
891                population: IgnoredEntries::Exclude,
892                control_limits: limits(None, None),
893                ..ScanConfig::default()
894            }
895            .snapshot_identity(),
896        ];
897        for scope in [
898            EntryScope { max_depth: Some(0), ..entries.scope },
899            // The largest bound is a bound, never the unbounded depth.
900            EntryScope { max_depth: Some(usize::MAX), ..entries.scope },
901            EntryScope { follow_symlinks: true, ..entries.scope },
902            EntryScope { one_filesystem: true, ..entries.scope },
903            EntryScope { exclude_special: true, ..entries.scope },
904            EntryScope { hidden_fingerprint: u64::MAX, ..entries.scope },
905        ] {
906            all.push(SnapshotIdentity { entries: EntryTierIdentity { scope, ..entries }, ..base });
907        }
908        for entries in [
909            EntryTierIdentity { type_rules_fingerprint: 0, ..entries },
910            EntryTierIdentity { reducers_fingerprint: u64::MAX, ..entries },
911        ] {
912            all.push(SnapshotIdentity { entries, ..base });
913        }
914        for controls in [
915            ControlTierIdentity::NotObserved,
916            ControlTierIdentity::Observed { limits: limits(None, defaults.line_limit) },
917            ControlTierIdentity::Observed { limits: limits(Some(0), defaults.line_limit) },
918            ControlTierIdentity::Observed { limits: limits(defaults.budget, None) },
919            ControlTierIdentity::Observed { limits: limits(defaults.budget, Some(usize::MAX)) },
920        ] {
921            all.push(SnapshotIdentity { controls, ..base });
922        }
923        all
924    }
925
926    #[test]
927    fn every_identity_round_trips_through_its_fixed_width_encoding() {
928        let identities = identities();
929        let encoded = identities.iter().map(|identity| identity.encode()).collect::<Vec<_>>();
930        for (identity, bytes) in identities.iter().zip(&encoded) {
931            let engine = identity.entries.engine;
932            assert_eq!(SnapshotIdentity::decode(engine, bytes), Some(*identity));
933            let (entry_bytes, control_bytes) = bytes.split_at(ENTRY_TIER_BYTES);
934            assert_eq!(
935                EntryTierIdentity::decode(engine, entry_bytes.try_into().expect("width")),
936                Some(identity.entries)
937            );
938            assert_eq!(
939                ControlTierIdentity::decode(control_bytes.try_into().expect("width")),
940                Some(identity.controls)
941            );
942        }
943        // Canonical: distinct identities never share an encoding.
944        for (index, bytes) in encoded.iter().enumerate() {
945            assert!(!encoded[index + 1..].contains(bytes), "{:?}", identities[index]);
946        }
947    }
948
949    #[test]
950    fn bytes_no_encoder_writes_are_refused() {
951        let base = ScanConfig::default().snapshot_identity();
952        let engine = base.entries.engine;
953        let bounded = EntryTierIdentity {
954            scope: EntryScope { max_depth: Some(3), ..base.entries.scope },
955            ..base.entries
956        };
957        let (depth_tag_at, depth_at, flags_at) = (0, 1, BOUND_BYTES);
958        let mut forged_entries = Vec::new();
959        let mut unknown_flag = base.entries.encode();
960        unknown_flag[flags_at] |= 1 << 7;
961        forged_entries.push(unknown_flag);
962        let mut incompatible_population = base.entries.encode();
963        incompatible_population[flags_at] |= SCOPE_POPULATION_EXCLUDE | SCOPE_POPULATION_ONLY;
964        forged_entries.push(incompatible_population);
965        let mut missing_control_fingerprint = base.entries.encode();
966        missing_control_fingerprint[flags_at] |= SCOPE_POPULATION_EXCLUDE;
967        forged_entries.push(missing_control_fingerprint);
968        let mut unexplained_control_fingerprint = base.entries.encode();
969        unexplained_control_fingerprint[BOUND_BYTES + 1 + 8] = 1;
970        forged_entries.push(unexplained_control_fingerprint);
971        let mut unknown_depth_tag = bounded.encode();
972        assert_eq!(unknown_depth_tag[depth_tag_at], BOUNDED);
973        unknown_depth_tag[depth_tag_at] = 2;
974        forged_entries.push(unknown_depth_tag);
975        let mut unbounded_depth_with_a_value = base.entries.encode();
976        assert_eq!(unbounded_depth_with_a_value[depth_tag_at], UNBOUNDED);
977        unbounded_depth_with_a_value[depth_at] = 1;
978        forged_entries.push(unbounded_depth_with_a_value);
979        for bytes in forged_entries {
980            assert_eq!(EntryTierIdentity::decode(engine, &bytes), None, "{bytes:?}");
981        }
982
983        let observed = ControlTierIdentity::Observed { limits: limits(None, Some(1)) };
984        let controls = observed.encode();
985        let (budget_tag_at, budget_at, line_tag_at) = (1, 2, 1 + BOUND_BYTES);
986        assert_eq!(controls[budget_tag_at], UNBOUNDED);
987        assert_eq!(controls[line_tag_at], BOUNDED);
988        let mut forged = Vec::new();
989        let mut unknown_tag = controls;
990        unknown_tag[0] = 2;
991        forged.push(unknown_tag);
992        let mut unknown_limit_tag = controls;
993        unknown_limit_tag[line_tag_at] = 2;
994        forged.push(unknown_limit_tag);
995        let mut unbounded_with_a_value = controls;
996        unbounded_with_a_value[budget_at] = 1;
997        forged.push(unbounded_with_a_value);
998        let mut unobserved_with_limits = controls;
999        unobserved_with_limits[0] = CONTROLS_NOT_OBSERVED;
1000        forged.push(unobserved_with_limits);
1001        for bytes in forged {
1002            assert_eq!(ControlTierIdentity::decode(&bytes), None, "{bytes:?}");
1003        }
1004    }
1005
1006    /// A content tier states its type rules once, in its entry tier, and every record it
1007    /// holds carries exactly those rules.
1008    #[test]
1009    fn a_content_tier_holds_its_records_type_rules_in_its_entry_tier() {
1010        use crate::content::AnalysisRequest;
1011
1012        let entries = ScanConfig::default().snapshot_identity().entries;
1013        let request = AnalysisRequest { profile: AnalysisSet::ALL, ..AnalysisRequest::default() };
1014        let records = ContentProvenance::for_request(request, entries.type_rules_fingerprint);
1015        let identity = ContentTierIdentity::for_request(entries, request.profile);
1016        assert_eq!(identity.record_provenance(), records);
1017        assert!(identity.admit_record(request.profile, &records).is_some());
1018
1019        let other_rules = ContentProvenance::for_request(request, !entries.type_rules_fingerprint);
1020        assert!(identity.admit_record(request.profile, &other_rules).is_none(), "other type rules");
1021        let lines = AnalysisSet::NONE.with_lines();
1022        assert!(identity.admit_record(lines, &records).is_none(), "another analyzer set's label");
1023    }
1024
1025    #[test]
1026    fn content_admission_refuses_every_identity_difference_and_applies_exact_identity() {
1027        let entries = ScanConfig::default().snapshot_identity().entries;
1028        let wanted = ContentTierIdentity::for_request(entries, AnalysisSet::ALL);
1029        assert_eq!(wanted.admit(&wanted).expect("exact admission").identity(), &wanted);
1030        let changes: &[fn(&mut ContentTierIdentity)] = &[
1031            |identity| identity.entries.engine ^= 1,
1032            |identity| identity.entries.scope.max_depth = Some(1),
1033            |identity| identity.entries.type_rules_fingerprint ^= 1,
1034            |identity| identity.entries.reducers_fingerprint ^= 1,
1035            |identity| identity.analysis = AnalysisSet::LINES_ONLY,
1036            |identity| identity.provenance.options_fingerprint.0 ^= 1,
1037            |identity| identity.provenance.analyzers[0].1.0 += 1,
1038            |identity| {
1039                identity.provenance.analyzers.pop();
1040            },
1041        ];
1042        for change in changes {
1043            let mut other = wanted.clone();
1044            change(&mut other);
1045            assert!(wanted.admit(&other).is_none(), "stored mismatch: {other:?}");
1046            assert!(other.admit(&wanted).is_none(), "requested mismatch: {other:?}");
1047        }
1048    }
1049
1050    #[test]
1051    fn the_engine_fingerprint_comes_from_the_store_not_the_encoding() {
1052        let identity = ScanConfig::default().snapshot_identity();
1053        let bytes = identity.encode();
1054        let other = SnapshotIdentity::decode(!identity.entries.engine, &bytes).expect("decode");
1055        assert_eq!(other.entries.engine, !identity.entries.engine);
1056        assert_eq!(serves_snapshot(other, identity), Serves::Refuse);
1057    }
1058}