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
51pub 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 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 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 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 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}