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