1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::Result;
5use sha2::{Digest, Sha256};
6
7use crate::rules::artifact::CompiledRulesArtifact;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum ArtifactLoad {
11 Loaded(CompiledRulesArtifact),
12 FailOpen {
13 kind: ArtifactLoadErrorKind,
14 message: String,
15 },
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ArtifactLoadErrorKind {
20 Missing,
21 Read,
22 Parse,
23 Validate,
24}
25
26pub fn artifact_path_for_project(data_dir: impl AsRef<Path>, project: &str) -> PathBuf {
27 data_dir
28 .as_ref()
29 .join("compiled_rules")
30 .join(format!("{}.json", project_hash(project)))
31}
32
33pub fn write_artifact_atomic(
34 path: impl AsRef<Path>,
35 artifact: &CompiledRulesArtifact,
36) -> Result<()> {
37 artifact.validate()?;
38 let mut contents = serde_json::to_vec_pretty(artifact)?;
39 contents.push(b'\n');
40 crate::atomic_file::write_atomic(path, contents)
41}
42
43pub fn load_artifact_fail_open(path: impl AsRef<Path>) -> ArtifactLoad {
44 let path = path.as_ref();
45 let text = match fs::read_to_string(path) {
46 Ok(text) => text,
47 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
48 return ArtifactLoad::FailOpen {
49 kind: ArtifactLoadErrorKind::Missing,
50 message: format!("compiled rules artifact missing: {}", path.display()),
51 };
52 }
53 Err(err) => {
54 return ArtifactLoad::FailOpen {
55 kind: ArtifactLoadErrorKind::Read,
56 message: format!(
57 "read compiled rules artifact {} failed: {err}",
58 path.display()
59 ),
60 };
61 }
62 };
63
64 let artifact = match serde_json::from_str::<CompiledRulesArtifact>(&text) {
65 Ok(artifact) => artifact,
66 Err(err) => {
67 return ArtifactLoad::FailOpen {
68 kind: ArtifactLoadErrorKind::Parse,
69 message: format!(
70 "parse compiled rules artifact {} failed: {err}",
71 path.display()
72 ),
73 };
74 }
75 };
76
77 match artifact.validate() {
78 Ok(()) => ArtifactLoad::Loaded(artifact),
79 Err(err) => ArtifactLoad::FailOpen {
80 kind: ArtifactLoadErrorKind::Validate,
81 message: format!(
82 "validate compiled rules artifact {} failed: {err}",
83 path.display()
84 ),
85 },
86 }
87}
88
89fn project_hash(project: &str) -> String {
90 let digest = Sha256::digest(project.as_bytes());
91 digest.iter().map(|byte| format!("{byte:02x}")).collect()
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use crate::rules::artifact::ARTIFACT_VERSION;
98 use crate::rules::test_support::{package_manager_artifact, test_dir};
99
100 #[test]
101 fn artifact_path_uses_stable_project_hash() {
102 let left = artifact_path_for_project("/tmp/remem", "/workspace/project");
103 let right = artifact_path_for_project("/tmp/remem", "/workspace/project");
104 let other = artifact_path_for_project("/tmp/remem", "/workspace/other");
105
106 assert_eq!(left, right);
107 assert_ne!(left, other);
108 assert_eq!(
109 left.parent().and_then(Path::file_name),
110 Some("compiled_rules".as_ref())
111 );
112 assert_eq!(left.extension().and_then(|ext| ext.to_str()), Some("json"));
113 }
114
115 #[test]
116 fn write_and_load_artifact_round_trip() -> Result<()> {
117 let dir = test_dir("round-trip");
118 let path = dir.join("artifact.json");
119 let artifact = package_manager_artifact();
120
121 write_artifact_atomic(&path, &artifact)?;
122 let loaded = load_artifact_fail_open(&path);
123
124 assert_eq!(loaded, ArtifactLoad::Loaded(artifact));
125 fs::remove_dir_all(dir)?;
126 Ok(())
127 }
128
129 #[test]
130 fn load_missing_artifact_fails_open() {
131 let path = test_dir("missing").join("artifact.json");
132
133 let loaded = load_artifact_fail_open(&path);
134
135 assert!(matches!(
136 loaded,
137 ArtifactLoad::FailOpen {
138 kind: ArtifactLoadErrorKind::Missing,
139 ..
140 }
141 ));
142 }
143
144 #[test]
145 fn load_corrupt_artifact_fails_open() -> Result<()> {
146 let dir = test_dir("corrupt");
147 let path = dir.join("artifact.json");
148 fs::create_dir_all(&dir)?;
149 fs::write(&path, "{not-json")?;
150
151 let loaded = load_artifact_fail_open(&path);
152
153 assert!(matches!(
154 loaded,
155 ArtifactLoad::FailOpen {
156 kind: ArtifactLoadErrorKind::Parse,
157 ..
158 }
159 ));
160 fs::remove_dir_all(dir)?;
161 Ok(())
162 }
163
164 #[test]
165 fn load_wrong_version_artifact_fails_open() -> Result<()> {
166 let dir = test_dir("wrong-version");
167 let path = dir.join("artifact.json");
168 fs::create_dir_all(&dir)?;
169 fs::write(
170 &path,
171 format!(
172 r#"{{"version":{},"compiled_at_epoch":1,"rules":[]}}"#,
173 ARTIFACT_VERSION + 1
174 ),
175 )?;
176
177 let loaded = load_artifact_fail_open(&path);
178
179 assert!(matches!(
180 loaded,
181 ArtifactLoad::FailOpen {
182 kind: ArtifactLoadErrorKind::Validate,
183 ..
184 }
185 ));
186 fs::remove_dir_all(dir)?;
187 Ok(())
188 }
189
190 #[test]
191 fn atomic_writer_preserves_existing_artifact_on_rename_failure() -> Result<()> {
192 let dir = test_dir("atomic-failure");
193 let path = dir.join("artifact.json");
194 let original = CompiledRulesArtifact::new(1, Vec::new());
195 write_artifact_atomic(&path, &original)?;
196 let replacement = package_manager_artifact();
197
198 let _guard = crate::atomic_file::failpoint_test_lock();
199 crate::atomic_file::fail_next_rename_for_path_for_test(&path);
200 let err = write_artifact_atomic(&path, &replacement)
201 .expect_err("injected rename failure must surface");
202 crate::atomic_file::clear_failpoints_for_test();
203
204 assert!(err.to_string().contains("injected atomic write failure"));
205 assert_eq!(
206 load_artifact_fail_open(&path),
207 ArtifactLoad::Loaded(original)
208 );
209 let temp_entries = fs::read_dir(&dir)?
210 .filter_map(|entry| entry.ok())
211 .filter(|entry| entry.file_name().to_string_lossy().contains(".tmp."))
212 .count();
213 assert_eq!(temp_entries, 0);
214 fs::remove_dir_all(dir)?;
215 Ok(())
216 }
217}