lib

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

identity.rs (37764B)


      1 #[path = "../src/test_fixtures.rs"]
      2 mod test_fixtures;
      3 
      4 use radroots_events::profile::RadrootsProfile;
      5 use radroots_identity::{
      6     DEFAULT_IDENTITY_PATH, IdentityError, RadrootsIdentity, RadrootsIdentityId,
      7     RadrootsIdentityProfile, RadrootsIdentityPublic, RadrootsIdentitySecretKeyFormat,
      8 };
      9 use radroots_identity::{
     10     RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT, RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX,
     11     RadrootsEncryptedIdentityFile, encrypted_identity_wrapping_key_path, load_encrypted_identity,
     12     load_encrypted_identity_with_key_slot, load_identity_profile, rotate_encrypted_identity,
     13     rotate_encrypted_identity_with_key_slot, store_encrypted_identity,
     14     store_encrypted_identity_with_key_slot, store_identity_profile,
     15 };
     16 #[cfg(feature = "nip49")]
     17 use radroots_identity::{
     18     RadrootsIdentityEncryptedSecretKeyOptions, RadrootsIdentityEncryptedSecretKeySecurity,
     19 };
     20 use radroots_protected_store::{RadrootsProtectedFileKeySource, RadrootsProtectedStoreEnvelope};
     21 use radroots_runtime_paths::{
     22     RadrootsHostEnvironment, RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver,
     23     RadrootsPlatform,
     24 };
     25 use std::{
     26     ffi::OsString,
     27     path::PathBuf,
     28     sync::{Mutex, OnceLock},
     29 };
     30 use test_fixtures::{ApprovedFixtureIdentity, FIXTURE_ALICE, FIXTURE_BOB};
     31 
     32 fn home_env_lock() -> &'static Mutex<()> {
     33     static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
     34     LOCK.get_or_init(|| Mutex::new(()))
     35 }
     36 
     37 struct EnvVarGuard {
     38     key: &'static str,
     39     previous: Option<OsString>,
     40 }
     41 
     42 impl EnvVarGuard {
     43     fn remove(key: &'static str) -> Self {
     44         let previous = std::env::var_os(key);
     45         unsafe { std::env::remove_var(key) };
     46         Self { key, previous }
     47     }
     48 }
     49 
     50 impl Drop for EnvVarGuard {
     51     fn drop(&mut self) {
     52         if let Some(value) = self.previous.as_ref() {
     53             unsafe { std::env::set_var(self.key, value) };
     54         } else {
     55             unsafe { std::env::remove_var(self.key) };
     56         }
     57     }
     58 }
     59 
     60 fn fixture_keys(fixture: ApprovedFixtureIdentity) -> nostr::Keys {
     61     let secret = nostr::SecretKey::from_hex(fixture.secret_key_hex).unwrap();
     62     nostr::Keys::new(secret)
     63 }
     64 
     65 fn fixture_identity(fixture: ApprovedFixtureIdentity) -> RadrootsIdentity {
     66     RadrootsIdentity::from_secret_key_str(fixture.secret_key_hex).unwrap()
     67 }
     68 
     69 fn profile_with_identifier(value: &str) -> RadrootsIdentityProfile {
     70     RadrootsIdentityProfile {
     71         identifier: Some(value.to_string()),
     72         ..Default::default()
     73     }
     74 }
     75 
     76 fn sample_event(content: &str) -> nostr::Event {
     77     nostr::EventBuilder::text_note(content)
     78         .sign_with_keys(&fixture_keys(FIXTURE_ALICE))
     79         .unwrap()
     80 }
     81 
     82 #[test]
     83 fn load_from_json_file_hex() {
     84     let identity = fixture_identity(FIXTURE_ALICE);
     85     let json = serde_json::to_string(&identity.to_file()).unwrap();
     86 
     87     let dir = tempfile::tempdir().unwrap();
     88     let path = dir.path().join("identity.json");
     89     std::fs::write(&path, json).unwrap();
     90 
     91     let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap();
     92     assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex);
     93 }
     94 
     95 #[test]
     96 fn load_from_json_file_profile() {
     97     let mut identity = fixture_identity(FIXTURE_ALICE);
     98     let profile = RadrootsProfile {
     99         name: "relay-agent".to_string(),
    100         display_name: Some("Relay Agent".to_string()),
    101         nip05: None,
    102         about: Some("hello".to_string()),
    103         website: None,
    104         picture: None,
    105         banner: None,
    106         lud06: None,
    107         lud16: None,
    108         bot: None,
    109     };
    110     identity.set_profile(RadrootsIdentityProfile {
    111         profile: Some(profile),
    112         ..Default::default()
    113     });
    114     let json = serde_json::to_string(&identity.to_file()).unwrap();
    115 
    116     let dir = tempfile::tempdir().unwrap();
    117     let path = dir.path().join("identity.json");
    118     std::fs::write(&path, json).unwrap();
    119 
    120     let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap();
    121     let loaded_profile = loaded.profile().and_then(|p| p.profile.as_ref()).unwrap();
    122     assert_eq!(loaded_profile.name, "relay-agent");
    123     assert_eq!(loaded_profile.display_name.as_deref(), Some("Relay Agent"));
    124     assert_eq!(loaded_profile.about.as_deref(), Some("hello"));
    125 }
    126 
    127 #[test]
    128 fn load_from_text_file_hex() {
    129     let identity = fixture_identity(FIXTURE_ALICE);
    130     let secret = identity.secret_key_hex();
    131 
    132     let dir = tempfile::tempdir().unwrap();
    133     let path = dir.path().join("identity.txt");
    134     std::fs::write(&path, secret).unwrap();
    135 
    136     let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap();
    137     assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex);
    138 }
    139 
    140 #[test]
    141 fn load_from_text_file_nsec() {
    142     let identity = fixture_identity(FIXTURE_ALICE);
    143     let secret = identity.secret_key_nsec();
    144 
    145     let dir = tempfile::tempdir().unwrap();
    146     let path = dir.path().join("identity.txt");
    147     std::fs::write(&path, secret).unwrap();
    148 
    149     let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap();
    150     assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex);
    151 }
    152 
    153 #[test]
    154 fn load_from_binary_file() {
    155     let identity = fixture_identity(FIXTURE_ALICE);
    156     let secret = identity.secret_key_bytes();
    157 
    158     let dir = tempfile::tempdir().unwrap();
    159     let path = dir.path().join("identity.key");
    160     std::fs::write(&path, secret).unwrap();
    161 
    162     let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap();
    163     assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex);
    164 }
    165 
    166 #[test]
    167 fn load_or_generate_missing_disallowed() {
    168     let dir = tempfile::tempdir().unwrap();
    169     let path = dir.path().join("identity.json");
    170 
    171     let err = RadrootsIdentity::load_or_generate(Some(&path), false).unwrap_err();
    172     assert!(matches!(err, IdentityError::GenerationNotAllowed(p) if p == path));
    173 }
    174 
    175 #[test]
    176 fn load_or_generate_missing_allowed_creates_json() {
    177     let dir = tempfile::tempdir().unwrap();
    178     let path = dir.path().join("identity.json");
    179 
    180     let identity = RadrootsIdentity::load_or_generate(Some(&path), true).unwrap();
    181     assert!(path.exists());
    182 
    183     let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap();
    184     assert_eq!(loaded.public_key(), identity.public_key());
    185 }
    186 
    187 #[test]
    188 fn load_from_json_file_public_key_npub() {
    189     let identity = fixture_identity(FIXTURE_ALICE);
    190     let mut file = identity.to_file();
    191     file.public_key = Some(identity.public_key_npub());
    192     let json = serde_json::to_string(&file).unwrap();
    193 
    194     let dir = tempfile::tempdir().unwrap();
    195     let path = dir.path().join("identity.json");
    196     std::fs::write(&path, json).unwrap();
    197 
    198     let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap();
    199     assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex);
    200 }
    201 
    202 #[test]
    203 fn load_from_json_file_public_key_mismatch() {
    204     let identity = fixture_identity(FIXTURE_ALICE);
    205     let mut file = identity.to_file();
    206     file.public_key = Some(FIXTURE_BOB.public_key_hex.to_string());
    207     let json = serde_json::to_string(&file).unwrap();
    208 
    209     let dir = tempfile::tempdir().unwrap();
    210     let path = dir.path().join("identity.json");
    211     std::fs::write(&path, json).unwrap();
    212 
    213     let err = RadrootsIdentity::load_from_path_auto(&path).unwrap_err();
    214     assert!(matches!(err, IdentityError::PublicKeyMismatch));
    215 }
    216 
    217 #[test]
    218 fn identity_id_matches_public_key_hex() {
    219     let identity = fixture_identity(FIXTURE_ALICE);
    220 
    221     let id = identity.id();
    222     assert_eq!(id.as_str(), FIXTURE_ALICE.public_key_hex);
    223 }
    224 
    225 #[test]
    226 fn identity_id_parses_hex_and_npub() {
    227     let from_hex = RadrootsIdentityId::parse(FIXTURE_ALICE.public_key_hex).unwrap();
    228     let from_npub = RadrootsIdentityId::parse(FIXTURE_ALICE.npub).unwrap();
    229     assert_eq!(from_hex.as_str(), FIXTURE_ALICE.public_key_hex);
    230     assert_eq!(from_npub.as_str(), FIXTURE_ALICE.public_key_hex);
    231 }
    232 
    233 #[test]
    234 fn to_public_projection_excludes_secret_key_fields() {
    235     let identity = fixture_identity(FIXTURE_ALICE);
    236     let public = identity.to_public();
    237 
    238     assert_eq!(public.id.as_str(), FIXTURE_ALICE.public_key_hex);
    239     assert_eq!(public.public_key_hex, FIXTURE_ALICE.public_key_hex);
    240     assert_eq!(public.public_key_npub, FIXTURE_ALICE.npub);
    241     assert!(public.profile.is_none());
    242 
    243     let json = serde_json::to_string(&public).unwrap();
    244     assert!(!json.contains("secret_key"));
    245     assert!(!json.contains(&identity.secret_key_hex()));
    246 }
    247 
    248 #[test]
    249 fn identity_id_trait_paths_and_string_conversions() {
    250     let public_key = fixture_identity(FIXTURE_ALICE).public_key();
    251     let public_key_hex = FIXTURE_ALICE.public_key_hex.to_string();
    252 
    253     let from_impl = RadrootsIdentityId::from(public_key);
    254     assert_eq!(from_impl.as_ref(), public_key_hex);
    255 
    256     let from_try = RadrootsIdentityId::try_from(public_key_hex.as_str()).unwrap();
    257     assert_eq!(from_try.to_string(), public_key_hex);
    258     assert_eq!(from_try.clone().into_string(), public_key_hex);
    259 }
    260 
    261 #[test]
    262 fn identity_profile_state_mutation_paths() {
    263     let mut identity = RadrootsIdentity::with_profile(
    264         fixture_keys(FIXTURE_ALICE),
    265         RadrootsIdentityProfile::default(),
    266     );
    267     assert!(identity.profile().is_none());
    268 
    269     identity.set_profile(RadrootsIdentityProfile::default());
    270     assert!(identity.profile().is_none());
    271 
    272     let profile = profile_with_identifier("radroots-user");
    273     identity.set_profile(profile.clone());
    274     assert!(identity.profile().is_some());
    275 
    276     let profile_mut = identity.profile_mut().unwrap();
    277     profile_mut.identifier = Some("radroots-user-updated".to_string());
    278     assert_eq!(
    279         identity.profile().and_then(|p| p.identifier.as_deref()),
    280         Some("radroots-user-updated")
    281     );
    282 
    283     let public = identity.to_public();
    284     assert!(public.profile.is_some());
    285 
    286     identity.clear_profile();
    287     assert!(identity.profile().is_none());
    288 
    289     let public_without_profile = RadrootsIdentityPublic::new(identity.public_key())
    290         .with_profile(RadrootsIdentityProfile::default());
    291     assert!(public_without_profile.profile.is_none());
    292 
    293     let public_with_profile =
    294         RadrootsIdentityPublic::new(identity.public_key()).with_profile(profile);
    295     assert!(public_with_profile.profile.is_some());
    296 }
    297 
    298 #[test]
    299 fn identity_accessor_paths_and_secret_formats() {
    300     let identity = fixture_identity(FIXTURE_ALICE);
    301 
    302     assert_eq!(
    303         identity.keys().public_key().to_hex(),
    304         FIXTURE_ALICE.public_key_hex
    305     );
    306     assert_eq!(identity.public_key().to_hex(), FIXTURE_ALICE.public_key_hex);
    307     assert_eq!(identity.npub(), FIXTURE_ALICE.npub);
    308     assert_eq!(identity.nsec(), FIXTURE_ALICE.nsec);
    309 
    310     let file_nsec = identity.to_file_with_secret_format(RadrootsIdentitySecretKeyFormat::Nsec);
    311     assert_eq!(file_nsec.secret_key, FIXTURE_ALICE.nsec);
    312 
    313     let from_keys: RadrootsIdentity = fixture_keys(FIXTURE_ALICE).into();
    314     let roundtrip_keys = from_keys.clone().into_keys();
    315     assert_eq!(
    316         roundtrip_keys.public_key().to_hex(),
    317         FIXTURE_ALICE.public_key_hex
    318     );
    319 }
    320 
    321 #[cfg(feature = "nip49")]
    322 #[test]
    323 fn encrypted_secret_key_round_trips_to_identity() {
    324     let identity = fixture_identity(FIXTURE_ALICE);
    325     let encrypted = identity
    326         .encrypt_secret_key_ncryptsec("fixture-password")
    327         .unwrap();
    328     assert!(encrypted.starts_with("ncryptsec1"));
    329 
    330     let decrypted =
    331         RadrootsIdentity::from_encrypted_secret_key_str(&encrypted, "fixture-password").unwrap();
    332     assert_eq!(decrypted.public_key(), identity.public_key());
    333 }
    334 
    335 #[cfg(feature = "nip49")]
    336 #[test]
    337 fn encrypted_secret_key_options_propagate_to_output() {
    338     use nostr::nips::nip19::FromBech32;
    339     use nostr::nips::nip49::{EncryptedSecretKey, KeySecurity};
    340 
    341     let identity = fixture_identity(FIXTURE_ALICE);
    342     let encrypted = identity
    343         .encrypt_secret_key_ncryptsec_with_options(
    344             "fixture-password",
    345             RadrootsIdentityEncryptedSecretKeyOptions {
    346                 log_n: 15,
    347                 key_security: RadrootsIdentityEncryptedSecretKeySecurity::Medium,
    348             },
    349         )
    350         .unwrap();
    351     let parsed = EncryptedSecretKey::from_bech32(&encrypted).unwrap();
    352     assert_eq!(parsed.log_n(), 15);
    353     assert_eq!(parsed.key_security(), KeySecurity::Medium);
    354 }
    355 
    356 #[cfg(feature = "nip49")]
    357 #[test]
    358 fn encrypted_secret_key_weak_security_and_invalid_log_n_paths() {
    359     use nostr::nips::nip49::KeySecurity;
    360 
    361     assert_eq!(
    362         KeySecurity::from(RadrootsIdentityEncryptedSecretKeySecurity::Weak),
    363         KeySecurity::Weak
    364     );
    365 
    366     let identity = fixture_identity(FIXTURE_ALICE);
    367     let err = identity
    368         .encrypt_secret_key_ncryptsec_with_options(
    369             "fixture-password",
    370             RadrootsIdentityEncryptedSecretKeyOptions {
    371                 log_n: 255,
    372                 key_security: RadrootsIdentityEncryptedSecretKeySecurity::Weak,
    373             },
    374         )
    375         .unwrap_err();
    376     assert!(matches!(err, IdentityError::EncryptSecretKey(_)));
    377 }
    378 
    379 #[cfg(feature = "nip49")]
    380 #[test]
    381 fn encrypted_secret_key_rejects_invalid_and_wrong_password_inputs() {
    382     let identity = fixture_identity(FIXTURE_ALICE);
    383     let encrypted = identity
    384         .encrypt_secret_key_ncryptsec("fixture-password")
    385         .unwrap();
    386 
    387     let invalid =
    388         RadrootsIdentity::from_encrypted_secret_key_str("not-an-encrypted-secret", "password")
    389             .unwrap_err();
    390     assert!(matches!(
    391         invalid,
    392         IdentityError::InvalidEncryptedSecretKey(_)
    393     ));
    394 
    395     let wrong_password =
    396         RadrootsIdentity::from_encrypted_secret_key_str(&encrypted, "wrong-password").unwrap_err();
    397     assert!(matches!(
    398         wrong_password,
    399         IdentityError::DecryptEncryptedSecretKey(_)
    400     ));
    401 }
    402 
    403 #[cfg(feature = "nip49")]
    404 #[test]
    405 fn load_from_path_auto_rejects_nip49_export_format() {
    406     let identity = fixture_identity(FIXTURE_ALICE);
    407     let encrypted = identity
    408         .encrypt_secret_key_ncryptsec("fixture-password")
    409         .unwrap();
    410 
    411     let dir = tempfile::tempdir().unwrap();
    412     let path = dir.path().join("identity.ncryptsec");
    413     std::fs::write(&path, encrypted).unwrap();
    414 
    415     let err = RadrootsIdentity::load_from_path_auto(&path).unwrap_err();
    416     assert!(matches!(err, IdentityError::InvalidSecretKey(_)));
    417 }
    418 
    419 #[test]
    420 fn parse_failures_cover_public_key_errors() {
    421     let err_empty = RadrootsIdentityId::parse("   ").unwrap_err();
    422     assert!(matches!(err_empty, IdentityError::InvalidPublicKey(_)));
    423 
    424     let err_invalid = RadrootsIdentityId::parse("invalid-public-key-value").unwrap_err();
    425     assert!(matches!(err_invalid, IdentityError::InvalidPublicKey(_)));
    426 }
    427 
    428 #[test]
    429 fn from_secret_key_bytes_rejects_wrong_length() {
    430     let err = RadrootsIdentity::from_secret_key_bytes(&[1, 2, 3]).unwrap_err();
    431     assert!(matches!(err, IdentityError::InvalidIdentityFormat));
    432 }
    433 
    434 #[test]
    435 fn from_secret_key_str_rejects_invalid_secret() {
    436     let err = RadrootsIdentity::from_secret_key_str("not-a-secret-key").unwrap_err();
    437     assert!(matches!(err, IdentityError::InvalidSecretKey(_)));
    438 }
    439 
    440 #[test]
    441 fn from_secret_key_bytes_rejects_invalid_scalar() {
    442     let err = RadrootsIdentity::from_secret_key_bytes(&[0u8; 32]).unwrap_err();
    443     assert!(matches!(err, IdentityError::InvalidSecretKey(_)));
    444 }
    445 
    446 #[test]
    447 fn load_from_path_reports_not_found_and_read_errors() {
    448     let dir = tempfile::tempdir().unwrap();
    449     let missing = dir.path().join("missing-identity.json");
    450     let not_found = RadrootsIdentity::load_from_path_auto(&missing).unwrap_err();
    451     assert!(matches!(not_found, IdentityError::NotFound(path) if path == missing));
    452 
    453     let read_error = RadrootsIdentity::load_from_path_auto(dir.path()).unwrap_err();
    454     assert!(matches!(read_error, IdentityError::Read(path, _) if path == dir.path()));
    455 }
    456 
    457 #[test]
    458 fn load_from_path_rejects_invalid_payloads() {
    459     let dir = tempfile::tempdir().unwrap();
    460 
    461     let blank_path = dir.path().join("identity-blank.txt");
    462     std::fs::write(&blank_path, "   \n\t ").unwrap();
    463     let blank_err = RadrootsIdentity::load_from_path_auto(&blank_path).unwrap_err();
    464     assert!(matches!(blank_err, IdentityError::InvalidIdentityFormat));
    465 
    466     let invalid_utf8_path = dir.path().join("identity-invalid-utf8.bin");
    467     std::fs::write(&invalid_utf8_path, [0xff, 0xfe, 0xfd]).unwrap();
    468     let utf8_err = RadrootsIdentity::load_from_path_auto(&invalid_utf8_path).unwrap_err();
    469     assert!(matches!(utf8_err, IdentityError::InvalidIdentityFormat));
    470 
    471     let invalid_json_path = dir.path().join("identity-invalid-json.json");
    472     std::fs::write(&invalid_json_path, "{invalid").unwrap();
    473     let json_err = RadrootsIdentity::load_from_path_auto(&invalid_json_path).unwrap_err();
    474     assert!(matches!(json_err, IdentityError::InvalidJson(_)));
    475 }
    476 
    477 #[test]
    478 fn load_from_json_file_without_public_key_succeeds() {
    479     let identity = fixture_identity(FIXTURE_ALICE);
    480     let mut file = identity.to_file();
    481     file.public_key = None;
    482     let json = serde_json::to_string(&file).unwrap();
    483 
    484     let dir = tempfile::tempdir().unwrap();
    485     let path = dir.path().join("identity.json");
    486     std::fs::write(&path, json).unwrap();
    487 
    488     let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap();
    489     assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex);
    490 }
    491 
    492 #[test]
    493 fn load_from_json_file_rejects_invalid_secret_key_string() {
    494     let payload = serde_json::json!({
    495         "secret_key": "invalid-secret-key",
    496         "public_key": null,
    497     });
    498     let dir = tempfile::tempdir().unwrap();
    499     let path = dir.path().join("identity.json");
    500     std::fs::write(&path, payload.to_string()).unwrap();
    501 
    502     let err = RadrootsIdentity::load_from_path_auto(&path).unwrap_err();
    503     assert!(matches!(err, IdentityError::InvalidSecretKey(_)));
    504 }
    505 
    506 #[test]
    507 fn load_from_json_file_rejects_invalid_public_key_value() {
    508     let identity = fixture_identity(FIXTURE_ALICE);
    509     let mut file = identity.to_file();
    510     file.public_key = Some("invalid-public-key".to_string());
    511     let json = serde_json::to_string(&file).unwrap();
    512 
    513     let dir = tempfile::tempdir().unwrap();
    514     let path = dir.path().join("identity.json");
    515     std::fs::write(&path, json).unwrap();
    516 
    517     let err = RadrootsIdentity::load_from_path_auto(&path).unwrap_err();
    518     assert!(matches!(err, IdentityError::InvalidPublicKey(_)));
    519 }
    520 
    521 #[test]
    522 fn save_json_rejects_directory_target() {
    523     let identity = fixture_identity(FIXTURE_ALICE);
    524     let dir = tempfile::tempdir().unwrap();
    525     let err = identity.save_json(dir.path()).unwrap_err();
    526     assert!(matches!(err, IdentityError::Store(_)));
    527 }
    528 
    529 #[cfg(unix)]
    530 #[test]
    531 fn save_json_reports_write_failure_on_read_only_directory() {
    532     use std::os::unix::fs::PermissionsExt;
    533 
    534     let identity = fixture_identity(FIXTURE_ALICE);
    535     let dir = tempfile::tempdir().unwrap();
    536     let path = dir.path().join("identity.json");
    537     identity.save_json(path.as_path()).unwrap();
    538 
    539     std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
    540     let err_path = identity.save_json(path.as_path()).unwrap_err();
    541     assert!(matches!(err_path, IdentityError::Store(_)));
    542     let err_path_buf = identity.save_json(&path).unwrap_err();
    543     assert!(matches!(err_path_buf, IdentityError::Store(_)));
    544     std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
    545 }
    546 
    547 #[cfg(unix)]
    548 #[test]
    549 fn load_or_generate_reports_save_failure_when_parent_not_writable() {
    550     use std::os::unix::fs::PermissionsExt;
    551 
    552     let dir = tempfile::tempdir().unwrap();
    553     let parent = dir.path().join("readonly");
    554     std::fs::create_dir(&parent).unwrap();
    555     std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o500)).unwrap();
    556 
    557     let path = parent.join("identity.json");
    558     let err = RadrootsIdentity::load_or_generate::<&std::path::Path>(Some(path.as_path()), true)
    559         .unwrap_err();
    560     assert!(matches!(err, IdentityError::Store(_)));
    561     let err_path_buf = RadrootsIdentity::load_or_generate(Some(&path), true).unwrap_err();
    562     assert!(matches!(err_path_buf, IdentityError::Store(_)));
    563     std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap();
    564 }
    565 
    566 #[test]
    567 fn load_or_generate_uses_default_path_when_missing() {
    568     let resolver = RadrootsPathResolver::new(
    569         RadrootsPlatform::Linux,
    570         RadrootsHostEnvironment {
    571             home_dir: Some(PathBuf::from("/home/treesap")),
    572             ..RadrootsHostEnvironment::default()
    573         },
    574     );
    575     let default_path = RadrootsIdentity::default_path_for(
    576         &resolver,
    577         RadrootsPathProfile::InteractiveUser,
    578         &RadrootsPathOverrides::default(),
    579     )
    580     .unwrap();
    581 
    582     let denied = RadrootsIdentity::load_or_generate::<&std::path::Path>(Some(&default_path), false)
    583         .unwrap_err();
    584     assert!(matches!(denied, IdentityError::GenerationNotAllowed(path) if path == default_path));
    585     assert_eq!(
    586         default_path.file_name().and_then(std::ffi::OsStr::to_str),
    587         Some(DEFAULT_IDENTITY_PATH)
    588     );
    589     assert_eq!(
    590         default_path,
    591         PathBuf::from("/home/treesap/.radroots/secrets/shared/identities/default.json")
    592     );
    593 }
    594 
    595 #[test]
    596 fn default_path_matches_current_resolver_default_path() {
    597     let expected = RadrootsIdentity::default_path_for(
    598         &RadrootsPathResolver::current(),
    599         RadrootsPathProfile::InteractiveUser,
    600         &RadrootsPathOverrides::default(),
    601     )
    602     .unwrap();
    603 
    604     assert_eq!(RadrootsIdentity::default_path().unwrap(), expected);
    605 }
    606 
    607 #[test]
    608 fn default_path_for_reports_missing_home_dir() {
    609     let resolver =
    610         RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default());
    611     let err = RadrootsIdentity::default_path_for(
    612         &resolver,
    613         RadrootsPathProfile::InteractiveUser,
    614         &RadrootsPathOverrides::default(),
    615     )
    616     .unwrap_err();
    617     assert!(matches!(err, IdentityError::Paths(_)));
    618 }
    619 
    620 #[test]
    621 fn load_or_generate_without_explicit_path_propagates_default_path_errors() {
    622     let _lock = home_env_lock().lock().unwrap();
    623     let _guard = EnvVarGuard::remove("HOME");
    624 
    625     let err = RadrootsIdentity::load_or_generate::<&std::path::Path>(None, false).unwrap_err();
    626     assert!(matches!(err, IdentityError::Paths(_)));
    627 }
    628 
    629 #[test]
    630 fn load_or_generate_creates_at_explicit_default_path() {
    631     let dir = tempfile::tempdir().unwrap();
    632     let default_path = dir.path().join(DEFAULT_IDENTITY_PATH);
    633     let generated =
    634         RadrootsIdentity::load_or_generate::<&std::path::Path>(Some(&default_path), true).unwrap();
    635     assert!(default_path.exists());
    636 
    637     let loaded = RadrootsIdentity::load_from_path_auto(&default_path).unwrap();
    638     assert_eq!(generated.public_key(), loaded.public_key());
    639 }
    640 
    641 #[test]
    642 fn load_or_generate_prefers_existing_path() {
    643     let identity = fixture_identity(FIXTURE_ALICE);
    644     let payload = serde_json::to_string(&identity.to_file()).unwrap();
    645 
    646     let dir = tempfile::tempdir().unwrap();
    647     let path = dir.path().join("identity.json");
    648     std::fs::write(&path, payload).unwrap();
    649 
    650     let loaded = RadrootsIdentity::load_or_generate(Some(&path), false).unwrap();
    651     assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex);
    652 }
    653 
    654 #[test]
    655 fn path_ref_variants_cover_success_paths() {
    656     let identity = fixture_identity(FIXTURE_ALICE);
    657     let dir = tempfile::tempdir().unwrap();
    658 
    659     let saved_path = dir.path().join("saved.json");
    660     identity.save_json(saved_path.as_path()).unwrap();
    661     let loaded = RadrootsIdentity::load_from_path_auto(saved_path.as_path()).unwrap();
    662     assert_eq!(loaded.public_key(), identity.public_key());
    663 
    664     let generated_path = dir.path().join("generated.json");
    665     let generated =
    666         RadrootsIdentity::load_or_generate(Some(generated_path.as_path()), true).unwrap();
    667     assert!(generated_path.exists());
    668     let roundtrip = RadrootsIdentity::load_from_path_auto(generated_path.as_path()).unwrap();
    669     assert_eq!(generated.public_key(), roundtrip.public_key());
    670 }
    671 
    672 #[test]
    673 fn generate_with_profile_retains_profile() {
    674     let profile = profile_with_identifier("runtime-user");
    675     let identity = RadrootsIdentity::generate_with_profile(profile);
    676     assert_eq!(
    677         identity.profile().and_then(|p| p.identifier.as_deref()),
    678         Some("runtime-user")
    679     );
    680 }
    681 
    682 #[test]
    683 fn identity_profile_is_empty_checks_metadata_and_application_handler() {
    684     let profile_with_metadata = RadrootsIdentityProfile {
    685         metadata: Some(sample_event("metadata")),
    686         ..Default::default()
    687     };
    688     assert!(!profile_with_metadata.is_empty());
    689 
    690     let profile_with_handler = RadrootsIdentityProfile {
    691         application_handler: Some(sample_event("handler")),
    692         ..Default::default()
    693     };
    694     assert!(!profile_with_handler.is_empty());
    695 }
    696 
    697 #[test]
    698 fn identity_error_display_variants_are_exercised() {
    699     let missing_path = PathBuf::from("/tmp/missing-identity.json");
    700     assert_eq!(
    701         IdentityError::NotFound(missing_path.clone()).to_string(),
    702         format!("identity file missing at {}", missing_path.display())
    703     );
    704     assert_eq!(
    705         IdentityError::GenerationNotAllowed(missing_path.clone()).to_string(),
    706         format!(
    707             "identity file missing at {} and generation is not permitted (pass --allow-generate-identity)",
    708             missing_path.display()
    709         )
    710     );
    711     assert!(
    712         IdentityError::Read(missing_path.clone(), std::io::Error::other("boom"))
    713             .to_string()
    714             .contains("failed to read identity file")
    715     );
    716 
    717     let json_err = serde_json::from_str::<serde_json::Value>("{").unwrap_err();
    718     assert!(
    719         IdentityError::InvalidJson(json_err)
    720             .to_string()
    721             .contains("invalid identity JSON")
    722     );
    723 
    724     let secret_err = nostr::Keys::parse("not-a-secret-key").unwrap_err();
    725     assert!(
    726         IdentityError::InvalidSecretKey(secret_err)
    727             .to_string()
    728             .contains("invalid secret key")
    729     );
    730 
    731     #[cfg(feature = "nip49")]
    732     {
    733         assert_eq!(
    734             IdentityError::EncryptSecretKey("encrypt failed".into()).to_string(),
    735             "failed to encrypt secret key: encrypt failed"
    736         );
    737         assert_eq!(
    738             IdentityError::InvalidEncryptedSecretKey("bad payload".into()).to_string(),
    739             "invalid encrypted secret key: bad payload"
    740         );
    741         assert_eq!(
    742             IdentityError::DecryptEncryptedSecretKey("bad password".into()).to_string(),
    743             "failed to decrypt encrypted secret key: bad password"
    744         );
    745     }
    746 
    747     assert_eq!(
    748         IdentityError::InvalidPublicKey("bad-pubkey".into()).to_string(),
    749         "invalid public key: bad-pubkey"
    750     );
    751     assert_eq!(
    752         IdentityError::PublicKeyMismatch.to_string(),
    753         "public key does not match secret key"
    754     );
    755     assert_eq!(
    756         IdentityError::InvalidIdentityFormat.to_string(),
    757         "unsupported identity file format"
    758     );
    759 
    760     #[cfg(all(feature = "std", feature = "json-file"))]
    761     {
    762         let store_err = fixture_identity(FIXTURE_ALICE)
    763             .save_json(tempfile::tempdir().unwrap().path())
    764             .unwrap_err();
    765         assert!(!store_err.to_string().is_empty());
    766     }
    767 
    768     let paths_err = IdentityError::from(
    769         radroots_runtime_paths::RadrootsRuntimePathsError::MissingHomeDir {
    770             platform: RadrootsPlatform::Linux,
    771         },
    772     );
    773     assert_eq!(
    774         paths_err.to_string(),
    775         "interactive_user on linux requires a home directory"
    776     );
    777 }
    778 
    779 #[cfg(feature = "secrecy")]
    780 #[test]
    781 fn secret_key_hex_secret_returns_secret_string() {
    782     use secrecy::ExposeSecret;
    783 
    784     let identity = fixture_identity(FIXTURE_ALICE);
    785     let secret = identity.secret_key_hex_secret();
    786     assert_eq!(secret.expose_secret(), &identity.secret_key_hex());
    787 }
    788 
    789 #[cfg(feature = "zeroize")]
    790 #[test]
    791 fn secret_key_zeroizing_bytes_matches_raw_secret() {
    792     let identity = fixture_identity(FIXTURE_ALICE);
    793     let raw = identity.secret_key_bytes();
    794     let protected = identity.secret_key_bytes_zeroizing();
    795     assert_eq!(&*protected, &raw);
    796 }
    797 
    798 #[test]
    799 fn encrypted_identity_storage_public_api_round_trips_and_reports_errors() {
    800     let temp = tempfile::tempdir().unwrap();
    801     let path = temp.path().join("identity.enc.json");
    802     let identity = fixture_identity(FIXTURE_ALICE);
    803 
    804     let default_file = RadrootsEncryptedIdentityFile::new(path.as_path());
    805     assert_eq!(default_file.path(), path.as_path());
    806     assert_eq!(
    807         default_file.key_slot(),
    808         RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT
    809     );
    810     assert_eq!(
    811         default_file.wrapping_key_path(),
    812         encrypted_identity_wrapping_key_path(path.as_path())
    813     );
    814 
    815     let custom_file =
    816         RadrootsEncryptedIdentityFile::with_key_slot(path.as_path(), "field_identity");
    817     assert_eq!(custom_file.key_slot(), "field_identity");
    818 
    819     store_encrypted_identity(path.as_path(), &identity).unwrap();
    820     rotate_encrypted_identity(path.as_path()).unwrap();
    821     let loaded = load_encrypted_identity(path.as_path()).unwrap();
    822     assert_eq!(loaded.public_key(), identity.public_key());
    823 
    824     store_encrypted_identity_with_key_slot(path.as_path(), "field_identity", &identity).unwrap();
    825     rotate_encrypted_identity_with_key_slot(path.as_path(), "field_identity").unwrap();
    826     let loaded = load_encrypted_identity_with_key_slot(path.as_path(), "field_identity").unwrap();
    827     assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex());
    828 
    829     let path_buf_api = temp.path().join("identity-pathbuf.enc.json");
    830     let path_buf_ref = &path_buf_api;
    831     let path_buf_file = RadrootsEncryptedIdentityFile::new(path_buf_ref);
    832     assert_eq!(path_buf_file.path(), path_buf_api.as_path());
    833     let path_buf_file =
    834         RadrootsEncryptedIdentityFile::with_key_slot(path_buf_ref, "path_buf_identity");
    835     assert_eq!(path_buf_file.key_slot(), "path_buf_identity");
    836     store_encrypted_identity(path_buf_ref, &identity).unwrap();
    837     rotate_encrypted_identity(path_buf_ref).unwrap();
    838     let loaded = load_encrypted_identity(path_buf_ref).unwrap();
    839     assert_eq!(loaded.public_key(), identity.public_key());
    840     store_encrypted_identity_with_key_slot(path_buf_ref, "path_buf_identity", &identity).unwrap();
    841     rotate_encrypted_identity_with_key_slot(path_buf_ref, "path_buf_identity").unwrap();
    842     let loaded = load_encrypted_identity_with_key_slot(path_buf_ref, "path_buf_identity").unwrap();
    843     assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex());
    844 
    845     let missing = temp.path().join("missing.enc.json");
    846     let missing_error = load_encrypted_identity(missing.as_path()).unwrap_err();
    847     assert!(matches!(missing_error, IdentityError::NotFound(error_path) if error_path == missing));
    848 
    849     let read_error = load_encrypted_identity(temp.path()).unwrap_err();
    850     assert!(matches!(read_error, IdentityError::Read(error_path, _) if error_path == temp.path()));
    851 
    852     let invalid = temp.path().join("invalid.enc.json");
    853     std::fs::write(&invalid, b"not-json").unwrap();
    854     let decode_error = load_encrypted_identity(invalid.as_path()).unwrap_err();
    855     assert!(matches!(
    856         decode_error,
    857         IdentityError::ProtectedStorage { path: error_path, message }
    858             if error_path == invalid && message.contains("decode encrypted identity")
    859     ));
    860 
    861     let invalid_plaintext = temp.path().join("invalid-plaintext.enc.json");
    862     let key_source = RadrootsProtectedFileKeySource::from_sidecar_suffix(
    863         invalid_plaintext.as_path(),
    864         RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX,
    865     );
    866     let envelope = RadrootsProtectedStoreEnvelope::seal_with_wrapped_key(
    867         &key_source,
    868         RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT,
    869         b"not identity json",
    870     )
    871     .unwrap();
    872     std::fs::write(&invalid_plaintext, envelope.encode_json().unwrap()).unwrap();
    873     let invalid_plaintext_error = load_encrypted_identity(invalid_plaintext.as_path()).unwrap_err();
    874     assert!(matches!(
    875         invalid_plaintext_error,
    876         IdentityError::InvalidJson(_)
    877     ));
    878 
    879     std::fs::write(
    880         encrypted_identity_wrapping_key_path(path.as_path()),
    881         b"short",
    882     )
    883     .unwrap();
    884     let open_error = load_encrypted_identity(path.as_path()).unwrap_err();
    885     assert!(matches!(
    886         open_error,
    887         IdentityError::ProtectedStorage { path: error_path, message }
    888             if error_path == path && message.contains("open encrypted identity")
    889     ));
    890 }
    891 
    892 #[test]
    893 fn encrypted_identity_storage_public_api_reports_store_errors() {
    894     let temp = tempfile::tempdir().unwrap();
    895     let identity = fixture_identity(FIXTURE_ALICE);
    896 
    897     let blocked_parent = temp.path().join("blocked-parent");
    898     std::fs::write(&blocked_parent, b"not-a-directory").unwrap();
    899     let create_path = blocked_parent.join("identity.enc.json");
    900     let create_error = store_encrypted_identity(create_path.as_path(), &identity).unwrap_err();
    901     assert!(matches!(create_error, IdentityError::CreateDir(path, _) if path == blocked_parent));
    902 
    903     let directory_path = temp.path().join("identity-as-directory.enc.json");
    904     std::fs::create_dir(&directory_path).unwrap();
    905     let write_error = store_encrypted_identity(directory_path.as_path(), &identity).unwrap_err();
    906     assert!(matches!(write_error, IdentityError::Write(path, _) if path == directory_path));
    907 
    908     let sealed_path = temp.path().join("seal-error.enc.json");
    909     std::fs::create_dir(encrypted_identity_wrapping_key_path(sealed_path.as_path())).unwrap();
    910     let seal_error = store_encrypted_identity(sealed_path.as_path(), &identity).unwrap_err();
    911     assert!(matches!(
    912         seal_error,
    913         IdentityError::ProtectedStorage { path, message }
    914             if path == sealed_path && message.contains("seal encrypted identity")
    915     ));
    916 }
    917 
    918 #[cfg(unix)]
    919 #[test]
    920 fn encrypted_identity_storage_public_api_restores_key_after_rotation_failure() {
    921     use std::os::unix::fs::PermissionsExt;
    922 
    923     let temp = tempfile::tempdir().unwrap();
    924     let path = temp.path().join("identity.enc.json");
    925     let identity = fixture_identity(FIXTURE_ALICE);
    926 
    927     store_encrypted_identity(path.as_path(), &identity).unwrap();
    928     let key_path = encrypted_identity_wrapping_key_path(path.as_path());
    929     let key_before = std::fs::read(&key_path).unwrap();
    930 
    931     std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap();
    932     let error = rotate_encrypted_identity(path.as_path()).unwrap_err();
    933     std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
    934 
    935     assert!(matches!(error, IdentityError::Write(error_path, _) if error_path == path));
    936     assert_eq!(std::fs::read(&key_path).unwrap(), key_before);
    937     let loaded = load_encrypted_identity(path.as_path()).unwrap();
    938     assert_eq!(loaded.public_key(), identity.public_key());
    939 }
    940 
    941 #[test]
    942 fn identity_profile_storage_public_api_reports_errors_and_private_fallback() {
    943     let temp = tempfile::tempdir().unwrap();
    944     let identity = fixture_identity(FIXTURE_ALICE);
    945 
    946     let path = temp.path().join("profile.json");
    947     store_identity_profile(path.as_path(), &identity).unwrap();
    948     let loaded = load_identity_profile(path.as_path()).unwrap();
    949     assert_eq!(loaded.id, identity.id());
    950 
    951     let path_buf_profile = temp.path().join("profile-pathbuf.json");
    952     store_identity_profile(&path_buf_profile, &identity).unwrap();
    953     let loaded = load_identity_profile(&path_buf_profile).unwrap();
    954     assert_eq!(loaded.id, identity.id());
    955 
    956     let blocked_parent = temp.path().join("blocked-profile-parent");
    957     std::fs::write(&blocked_parent, b"not-a-directory").unwrap();
    958     let create_path = blocked_parent.join("profile.json");
    959     let create_error = store_identity_profile(create_path.as_path(), &identity).unwrap_err();
    960     assert!(matches!(create_error, IdentityError::CreateDir(path, _) if path == blocked_parent));
    961 
    962     let directory_path = temp.path().join("profile-as-directory.json");
    963     std::fs::create_dir(&directory_path).unwrap();
    964     let write_error = store_identity_profile(directory_path.as_path(), &identity).unwrap_err();
    965     assert!(matches!(write_error, IdentityError::Write(path, _) if path == directory_path));
    966 
    967     let missing = temp.path().join("missing-profile.json");
    968     let missing_error = load_identity_profile(missing.as_path()).unwrap_err();
    969     assert!(matches!(missing_error, IdentityError::NotFound(path) if path == missing));
    970 
    971     let read_error = load_identity_profile(temp.path()).unwrap_err();
    972     assert!(matches!(read_error, IdentityError::Read(path, _) if path == temp.path()));
    973 
    974     let private_profile = temp.path().join("private-profile.json");
    975     std::fs::write(
    976         &private_profile,
    977         serde_json::to_vec(&identity.to_file()).unwrap(),
    978     )
    979     .unwrap();
    980     let loaded = load_identity_profile(private_profile.as_path()).unwrap();
    981     assert_eq!(loaded.public_key_hex, FIXTURE_ALICE.public_key_hex);
    982 }
    983 
    984 #[test]
    985 fn storage_public_api_supports_parentless_relative_files() {
    986     let temp = tempfile::tempdir().unwrap();
    987     let previous = std::env::current_dir().unwrap();
    988     std::env::set_current_dir(temp.path()).unwrap();
    989 
    990     let identity = fixture_identity(FIXTURE_ALICE);
    991     let encrypted_path = std::path::Path::new("identity.enc.json");
    992     store_encrypted_identity(encrypted_path, &identity).unwrap();
    993     let loaded = load_encrypted_identity(encrypted_path).unwrap();
    994     assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex());
    995 
    996     let profile_path = std::path::Path::new("profile.json");
    997     store_identity_profile(profile_path, &identity).unwrap();
    998     let loaded = load_identity_profile(profile_path).unwrap();
    999     assert_eq!(loaded.id, identity.id());
   1000 
   1001     let empty_path = std::path::Path::new("");
   1002     let encrypted_error = store_encrypted_identity(empty_path, &identity).unwrap_err();
   1003     assert!(matches!(encrypted_error, IdentityError::Write(_, _)));
   1004     let profile_error = store_identity_profile(empty_path, &identity).unwrap_err();
   1005     assert!(matches!(profile_error, IdentityError::Write(_, _)));
   1006 
   1007     std::env::set_current_dir(previous).unwrap();
   1008 }