Skip to main content

yarp_cli/rule_pack/
compiled.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fs::{self, File};
3use std::io::{Read as _, Seek as _, SeekFrom};
4use std::path::{Path, PathBuf};
5
6use sha2::{Digest as _, Sha256};
7
8use super::model::{ENGINE_ABI_VERSION, Rule};
9use super::source::SourcePack;
10use super::strict_json;
11use super::validation::{
12    MAX_COMPILED_BYTES, MAX_RULES, MAX_SOURCE_FILE_BYTES, validate_manifest, validate_rule,
13    validate_rules,
14};
15
16const MAGIC: &[u8; 8] = b"YARPRUL\0";
17const FORMAT_VERSION: u16 = 1;
18const HEADER_LEN: usize = 128;
19const DIGEST_OFFSET: usize = 80;
20const DIGEST_LEN: usize = 32;
21const MAX_ID_BYTES: usize = 128;
22const MAX_PROGRAMS: usize = MAX_RULES * 32;
23
24#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct RuleRecord {
26    pub id: String,
27    offset: u64,
28    length: u32,
29    digest: [u8; 32],
30}
31
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub struct ProgramEntry {
34    pub program: String,
35    pub candidates: Vec<u32>,
36}
37
38#[derive(Debug)]
39pub struct CompiledPack {
40    pub path: PathBuf,
41    pub id: String,
42    pub source_digest: [u8; 32],
43    pub compiled_digest: [u8; 32],
44    pub rules: Vec<RuleRecord>,
45    pub programs: Vec<ProgramEntry>,
46    records_offset: u64,
47    records_len: u64,
48    file: File,
49}
50
51/// Compile a validated source pack into deterministic indexed bytes.
52///
53/// # Errors
54///
55/// Returns an error when the source model is invalid or a compiled size or index conversion fails.
56pub fn compile(pack: &SourcePack) -> Result<Vec<u8>, String> {
57    validate_manifest(&pack.manifest)?;
58    validate_rules(&pack.rules)?;
59    let mut sorted_rules: Vec<&Rule> = pack.rules.iter().collect();
60    sorted_rules.sort_by(|left, right| left.id.cmp(&right.id));
61
62    let mut records = Vec::new();
63    let mut rule_records = Vec::with_capacity(sorted_rules.len());
64    for rule in &sorted_rules {
65        let body = serde_jcs::to_vec(rule)
66            .map_err(|error| format!("could not encode rule {}: {error}", rule.id))?;
67        if body.len() > MAX_SOURCE_FILE_BYTES {
68            return Err(format!(
69                "compiled rule {} exceeds {MAX_SOURCE_FILE_BYTES} bytes",
70                rule.id
71            ));
72        }
73        let offset = u64::try_from(records.len())
74            .map_err(|_| "compiled record offset does not fit u64".to_owned())?;
75        let length = u32::try_from(body.len())
76            .map_err(|_| format!("compiled rule {} is too large", rule.id))?;
77        let digest = Sha256::digest(&body).into();
78        records.extend_from_slice(&body);
79        rule_records.push(RuleRecord {
80            id: rule.id.clone(),
81            offset,
82            length,
83            digest,
84        });
85    }
86
87    let rule_indices: BTreeMap<&str, u32> = sorted_rules
88        .iter()
89        .enumerate()
90        .map(|(index, rule)| {
91            Ok((
92                rule.id.as_str(),
93                u32::try_from(index).map_err(|_| "rule index does not fit u32".to_owned())?,
94            ))
95        })
96        .collect::<Result<_, String>>()?;
97    let mut program_map = BTreeMap::<String, Vec<u32>>::new();
98    for rule in sorted_rules {
99        let index = rule_indices[rule.id.as_str()];
100        for program in &rule.matcher.program {
101            program_map.entry(program.clone()).or_default().push(index);
102        }
103    }
104    let programs: Vec<ProgramEntry> = program_map
105        .into_iter()
106        .map(|(program, mut candidates)| {
107            candidates.sort_unstable();
108            candidates.dedup();
109            ProgramEntry {
110                program,
111                candidates,
112            }
113        })
114        .collect();
115
116    let index = encode_index(&rule_records, &programs)?;
117    let pack_id = pack.manifest.id.as_bytes();
118    let header = encode_header(
119        pack_id,
120        rule_records.len(),
121        programs.len(),
122        &index,
123        &records,
124        pack.source_digest,
125    )?;
126
127    let capacity = HEADER_LEN
128        .checked_add(pack_id.len())
129        .and_then(|value| value.checked_add(index.len()))
130        .and_then(|value| value.checked_add(records.len()))
131        .ok_or_else(|| "compiled pack size overflowed".to_owned())?;
132    if capacity as u64 > MAX_COMPILED_BYTES {
133        return Err(format!("compiled pack exceeds {MAX_COMPILED_BYTES} bytes"));
134    }
135    let mut output = Vec::with_capacity(capacity);
136    output.extend_from_slice(&header);
137    output.extend_from_slice(pack_id);
138    output.extend_from_slice(&index);
139    output.extend_from_slice(&records);
140    Ok(output)
141}
142
143fn encode_header(
144    pack_id: &[u8],
145    rule_count: usize,
146    program_count: usize,
147    index: &[u8],
148    records: &[u8],
149    source_digest: [u8; 32],
150) -> Result<[u8; HEADER_LEN], String> {
151    let mut header = [0_u8; HEADER_LEN];
152    header[0..8].copy_from_slice(MAGIC);
153    put_u16(&mut header, 8, FORMAT_VERSION);
154    put_u16(&mut header, 10, ENGINE_ABI_VERSION);
155    put_u16(&mut header, 12, ENGINE_ABI_VERSION);
156    put_u32(
157        &mut header,
158        16,
159        u32::try_from(HEADER_LEN).map_err(|_| "header length does not fit u32".to_owned())?,
160    );
161    put_u32(
162        &mut header,
163        20,
164        u32::try_from(pack_id.len()).map_err(|_| "pack id is too long".to_owned())?,
165    );
166    put_u32(
167        &mut header,
168        24,
169        u32::try_from(rule_count).map_err(|_| "too many rules".to_owned())?,
170    );
171    put_u32(
172        &mut header,
173        28,
174        u32::try_from(program_count).map_err(|_| "too many programs".to_owned())?,
175    );
176    put_u64(
177        &mut header,
178        32,
179        u64::try_from(index.len())
180            .map_err(|_| "compiled index length does not fit u64".to_owned())?,
181    );
182    put_u64(
183        &mut header,
184        40,
185        u64::try_from(records.len())
186            .map_err(|_| "compiled records length does not fit u64".to_owned())?,
187    );
188    header[48..80].copy_from_slice(&source_digest);
189    let digest = header_index_digest(&header, pack_id, index);
190    header[DIGEST_OFFSET..DIGEST_OFFSET + DIGEST_LEN].copy_from_slice(&digest);
191    Ok(header)
192}
193
194impl CompiledPack {
195    /// Open and verify a compiled pack header and program index.
196    ///
197    /// # Errors
198    ///
199    /// Returns an error for an unsafe path, incompatible format, corrupt index, or digest mismatch.
200    pub fn open(
201        path: &Path,
202        expected_source_digest: Option<[u8; 32]>,
203        expected_compiled_digest: Option<[u8; 32]>,
204    ) -> Result<Self, String> {
205        let metadata =
206            fs::symlink_metadata(path).map_err(|error| format!("{}: {error}", path.display()))?;
207        if metadata.file_type().is_symlink() {
208            return Err(format!("{}: symlinks are not allowed", path.display()));
209        }
210        if !metadata.is_file() {
211            return Err(format!("{}: expected a regular file", path.display()));
212        }
213        if metadata.len() > MAX_COMPILED_BYTES {
214            return Err(format!(
215                "{}: compiled pack exceeds {MAX_COMPILED_BYTES} bytes",
216                path.display()
217            ));
218        }
219        let path = fs::canonicalize(path)
220            .map_err(|error| format!("could not resolve {}: {error}", path.display()))?;
221        let mut file = File::open(&path)
222            .map_err(|error| format!("could not open {}: {error}", path.display()))?;
223        let opened_metadata = file
224            .metadata()
225            .map_err(|error| format!("could not stat {}: {error}", path.display()))?;
226        if !opened_metadata.is_file() || opened_metadata.len() > MAX_COMPILED_BYTES {
227            return Err(format!("{}: invalid compiled pack file", path.display()));
228        }
229        let compiled_digest = file_digest(&mut file, &path)?;
230        if let Some(expected) = expected_compiled_digest
231            && expected != compiled_digest
232        {
233            return Err(format!("{}: compiled pack digest changed", path.display()));
234        }
235        let mut header = [0_u8; HEADER_LEN];
236        file.read_exact(&mut header)
237            .map_err(|error| format!("{}: truncated header: {error}", path.display()))?;
238        let parsed = parse_header(&header, opened_metadata.len())?;
239        if let Some(expected) = expected_source_digest
240            && expected != parsed.source_digest
241        {
242            return Err(format!("{}: source digest changed", path.display()));
243        }
244        let mut pack_id = vec![0_u8; parsed.pack_id_len];
245        file.read_exact(&mut pack_id)
246            .map_err(|error| format!("{}: truncated pack id: {error}", path.display()))?;
247        let id = String::from_utf8(pack_id.clone())
248            .map_err(|_| format!("{}: pack id is not UTF-8", path.display()))?;
249        validate_compiled_pack_id(&id)?;
250        let mut index = vec![0_u8; parsed.index_len];
251        file.read_exact(&mut index)
252            .map_err(|error| format!("{}: truncated index: {error}", path.display()))?;
253        let digest = header_index_digest(&header, &pack_id, &index);
254        if digest != parsed.header_index_digest {
255            return Err(format!(
256                "{}: header or index digest mismatch",
257                path.display()
258            ));
259        }
260        let (rules, programs) = decode_index(&index, parsed.rule_count, parsed.program_count)?;
261        validate_index(&rules, &programs, parsed.records_len)?;
262        let records_offset = u64::try_from(HEADER_LEN)
263            .ok()
264            .and_then(|value| value.checked_add(parsed.pack_id_len as u64))
265            .and_then(|value| value.checked_add(parsed.index_len as u64))
266            .ok_or_else(|| "compiled record offset overflowed".to_owned())?;
267        let mut pack = Self {
268            path,
269            id,
270            source_digest: parsed.source_digest,
271            compiled_digest,
272            rules,
273            programs,
274            records_offset,
275            records_len: parsed.records_len,
276            file,
277        };
278        pack.verify_compiled_digest()?;
279        Ok(pack)
280    }
281
282    #[must_use]
283    pub fn candidate_indices(&self, program: &str) -> &[u32] {
284        self.programs
285            .binary_search_by(|entry| entry.program.as_str().cmp(program))
286            .ok()
287            .map_or(&[], |index| self.programs[index].candidates.as_slice())
288    }
289
290    /// Read and validate one indexed rule record.
291    ///
292    /// # Errors
293    ///
294    /// Returns an error when the index is invalid or the selected record is corrupt or invalid.
295    pub fn read_rule(&mut self, index: u32) -> Result<Rule, String> {
296        let record = self
297            .rules
298            .get(index as usize)
299            .ok_or_else(|| format!("{}: invalid rule index {index}", self.path.display()))?;
300        let absolute = self
301            .records_offset
302            .checked_add(record.offset)
303            .ok_or_else(|| "compiled record offset overflowed".to_owned())?;
304        self.file
305            .seek(SeekFrom::Start(absolute))
306            .map_err(|error| format!("{}: could not seek to rule: {error}", self.path.display()))?;
307        let mut body = vec![0_u8; record.length as usize];
308        self.file
309            .read_exact(&mut body)
310            .map_err(|error| format!("{}: truncated rule record: {error}", self.path.display()))?;
311        let digest: [u8; 32] = Sha256::digest(&body).into();
312        if digest != record.digest {
313            return Err(format!(
314                "{}: rule record digest mismatch for {}",
315                self.path.display(),
316                record.id
317            ));
318        }
319        let rule: Rule = strict_json::from_slice(&body)
320            .map_err(|error| format!("{}: {}: {error}", self.path.display(), record.id))?;
321        validate_rule(&rule).map_err(|error| format!("{}: {error}", self.path.display()))?;
322        if rule.id != record.id {
323            return Err(format!(
324                "{}: rule record id does not match index",
325                self.path.display()
326            ));
327        }
328        Ok(rule)
329    }
330
331    /// Rehash the open file and require the bytes to match those observed before parsing.
332    ///
333    /// # Errors
334    ///
335    /// Returns an error when the file changed after it was opened.
336    pub fn verify_compiled_digest(&mut self) -> Result<(), String> {
337        let digest = file_digest(&mut self.file, &self.path)?;
338        if digest != self.compiled_digest {
339            return Err(format!(
340                "{}: compiled pack changed while loading",
341                self.path.display()
342            ));
343        }
344        Ok(())
345    }
346
347    /// Verify every record and every program-to-rule reference in the pack.
348    ///
349    /// # Errors
350    ///
351    /// Returns an error when any record or index relationship is invalid.
352    pub fn verify_all(&mut self) -> Result<(), String> {
353        let mut seen = BTreeSet::new();
354        for index in 0..self.rules.len() {
355            let index =
356                u32::try_from(index).map_err(|_| "rule index does not fit u32".to_owned())?;
357            let rule = self.read_rule(index)?;
358            for program in &rule.matcher.program {
359                let candidates = self.candidate_indices(program);
360                if !candidates.contains(&index) {
361                    return Err(format!(
362                        "{}: rule {} is missing from program index {program}",
363                        self.path.display(),
364                        rule.id
365                    ));
366                }
367            }
368            seen.insert(index);
369        }
370        for entry in self.programs.clone() {
371            for candidate in entry.candidates {
372                if !seen.contains(&candidate) {
373                    return Err(format!(
374                        "{}: program index references unknown rule",
375                        self.path.display()
376                    ));
377                }
378                let rule = self.read_rule(candidate)?;
379                if !rule.matcher.program.contains(&entry.program) {
380                    return Err(format!(
381                        "{}: program index disagrees with rule {}",
382                        self.path.display(),
383                        rule.id
384                    ));
385                }
386            }
387        }
388        self.verify_compiled_digest()
389    }
390
391    #[must_use]
392    pub const fn records_len(&self) -> u64 {
393        self.records_len
394    }
395}
396
397#[derive(Clone, Copy)]
398struct ParsedHeader {
399    pack_id_len: usize,
400    rule_count: usize,
401    program_count: usize,
402    index_len: usize,
403    records_len: u64,
404    source_digest: [u8; 32],
405    header_index_digest: [u8; 32],
406}
407
408fn parse_header(header: &[u8; HEADER_LEN], file_len: u64) -> Result<ParsedHeader, String> {
409    if &header[0..8] != MAGIC {
410        return Err("invalid compiled pack magic".to_owned());
411    }
412    if get_u16(header, 8) != FORMAT_VERSION {
413        return Err("unsupported compiled pack format".to_owned());
414    }
415    let abi_min = get_u16(header, 10);
416    let abi_max = get_u16(header, 12);
417    if ENGINE_ABI_VERSION < abi_min || ENGINE_ABI_VERSION > abi_max {
418        return Err(format!(
419            "engine ABI {ENGINE_ABI_VERSION} is outside supported range {abi_min}..={abi_max}"
420        ));
421    }
422    if get_u16(header, 14) != 0 || get_u32(header, 16) as usize != HEADER_LEN {
423        return Err("invalid compiled pack header".to_owned());
424    }
425    if header[112..].iter().any(|byte| *byte != 0) {
426        return Err("compiled pack reserved header bytes are not zero".to_owned());
427    }
428    let pack_id_len = get_u32(header, 20) as usize;
429    let rule_count = get_u32(header, 24) as usize;
430    let program_count = get_u32(header, 28) as usize;
431    let index_len = usize::try_from(get_u64(header, 32))
432        .map_err(|_| "compiled index length does not fit usize".to_owned())?;
433    let records_len = get_u64(header, 40);
434    if pack_id_len == 0 || pack_id_len > MAX_ID_BYTES {
435        return Err("invalid compiled pack id length".to_owned());
436    }
437    if rule_count == 0
438        || rule_count > MAX_RULES
439        || program_count == 0
440        || program_count > MAX_PROGRAMS
441    {
442        return Err("invalid compiled pack counts".to_owned());
443    }
444    let expected_len = (HEADER_LEN as u64)
445        .checked_add(pack_id_len as u64)
446        .and_then(|value| value.checked_add(index_len as u64))
447        .and_then(|value| value.checked_add(records_len))
448        .ok_or_else(|| "compiled pack length overflowed".to_owned())?;
449    if expected_len != file_len {
450        return Err(format!(
451            "compiled pack length mismatch: expected {expected_len}, got {file_len}"
452        ));
453    }
454    let mut source_digest = [0_u8; 32];
455    source_digest.copy_from_slice(&header[48..80]);
456    let mut header_index_digest = [0_u8; 32];
457    header_index_digest.copy_from_slice(&header[80..112]);
458    Ok(ParsedHeader {
459        pack_id_len,
460        rule_count,
461        program_count,
462        index_len,
463        records_len,
464        source_digest,
465        header_index_digest,
466    })
467}
468
469fn encode_index(rules: &[RuleRecord], programs: &[ProgramEntry]) -> Result<Vec<u8>, String> {
470    let mut output = Vec::new();
471    write_u32(&mut output, rules.len())?;
472    for rule in rules {
473        write_string(&mut output, &rule.id)?;
474        output.extend_from_slice(&rule.offset.to_le_bytes());
475        output.extend_from_slice(&rule.length.to_le_bytes());
476        output.extend_from_slice(&rule.digest);
477    }
478    write_u32(&mut output, programs.len())?;
479    for entry in programs {
480        write_string(&mut output, &entry.program)?;
481        write_u32(&mut output, entry.candidates.len())?;
482        for candidate in &entry.candidates {
483            output.extend_from_slice(&candidate.to_le_bytes());
484        }
485    }
486    Ok(output)
487}
488
489fn decode_index(
490    bytes: &[u8],
491    rule_count: usize,
492    program_count: usize,
493) -> Result<(Vec<RuleRecord>, Vec<ProgramEntry>), String> {
494    let mut cursor = SliceCursor::new(bytes);
495    if cursor.read_u32()? as usize != rule_count {
496        return Err("compiled rule count disagrees with index".to_owned());
497    }
498    let mut rules = Vec::with_capacity(rule_count);
499    for _ in 0..rule_count {
500        let id = cursor.read_string()?;
501        let offset = cursor.read_u64()?;
502        let length = cursor.read_u32()?;
503        let digest = cursor.read_array()?;
504        rules.push(RuleRecord {
505            id,
506            offset,
507            length,
508            digest,
509        });
510    }
511    if cursor.read_u32()? as usize != program_count {
512        return Err("compiled program count disagrees with index".to_owned());
513    }
514    let mut programs = Vec::with_capacity(program_count);
515    for _ in 0..program_count {
516        let program = cursor.read_string()?;
517        let count = cursor.read_u32()? as usize;
518        if count == 0 || count > rule_count {
519            return Err("invalid program candidate count".to_owned());
520        }
521        let mut candidates = Vec::with_capacity(count);
522        for _ in 0..count {
523            candidates.push(cursor.read_u32()?);
524        }
525        programs.push(ProgramEntry {
526            program,
527            candidates,
528        });
529    }
530    if !cursor.is_empty() {
531        return Err("compiled index has trailing bytes".to_owned());
532    }
533    Ok((rules, programs))
534}
535
536fn validate_index(
537    rules: &[RuleRecord],
538    programs: &[ProgramEntry],
539    records_len: u64,
540) -> Result<(), String> {
541    if rules.windows(2).any(|pair| pair[0].id >= pair[1].id) {
542        return Err("compiled rule table is not strictly sorted".to_owned());
543    }
544    if programs
545        .windows(2)
546        .any(|pair| pair[0].program >= pair[1].program)
547    {
548        return Err("compiled program table is not strictly sorted".to_owned());
549    }
550    for rule in rules {
551        validate_compiled_rule_id(&rule.id)?;
552        let end = rule
553            .offset
554            .checked_add(u64::from(rule.length))
555            .ok_or_else(|| "compiled rule bounds overflowed".to_owned())?;
556        if rule.length == 0 || rule.length as usize > MAX_SOURCE_FILE_BYTES || end > records_len {
557            return Err("compiled rule record is out of bounds".to_owned());
558        }
559    }
560    let mut ranges: Vec<(u64, u64)> = rules
561        .iter()
562        .map(|rule| (rule.offset, rule.offset + u64::from(rule.length)))
563        .collect();
564    ranges.sort_unstable();
565    if ranges.first().is_none_or(|range| range.0 != 0)
566        || ranges.windows(2).any(|pair| pair[0].1 != pair[1].0)
567        || ranges.last().is_none_or(|range| range.1 != records_len)
568    {
569        return Err("compiled rule records must cover the record section exactly".to_owned());
570    }
571    for entry in programs {
572        validate_program(&entry.program)?;
573        if entry.candidates.windows(2).any(|pair| pair[0] >= pair[1]) {
574            return Err("program candidates are not strictly sorted".to_owned());
575        }
576        if entry
577            .candidates
578            .iter()
579            .any(|candidate| *candidate as usize >= rules.len())
580        {
581            return Err("program candidate is out of bounds".to_owned());
582        }
583    }
584    Ok(())
585}
586
587fn file_digest(file: &mut File, path: &Path) -> Result<[u8; 32], String> {
588    file.seek(SeekFrom::Start(0))
589        .map_err(|error| format!("could not rewind {}: {error}", path.display()))?;
590    let mut hasher = Sha256::new();
591    let mut buffer = vec![0_u8; 64 * 1024];
592    loop {
593        let count = file
594            .read(&mut buffer)
595            .map_err(|error| format!("could not hash {}: {error}", path.display()))?;
596        if count == 0 {
597            break;
598        }
599        hasher.update(&buffer[..count]);
600    }
601    file.seek(SeekFrom::Start(0))
602        .map_err(|error| format!("could not rewind {}: {error}", path.display()))?;
603    Ok(hasher.finalize().into())
604}
605
606fn header_index_digest(header: &[u8; HEADER_LEN], pack_id: &[u8], index: &[u8]) -> [u8; 32] {
607    let mut clean_header = *header;
608    clean_header[DIGEST_OFFSET..DIGEST_OFFSET + DIGEST_LEN].fill(0);
609    let mut hasher = Sha256::new();
610    hasher.update(b"yarp-rule-index-v1\0");
611    hasher.update(clean_header);
612    hasher.update(pack_id);
613    hasher.update(index);
614    hasher.finalize().into()
615}
616
617fn validate_compiled_pack_id(id: &str) -> Result<(), String> {
618    validate_compiled_id(id, false, "pack")
619}
620
621fn validate_compiled_rule_id(id: &str) -> Result<(), String> {
622    validate_compiled_id(id, true, "rule")
623}
624
625fn validate_compiled_id(id: &str, slash: bool, label: &str) -> Result<(), String> {
626    let valid = |byte: u8| {
627        byte.is_ascii_lowercase()
628            || byte.is_ascii_digit()
629            || matches!(byte, b'.' | b'_' | b'-')
630            || (slash && byte == b'/')
631    };
632    if id.is_empty()
633        || id.len() > MAX_ID_BYTES
634        || !id.is_ascii()
635        || !id.bytes().all(valid)
636        || !id.as_bytes()[0].is_ascii_alphanumeric()
637        || !id.as_bytes()[id.len() - 1].is_ascii_alphanumeric()
638        || id
639            .as_bytes()
640            .windows(2)
641            .any(|pair| is_id_separator(pair[0], slash) && is_id_separator(pair[1], slash))
642    {
643        return Err(format!("invalid compiled {label} id"));
644    }
645    Ok(())
646}
647
648const fn is_id_separator(byte: u8, slash: bool) -> bool {
649    matches!(byte, b'.' | b'_' | b'-') || (slash && byte == b'/')
650}
651
652fn validate_program(program: &str) -> Result<(), String> {
653    if program.is_empty()
654        || program.len() > MAX_ID_BYTES
655        || !program.is_ascii()
656        || program.bytes().any(|byte| {
657            byte.is_ascii_whitespace()
658                || matches!(
659                    byte,
660                    b'/' | b'\\'
661                        | 0
662                        | b'|'
663                        | b'&'
664                        | b';'
665                        | b'<'
666                        | b'>'
667                        | b'('
668                        | b')'
669                        | b'`'
670                        | b'$'
671                        | b'#'
672                )
673        })
674    {
675        return Err("invalid compiled program".to_owned());
676    }
677    Ok(())
678}
679
680fn write_string(output: &mut Vec<u8>, value: &str) -> Result<(), String> {
681    let length = u16::try_from(value.len()).map_err(|_| "index string is too long".to_owned())?;
682    output.extend_from_slice(&length.to_le_bytes());
683    output.extend_from_slice(value.as_bytes());
684    Ok(())
685}
686
687fn write_u32(output: &mut Vec<u8>, value: usize) -> Result<(), String> {
688    output.extend_from_slice(
689        &u32::try_from(value)
690            .map_err(|_| "index count does not fit u32".to_owned())?
691            .to_le_bytes(),
692    );
693    Ok(())
694}
695
696fn put_u16(output: &mut [u8], offset: usize, value: u16) {
697    output[offset..offset + 2].copy_from_slice(&value.to_le_bytes());
698}
699
700fn put_u32(output: &mut [u8], offset: usize, value: u32) {
701    output[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
702}
703
704fn put_u64(output: &mut [u8], offset: usize, value: u64) {
705    output[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
706}
707
708fn get_u16(input: &[u8], offset: usize) -> u16 {
709    u16::from_le_bytes([input[offset], input[offset + 1]])
710}
711
712fn get_u32(input: &[u8], offset: usize) -> u32 {
713    u32::from_le_bytes(input[offset..offset + 4].try_into().unwrap_or_default())
714}
715
716fn get_u64(input: &[u8], offset: usize) -> u64 {
717    u64::from_le_bytes(input[offset..offset + 8].try_into().unwrap_or_default())
718}
719
720struct SliceCursor<'a> {
721    body: &'a [u8],
722    offset: usize,
723}
724
725impl<'a> SliceCursor<'a> {
726    const fn new(body: &'a [u8]) -> Self {
727        Self { body, offset: 0 }
728    }
729
730    fn read_exact(&mut self, length: usize) -> Result<&'a [u8], String> {
731        let end = self
732            .offset
733            .checked_add(length)
734            .ok_or_else(|| "compiled index offset overflowed".to_owned())?;
735        let value = self
736            .body
737            .get(self.offset..end)
738            .ok_or_else(|| "compiled index is truncated".to_owned())?;
739        self.offset = end;
740        Ok(value)
741    }
742
743    fn read_u32(&mut self) -> Result<u32, String> {
744        let bytes: [u8; 4] = self
745            .read_exact(4)?
746            .try_into()
747            .map_err(|_| "compiled u32 is truncated".to_owned())?;
748        Ok(u32::from_le_bytes(bytes))
749    }
750
751    fn read_u64(&mut self) -> Result<u64, String> {
752        let bytes: [u8; 8] = self
753            .read_exact(8)?
754            .try_into()
755            .map_err(|_| "compiled u64 is truncated".to_owned())?;
756        Ok(u64::from_le_bytes(bytes))
757    }
758
759    fn read_array(&mut self) -> Result<[u8; 32], String> {
760        self.read_exact(32)?
761            .try_into()
762            .map_err(|_| "compiled digest is truncated".to_owned())
763    }
764
765    fn read_string(&mut self) -> Result<String, String> {
766        let length_bytes: [u8; 2] = self
767            .read_exact(2)?
768            .try_into()
769            .map_err(|_| "compiled string length is truncated".to_owned())?;
770        let length = u16::from_le_bytes(length_bytes) as usize;
771        String::from_utf8(self.read_exact(length)?.to_vec())
772            .map_err(|_| "compiled string is not UTF-8".to_owned())
773    }
774
775    fn is_empty(&self) -> bool {
776        self.offset == self.body.len()
777    }
778}
779
780#[cfg(test)]
781mod tests {
782    use std::io::Write as _;
783
784    use tempfile::{NamedTempFile, TempDir};
785
786    use super::super::source::SourcePack;
787    use super::*;
788
789    #[test]
790    fn compiles_and_verifies_a_deterministic_index() {
791        let directory = source_pack();
792        let source = SourcePack::load(directory.path()).expect("source");
793        let first = compile(&source).expect("compile");
794        let second = compile(&source).expect("compile");
795        assert_eq!(first, second);
796        let mut file = NamedTempFile::new().expect("pack file");
797        file.write_all(&first).expect("write pack");
798        file.flush().expect("flush pack");
799        let mut pack =
800            CompiledPack::open(file.path(), Some(source.source_digest), None).expect("open");
801        assert_eq!(pack.id, "test-pack");
802        let expected_compiled_digest: [u8; 32] = Sha256::digest(&first).into();
803        assert_eq!(pack.compiled_digest, expected_compiled_digest);
804        assert_eq!(pack.candidate_indices("tool"), &[0]);
805        assert_eq!(pack.read_rule(0).expect("rule").id, "tests/run");
806        pack.verify_all().expect("verify");
807        assert!(
808            CompiledPack::open(file.path(), None, Some([0_u8; 32]))
809                .expect_err("compiled digest mismatch")
810                .contains("compiled pack digest changed")
811        );
812    }
813
814    #[test]
815    fn compiles_and_reads_transform_rules() {
816        let directory = TempDir::new().expect("temp directory");
817        fs::create_dir(directory.path().join("rules")).expect("rules directory");
818        fs::write(
819            directory.path().join("pack.json"),
820            r#"{"schema_version":1,"id":"transform-pack","rules":["rules/head.json"]}"#,
821        )
822        .expect("manifest");
823        fs::write(
824            directory.path().join("rules/head.json"),
825            r#"{"id":"pipeline/head","match":{"program":["head"]},"action":"transform","transform":{"kind":"line_preserving"}}"#,
826        )
827        .expect("rule");
828        let source = SourcePack::load(directory.path()).expect("source");
829        let bytes = compile(&source).expect("compile");
830        let mut file = NamedTempFile::new().expect("pack file");
831        file.write_all(&bytes).expect("write pack");
832        file.flush().expect("flush pack");
833        let mut pack = CompiledPack::open(file.path(), None, None).expect("open");
834        let rule = pack.read_rule(0).expect("transform rule");
835        assert_eq!(rule.action, super::super::model::Action::Transform);
836        assert_eq!(
837            rule.transform,
838            Some(super::super::model::Transform::LinePreserving)
839        );
840    }
841
842    #[test]
843    fn rejects_packs_from_the_previous_engine_abi() {
844        let directory = source_pack();
845        let source = SourcePack::load(directory.path()).expect("source");
846        let mut bytes = compile(&source).expect("compile");
847        let mut header: [u8; HEADER_LEN] = bytes[..HEADER_LEN].try_into().expect("compiled header");
848        let previous_abi = ENGINE_ABI_VERSION.checked_sub(1).expect("previous ABI");
849        put_u16(&mut header, 10, previous_abi);
850        put_u16(&mut header, 12, previous_abi);
851        let pack_id_len = get_u32(&header, 20) as usize;
852        let index_len = usize::try_from(get_u64(&header, 32)).expect("index length");
853        let pack_id_end = HEADER_LEN + pack_id_len;
854        let index_end = pack_id_end + index_len;
855        let digest = header_index_digest(
856            &header,
857            &bytes[HEADER_LEN..pack_id_end],
858            &bytes[pack_id_end..index_end],
859        );
860        header[DIGEST_OFFSET..DIGEST_OFFSET + DIGEST_LEN].copy_from_slice(&digest);
861        bytes[..HEADER_LEN].copy_from_slice(&header);
862
863        let mut file = NamedTempFile::new().expect("pack file");
864        file.write_all(&bytes).expect("write pack");
865        file.flush().expect("flush pack");
866        assert!(
867            CompiledPack::open(file.path(), None, None)
868                .expect_err("previous ABI")
869                .contains("outside supported range")
870        );
871    }
872
873    #[test]
874    fn detects_changes_after_the_pack_was_opened() {
875        let directory = source_pack();
876        let source = SourcePack::load(directory.path()).expect("source");
877        let bytes = compile(&source).expect("compile");
878        let mut file = NamedTempFile::new().expect("pack file");
879        file.write_all(&bytes).expect("write pack");
880        file.flush().expect("flush pack");
881        let mut pack = CompiledPack::open(file.path(), None, None).expect("open");
882
883        file.as_file_mut()
884            .seek(SeekFrom::Start(
885                u64::try_from(bytes.len() - 1).expect("offset"),
886            ))
887            .expect("seek");
888        file.write_all(&[bytes[bytes.len() - 1] ^ 1])
889            .expect("modify pack");
890        file.flush().expect("flush modification");
891
892        assert!(
893            pack.verify_compiled_digest()
894                .expect_err("changed pack")
895                .contains("changed while loading")
896        );
897    }
898
899    #[test]
900    fn rejects_oversized_indexed_records_before_allocation() {
901        let length = u32::try_from(MAX_SOURCE_FILE_BYTES + 1).expect("record length");
902        let rules = [RuleRecord {
903            id: "tests/run".to_owned(),
904            offset: 0,
905            length,
906            digest: [0_u8; 32],
907        }];
908        let programs = [ProgramEntry {
909            program: "tool".to_owned(),
910            candidates: vec![0],
911        }];
912        assert!(
913            validate_index(&rules, &programs, u64::from(length))
914                .expect_err("oversized record")
915                .contains("out of bounds")
916        );
917    }
918
919    #[test]
920    fn rejects_corrupt_headers_indexes_and_records() {
921        let directory = source_pack();
922        let source = SourcePack::load(directory.path()).expect("source");
923        let bytes = compile(&source).expect("compile");
924        for offset in [0, 80, bytes.len() - 1] {
925            let mut corrupt = bytes.clone();
926            corrupt[offset] ^= 1;
927            let mut file = NamedTempFile::new().expect("pack file");
928            file.write_all(&corrupt).expect("write pack");
929            file.flush().expect("flush pack");
930            let opened = CompiledPack::open(file.path(), None, None);
931            if offset == bytes.len() - 1 {
932                let mut pack = opened.expect("record corruption loads index");
933                assert!(pack.verify_all().is_err());
934            } else {
935                assert!(opened.is_err());
936            }
937        }
938    }
939
940    fn source_pack() -> TempDir {
941        let directory = TempDir::new().expect("temp directory");
942        fs::create_dir(directory.path().join("rules")).expect("rules directory");
943        fs::write(
944            directory.path().join("pack.json"),
945            r#"{"schema_version":1,"id":"test-pack","rules":["rules/test.json"]}"#,
946        )
947        .expect("manifest");
948        fs::write(
949            directory.path().join("rules/test.json"),
950            r#"{"id":"tests/run","match":{"program":["tool"],"argv_prefix":["run"]},"action":"reduce","reducer":{"kind":"test_summary"},"success":{"max_line_bytes":16384,"max_output_bytes":32768,"min_savings_bytes":120,"min_savings_basis_points":1000},"failure":{"max_line_bytes":16384,"max_output_bytes":65536,"min_savings_bytes":120,"min_savings_basis_points":500}}"#,
951        )
952        .expect("rule");
953        directory
954    }
955}