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