1use std::fs::OpenOptions;
2use std::io::Read;
3use std::io::Seek;
4use std::io::SeekFrom;
5use std::io::Write;
6use std::path::Path;
7use std::path::PathBuf;
8
9use crate::decision::Decision;
10use crate::rule::NetworkRuleProtocol;
11use crate::rule::normalize_network_rule_host;
12use thiserror::Error;
13
14#[derive(Debug, Error)]
15pub enum AmendError {
16 #[error("prefix rule requires at least one token")]
17 EmptyPrefix,
18 #[error("invalid network rule: {0}")]
19 InvalidNetworkRule(String),
20 #[error("policy path has no parent: {path}")]
21 MissingParent { path: PathBuf },
22 #[error("failed to create policy directory {dir}: {source}")]
23 CreatePolicyDir {
24 dir: PathBuf,
25 source: std::io::Error,
26 },
27 #[error("failed to format prefix tokens: {source}")]
28 SerializePrefix { source: serde_json::Error },
29 #[error("failed to serialize network rule field: {source}")]
30 SerializeNetworkRule { source: serde_json::Error },
31 #[error("failed to open policy file {path}: {source}")]
32 OpenPolicyFile {
33 path: PathBuf,
34 source: std::io::Error,
35 },
36 #[error("failed to write to policy file {path}: {source}")]
37 WritePolicyFile {
38 path: PathBuf,
39 source: std::io::Error,
40 },
41 #[error("failed to lock policy file {path}: {source}")]
42 LockPolicyFile {
43 path: PathBuf,
44 source: std::io::Error,
45 },
46 #[error("failed to seek policy file {path}: {source}")]
47 SeekPolicyFile {
48 path: PathBuf,
49 source: std::io::Error,
50 },
51 #[error("failed to read policy file {path}: {source}")]
52 ReadPolicyFile {
53 path: PathBuf,
54 source: std::io::Error,
55 },
56 #[error("failed to read metadata for policy file {path}: {source}")]
57 PolicyMetadata {
58 path: PathBuf,
59 source: std::io::Error,
60 },
61}
62
63pub fn blocking_append_allow_prefix_rule(
66 policy_path: &Path,
67 prefix: &[String],
68) -> Result<(), AmendError> {
69 if prefix.is_empty() {
70 return Err(AmendError::EmptyPrefix);
71 }
72
73 let tokens = prefix
74 .iter()
75 .map(serde_json::to_string)
76 .collect::<Result<Vec<_>, _>>()
77 .map_err(|source| AmendError::SerializePrefix { source })?;
78 let pattern = format!("[{}]", tokens.join(", "));
79 let rule = format!(r#"prefix_rule(pattern={pattern}, decision="allow")"#);
80 append_rule_line(policy_path, &rule)
81}
82
83pub fn blocking_append_network_rule(
86 policy_path: &Path,
87 host: &str,
88 protocol: NetworkRuleProtocol,
89 decision: Decision,
90 justification: Option<&str>,
91) -> Result<(), AmendError> {
92 let host = normalize_network_rule_host(host)
93 .map_err(|err| AmendError::InvalidNetworkRule(err.to_string()))?;
94 if let Some(raw) = justification
95 && raw.trim().is_empty()
96 {
97 return Err(AmendError::InvalidNetworkRule(
98 "justification cannot be empty".to_string(),
99 ));
100 }
101
102 let host = serde_json::to_string(&host)
103 .map_err(|source| AmendError::SerializeNetworkRule { source })?;
104 let protocol = serde_json::to_string(protocol.as_policy_string())
105 .map_err(|source| AmendError::SerializeNetworkRule { source })?;
106 let decision = serde_json::to_string(match decision {
107 Decision::Allow => "allow",
108 Decision::Prompt => "prompt",
109 Decision::Forbidden => "deny",
110 })
111 .map_err(|source| AmendError::SerializeNetworkRule { source })?;
112
113 let mut args = vec![
114 format!("host={host}"),
115 format!("protocol={protocol}"),
116 format!("decision={decision}"),
117 ];
118 if let Some(justification) = justification {
119 let justification = serde_json::to_string(justification)
120 .map_err(|source| AmendError::SerializeNetworkRule { source })?;
121 args.push(format!("justification={justification}"));
122 }
123 let rule = format!("network_rule({})", args.join(", "));
124 append_rule_line(policy_path, &rule)
125}
126
127fn append_rule_line(policy_path: &Path, rule: &str) -> Result<(), AmendError> {
128 let dir = policy_path
129 .parent()
130 .ok_or_else(|| AmendError::MissingParent {
131 path: policy_path.to_path_buf(),
132 })?;
133 match std::fs::create_dir(dir) {
134 Ok(()) => {}
135 Err(ref source) if source.kind() == std::io::ErrorKind::AlreadyExists => {}
136 Err(source) => {
137 return Err(AmendError::CreatePolicyDir {
138 dir: dir.to_path_buf(),
139 source,
140 });
141 }
142 }
143
144 append_locked_line(policy_path, rule)
145}
146
147fn append_locked_line(policy_path: &Path, line: &str) -> Result<(), AmendError> {
148 let mut file = OpenOptions::new()
149 .create(true)
150 .read(true)
151 .append(true)
152 .open(policy_path)
153 .map_err(|source| AmendError::OpenPolicyFile {
154 path: policy_path.to_path_buf(),
155 source,
156 })?;
157 file.lock().map_err(|source| AmendError::LockPolicyFile {
158 path: policy_path.to_path_buf(),
159 source,
160 })?;
161
162 file.seek(SeekFrom::Start(0))
163 .map_err(|source| AmendError::SeekPolicyFile {
164 path: policy_path.to_path_buf(),
165 source,
166 })?;
167 let mut contents = String::new();
168 file.read_to_string(&mut contents)
169 .map_err(|source| AmendError::ReadPolicyFile {
170 path: policy_path.to_path_buf(),
171 source,
172 })?;
173
174 if contents.lines().any(|existing| existing == line) {
175 return Ok(());
176 }
177
178 if !contents.is_empty() && !contents.ends_with('\n') {
179 file.write_all(b"\n")
180 .map_err(|source| AmendError::WritePolicyFile {
181 path: policy_path.to_path_buf(),
182 source,
183 })?;
184 }
185
186 file.write_all(format!("{line}\n").as_bytes())
187 .map_err(|source| AmendError::WritePolicyFile {
188 path: policy_path.to_path_buf(),
189 source,
190 })?;
191
192 Ok(())
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use pretty_assertions::assert_eq;
199 use tempfile::tempdir;
200
201 #[test]
202 fn appends_rule_and_creates_directories() {
203 let tmp = tempdir().expect("create temp dir");
204 let policy_path = tmp.path().join("rules").join("default.rules");
205
206 blocking_append_allow_prefix_rule(
207 &policy_path,
208 &[String::from("echo"), String::from("Hello, world!")],
209 )
210 .expect("append rule");
211
212 let contents = std::fs::read_to_string(&policy_path).expect("default.rules should exist");
213 assert_eq!(
214 contents,
215 r#"prefix_rule(pattern=["echo", "Hello, world!"], decision="allow")
216"#
217 );
218 }
219
220 #[test]
221 fn appends_rule_without_duplicate_newline() {
222 let tmp = tempdir().expect("create temp dir");
223 let policy_path = tmp.path().join("rules").join("default.rules");
224 std::fs::create_dir_all(policy_path.parent().unwrap()).expect("create policy dir");
225 std::fs::write(
226 &policy_path,
227 r#"prefix_rule(pattern=["ls"], decision="allow")
228"#,
229 )
230 .expect("write seed rule");
231
232 blocking_append_allow_prefix_rule(
233 &policy_path,
234 &[String::from("echo"), String::from("Hello, world!")],
235 )
236 .expect("append rule");
237
238 let contents = std::fs::read_to_string(&policy_path).expect("read policy");
239 assert_eq!(
240 contents,
241 r#"prefix_rule(pattern=["ls"], decision="allow")
242prefix_rule(pattern=["echo", "Hello, world!"], decision="allow")
243"#
244 );
245 }
246
247 #[test]
248 fn inserts_newline_when_missing_before_append() {
249 let tmp = tempdir().expect("create temp dir");
250 let policy_path = tmp.path().join("rules").join("default.rules");
251 std::fs::create_dir_all(policy_path.parent().unwrap()).expect("create policy dir");
252 std::fs::write(
253 &policy_path,
254 r#"prefix_rule(pattern=["ls"], decision="allow")"#,
255 )
256 .expect("write seed rule without newline");
257
258 blocking_append_allow_prefix_rule(
259 &policy_path,
260 &[String::from("echo"), String::from("Hello, world!")],
261 )
262 .expect("append rule");
263
264 let contents = std::fs::read_to_string(&policy_path).expect("read policy");
265 assert_eq!(
266 contents,
267 r#"prefix_rule(pattern=["ls"], decision="allow")
268prefix_rule(pattern=["echo", "Hello, world!"], decision="allow")
269"#
270 );
271 }
272
273 #[test]
274 fn appends_network_rule() {
275 let tmp = tempdir().expect("create temp dir");
276 let policy_path = tmp.path().join("rules").join("default.rules");
277
278 blocking_append_network_rule(
279 &policy_path,
280 "Api.GitHub.com",
281 NetworkRuleProtocol::Https,
282 Decision::Allow,
283 Some("Allow https_connect access to api.github.com"),
284 )
285 .expect("append network rule");
286
287 let contents = std::fs::read_to_string(&policy_path).expect("read policy");
288 assert_eq!(
289 contents,
290 r#"network_rule(host="api.github.com", protocol="https", decision="allow", justification="Allow https_connect access to api.github.com")
291"#
292 );
293 }
294
295 #[test]
296 fn appends_prefix_and_network_rules() {
297 let tmp = tempdir().expect("create temp dir");
298 let policy_path = tmp.path().join("rules").join("default.rules");
299
300 blocking_append_allow_prefix_rule(&policy_path, &[String::from("curl")])
301 .expect("append prefix rule");
302 blocking_append_network_rule(
303 &policy_path,
304 "api.github.com",
305 NetworkRuleProtocol::Https,
306 Decision::Allow,
307 Some("Allow https_connect access to api.github.com"),
308 )
309 .expect("append network rule");
310
311 let contents = std::fs::read_to_string(&policy_path).expect("read policy");
312 assert_eq!(
313 contents,
314 r#"prefix_rule(pattern=["curl"], decision="allow")
315network_rule(host="api.github.com", protocol="https", decision="allow", justification="Allow https_connect access to api.github.com")
316"#
317 );
318 }
319
320 #[test]
321 fn rejects_wildcard_network_rule_host() {
322 let tmp = tempdir().expect("create temp dir");
323 let policy_path = tmp.path().join("rules").join("default.rules");
324 let err = blocking_append_network_rule(
325 &policy_path,
326 "*.example.com",
327 NetworkRuleProtocol::Https,
328 Decision::Allow,
329 None,
330 )
331 .expect_err("wildcards should be rejected");
332 assert_eq!(
333 err.to_string(),
334 "invalid network rule: invalid rule: network_rule host must be a specific host; wildcards are not allowed"
335 );
336 }
337}