Skip to main content

lanekeep_cache/
entry.rs

1//! What a cache entry holds, and how it is encoded.
2//!
3//! One entry per `(file, key)`: everything the per-file pass produced for that file, plus
4//! the dependencies whose contents the result relied on.
5//!
6//! # Why a hand-written encoding
7//!
8//! The warm-run budget is milliseconds for the whole corpus, and a text format would spend
9//! most of it parsing. A binary serialization crate would do the job, but the value type
10//! here is a handful of strings, integers and one enum — small enough that the format is
11//! less code than the dependency review, and it leaves nothing to a crate's version
12//! compatibility rules.
13//!
14//! # Decoding is total
15//!
16//! Every decode path returns `None` rather than failing or panicking. The cache is
17//! disposable: a truncated file, a torn write, a byte flipped on disk, a file written by a
18//! different build — all of them mean "recompute", never an error the user has to act on. A
19//! cache that can break a run is worse than no cache.
20
21use lanekeep_core::fact::Fact;
22use lanekeep_core::fix::Fix;
23use lanekeep_core::suppression::{Date, Scope, Suppression};
24use lanekeep_core::tracked::{ContentHash, TrackedRead};
25use lanekeep_core::{FilePath, Location, Position, RuleId, Severity, Violation};
26
27/// Everything one file's pass produced.
28#[derive(Debug, Clone, PartialEq, Eq, Default)]
29pub struct Entry {
30    /// Violations found in this file.
31    pub violations: Vec<Violation>,
32    /// Facts this file's rules emitted.
33    pub facts: Vec<Fact>,
34    /// Files this file's rules read, and what they hashed to.
35    pub dependencies: Vec<TrackedRead>,
36
37    /// The file's suppression directives.
38    ///
39    /// Stored because a reduce-phase violation can be reported at a site in a file that was
40    /// not reprocessed this run. Without them, the warm path would drop the directive and
41    /// report a violation the author had already accepted.
42    pub suppressions: Vec<Suppression>,
43
44    /// Indices into `suppressions` of the directives that silenced something.
45    ///
46    /// Recorded because a warm run never sees what a directive suppressed — the entry holds
47    /// the violations that survived, and the ones it hid are gone. Without this, every
48    /// suppression in a cached file would look unused.
49    pub used_suppressions: Vec<u32>,
50}
51
52impl Entry {
53    /// Append the encoded form to `out`.
54    pub fn encode(&self, out: &mut Vec<u8>) {
55        write_len(out, self.violations.len());
56        for violation in &self.violations {
57            write_str(out, &violation.rule_id.to_string());
58            write_str(out, violation.location.file.as_str());
59            out.extend_from_slice(&violation.location.position.line.to_le_bytes());
60            out.extend_from_slice(&violation.location.position.column.to_le_bytes());
61            write_str(out, &violation.message);
62            write_str(out, &violation.remediation);
63            out.push(severity_code(violation.severity));
64            match &violation.fix {
65                Some(fix) => {
66                    out.push(1);
67                    write_len(out, fix.start);
68                    write_len(out, fix.end);
69                    write_str(out, &fix.replacement);
70                    out.push(u8::from(fix.safe));
71                }
72                None => out.push(0),
73            }
74        }
75
76        write_len(out, self.facts.len());
77        for fact in &self.facts {
78            write_str(out, &fact.rule_id.to_string());
79            write_str(out, fact.file.as_str());
80            write_str(out, &fact.kind);
81            write_str(out, &fact.data);
82            out.extend_from_slice(&fact.sequence.to_le_bytes());
83        }
84
85        write_len(out, self.suppressions.len());
86        for suppression in &self.suppressions {
87            out.push(match suppression.scope {
88                Scope::NextLine => 0,
89                Scope::File => 1,
90            });
91            out.extend_from_slice(&suppression.line.to_le_bytes());
92            out.extend_from_slice(&suppression.column.to_le_bytes());
93            write_str(out, &suppression.reason);
94
95            write_len(out, suppression.rules.len());
96            for rule in &suppression.rules {
97                write_str(out, &rule.to_string());
98            }
99
100            match suppression.expires {
101                Some(date) => {
102                    out.push(1);
103                    out.extend_from_slice(&date.year.to_le_bytes());
104                    out.push(date.month);
105                    out.push(date.day);
106                }
107                None => out.push(0),
108            }
109        }
110
111        write_len(out, self.used_suppressions.len());
112        for index in &self.used_suppressions {
113            out.extend_from_slice(&index.to_le_bytes());
114        }
115
116        write_len(out, self.dependencies.len());
117        for read in &self.dependencies {
118            write_str(out, read.path.as_str());
119            match read.hash {
120                // A present/absent flag rather than a sentinel hash: "this file was not
121                // there" is a distinct answer from any possible digest, and encoding it as
122                // one would make an unlucky file collide with absence.
123                Some(hash) => {
124                    out.push(1);
125                    out.extend_from_slice(hash.as_bytes());
126                }
127                None => out.push(0),
128            }
129        }
130    }
131
132    /// Decode an entry, or `None` if the bytes are not one.
133    #[must_use]
134    pub fn decode(bytes: &[u8]) -> Option<Self> {
135        let mut cursor = Cursor::new(bytes);
136
137        let mut violations = Vec::with_capacity(cursor.peek_len()?);
138        for _ in 0..cursor.read_len()? {
139            let rule_id = cursor.read_str()?.parse::<RuleId>().ok()?;
140            let file = FilePath::new(cursor.read_str()?);
141            let line = cursor.read_u32()?;
142            let column = cursor.read_u32()?;
143            violations.push(Violation {
144                rule_id,
145                location: Location::new(file, Position::new(line, column)),
146                message: cursor.read_str()?.to_owned(),
147                remediation: cursor.read_str()?.to_owned(),
148                severity: severity_from(cursor.read_u8()?)?,
149                fix: match cursor.read_u8()? {
150                    0 => None,
151                    1 => Some(Fix {
152                        start: cursor.read_len()?,
153                        end: cursor.read_len()?,
154                        replacement: cursor.read_str()?.to_owned(),
155                        safe: match cursor.read_u8()? {
156                            0 => false,
157                            1 => true,
158                            _ => return None,
159                        },
160                    }),
161                    _ => return None,
162                },
163            });
164        }
165
166        let mut facts = Vec::with_capacity(cursor.peek_len()?);
167        for _ in 0..cursor.read_len()? {
168            facts.push(Fact {
169                rule_id: cursor.read_str()?.parse::<RuleId>().ok()?,
170                file: FilePath::new(cursor.read_str()?),
171                kind: cursor.read_str()?.to_owned(),
172                data: cursor.read_str()?.to_owned(),
173                sequence: cursor.read_u32()?,
174            });
175        }
176
177        let mut suppressions = Vec::with_capacity(cursor.peek_len()?);
178        for _ in 0..cursor.read_len()? {
179            let scope = match cursor.read_u8()? {
180                0 => Scope::NextLine,
181                1 => Scope::File,
182                _ => return None,
183            };
184            let line = cursor.read_u32()?;
185            let column = cursor.read_u32()?;
186            let reason = cursor.read_str()?.to_owned();
187
188            let mut rules = Vec::with_capacity(cursor.peek_len()?);
189            for _ in 0..cursor.read_len()? {
190                rules.push(cursor.read_str()?.parse().ok()?);
191            }
192
193            let expires = match cursor.read_u8()? {
194                0 => None,
195                1 => Some(Date {
196                    year: u16::from_le_bytes(cursor.take(2)?.try_into().ok()?),
197                    month: cursor.read_u8()?,
198                    day: cursor.read_u8()?,
199                }),
200                _ => return None,
201            };
202
203            suppressions.push(Suppression {
204                scope,
205                rules,
206                reason,
207                expires,
208                line,
209                column,
210            });
211        }
212
213        let mut used_suppressions = Vec::with_capacity(cursor.peek_len()?);
214        for _ in 0..cursor.read_len()? {
215            let index = cursor.read_u32()?;
216            // An index past the end would be an entry claiming a directive that is not
217            // there. Refusing is one more way this stays total.
218            if index as usize >= suppressions.len() {
219                return None;
220            }
221            used_suppressions.push(index);
222        }
223
224        let mut dependencies = Vec::with_capacity(cursor.peek_len()?);
225        for _ in 0..cursor.read_len()? {
226            let path = FilePath::new(cursor.read_str()?);
227            let hash = match cursor.read_u8()? {
228                0 => None,
229                1 => Some(ContentHash::new(cursor.read_hash()?)),
230                _ => return None,
231            };
232            dependencies.push(TrackedRead { path, hash });
233        }
234
235        // Trailing bytes mean this is not the entry it claims to be. Accepting them would
236        // let a stale suffix ride along into whatever the format grows next.
237        cursor.finished().then_some(Self {
238            violations,
239            facts,
240            dependencies,
241            suppressions,
242            used_suppressions,
243        })
244    }
245}
246
247/// Reading a byte slice without trusting any of it.
248struct Cursor<'a> {
249    bytes: &'a [u8],
250    at: usize,
251}
252
253impl<'a> Cursor<'a> {
254    const fn new(bytes: &'a [u8]) -> Self {
255        Self { bytes, at: 0 }
256    }
257
258    fn take(&mut self, count: usize) -> Option<&'a [u8]> {
259        let end = self.at.checked_add(count)?;
260        let slice = self.bytes.get(self.at..end)?;
261        self.at = end;
262        Some(slice)
263    }
264
265    fn read_u8(&mut self) -> Option<u8> {
266        self.take(1).map(|b| b[0])
267    }
268
269    fn read_u32(&mut self) -> Option<u32> {
270        let bytes: [u8; 4] = self.take(4)?.try_into().ok()?;
271        Some(u32::from_le_bytes(bytes))
272    }
273
274    fn read_hash(&mut self) -> Option<[u8; 32]> {
275        self.take(32)?.try_into().ok()
276    }
277
278    fn read_len(&mut self) -> Option<usize> {
279        self.read_u32().map(|n| n as usize)
280    }
281
282    /// The next length without consuming it, for sizing a `Vec` before the loop.
283    ///
284    /// Deliberately clamped: a corrupt count of four billion would otherwise reserve
285    /// gigabytes before the first read failed. The loop still reads the real count, so a
286    /// clamped hint costs at most a reallocation on a legitimate entry.
287    fn peek_len(&self) -> Option<usize> {
288        let bytes: [u8; 4] = self.bytes.get(self.at..self.at + 4)?.try_into().ok()?;
289        Some((u32::from_le_bytes(bytes) as usize).min(1024))
290    }
291
292    fn read_str(&mut self) -> Option<&'a str> {
293        let len = self.read_len()?;
294        std::str::from_utf8(self.take(len)?).ok()
295    }
296
297    const fn finished(&self) -> bool {
298        self.at == self.bytes.len()
299    }
300}
301
302fn write_len(out: &mut Vec<u8>, len: usize) {
303    // Saturating rather than truncating: a count that did not fit would otherwise encode as
304    // a small number and silently drop the tail on the next read.
305    out.extend_from_slice(&u32::try_from(len).unwrap_or(u32::MAX).to_le_bytes());
306}
307
308fn write_str(out: &mut Vec<u8>, text: &str) {
309    write_len(out, text.len());
310    out.extend_from_slice(text.as_bytes());
311}
312
313/// Severity as one byte.
314///
315/// An explicit mapping rather than a cast, so reordering the enum cannot silently
316/// reinterpret every cached violation in the world.
317const fn severity_code(severity: Severity) -> u8 {
318    match severity {
319        Severity::Off => 0,
320        Severity::Warn => 1,
321        Severity::Error => 2,
322    }
323}
324
325const fn severity_from(code: u8) -> Option<Severity> {
326    match code {
327        0 => Some(Severity::Off),
328        1 => Some(Severity::Warn),
329        2 => Some(Severity::Error),
330        _ => None,
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    fn violation(rule: &str, file: &str, line: u32) -> Violation {
339        Violation {
340            rule_id: rule.parse().expect("valid id"),
341            location: Location::new(FilePath::new(file), Position::new(line, 1)),
342            message: "a message".to_owned(),
343            remediation: "a remediation".to_owned(),
344            severity: Severity::Error,
345            fix: None,
346        }
347    }
348
349    fn fact(rule: &str, file: &str, sequence: u32) -> Fact {
350        Fact {
351            rule_id: rule.parse().expect("valid id"),
352            file: FilePath::new(file),
353            kind: "export".to_owned(),
354            data: r#"{"kind":"export","symbol":"x"}"#.to_owned(),
355            sequence,
356        }
357    }
358
359    fn populated() -> Entry {
360        Entry {
361            violations: vec![
362                violation("local/a", "src/a.ts", 1),
363                violation("lanekeep/no-default-export", "src/a.ts", 9),
364            ],
365            facts: vec![
366                fact("local/a", "src/a.ts", 0),
367                fact("local/a", "src/a.ts", 1),
368            ],
369            dependencies: vec![
370                TrackedRead::found(FilePath::new("package.json"), ContentHash::new([7; 32])),
371                TrackedRead::absent(FilePath::new("tsconfig.json")),
372            ],
373            suppressions: vec![
374                Suppression {
375                    scope: Scope::NextLine,
376                    rules: vec!["local/a".parse().expect("valid id")],
377                    reason: "legacy".to_owned(),
378                    expires: None,
379                    line: 4,
380                    column: 3,
381                },
382                Suppression {
383                    scope: Scope::File,
384                    rules: vec![
385                        "local/a".parse().expect("valid id"),
386                        "lanekeep/no-default-export".parse().expect("valid id"),
387                    ],
388                    reason: "generated".to_owned(),
389                    expires: Some(Date {
390                        year: 2026,
391                        month: 12,
392                        day: 31,
393                    }),
394                    line: 1,
395                    column: 1,
396                },
397            ],
398            used_suppressions: vec![1],
399        }
400    }
401
402    #[test]
403    fn a_fix_survives_a_round_trip() {
404        let mut entry = Entry::default();
405        let mut with_fix = violation("local/a", "src/a.ts", 1);
406        with_fix.fix = Some(Fix {
407            start: 4,
408            end: 9,
409            replacement: "let".to_owned(),
410            safe: true,
411        });
412        entry.violations.push(with_fix.clone());
413        entry.violations.push(violation("local/b", "src/a.ts", 2));
414
415        let decoded = round_trip(&entry).expect("decodes");
416        assert_eq!(decoded.violations[0].fix, with_fix.fix);
417        assert_eq!(decoded.violations[1].fix, None, "absence survives too");
418    }
419
420    #[test]
421    fn a_suggestion_does_not_come_back_as_safe() {
422        // The bit that decides whether `--fix` may rewrite someone's code.
423        let mut entry = Entry::default();
424        let mut suggested = violation("local/a", "src/a.ts", 1);
425        suggested.fix = Some(Fix {
426            start: 0,
427            end: 1,
428            replacement: "x".to_owned(),
429            safe: false,
430        });
431        entry.violations.push(suggested);
432
433        let decoded = round_trip(&entry).expect("decodes");
434        assert_eq!(
435            decoded.violations[0].fix.as_ref().map(|f| f.safe),
436            Some(false)
437        );
438    }
439
440    #[test]
441    fn which_suppressions_were_used_survives_a_round_trip() {
442        // A warm run never sees what a directive suppressed, so without this every
443        // suppression in a cached file would look unused.
444        let entry = populated();
445        assert_eq!(
446            round_trip(&entry).expect("decodes").used_suppressions,
447            entry.used_suppressions
448        );
449    }
450
451    #[test]
452    fn a_used_index_past_the_end_is_refused() {
453        let mut entry = populated();
454        entry.used_suppressions = vec![99];
455        let mut bytes = Vec::new();
456        entry.encode(&mut bytes);
457        assert_eq!(Entry::decode(&bytes), None);
458    }
459
460    #[test]
461    fn suppressions_survive_a_round_trip() {
462        // A reduce-phase violation can land on a file that was a cache hit. Without these,
463        // the warm path would drop the directive and report a violation the author had
464        // already accepted.
465        let entry = populated();
466        let decoded = round_trip(&entry).expect("decodes");
467        assert_eq!(decoded.suppressions, entry.suppressions);
468    }
469
470    #[test]
471    fn an_expiry_survives_as_an_expiry() {
472        let entry = populated();
473        let decoded = round_trip(&entry).expect("decodes");
474        assert_eq!(decoded.suppressions[0].expires, None);
475        assert_eq!(
476            decoded.suppressions[1].expires,
477            Some(Date {
478                year: 2026,
479                month: 12,
480                day: 31
481            })
482        );
483    }
484
485    fn round_trip(entry: &Entry) -> Option<Entry> {
486        let mut bytes = Vec::new();
487        entry.encode(&mut bytes);
488        Entry::decode(&bytes)
489    }
490
491    #[test]
492    fn a_populated_entry_survives_a_round_trip() {
493        let entry = populated();
494        assert_eq!(round_trip(&entry).as_ref(), Some(&entry));
495    }
496
497    #[test]
498    fn an_empty_entry_survives_a_round_trip() {
499        let entry = Entry::default();
500        assert_eq!(round_trip(&entry).as_ref(), Some(&entry));
501    }
502
503    #[test]
504    fn absence_survives_as_absence() {
505        // The distinction a cache is wrong without: "this file was not there" must not come
506        // back as "this file hashed to something".
507        let entry = Entry {
508            dependencies: vec![TrackedRead::absent(FilePath::new("tsconfig.json"))],
509            ..Entry::default()
510        };
511        let decoded = round_trip(&entry).expect("decodes");
512        assert_eq!(decoded.dependencies[0].hash, None);
513    }
514
515    #[test]
516    fn every_severity_survives() {
517        for severity in [Severity::Off, Severity::Warn, Severity::Error] {
518            let mut entry = Entry::default();
519            let mut v = violation("local/a", "src/a.ts", 1);
520            v.severity = severity;
521            entry.violations.push(v);
522            assert_eq!(
523                round_trip(&entry).expect("decodes").violations[0].severity,
524                severity
525            );
526        }
527    }
528
529    #[test]
530    fn non_ascii_text_survives() {
531        // Messages carry rule-authored text, which is not ASCII in general — a length in
532        // characters rather than bytes would truncate here.
533        let mut entry = Entry::default();
534        let mut v = violation("local/a", "src/a.ts", 1);
535        v.message = "circular import: a → b → a".to_owned();
536        v.remediation = "casse le cycle — extrais le partagé".to_owned();
537        entry.violations.push(v);
538        assert_eq!(round_trip(&entry).as_ref(), Some(&entry));
539    }
540
541    #[test]
542    fn a_truncated_entry_decodes_to_nothing() {
543        // A torn write means recompute, not a panic and not a partial entry.
544        let entry = populated();
545        let mut bytes = Vec::new();
546        entry.encode(&mut bytes);
547
548        for cut in 0..bytes.len() {
549            assert_eq!(
550                Entry::decode(&bytes[..cut]),
551                None,
552                "a {cut}-byte prefix decoded as an entry"
553            );
554        }
555    }
556
557    #[test]
558    fn trailing_bytes_are_refused() {
559        // Otherwise a stale suffix rides along into whatever the format grows next.
560        let mut bytes = Vec::new();
561        populated().encode(&mut bytes);
562        bytes.push(0);
563        assert_eq!(Entry::decode(&bytes), None);
564    }
565
566    #[test]
567    fn a_corrupt_entry_never_panics() {
568        // Every byte flipped, one at a time. The cache is disposable: garbage means
569        // recompute, and a panic would make a corrupt file break every future run.
570        let mut bytes = Vec::new();
571        populated().encode(&mut bytes);
572
573        for index in 0..bytes.len() {
574            for bit in 0..8u32 {
575                let mut corrupt = bytes.clone();
576                corrupt[index] ^= 1 << bit;
577                // The result may legitimately decode — flipping a byte inside a message
578                // yields a different but valid entry. What must not happen is a panic.
579                let _ = Entry::decode(&corrupt);
580            }
581        }
582    }
583
584    #[test]
585    fn an_absurd_count_does_not_allocate_absurdly() {
586        // A corrupt length must not reserve gigabytes before the read fails.
587        let mut bytes = u32::MAX.to_le_bytes().to_vec();
588        bytes.extend_from_slice(&[0; 8]);
589        assert_eq!(Entry::decode(&bytes), None);
590    }
591
592    #[test]
593    fn a_bad_severity_code_is_refused() {
594        let mut bytes = Vec::new();
595        Entry {
596            violations: vec![violation("local/a", "src/a.ts", 1)],
597            ..Entry::default()
598        }
599        .encode(&mut bytes);
600
601        let last = bytes.len() - 1;
602        bytes[last] = 9;
603        assert_eq!(Entry::decode(&bytes), None);
604    }
605
606    #[test]
607    fn a_bad_rule_id_is_refused() {
608        // Rule ids are namespaced. A bare one in a cache file was written by something
609        // else, and decoding it would smuggle an invalid id into the run's output.
610        let mut bytes = Vec::new();
611        write_len(&mut bytes, 1);
612        write_str(&mut bytes, "bare-id");
613        write_str(&mut bytes, "src/a.ts");
614        bytes.extend_from_slice(&1u32.to_le_bytes());
615        bytes.extend_from_slice(&1u32.to_le_bytes());
616        write_str(&mut bytes, "m");
617        write_str(&mut bytes, "r");
618        bytes.push(2);
619        write_len(&mut bytes, 0);
620        write_len(&mut bytes, 0);
621
622        assert_eq!(Entry::decode(&bytes), None);
623    }
624
625    #[test]
626    fn invalid_utf8_is_refused() {
627        let mut bytes = Vec::new();
628        write_len(&mut bytes, 1);
629        write_len(&mut bytes, 2);
630        bytes.extend_from_slice(&[0xff, 0xfe]);
631        assert_eq!(Entry::decode(&bytes), None);
632    }
633}