commit 7c191931e1e07764e7ca47d0361f8573d8aff125
parent 4a452a1ddb203b3d33dc0232a6b14d2927fb810e
Author: triesap <tyson@radroots.org>
Date: Fri, 10 Apr 2026 22:59:00 +0000
runtime: cover remaining failure paths
Diffstat:
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);
}