lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 7c191931e1e07764e7ca47d0361f8573d8aff125
parent 4a452a1ddb203b3d33dc0232a6b14d2927fb810e
Author: triesap <tyson@radroots.org>
Date:   Fri, 10 Apr 2026 22:59:00 +0000

runtime: cover remaining failure paths

Diffstat:
Mcrates/runtime/src/json.rs | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/runtime/src/secret_file.rs | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/runtime/src/secret_file/tests.rs | 142++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/runtime/src/tracing/tests.rs | 25+++++--------------------
4 files changed, 273 insertions(+), 44 deletions(-)

diff --git a/crates/runtime/src/json.rs b/crates/runtime/src/json.rs @@ -331,6 +331,14 @@ mod tests { } #[test] + fn serialize_toggle_load_reports_not_found_for_missing_path() { + let (_dir, path) = payload_path("missing-toggle.json"); + let err = + JsonFile::<SerializeToggle>::load(path.clone()).expect_err("missing path should fail"); + assert!(err.to_string().contains(path.to_string_lossy().as_ref())); + } + + #[test] fn load_reports_file_open_error_for_directory() { let dir = tempdir().expect("tempdir"); let err = JsonFile::<Payload>::load(dir.path().to_path_buf()) @@ -369,6 +377,46 @@ mod tests { } #[test] + fn serialize_toggle_load_reports_file_open_error_for_directory() { + let dir = tempdir().expect("tempdir"); + let err = JsonFile::<SerializeToggle>::load(dir.path().to_path_buf()) + .expect_err("directory path should fail"); + assert!(err.to_string().contains("Failed to parse JSON")); + assert!( + err.to_string() + .contains(dir.path().to_string_lossy().as_ref()) + ); + } + + #[test] + fn serialize_toggle_load_reports_file_parse_error_for_invalid_json() { + let (_dir, path) = payload_path("invalid-toggle.json"); + std::fs::write(&path, "{invalid json").expect("write invalid json"); + let err = + JsonFile::<SerializeToggle>::load(path.clone()).expect_err("invalid json should fail"); + assert!(err.to_string().contains("Failed to parse JSON")); + } + + #[cfg(unix)] + #[test] + fn serialize_toggle_load_reports_file_open_error_for_unreadable_file_path() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("unreadable-toggle.json"); + std::fs::write(&path, r#"{"fail":false,"label":"ok"}"#).expect("write json"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)) + .expect("set unreadable permission"); + + let err = + JsonFile::<SerializeToggle>::load(path.clone()).expect_err("owned path should fail"); + assert!(err.to_string().contains("Failed to open JSON file")); + + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .expect("restore permission"); + } + + #[test] fn load_reads_valid_json_payload() { let (_dir, path) = payload_path("valid.json"); let payload = Payload { diff --git a/crates/runtime/src/secret_file.rs b/crates/runtime/src/secret_file.rs @@ -150,14 +150,18 @@ pub fn seal_local_secret_file( let envelope = RadrootsProtectedStoreEnvelope::seal_with_wrapped_key(&key_source, key_slot, payload) .map_err(|error| seal_error(path, error.to_string()))?; - let encoded = envelope - .encode_json() - .map_err(|error| seal_error(path, error.to_string()))?; + let encoded = match encode_secret_envelope(&envelope) { + Ok(encoded) => encoded, + Err(error) => return Err(seal_error(path, error.to_string())), + }; fs::write(path, encoded).map_err(|source| RuntimeProtectedFileError::Io { path: path.to_path_buf(), source, })?; - set_secret_permissions(path).map_err(|error| permissions_error(path, error.to_string()))?; + match set_secret_permissions(path) { + Ok(()) => {} + Err(error) => return Err(permissions_error(path, error.to_string())), + } Ok(()) } @@ -220,10 +224,100 @@ fn permissions_error(path: &Path, message: String) -> RuntimeProtectedFileError } } +fn encode_secret_envelope( + envelope: &RadrootsProtectedStoreEnvelope, +) -> Result<Vec<u8>, radroots_protected_store::error::RadrootsProtectedStoreError> { + #[cfg(test)] + if test_hooks::take_encode() { + return Err( + radroots_protected_store::error::RadrootsProtectedStoreError::EnvelopeEncodeFailed, + ); + } + + envelope.encode_json() +} + +#[cfg(test)] +mod test_hooks { + use std::collections::HashMap; + use std::sync::{Mutex, OnceLock}; + use std::thread::{self, ThreadId}; + + const FAIL_ENCODE: u8 = 1; + const FAIL_PERMS: u8 = 2; + + static FAIL_POINTS: OnceLock<Mutex<HashMap<ThreadId, u8>>> = OnceLock::new(); + + pub struct FailGuard { + thread_id: ThreadId, + } + + impl Drop for FailGuard { + fn drop(&mut self) { + clear(self.thread_id); + } + } + + pub fn fail_encode() -> FailGuard { + set(FAIL_ENCODE) + } + + pub fn fail_perms() -> FailGuard { + set(FAIL_PERMS) + } + + pub fn take_encode() -> bool { + take(FAIL_ENCODE) + } + + pub fn take_perms() -> bool { + take(FAIL_PERMS) + } + + fn set(point: u8) -> FailGuard { + let thread_id = thread::current().id(); + fail_map() + .lock() + .expect("lock fail hooks") + .insert(thread_id, point); + FailGuard { thread_id } + } + + fn clear(thread_id: ThreadId) { + fail_map() + .lock() + .expect("lock clear hooks") + .remove(&thread_id); + } + + fn take(point: u8) -> bool { + let thread_id = thread::current().id(); + let mut map = fail_map().lock().expect("lock take hooks"); + match map.get(&thread_id).copied() { + Some(current_point) if current_point == point => { + map.remove(&thread_id); + true + } + _ => false, + } + } + + fn fail_map() -> &'static Mutex<HashMap<ThreadId, u8>> { + FAIL_POINTS.get_or_init(|| Mutex::new(HashMap::new())) + } +} + #[cfg(unix)] fn set_secret_permissions(path: &Path) -> Result<(), RadrootsSecretVaultAccessError> { use std::os::unix::fs::PermissionsExt; + #[cfg(test)] + if test_hooks::take_perms() { + return Err(io_backend_error(std::io::Error::other( + "forced permissions failure", + ))); + } + let permissions = std::fs::Permissions::from_mode(0o600); fs::set_permissions(path, permissions).map_err(io_backend_error) } diff --git a/crates/runtime/src/secret_file/tests.rs b/crates/runtime/src/secret_file/tests.rs @@ -2,6 +2,7 @@ use super::{ LocalWrappedKeySource, RuntimeProtectedFileError, WRAPPED_KEY_VERSION, local_wrapping_key_path, open_local_secret_file, seal_local_secret_file, }; +use chacha20poly1305::aead::Error as AeadError; use radroots_secret_vault::RadrootsSecretKeyWrapping; use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; @@ -147,9 +148,11 @@ fn seal_local_secret_file_reports_create_dir_failure() { let err = seal_local_secret_file(&path, "runtime_test_identity", b"payload") .expect_err("parent file must block directory creation"); - assert!(matches!(err, RuntimeProtectedFileError::CreateDir { .. })); - if let RuntimeProtectedFileError::CreateDir { path: err_path, .. } = &err { - assert_eq!(err_path, &blocked_parent); + match &err { + RuntimeProtectedFileError::CreateDir { path: err_path, .. } => { + assert_eq!(err_path, &blocked_parent); + } + other => panic!("unexpected create-dir error: {other}"), } } @@ -162,14 +165,15 @@ fn seal_local_secret_file_reports_seal_failure_for_invalid_existing_wrapping_key let err = seal_local_secret_file(&path, "runtime_test_identity", b"payload") .expect_err("invalid sidecar should fail sealing"); - assert!(matches!(err, RuntimeProtectedFileError::Seal { .. })); - if let RuntimeProtectedFileError::Seal { - path: err_path, - message, - } = &err - { - assert_eq!(err_path, &path); - assert!(!message.is_empty()); + match &err { + RuntimeProtectedFileError::Seal { + path: err_path, + message, + } => { + assert_eq!(err_path, &path); + assert!(!message.is_empty()); + } + other => panic!("unexpected seal error: {other}"), } } @@ -182,9 +186,59 @@ fn seal_local_secret_file_reports_io_error_when_target_is_directory() { let err = seal_local_secret_file(&path, "runtime_test_identity", b"payload") .expect_err("directory target must fail write"); - assert!(matches!(err, RuntimeProtectedFileError::Io { .. })); - if let RuntimeProtectedFileError::Io { path: err_path, .. } = &err { - assert_eq!(err_path, &path); + match &err { + RuntimeProtectedFileError::Io { path: err_path, .. } => { + assert_eq!(err_path, &path); + } + other => panic!("unexpected io error: {other}"), + } +} + +#[test] +fn seal_local_secret_file_reports_encode_failure() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("identity.secret.json"); + let _guard = super::test_hooks::fail_encode(); + + let err = seal_local_secret_file(&path, "runtime_test_identity", b"payload") + .expect_err("forced encode failure must surface"); + + match &err { + RuntimeProtectedFileError::Seal { + path: err_path, + message, + } => { + assert_eq!(err_path, &path); + assert!(!message.is_empty()); + } + other => panic!("unexpected encode error: {other}"), + } +} + +#[cfg(unix)] +#[test] +fn seal_local_secret_file_reports_permissions_failure_for_payload_file() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("identity.secret.json"); + std::fs::write( + local_wrapping_key_path(&path), + [7_u8; super::RADROOTS_PROTECTED_STORE_KEY_LENGTH], + ) + .expect("write existing sidecar key"); + let _guard = super::test_hooks::fail_perms(); + + let err = seal_local_secret_file(&path, "runtime_test_identity", b"payload") + .expect_err("forced permissions failure must surface"); + + match &err { + RuntimeProtectedFileError::Permissions { + path: err_path, + message, + } => { + assert_eq!(err_path, &path); + assert!(!message.is_empty()); + } + other => panic!("unexpected permissions error: {other}"), } } @@ -196,9 +250,11 @@ fn open_local_secret_file_reports_io_error_for_missing_payload_file() { let err = open_local_secret_file(&path, "runtime_test_identity").expect_err("missing file must fail"); - assert!(matches!(err, RuntimeProtectedFileError::Io { .. })); - if let RuntimeProtectedFileError::Io { path: err_path, .. } = &err { - assert_eq!(err_path, &path); + match &err { + RuntimeProtectedFileError::Io { path: err_path, .. } => { + assert_eq!(err_path, &path); + } + other => panic!("unexpected open io error: {other}"), } } @@ -211,9 +267,11 @@ fn open_local_secret_file_reports_decode_error_for_invalid_payload() { let err = open_local_secret_file(&path, "runtime_test_identity") .expect_err("invalid json payload must fail"); - assert!(matches!(err, RuntimeProtectedFileError::Decode { .. })); - if let RuntimeProtectedFileError::Decode { path: err_path, .. } = &err { - assert_eq!(err_path, &path); + match &err { + RuntimeProtectedFileError::Decode { path: err_path, .. } => { + assert_eq!(err_path, &path); + } + other => panic!("unexpected decode error: {other}"), } } @@ -252,3 +310,47 @@ fn seal_local_secret_file_allows_parentless_paths() { std::env::set_current_dir(original).expect("restore cwd"); } + +#[test] +fn secret_file_helper_errors_preserve_expected_messages() { + let entropy = super::entropy_unavailable_error(getrandom::Error::UNSUPPORTED); + assert_eq!( + entropy.to_string(), + "secret vault access error: entropy unavailable" + ); + + let wrap = super::wrap_data_key_error(AeadError); + assert_eq!( + wrap.to_string(), + "secret vault access error: failed to wrap protected secret data key" + ); +} + +#[test] +fn secret_file_runtime_error_helpers_preserve_path_and_message() { + let path = PathBuf::from("identity.secret.json"); + + let seal = super::seal_error(&path, "seal failed".to_string()); + match seal { + RuntimeProtectedFileError::Seal { + path: err_path, + message, + } => { + assert_eq!(err_path, path); + assert_eq!(message, "seal failed"); + } + other => panic!("unexpected seal helper error: {other}"), + } + + let permissions = super::permissions_error(&path, "chmod failed".to_string()); + match permissions { + RuntimeProtectedFileError::Permissions { + path: err_path, + message, + } => { + assert_eq!(err_path, path); + assert_eq!(message, "chmod failed"); + } + other => panic!("unexpected permissions helper error: {other}"), + } +} diff --git a/crates/runtime/src/tracing/tests.rs b/crates/runtime/src/tracing/tests.rs @@ -107,10 +107,7 @@ fn default_shared_runtime_logs_dir_and_init_use_current_resolver() { let logs_dir = default_shared_runtime_logs_dir().expect("default shared runtime logs dir"); assert_eq!(logs_dir, dir.path().join(".radroots/logs/shared/runtime")); - let init_result = init(); - if let Err(err) = init_result { - assert!(!err.to_string().is_empty()); - } + let _ = init(); test_hooks::set_ignore_env(false); test_hooks::set_current_resolver(None); @@ -130,23 +127,11 @@ fn init_paths_execute() { test_hooks::set_ignore_env(true); let invalid = dir.path().join("not-a-dir"); std::fs::write(&invalid, "file").expect("write invalid path"); - let err_path = init_with_logs_dir(invalid.as_path(), Some("info")); - if let Err(err) = err_path { - assert!(!err.to_string().is_empty()); - } + let _ = init_with_logs_dir(invalid.as_path(), Some("info")); let invalid_str = invalid.to_string_lossy().to_string(); - let err_str = init_with_logs_dir(invalid_str.as_str(), Some("info")); - if let Err(err) = err_str { - assert!(!err.to_string().is_empty()); - } - let first = init_with_logs_dir(dir.path(), Some("info")); - if let Err(err) = first { - assert!(!err.to_string().is_empty()); - } + let _ = init_with_logs_dir(invalid_str.as_str(), Some("info")); + let _ = init_with_logs_dir(dir.path(), Some("info")); let owned_path = dir.path().to_path_buf(); - let third = init_with_logs_dir(owned_path.as_path(), None); - if let Err(err) = third { - assert!(!err.to_string().is_empty()); - } + let _ = init_with_logs_dir(owned_path.as_path(), None); test_hooks::set_ignore_env(false); }