yarp_cli/rule_pack/
source.rs1use std::fs;
2use std::path::{Component, Path, PathBuf};
3
4use sha2::{Digest as _, Sha256};
5
6use super::model::{PackManifest, Rule};
7use super::strict_json;
8use super::validation::{
9 MAX_SOURCE_BYTES, MAX_SOURCE_FILE_BYTES, validate_manifest, validate_rules,
10};
11
12const MANIFEST_NAME: &str = "pack.json";
13const SOURCE_DIGEST_DOMAIN: &[u8] = b"yarp-rule-source-v1\0";
14
15#[derive(Clone, Debug)]
16pub struct SourcePack {
17 pub root: PathBuf,
18 pub manifest: PackManifest,
19 pub rules: Vec<Rule>,
20 pub source_digest: [u8; 32],
21}
22
23impl SourcePack {
24 pub fn load(root: &Path) -> Result<Self, String> {
30 reject_symlink(root, "source pack root")?;
31 let root = fs::canonicalize(root)
32 .map_err(|error| format!("could not resolve source pack root: {error}"))?;
33 if !root.is_dir() {
34 return Err("source pack root is not a directory".to_owned());
35 }
36 let manifest_path = root.join(MANIFEST_NAME);
37 let manifest_bytes = read_source_file(&root, &manifest_path, MANIFEST_NAME)?;
38 let manifest: PackManifest = strict_json::from_slice(&manifest_bytes)
39 .map_err(|error| format!("{MANIFEST_NAME}: {error}"))?;
40 validate_manifest(&manifest).map_err(|error| format!("{MANIFEST_NAME}: {error}"))?;
41
42 let mut total_bytes = manifest_bytes.len();
43 let mut source_parts = vec![(MANIFEST_NAME.to_owned(), manifest_bytes)];
44 let mut rules = Vec::with_capacity(manifest.rules.len());
45 for relative in &manifest.rules {
46 validate_relative_path(relative)?;
47 let path = root.join(relative);
48 let body = read_source_file(&root, &path, relative)?;
49 total_bytes = total_bytes
50 .checked_add(body.len())
51 .ok_or_else(|| "source pack byte count overflowed".to_owned())?;
52 if total_bytes > MAX_SOURCE_BYTES {
53 return Err(format!("source pack exceeds {MAX_SOURCE_BYTES} bytes"));
54 }
55 let rule: Rule =
56 strict_json::from_slice(&body).map_err(|error| format!("{relative}: {error}"))?;
57 rules.push(rule);
58 source_parts.push((relative.clone(), body));
59 }
60 validate_rules(&rules)?;
61 let source_digest = hash_source_parts(&source_parts)?;
62 Ok(Self {
63 root,
64 manifest,
65 rules,
66 source_digest,
67 })
68 }
69}
70
71fn read_source_file(root: &Path, path: &Path, label: &str) -> Result<Vec<u8>, String> {
72 reject_path_symlinks(root, path, label)?;
73 let metadata = fs::metadata(path).map_err(|error| format!("{label}: {error}"))?;
74 if !metadata.is_file() {
75 return Err(format!("{label}: expected a regular file"));
76 }
77 if metadata.len() > MAX_SOURCE_FILE_BYTES as u64 {
78 return Err(format!(
79 "{label}: source file exceeds {MAX_SOURCE_FILE_BYTES} bytes"
80 ));
81 }
82 let canonical = fs::canonicalize(path).map_err(|error| format!("{label}: {error}"))?;
83 if !canonical.starts_with(root) {
84 return Err(format!("{label}: path escapes source pack root"));
85 }
86 let body = fs::read(&canonical).map_err(|error| format!("{label}: {error}"))?;
87 if body.len() > MAX_SOURCE_FILE_BYTES {
88 return Err(format!(
89 "{label}: source file exceeds {MAX_SOURCE_FILE_BYTES} bytes"
90 ));
91 }
92 Ok(body)
93}
94
95fn reject_symlink(path: &Path, label: &str) -> Result<(), String> {
96 let metadata = fs::symlink_metadata(path).map_err(|error| format!("{label}: {error}"))?;
97 if metadata.file_type().is_symlink() {
98 return Err(format!("{label}: symlinks are not allowed"));
99 }
100 Ok(())
101}
102
103fn reject_path_symlinks(root: &Path, path: &Path, label: &str) -> Result<(), String> {
104 let relative = path
105 .strip_prefix(root)
106 .map_err(|_| format!("{label}: path escapes source pack root"))?;
107 let mut current = root.to_path_buf();
108 for component in relative.components() {
109 current.push(component.as_os_str());
110 reject_symlink(¤t, label)?;
111 }
112 Ok(())
113}
114
115fn validate_relative_path(path: &str) -> Result<(), String> {
116 let parsed = Path::new(path);
117 if parsed.is_absolute()
118 || parsed
119 .components()
120 .any(|component| !matches!(component, Component::Normal(_)))
121 {
122 return Err(format!("invalid rule path: {path}"));
123 }
124 Ok(())
125}
126
127fn hash_source_parts(parts: &[(String, Vec<u8>)]) -> Result<[u8; 32], String> {
128 let mut hasher = Sha256::new();
129 hasher.update(SOURCE_DIGEST_DOMAIN);
130 for (path, body) in parts {
131 hash_length_and_bytes(&mut hasher, path.as_bytes())?;
132 hash_length_and_bytes(&mut hasher, body)?;
133 }
134 Ok(hasher.finalize().into())
135}
136
137fn hash_length_and_bytes(hasher: &mut Sha256, bytes: &[u8]) -> Result<(), String> {
138 let length = u64::try_from(bytes.len())
139 .map_err(|_| "source component length does not fit u64".to_owned())?;
140 hasher.update(length.to_le_bytes());
141 hasher.update(bytes);
142 Ok(())
143}
144
145#[cfg(test)]
146mod tests {
147 use std::io::Write as _;
148
149 use tempfile::TempDir;
150
151 use super::*;
152
153 #[test]
154 fn loads_a_strict_source_pack_deterministically() {
155 let directory = TempDir::new().expect("temp directory");
156 fs::create_dir(directory.path().join("rules")).expect("rules directory");
157 fs::write(
158 directory.path().join("pack.json"),
159 r#"{"schema_version":1,"id":"test-pack","rules":["rules/test.json"]}"#,
160 )
161 .expect("manifest");
162 fs::write(
163 directory.path().join("rules/test.json"),
164 r#"{"id":"tests/run","match":{"program":["test"],"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}}"#,
165 )
166 .expect("rule");
167 let first = SourcePack::load(directory.path()).expect("source pack");
168 let second = SourcePack::load(directory.path()).expect("source pack");
169 assert_eq!(first.source_digest, second.source_digest);
170 assert_eq!(first.rules.len(), 1);
171 }
172
173 #[cfg(unix)]
174 #[test]
175 fn rejects_symlinked_rule_directories() {
176 use std::os::unix::fs::symlink;
177
178 let directory = TempDir::new().expect("temp directory");
179 let outside = TempDir::new().expect("outside directory");
180 fs::write(
181 directory.path().join("pack.json"),
182 r#"{"schema_version":1,"id":"test-pack","rules":["rules/test.json"]}"#,
183 )
184 .expect("manifest");
185 fs::write(outside.path().join("test.json"), b"{}").expect("outside rule");
186 symlink(outside.path(), directory.path().join("rules")).expect("symlink");
187 let error = SourcePack::load(directory.path()).expect_err("symlink rejection");
188 assert!(error.contains("symlink"));
189 }
190
191 #[test]
192 fn rejects_duplicate_json_keys() {
193 let directory = TempDir::new().expect("temp directory");
194 let mut file = fs::File::create(directory.path().join("pack.json")).expect("manifest");
195 write!(
196 file,
197 r#"{{"schema_version":1,"id":"one","id":"two","rules":[]}}"#
198 )
199 .expect("write");
200 let error = SourcePack::load(directory.path()).expect_err("duplicate key");
201 assert!(error.contains("duplicate object key"));
202 }
203}