Skip to main content

remem/rules/
store.rs

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}