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