Skip to main content

yarp_cli/rule_pack/
source.rs

1use 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    /// Load and validate one explicit source-pack directory.
25    ///
26    /// # Errors
27    ///
28    /// Returns an error for unsafe paths, invalid JSON, schema errors, conflicts, or size limits.
29    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(&current, 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}