lib

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

storage.rs (26351B)


      1 use std::borrow::Cow;
      2 use std::fs;
      3 use std::path::{Path, PathBuf};
      4 
      5 use radroots_protected_store::{
      6     RadrootsProtectedFileKeySource, RadrootsProtectedStoreEnvelope, sidecar_path,
      7 };
      8 use radroots_secret_vault::RadrootsSecretVaultAccessError;
      9 
     10 use crate::{IdentityError, RadrootsIdentity, RadrootsIdentityFile, RadrootsIdentityPublic};
     11 
     12 pub const RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT: &str = "radroots_identity";
     13 pub const RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX: &str = ".key";
     14 
     15 #[derive(Debug, Clone)]
     16 pub struct RadrootsEncryptedIdentityFile {
     17     path: PathBuf,
     18     key_slot: Cow<'static, str>,
     19 }
     20 
     21 impl RadrootsEncryptedIdentityFile {
     22     #[must_use]
     23     pub fn new(path: impl AsRef<Path>) -> Self {
     24         Self::new_path(path.as_ref())
     25     }
     26 
     27     #[must_use]
     28     fn new_path(path: &Path) -> Self {
     29         Self::with_key_slot_path(path, RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT)
     30     }
     31 
     32     #[must_use]
     33     pub fn with_key_slot(path: impl AsRef<Path>, key_slot: impl Into<Cow<'static, str>>) -> Self {
     34         Self::with_key_slot_path(path.as_ref(), key_slot)
     35     }
     36 
     37     #[must_use]
     38     fn with_key_slot_path(path: &Path, key_slot: impl Into<Cow<'static, str>>) -> Self {
     39         Self {
     40             path: path.to_path_buf(),
     41             key_slot: key_slot.into(),
     42         }
     43     }
     44 
     45     #[must_use]
     46     pub fn path(&self) -> &Path {
     47         self.path.as_path()
     48     }
     49 
     50     #[must_use]
     51     pub fn key_slot(&self) -> &str {
     52         self.key_slot.as_ref()
     53     }
     54 
     55     #[must_use]
     56     pub fn wrapping_key_path(&self) -> PathBuf {
     57         encrypted_identity_wrapping_key_path(&self.path)
     58     }
     59 
     60     pub fn store(&self, identity: &RadrootsIdentity) -> Result<(), IdentityError> {
     61         if let Some(parent) = self.path.parent()
     62             && !parent.as_os_str().is_empty()
     63         {
     64             fs::create_dir_all(parent)
     65                 .map_err(|source| IdentityError::CreateDir(parent.to_path_buf(), source))?;
     66         }
     67 
     68         let payload = identity_file_payload(identity);
     69         let key_source = RadrootsProtectedFileKeySource::from_sidecar_suffix(
     70             &self.path,
     71             RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX,
     72         );
     73         let envelope = RadrootsProtectedStoreEnvelope::seal_with_wrapped_key(
     74             &key_source,
     75             self.key_slot(),
     76             &payload,
     77         )
     78         .map_err(|error| {
     79             protected_storage_message(&self.path, "seal encrypted identity", &error)
     80         })?;
     81         let encoded = encode_encrypted_identity(&envelope);
     82         fs::write(&self.path, encoded)
     83             .map_err(|source| IdentityError::Write(self.path.clone(), source))?;
     84         apply_secret_permissions(&self.path)?;
     85         Ok(())
     86     }
     87 
     88     pub fn load(&self) -> Result<RadrootsIdentity, IdentityError> {
     89         let encoded = fs::read(&self.path).map_err(|source| {
     90             if source.kind() == std::io::ErrorKind::NotFound {
     91                 IdentityError::NotFound(self.path.clone())
     92             } else {
     93                 IdentityError::Read(self.path.clone(), source)
     94             }
     95         })?;
     96         let key_source = RadrootsProtectedFileKeySource::from_sidecar_suffix(
     97             &self.path,
     98             RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX,
     99         );
    100         let envelope = RadrootsProtectedStoreEnvelope::decode_json(&encoded).map_err(|error| {
    101             protected_storage_message(&self.path, "decode encrypted identity", &error)
    102         })?;
    103         let plaintext = envelope
    104             .open_with_wrapped_key(&key_source)
    105             .map_err(|error| {
    106                 protected_storage_message(&self.path, "open encrypted identity", &error)
    107             })?;
    108         let file: RadrootsIdentityFile = serde_json::from_slice(&plaintext)?;
    109         RadrootsIdentity::try_from(file)
    110     }
    111 
    112     pub fn rotate(&self) -> Result<(), IdentityError> {
    113         let identity = self.load()?;
    114         let backup = self.rotation_backup()?;
    115 
    116         if let Err(error) = self.store(&identity) {
    117             let _ = fs::write(&self.path, &backup.envelope);
    118             let _ = set_secret_permissions(&self.path);
    119             let _ = fs::write(&backup.key_path, &backup.key);
    120             let _ = set_secret_permissions(&backup.key_path);
    121             return Err(error);
    122         }
    123 
    124         Ok(())
    125     }
    126 
    127     #[cfg_attr(coverage_nightly, coverage(off))]
    128     fn rotation_backup(&self) -> Result<EncryptedIdentityRotationBackup, IdentityError> {
    129         let envelope = fs::read(&self.path)
    130             .map_err(|source| IdentityError::Read(self.path.clone(), source))?;
    131         let key_path = self.wrapping_key_path();
    132         let key =
    133             fs::read(&key_path).map_err(|source| IdentityError::Read(key_path.clone(), source))?;
    134 
    135         fs::remove_file(&key_path)
    136             .map_err(|source| IdentityError::Write(key_path.clone(), source))?;
    137 
    138         Ok(EncryptedIdentityRotationBackup {
    139             envelope,
    140             key_path,
    141             key,
    142         })
    143     }
    144 }
    145 
    146 struct EncryptedIdentityRotationBackup {
    147     envelope: Vec<u8>,
    148     key_path: PathBuf,
    149     key: Vec<u8>,
    150 }
    151 
    152 #[must_use]
    153 pub fn encrypted_identity_wrapping_key_path(path: impl AsRef<Path>) -> PathBuf {
    154     encrypted_identity_wrapping_key_path_ref(path.as_ref())
    155 }
    156 
    157 fn encrypted_identity_wrapping_key_path_ref(path: &Path) -> PathBuf {
    158     sidecar_path(path, RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX)
    159 }
    160 
    161 pub fn store_encrypted_identity(
    162     path: impl AsRef<Path>,
    163     identity: &RadrootsIdentity,
    164 ) -> Result<(), IdentityError> {
    165     store_encrypted_identity_path(path.as_ref(), identity)
    166 }
    167 
    168 fn store_encrypted_identity_path(
    169     path: &Path,
    170     identity: &RadrootsIdentity,
    171 ) -> Result<(), IdentityError> {
    172     RadrootsEncryptedIdentityFile::new_path(path).store(identity)
    173 }
    174 
    175 pub fn store_encrypted_identity_with_key_slot(
    176     path: impl AsRef<Path>,
    177     key_slot: impl Into<Cow<'static, str>>,
    178     identity: &RadrootsIdentity,
    179 ) -> Result<(), IdentityError> {
    180     store_encrypted_identity_with_key_slot_path(path.as_ref(), key_slot, identity)
    181 }
    182 
    183 fn store_encrypted_identity_with_key_slot_path(
    184     path: &Path,
    185     key_slot: impl Into<Cow<'static, str>>,
    186     identity: &RadrootsIdentity,
    187 ) -> Result<(), IdentityError> {
    188     RadrootsEncryptedIdentityFile::with_key_slot_path(path, key_slot).store(identity)
    189 }
    190 
    191 pub fn rotate_encrypted_identity(path: impl AsRef<Path>) -> Result<(), IdentityError> {
    192     rotate_encrypted_identity_path(path.as_ref())
    193 }
    194 
    195 fn rotate_encrypted_identity_path(path: &Path) -> Result<(), IdentityError> {
    196     RadrootsEncryptedIdentityFile::new_path(path).rotate()
    197 }
    198 
    199 pub fn rotate_encrypted_identity_with_key_slot(
    200     path: impl AsRef<Path>,
    201     key_slot: impl Into<Cow<'static, str>>,
    202 ) -> Result<(), IdentityError> {
    203     rotate_encrypted_identity_with_key_slot_path(path.as_ref(), key_slot)
    204 }
    205 
    206 fn rotate_encrypted_identity_with_key_slot_path(
    207     path: &Path,
    208     key_slot: impl Into<Cow<'static, str>>,
    209 ) -> Result<(), IdentityError> {
    210     RadrootsEncryptedIdentityFile::with_key_slot_path(path, key_slot).rotate()
    211 }
    212 
    213 pub fn load_encrypted_identity(path: impl AsRef<Path>) -> Result<RadrootsIdentity, IdentityError> {
    214     load_encrypted_identity_path(path.as_ref())
    215 }
    216 
    217 fn load_encrypted_identity_path(path: &Path) -> Result<RadrootsIdentity, IdentityError> {
    218     RadrootsEncryptedIdentityFile::new_path(path).load()
    219 }
    220 
    221 pub fn load_encrypted_identity_with_key_slot(
    222     path: impl AsRef<Path>,
    223     key_slot: impl Into<Cow<'static, str>>,
    224 ) -> Result<RadrootsIdentity, IdentityError> {
    225     load_encrypted_identity_with_key_slot_path(path.as_ref(), key_slot)
    226 }
    227 
    228 fn load_encrypted_identity_with_key_slot_path(
    229     path: &Path,
    230     key_slot: impl Into<Cow<'static, str>>,
    231 ) -> Result<RadrootsIdentity, IdentityError> {
    232     RadrootsEncryptedIdentityFile::with_key_slot_path(path, key_slot).load()
    233 }
    234 
    235 pub fn store_identity_profile(
    236     path: impl AsRef<Path>,
    237     identity: &RadrootsIdentity,
    238 ) -> Result<(), IdentityError> {
    239     store_identity_profile_path(path.as_ref(), identity)
    240 }
    241 
    242 fn store_identity_profile_path(
    243     path: &Path,
    244     identity: &RadrootsIdentity,
    245 ) -> Result<(), IdentityError> {
    246     if let Some(parent) = path.parent()
    247         && !parent.as_os_str().is_empty()
    248     {
    249         fs::create_dir_all(parent)
    250             .map_err(|source| IdentityError::CreateDir(parent.to_path_buf(), source))?;
    251     }
    252 
    253     let encoded = identity_profile_payload(identity);
    254     fs::write(path, encoded).map_err(|source| IdentityError::Write(path.to_path_buf(), source))?;
    255     apply_secret_permissions(path)?;
    256     Ok(())
    257 }
    258 
    259 pub fn load_identity_profile(
    260     path: impl AsRef<Path>,
    261 ) -> Result<RadrootsIdentityPublic, IdentityError> {
    262     load_identity_profile_path(path.as_ref())
    263 }
    264 
    265 fn load_identity_profile_path(path: &Path) -> Result<RadrootsIdentityPublic, IdentityError> {
    266     let encoded = match fs::read(path) {
    267         Ok(encoded) => encoded,
    268         Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
    269             return Err(IdentityError::NotFound(path.to_path_buf()));
    270         }
    271         Err(source) => return Err(IdentityError::Read(path.to_path_buf(), source)),
    272     };
    273     if let Ok(public_identity) = serde_json::from_slice::<RadrootsIdentityPublic>(&encoded) {
    274         return Ok(public_identity);
    275     }
    276     RadrootsIdentity::load_from_path_auto(path).map(|identity| identity.to_public())
    277 }
    278 
    279 fn identity_file_payload(identity: &RadrootsIdentity) -> Vec<u8> {
    280     serde_json::to_vec(&identity.to_file()).expect("identity file serialization is infallible")
    281 }
    282 
    283 fn identity_profile_payload(identity: &RadrootsIdentity) -> Vec<u8> {
    284     serde_json::to_vec_pretty(&identity.to_public())
    285         .expect("identity profile serialization is infallible")
    286 }
    287 
    288 fn encode_encrypted_identity(envelope: &RadrootsProtectedStoreEnvelope) -> Vec<u8> {
    289     envelope
    290         .encode_json()
    291         .expect("protected-store envelope serialization is infallible")
    292 }
    293 
    294 #[cfg_attr(coverage_nightly, coverage(off))]
    295 fn apply_secret_permissions(path: &Path) -> Result<(), IdentityError> {
    296     set_secret_permissions(path).map_err(|error| secret_permission_error(path, error))
    297 }
    298 
    299 fn protected_storage_message(
    300     path: &Path,
    301     action: &str,
    302     message: &dyn core::fmt::Display,
    303 ) -> IdentityError {
    304     IdentityError::ProtectedStorage {
    305         path: path.to_path_buf(),
    306         message: format!("failed to {action}: {message}"),
    307     }
    308 }
    309 
    310 fn secret_permission_error(path: &Path, error: RadrootsSecretVaultAccessError) -> IdentityError {
    311     protected_storage_message(path, "update secret-file permissions", &error)
    312 }
    313 
    314 #[cfg(unix)]
    315 #[cfg_attr(coverage_nightly, coverage(off))]
    316 fn set_secret_permissions(path: &Path) -> Result<(), RadrootsSecretVaultAccessError> {
    317     use std::os::unix::fs::PermissionsExt;
    318 
    319     let permissions = std::fs::Permissions::from_mode(0o600);
    320     fs::set_permissions(path, permissions)
    321         .map_err(|source| RadrootsSecretVaultAccessError::Backend(source.to_string()))
    322 }
    323 
    324 #[cfg(not(unix))]
    325 #[cfg_attr(coverage_nightly, coverage(off))]
    326 fn set_secret_permissions(_path: &Path) -> Result<(), RadrootsSecretVaultAccessError> {
    327     Ok(())
    328 }
    329 
    330 #[cfg(test)]
    331 mod tests {
    332     use super::*;
    333 
    334     #[cfg_attr(coverage_nightly, coverage(off))]
    335     #[test]
    336     fn encrypted_identity_round_trips() {
    337         let temp = tempfile::tempdir().expect("tempdir");
    338         let path = temp.path().join("identity.enc.json");
    339         let identity = RadrootsIdentity::from_secret_key_str(
    340             "1111111111111111111111111111111111111111111111111111111111111111",
    341         )
    342         .expect("identity");
    343 
    344         store_encrypted_identity(&path, &identity).expect("store encrypted identity");
    345 
    346         let loaded = load_encrypted_identity(&path).expect("load encrypted identity");
    347         assert_eq!(loaded.id(), identity.id());
    348         assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex());
    349         assert!(encrypted_identity_wrapping_key_path(&path).is_file());
    350     }
    351 
    352     #[cfg_attr(coverage_nightly, coverage(off))]
    353     #[test]
    354     fn encrypted_identity_rotation_rewraps_key() {
    355         let temp = tempfile::tempdir().expect("tempdir");
    356         let path = temp.path().join("identity.enc.json");
    357         let identity = RadrootsIdentity::from_secret_key_str(
    358             "1111111111111111111111111111111111111111111111111111111111111111",
    359         )
    360         .expect("identity");
    361 
    362         store_encrypted_identity(&path, &identity).expect("store encrypted identity");
    363         let key_path = encrypted_identity_wrapping_key_path(&path);
    364         let before = fs::read(&key_path).expect("key before");
    365 
    366         rotate_encrypted_identity(&path).expect("rotate encrypted identity");
    367 
    368         let after = fs::read(&key_path).expect("key after");
    369         assert_ne!(before, after);
    370         let loaded = load_encrypted_identity(&path).expect("load rotated identity");
    371         assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex());
    372     }
    373 
    374     #[cfg_attr(coverage_nightly, coverage(off))]
    375     #[test]
    376     fn encrypted_identity_supports_custom_key_slot() {
    377         let temp = tempfile::tempdir().expect("tempdir");
    378         let path = temp.path().join("identity.enc.json");
    379         let identity = RadrootsIdentity::from_secret_key_str(
    380             "1111111111111111111111111111111111111111111111111111111111111111",
    381         )
    382         .expect("identity");
    383 
    384         store_encrypted_identity_with_key_slot(&path, "myc_identity", &identity)
    385             .expect("store encrypted identity");
    386         let loaded = load_encrypted_identity_with_key_slot(&path, "myc_identity")
    387             .expect("load encrypted identity");
    388         assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex());
    389     }
    390 
    391     #[cfg_attr(coverage_nightly, coverage(off))]
    392     #[test]
    393     fn identity_profile_round_trips() {
    394         let temp = tempfile::tempdir().expect("tempdir");
    395         let path = temp.path().join("profile.json");
    396         let mut identity = RadrootsIdentity::from_secret_key_str(
    397             "1111111111111111111111111111111111111111111111111111111111111111",
    398         )
    399         .expect("identity");
    400         identity.set_profile(crate::RadrootsIdentityProfile::default());
    401 
    402         store_identity_profile(&path, &identity).expect("store profile");
    403 
    404         let loaded = load_identity_profile(&path).expect("load profile");
    405         assert_eq!(loaded.id, identity.id());
    406     }
    407 
    408     #[cfg_attr(coverage_nightly, coverage(off))]
    409     #[test]
    410     fn encrypted_identity_file_accessors_and_wrappers_use_expected_paths() {
    411         let temp = tempfile::tempdir().expect("tempdir");
    412         let path = temp.path().join("identity.enc.json");
    413         let identity = RadrootsIdentity::from_secret_key_str(
    414             "1111111111111111111111111111111111111111111111111111111111111111",
    415         )
    416         .expect("identity");
    417 
    418         let default_file = RadrootsEncryptedIdentityFile::new(path.as_path());
    419         assert_eq!(default_file.path(), path.as_path());
    420         assert_eq!(
    421             default_file.key_slot(),
    422             RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT
    423         );
    424         assert_eq!(
    425             default_file.wrapping_key_path(),
    426             encrypted_identity_wrapping_key_path(path.as_path())
    427         );
    428 
    429         let custom_file =
    430             RadrootsEncryptedIdentityFile::with_key_slot(path.as_path(), "custom_identity");
    431         assert_eq!(custom_file.key_slot(), "custom_identity");
    432 
    433         store_encrypted_identity(path.as_path(), &identity).expect("store encrypted identity");
    434         rotate_encrypted_identity(path.as_path()).expect("rotate encrypted identity");
    435         let loaded = load_encrypted_identity(path.as_path()).expect("load encrypted identity");
    436         assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex());
    437 
    438         store_encrypted_identity_with_key_slot(path.as_path(), "custom_identity", &identity)
    439             .expect("store encrypted identity with slot");
    440         rotate_encrypted_identity_with_key_slot(path.as_path(), "custom_identity")
    441             .expect("rotate encrypted identity with slot");
    442         let loaded = load_encrypted_identity_with_key_slot(path.as_path(), "custom_identity")
    443             .expect("load encrypted identity with slot");
    444         assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex());
    445     }
    446 
    447     #[cfg_attr(coverage_nightly, coverage(off))]
    448     #[test]
    449     fn encrypted_identity_load_reports_read_decode_and_open_errors() {
    450         let temp = tempfile::tempdir().expect("tempdir");
    451         let missing = temp.path().join("missing.enc.json");
    452         let missing_error = load_encrypted_identity(missing.as_path()).expect_err("missing");
    453         assert!(matches!(missing_error, IdentityError::NotFound(path) if path == missing));
    454 
    455         let read_error = load_encrypted_identity(temp.path()).expect_err("directory read");
    456         assert!(matches!(read_error, IdentityError::Read(path, _) if path == temp.path()));
    457 
    458         let invalid = temp.path().join("invalid.enc.json");
    459         fs::write(&invalid, b"not-json").expect("write invalid envelope");
    460         let decode_error = load_encrypted_identity(invalid.as_path()).expect_err("decode error");
    461         assert!(matches!(
    462             decode_error,
    463             IdentityError::ProtectedStorage { path, message }
    464                 if path == invalid && message.contains("decode encrypted identity")
    465         ));
    466 
    467         let invalid_plaintext = temp.path().join("invalid-plaintext.enc.json");
    468         let key_source = RadrootsProtectedFileKeySource::from_sidecar_suffix(
    469             invalid_plaintext.as_path(),
    470             RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX,
    471         );
    472         let envelope = RadrootsProtectedStoreEnvelope::seal_with_wrapped_key(
    473             &key_source,
    474             RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT,
    475             b"not identity json",
    476         )
    477         .expect("seal invalid plaintext");
    478         fs::write(
    479             &invalid_plaintext,
    480             envelope.encode_json().expect("encode invalid plaintext"),
    481         )
    482         .expect("write invalid plaintext envelope");
    483         let invalid_plaintext_error =
    484             load_encrypted_identity(invalid_plaintext.as_path()).expect_err("invalid plaintext");
    485         assert!(matches!(
    486             invalid_plaintext_error,
    487             IdentityError::InvalidJson(_)
    488         ));
    489 
    490         let path = temp.path().join("identity.enc.json");
    491         let identity = RadrootsIdentity::from_secret_key_str(
    492             "1111111111111111111111111111111111111111111111111111111111111111",
    493         )
    494         .expect("identity");
    495         store_encrypted_identity_with_key_slot(path.as_path(), "right_slot", &identity)
    496             .expect("store encrypted identity");
    497         fs::write(
    498             encrypted_identity_wrapping_key_path(path.as_path()),
    499             b"short",
    500         )
    501         .expect("corrupt wrapping key");
    502         let open_error = load_encrypted_identity(path.as_path()).expect_err("open");
    503         assert!(matches!(
    504             open_error,
    505             IdentityError::ProtectedStorage { path: error_path, message }
    506                 if error_path == path && message.contains("open encrypted identity")
    507         ));
    508     }
    509 
    510     #[cfg_attr(coverage_nightly, coverage(off))]
    511     #[test]
    512     fn encrypted_identity_store_reports_create_write_and_seal_errors() {
    513         let temp = tempfile::tempdir().expect("tempdir");
    514         let identity = RadrootsIdentity::from_secret_key_str(
    515             "1111111111111111111111111111111111111111111111111111111111111111",
    516         )
    517         .expect("identity");
    518 
    519         let blocked_parent = temp.path().join("blocked-parent");
    520         fs::write(&blocked_parent, b"not-a-directory").expect("blocked parent");
    521         let create_path = blocked_parent.join("identity.enc.json");
    522         let create_error =
    523             store_encrypted_identity(create_path.as_path(), &identity).expect_err("create dir");
    524         assert!(
    525             matches!(create_error, IdentityError::CreateDir(path, _) if path == blocked_parent)
    526         );
    527 
    528         let directory_path = temp.path().join("identity-as-directory.enc.json");
    529         fs::create_dir(&directory_path).expect("identity directory");
    530         let write_error =
    531             store_encrypted_identity(directory_path.as_path(), &identity).expect_err("write dir");
    532         assert!(matches!(write_error, IdentityError::Write(path, _) if path == directory_path));
    533 
    534         let sealed_path = temp.path().join("seal-error.enc.json");
    535         fs::create_dir(encrypted_identity_wrapping_key_path(sealed_path.as_path()))
    536             .expect("blocking key directory");
    537         let seal_error =
    538             store_encrypted_identity(sealed_path.as_path(), &identity).expect_err("seal");
    539         assert!(matches!(
    540             seal_error,
    541             IdentityError::ProtectedStorage { path, message }
    542                 if path == sealed_path && message.contains("seal encrypted identity")
    543         ));
    544     }
    545 
    546     #[cfg(unix)]
    547     #[cfg_attr(coverage_nightly, coverage(off))]
    548     #[test]
    549     fn encrypted_identity_rotation_restores_wrapping_key_after_store_failure() {
    550         use std::os::unix::fs::PermissionsExt;
    551 
    552         let temp = tempfile::tempdir().expect("tempdir");
    553         let path = temp.path().join("identity.enc.json");
    554         let identity = RadrootsIdentity::from_secret_key_str(
    555             "1111111111111111111111111111111111111111111111111111111111111111",
    556         )
    557         .expect("identity");
    558 
    559         store_encrypted_identity(path.as_path(), &identity).expect("store encrypted identity");
    560         let key_path = encrypted_identity_wrapping_key_path(path.as_path());
    561         let key_before = fs::read(&key_path).expect("key before");
    562 
    563         fs::set_permissions(&path, fs::Permissions::from_mode(0o400)).expect("read only");
    564         let error = rotate_encrypted_identity(path.as_path()).expect_err("rotate failure");
    565         fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("writable");
    566 
    567         assert!(matches!(error, IdentityError::Write(error_path, _) if error_path == path));
    568         assert_eq!(fs::read(&key_path).expect("restored key"), key_before);
    569         let loaded = load_encrypted_identity(path.as_path()).expect("load restored identity");
    570         assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex());
    571     }
    572 
    573     #[cfg_attr(coverage_nightly, coverage(off))]
    574     #[test]
    575     fn identity_profile_storage_reports_errors_and_private_fallback() {
    576         let temp = tempfile::tempdir().expect("tempdir");
    577         let identity = RadrootsIdentity::from_secret_key_str(
    578             "1111111111111111111111111111111111111111111111111111111111111111",
    579         )
    580         .expect("identity");
    581 
    582         let blocked_parent = temp.path().join("blocked-profile-parent");
    583         fs::write(&blocked_parent, b"not-a-directory").expect("blocked parent");
    584         let create_path = blocked_parent.join("profile.json");
    585         let create_error =
    586             store_identity_profile(create_path.as_path(), &identity).expect_err("create dir");
    587         assert!(
    588             matches!(create_error, IdentityError::CreateDir(path, _) if path == blocked_parent)
    589         );
    590 
    591         let directory_path = temp.path().join("profile-as-directory.json");
    592         fs::create_dir(&directory_path).expect("profile directory");
    593         let write_error =
    594             store_identity_profile(directory_path.as_path(), &identity).expect_err("write dir");
    595         assert!(matches!(write_error, IdentityError::Write(path, _) if path == directory_path));
    596 
    597         let missing = temp.path().join("missing-profile.json");
    598         let missing_error = load_identity_profile(missing.as_path()).expect_err("missing");
    599         assert!(matches!(missing_error, IdentityError::NotFound(path) if path == missing));
    600 
    601         let read_error = load_identity_profile(temp.path()).expect_err("directory read");
    602         assert!(matches!(read_error, IdentityError::Read(path, _) if path == temp.path()));
    603 
    604         let private_profile = temp.path().join("private-profile.json");
    605         fs::write(
    606             &private_profile,
    607             serde_json::to_vec(&identity.to_file()).expect("identity file"),
    608         )
    609         .expect("write private profile");
    610         let loaded = load_identity_profile(private_profile.as_path()).expect("load fallback");
    611         assert_eq!(loaded.id, identity.id());
    612     }
    613 
    614     #[cfg_attr(coverage_nightly, coverage(off))]
    615     #[test]
    616     fn protected_storage_permission_message_uses_operator_action() {
    617         let path = Path::new("missing-secret-file");
    618         let error = secret_permission_error(
    619             path,
    620             RadrootsSecretVaultAccessError::Backend("permission denied".into()),
    621         );
    622 
    623         assert!(matches!(
    624             error,
    625             IdentityError::ProtectedStorage { path: error_path, message }
    626                 if error_path == path
    627                     && message.contains("update secret-file permissions")
    628                     && message.contains("permission denied")
    629         ));
    630     }
    631 
    632     #[cfg_attr(coverage_nightly, coverage(off))]
    633     #[test]
    634     fn storage_supports_parentless_relative_files() {
    635         let temp = tempfile::tempdir().expect("tempdir");
    636         let previous = std::env::current_dir().expect("current dir");
    637         std::env::set_current_dir(temp.path()).expect("set temp cwd");
    638 
    639         let identity = RadrootsIdentity::from_secret_key_str(
    640             "1111111111111111111111111111111111111111111111111111111111111111",
    641         )
    642         .expect("identity");
    643         let encrypted_path = Path::new("identity.enc.json");
    644         store_encrypted_identity(encrypted_path, &identity).expect("store encrypted");
    645         let loaded = load_encrypted_identity(encrypted_path).expect("load encrypted");
    646         assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex());
    647 
    648         let profile_path = Path::new("profile.json");
    649         store_identity_profile(profile_path, &identity).expect("store profile");
    650         let loaded = load_identity_profile(profile_path).expect("load profile");
    651         assert_eq!(loaded.id, identity.id());
    652 
    653         let empty_path = Path::new("");
    654         let encrypted_error =
    655             store_encrypted_identity(empty_path, &identity).expect_err("empty encrypted path");
    656         assert!(matches!(encrypted_error, IdentityError::Write(_, _)));
    657         let profile_error =
    658             store_identity_profile(empty_path, &identity).expect_err("empty profile path");
    659         assert!(matches!(profile_error, IdentityError::Write(_, _)));
    660 
    661         std::env::set_current_dir(previous).expect("restore cwd");
    662     }
    663 }