1use std::path::{Path, PathBuf};
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct TrustStorePath {
22 pub path: PathBuf,
24 pub directory: bool,
26}
27
28#[cfg(target_os = "linux")]
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub(crate) struct TrustStoreCandidate<'a> {
32 pub(crate) path: &'a str,
34 pub(crate) directory: bool,
37}
38
39#[cfg(target_os = "linux")]
44pub(crate) const TRUST_STORE_CANDIDATES: &[TrustStoreCandidate<'static>] = &[
45 TrustStoreCandidate {
47 path: "/etc/ssl/certs/ca-certificates.crt",
48 directory: false,
49 },
50 TrustStoreCandidate {
52 path: "/etc/pki/tls/certs/ca-bundle.crt",
53 directory: false,
54 },
55 TrustStoreCandidate {
57 path: "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem",
58 directory: false,
59 },
60 TrustStoreCandidate {
62 path: "/etc/ssl/ca-bundle.pem",
63 directory: false,
64 },
65 TrustStoreCandidate {
67 path: "/etc/pki/tls/cacert.pem",
68 directory: false,
69 },
70 TrustStoreCandidate {
72 path: "/etc/ssl/cert.pem",
73 directory: false,
74 },
75 TrustStoreCandidate {
77 path: "/usr/lib/ssl/cert.pem",
78 directory: false,
79 },
80 TrustStoreCandidate {
82 path: "/etc/ssl/certs",
83 directory: true,
84 },
85 TrustStoreCandidate {
87 path: "/etc/pki/tls/certs",
88 directory: true,
89 },
90 TrustStoreCandidate {
92 path: "/etc/pki/ca-trust/extracted/pem",
93 directory: true,
94 },
95 TrustStoreCandidate {
97 path: "/usr/lib/ssl/certs",
98 directory: true,
99 },
100 TrustStoreCandidate {
102 path: "/var/lib/ca-certificates/pem",
103 directory: true,
104 },
105];
106
107pub fn derive_trust_store(
120 shell_allow: &[String],
121 network_allow: &[String],
122 capsule_writable: &[&Path],
123) -> Vec<TrustStorePath> {
124 #[cfg(target_os = "linux")]
125 {
126 derive_in(
127 TRUST_STORE_CANDIDATES,
128 shell_allow,
129 network_allow,
130 capsule_writable,
131 )
132 }
133 #[cfg(not(target_os = "linux"))]
134 {
135 let _ = (shell_allow, network_allow, capsule_writable);
136 Vec::new()
137 }
138}
139
140#[cfg(target_os = "linux")]
152pub(crate) fn derive_in(
153 candidates: &[TrustStoreCandidate<'_>],
154 shell_allow: &[String],
155 network_allow: &[String],
156 capsule_writable: &[&Path],
157) -> Vec<TrustStorePath> {
158 if shell_allow.is_empty() || network_allow.is_empty() {
159 return Vec::new();
160 }
161 let roots: Vec<PathBuf> = capsule_writable
162 .iter()
163 .map(|root| crate::protected_paths::canonicalize_existing_prefix(root))
164 .collect();
165 let grantable =
166 |path: &Path| path != Path::new("/") && !crate::reachability::under_any(path, &roots);
167
168 let mut dirs: Vec<PathBuf> = Vec::new();
169 let mut files: Vec<PathBuf> = Vec::new();
170 for candidate in candidates {
171 let Ok(canonical) = std::fs::canonicalize(candidate.path) else {
172 continue;
173 };
174 let Ok(metadata) = std::fs::metadata(&canonical) else {
175 continue;
176 };
177 if !grantable(&canonical) {
178 continue;
179 }
180 if candidate.directory && metadata.is_dir() {
181 dirs.push(canonical);
182 } else if !candidate.directory && metadata.is_file() {
183 files.push(canonical);
184 }
185 }
186
187 let inside_kept_dir = |path: &Path| dirs.iter().any(|dir| path.starts_with(dir));
188 let encloses_kept_path = |parent: &Path| {
189 dirs.iter()
190 .chain(files.iter())
191 .any(|kept| kept.starts_with(parent))
192 };
193 let mut linked_dirs: Vec<PathBuf> = Vec::new();
194 for dir in &dirs {
195 let Ok(listing) = std::fs::read_dir(dir) else {
196 continue;
197 };
198 for entry in listing.flatten() {
199 let path = entry.path();
200 if !std::fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_symlink()) {
201 continue;
202 }
203 let Ok(target) = std::fs::canonicalize(&path) else {
204 continue;
205 };
206 if !std::fs::metadata(&target).is_ok_and(|m| m.is_file()) || inside_kept_dir(&target) {
207 continue;
208 }
209 let Some(parent) = target.parent() else {
210 continue;
211 };
212 if grantable(parent) && !encloses_kept_path(parent) {
213 linked_dirs.push(parent.to_path_buf());
214 }
215 }
216 }
217
218 let mut derived: Vec<TrustStorePath> = dirs
219 .iter()
220 .chain(linked_dirs.iter())
221 .map(|path| TrustStorePath {
222 path: path.clone(),
223 directory: true,
224 })
225 .collect();
226 derived.extend(
227 files
228 .into_iter()
229 .filter(|file| {
230 !dirs
231 .iter()
232 .chain(linked_dirs.iter())
233 .any(|dir| file.starts_with(dir))
234 })
235 .map(|path| TrustStorePath {
236 path,
237 directory: false,
238 }),
239 );
240 derived.sort_by(|a, b| a.path.cmp(&b.path));
241 derived.dedup_by(|a, b| a.path == b.path);
242 derived
243}
244
245#[cfg(all(test, target_os = "linux"))]
246mod tests {
247 use super::*;
248 use std::os::unix::fs::symlink;
249
250 fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) {
251 let temp = tempfile::tempdir().unwrap();
252 let root = std::fs::canonicalize(temp.path()).unwrap();
253 (temp, root)
254 }
255
256 fn allow() -> Vec<String> {
257 vec!["curl".to_string()]
258 }
259
260 fn network() -> Vec<String> {
261 vec!["example.com".to_string()]
262 }
263
264 fn candidates(paths: &[(String, bool)]) -> Vec<TrustStoreCandidate<'_>> {
266 paths
267 .iter()
268 .map(|(path, directory)| TrustStoreCandidate {
269 path,
270 directory: *directory,
271 })
272 .collect()
273 }
274
275 fn spec(path: &Path, directory: bool) -> (String, bool) {
276 (path.to_str().unwrap().to_string(), directory)
277 }
278
279 fn dir(path: &Path) -> TrustStorePath {
280 TrustStorePath {
281 path: path.to_path_buf(),
282 directory: true,
283 }
284 }
285
286 fn file(path: &Path) -> TrustStorePath {
287 TrustStorePath {
288 path: path.to_path_buf(),
289 directory: false,
290 }
291 }
292
293 fn write(path: &Path, contents: &str) {
294 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
295 std::fs::write(path, contents).unwrap();
296 }
297
298 fn debian_layout(root: &Path) -> PathBuf {
302 let certs = root.join("etc/ssl/certs");
303 std::fs::create_dir_all(&certs).unwrap();
304 std::fs::create_dir_all(root.join("etc/ssl/private")).unwrap();
305 write(&root.join("etc/ssl/openssl.cnf"), "[openssl_init]\n");
306 write(
307 &root.join("share/mozilla/X.crt"),
308 "-----BEGIN CERTIFICATE-----\n",
309 );
310 write(
311 &certs.join("ca-certificates.crt"),
312 "-----BEGIN CERTIFICATE-----\n",
313 );
314 symlink("../../../share/mozilla/X.crt", certs.join("X.pem")).unwrap();
315 symlink("X.pem", certs.join("abcd1234.0")).unwrap();
316 certs
317 }
318
319 #[test]
320 fn nothing_is_derived_without_both_a_shell_and_a_network_allowlist() {
321 let (_temp, root) = canonical_tempdir();
322 let certs = debian_layout(&root);
323 let table = [spec(&certs, true)];
324 let table = candidates(&table);
325 assert!(derive_in(&table, &[], &network(), &[]).is_empty());
326 assert!(derive_in(&table, &allow(), &[], &[]).is_empty());
327 assert!(derive_in(&table, &[], &[], &[]).is_empty());
328 assert!(!derive_in(&table, &allow(), &network(), &[]).is_empty());
329 assert!(derive_trust_store(&[], &network(), &[]).is_empty());
331 assert!(derive_trust_store(&allow(), &[], &[]).is_empty());
332 }
333
334 #[test]
335 fn missing_and_wrong_kind_candidates_contribute_nothing() {
336 let (_temp, root) = canonical_tempdir();
337 let a_dir = root.join("a-dir");
338 let a_file = root.join("a-file.pem");
339 std::fs::create_dir_all(&a_dir).unwrap();
340 write(&a_file, "x");
341 symlink(root.join("nowhere"), root.join("dangling")).unwrap();
342 let table = [
343 spec(&root.join("missing"), true),
344 spec(&root.join("missing.pem"), false),
345 spec(&root.join("dangling"), false),
346 spec(&a_dir, false),
347 spec(&a_file, true),
348 ];
349 assert!(derive_in(&candidates(&table), &allow(), &network(), &[]).is_empty());
350 }
351
352 #[test]
353 fn a_candidate_directory_is_granted_listable_at_its_canonical_path() {
354 let (_temp, root) = canonical_tempdir();
355 let real = root.join("etc/pki/tls/certs");
356 std::fs::create_dir_all(&real).unwrap();
357 let alias = root.join("usr-lib-ssl-certs");
358 symlink(&real, &alias).unwrap();
359 let table = [spec(&alias, true), spec(&real, true)];
360 assert_eq!(
361 derive_in(&candidates(&table), &allow(), &network(), &[]),
362 vec![dir(&real)]
363 );
364 }
365
366 #[test]
367 fn certificate_links_grant_the_directory_they_point_into() {
368 let (_temp, root) = canonical_tempdir();
369 let certs = debian_layout(&root);
370 let table = [spec(&certs, true)];
371 assert_eq!(
372 derive_in(&candidates(&table), &allow(), &network(), &[]),
373 vec![dir(&certs), dir(&root.join("share/mozilla"))]
374 );
375 }
376
377 #[test]
378 fn a_link_to_a_sibling_of_the_store_does_not_widen_the_grant_to_its_parent() {
379 let (_temp, root) = canonical_tempdir();
380 let certs = debian_layout(&root);
381 symlink("../openssl.cnf", certs.join("evil")).unwrap();
382 symlink("../private", certs.join("keys")).unwrap();
383 let bundle = root.join("etc/ssl/cert.pem");
384 write(&bundle, "-----BEGIN CERTIFICATE-----\n");
385 symlink("../cert.pem", certs.join("bundle-link")).unwrap();
386 let table = [spec(&bundle, false), spec(&certs, true)];
387 let derived = derive_in(&candidates(&table), &allow(), &network(), &[]);
388 assert_eq!(
389 derived,
390 vec![file(&bundle), dir(&certs), dir(&root.join("share/mozilla"))]
391 );
392 for path in derived.iter().map(|entry| &entry.path) {
393 assert_ne!(path, &root.join("etc/ssl"));
394 assert!(!path.starts_with(root.join("etc/ssl/private")));
395 assert!(!certs.starts_with(path) || path == &certs, "{path:?}");
396 }
397 }
398
399 #[test]
400 fn links_into_writable_roots_dangling_links_and_directory_links_are_dropped() {
401 let (_temp, root) = canonical_tempdir();
402 let certs = root.join("etc/ssl/certs");
403 std::fs::create_dir_all(&certs).unwrap();
404 let work = root.join("work");
405 write(&work.join("planted.crt"), "x");
406 std::fs::create_dir_all(root.join("share/subdir")).unwrap();
407 symlink(work.join("planted.crt"), certs.join("planted.pem")).unwrap();
408 symlink(root.join("gone.crt"), certs.join("dangling.pem")).unwrap();
409 symlink(root.join("share/subdir"), certs.join("subdir")).unwrap();
410 let fifo = root.join("share/fifo");
411 let status = std::process::Command::new("mkfifo")
412 .arg(&fifo)
413 .status()
414 .unwrap();
415 assert!(status.success());
416 symlink(&fifo, certs.join("fifo.pem")).unwrap();
417 let table = [spec(&certs, true)];
418 assert_eq!(
419 derive_in(&candidates(&table), &allow(), &network(), &[&work]),
420 vec![dir(&certs)]
421 );
422 }
423
424 #[test]
425 fn a_candidate_under_a_writable_root_is_dropped() {
426 let (_temp, root) = canonical_tempdir();
427 let work = root.join("work");
428 let certs = work.join("certs");
429 std::fs::create_dir_all(&certs).unwrap();
430 write(&work.join("bundle.pem"), "x");
431 let table = [spec(&certs, true), spec(&work.join("bundle.pem"), false)];
432 assert!(derive_in(&candidates(&table), &allow(), &network(), &[&work]).is_empty());
433 }
434
435 #[test]
436 fn a_file_candidate_inside_a_granted_directory_is_listed_once() {
437 let (_temp, root) = canonical_tempdir();
438 let certs = debian_layout(&root);
439 let inside = certs.join("ca-certificates.crt");
440 let alias = root.join("usr-lib-ssl-cert.pem");
441 symlink(&inside, &alias).unwrap();
442 let outside = root.join("etc/pki/tls/cacert.pem");
443 write(&outside, "x");
444 let table = [
445 spec(&inside, false),
446 spec(&alias, false),
447 spec(&outside, false),
448 spec(&certs, true),
449 ];
450 assert_eq!(
451 derive_in(&candidates(&table), &allow(), &network(), &[]),
452 vec![
453 file(&outside),
454 dir(&certs),
455 dir(&root.join("share/mozilla"))
456 ]
457 );
458 }
459
460 #[test]
461 fn the_result_is_sorted_and_deduplicated() {
462 let (_temp, root) = canonical_tempdir();
463 let b = root.join("b/certs");
464 let a = root.join("a/certs");
465 std::fs::create_dir_all(&a).unwrap();
466 std::fs::create_dir_all(&b).unwrap();
467 write(&root.join("store/1.crt"), "x");
468 write(&root.join("store/2.crt"), "x");
469 symlink(root.join("store/1.crt"), a.join("1.pem")).unwrap();
470 symlink(root.join("store/2.crt"), b.join("2.pem")).unwrap();
471 let table = [spec(&b, true), spec(&a, true), spec(&b, true)];
472 assert_eq!(
473 derive_in(&candidates(&table), &allow(), &network(), &[]),
474 vec![dir(&a), dir(&b), dir(&root.join("store"))]
475 );
476 }
477
478 #[test]
479 fn the_candidate_table_is_absolute_and_names_no_key_or_config_directory() {
480 assert_eq!(TRUST_STORE_CANDIDATES.len(), 12);
481 for candidate in TRUST_STORE_CANDIDATES {
482 let path = Path::new(candidate.path);
483 assert!(path.is_absolute(), "{path:?}");
484 assert!(
485 !path.components().any(|c| c.as_os_str() == "private"),
486 "{path:?}"
487 );
488 for broad in [
489 "/",
490 "/etc",
491 "/etc/ssl",
492 "/etc/pki",
493 "/etc/pki/tls",
494 "/usr/lib/ssl",
495 ] {
496 assert_ne!(path, Path::new(broad));
497 }
498 }
499 }
500}