myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

persistence_cli.rs (17951B)


      1 use std::path::Path;
      2 use std::process::Command;
      3 
      4 use myc::{
      5     MycConfig, MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord,
      6     MycRuntime, MycRuntimeAuditBackend, MycSignerStateBackend,
      7 };
      8 use nostr::PublicKey;
      9 use radroots_identity::RadrootsIdentity;
     10 use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionDraft;
     11 use serde_json::Value;
     12 
     13 fn write_identity(path: &Path, secret_key: &str) {
     14     let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity");
     15     myc::identity_files::store_encrypted_identity(path, &identity).expect("save identity");
     16 }
     17 
     18 fn copy_dir_recursive(source: &Path, destination: &Path) {
     19     std::fs::create_dir_all(destination).expect("create copied dir");
     20     for entry in std::fs::read_dir(source).expect("read copied dir source") {
     21         let entry = entry.expect("dir entry");
     22         let source_path = entry.path();
     23         let destination_path = destination.join(entry.file_name());
     24         if source_path.is_dir() {
     25             copy_dir_recursive(&source_path, &destination_path);
     26         } else {
     27             std::fs::copy(&source_path, &destination_path).expect("copy file");
     28         }
     29     }
     30 }
     31 
     32 fn bootstrap_populated_json_runtime(temp: &tempfile::TempDir) -> (MycConfig, MycConfig) {
     33     let mut json_config = MycConfig::default();
     34     json_config.paths.state_dir = temp.path().join("state");
     35     json_config.paths.signer_identity_path = temp.path().join("signer.json");
     36     json_config.paths.user_identity_path = temp.path().join("user.json");
     37 
     38     write_identity(
     39         &json_config.paths.signer_identity_path,
     40         "1111111111111111111111111111111111111111111111111111111111111111",
     41     );
     42     write_identity(
     43         &json_config.paths.user_identity_path,
     44         "2222222222222222222222222222222222222222222222222222222222222222",
     45     );
     46 
     47     let runtime = MycRuntime::bootstrap(json_config.clone()).expect("json runtime");
     48     let manager = runtime.signer_manager().expect("manager");
     49     let connection = manager
     50         .register_connection(RadrootsNostrSignerConnectionDraft::new(
     51             PublicKey::from_hex("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798")
     52                 .expect("pubkey"),
     53             runtime.user_public_identity(),
     54         ))
     55         .expect("register connection");
     56     runtime.record_operation_audit(&MycOperationAuditRecord::new(
     57         MycOperationAuditKind::ListenerResponsePublish,
     58         MycOperationAuditOutcome::Succeeded,
     59         Some(&connection.connection_id),
     60         Some("request-1"),
     61         1,
     62         1,
     63         "publish succeeded",
     64     ));
     65 
     66     let mut sqlite_config = json_config.clone();
     67     sqlite_config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite;
     68     sqlite_config.persistence.runtime_audit_backend = MycRuntimeAuditBackend::Sqlite;
     69 
     70     (json_config, sqlite_config)
     71 }
     72 
     73 fn migrate_to_sqlite(temp: &tempfile::TempDir) -> MycConfig {
     74     let (_json_config, sqlite_config) = bootstrap_populated_json_runtime(temp);
     75     let env_path = temp.path().join("myc-sqlite.env");
     76     std::fs::write(
     77         &env_path,
     78         sqlite_config.to_env_string().expect("render sqlite env"),
     79     )
     80     .expect("write sqlite env");
     81 
     82     let output = Command::new(env!("CARGO_BIN_EXE_myc"))
     83         .arg("--env-file")
     84         .arg(&env_path)
     85         .arg("persistence")
     86         .arg("import-json-to-sqlite")
     87         .output()
     88         .expect("run import");
     89     assert!(output.status.success(), "{:?}", output);
     90 
     91     sqlite_config
     92 }
     93 
     94 fn write_env(path: &Path, config: &MycConfig) {
     95     std::fs::write(path, config.to_env_string().expect("render env")).expect("write env");
     96 }
     97 
     98 fn run_myc(env_path: &Path, args: &[&str]) -> std::process::Output {
     99     let mut command = Command::new(env!("CARGO_BIN_EXE_myc"));
    100     command.arg("--env-file").arg(env_path);
    101     for arg in args {
    102         command.arg(arg);
    103     }
    104     command.output().expect("run myc")
    105 }
    106 
    107 #[test]
    108 fn persistence_import_json_to_sqlite_cli_migrates_state_and_rejects_rerun() {
    109     let temp = tempfile::tempdir().expect("tempdir");
    110     let (_json_config, sqlite_config) = bootstrap_populated_json_runtime(&temp);
    111     let env_path = temp.path().join("myc-sqlite.env");
    112     std::fs::write(
    113         &env_path,
    114         sqlite_config.to_env_string().expect("render sqlite env"),
    115     )
    116     .expect("write env");
    117 
    118     let output = Command::new(env!("CARGO_BIN_EXE_myc"))
    119         .arg("--env-file")
    120         .arg(&env_path)
    121         .arg("persistence")
    122         .arg("import-json-to-sqlite")
    123         .output()
    124         .expect("run import");
    125 
    126     assert!(output.status.success(), "{:?}", output);
    127 
    128     let parsed: Value = serde_json::from_slice(&output.stdout).expect("import json");
    129     assert_eq!(parsed["signer_state"]["connection_count"], 1);
    130     assert_eq!(parsed["signer_state"]["request_audit_count"], 0);
    131     assert_eq!(parsed["runtime_audit"]["record_count"], 1);
    132     assert!(
    133         parsed["signer_state"]["destination_path"]
    134             .as_str()
    135             .expect("sqlite signer destination")
    136             .ends_with("signer-state.sqlite")
    137     );
    138     assert!(
    139         parsed["runtime_audit"]["destination_path"]
    140             .as_str()
    141             .expect("sqlite audit destination")
    142             .ends_with("operations.sqlite")
    143     );
    144 
    145     let sqlite_runtime = MycRuntime::bootstrap(sqlite_config.clone()).expect("sqlite runtime");
    146     assert_eq!(
    147         sqlite_runtime
    148             .signer_manager()
    149             .expect("sqlite manager")
    150             .list_connections()
    151             .expect("sqlite connections")
    152             .len(),
    153         1
    154     );
    155     assert_eq!(
    156         sqlite_runtime
    157             .operation_audit_store()
    158             .list_all()
    159             .expect("sqlite audit records")
    160             .len(),
    161         1
    162     );
    163 
    164     let rerun = Command::new(env!("CARGO_BIN_EXE_myc"))
    165         .arg("--env-file")
    166         .arg(&env_path)
    167         .arg("persistence")
    168         .arg("import-json-to-sqlite")
    169         .output()
    170         .expect("rerun import");
    171 
    172     assert!(!rerun.status.success(), "{:?}", rerun);
    173     let stderr = String::from_utf8(rerun.stderr).expect("rerun stderr");
    174     assert!(stderr.contains("sqlite signer-state destination"));
    175 }
    176 
    177 #[test]
    178 fn persistence_backup_cli_copies_sqlite_state_and_identity_files() {
    179     let source = tempfile::tempdir().expect("source tempdir");
    180     let sqlite_config = migrate_to_sqlite(&source);
    181     let env_path = source.path().join("sqlite.env");
    182     write_env(&env_path, &sqlite_config);
    183     let backup_dir = source.path().join("backup");
    184 
    185     let output = run_myc(&env_path, &["persistence", "backup", "--out"]);
    186     assert!(
    187         !output.status.success(),
    188         "missing backup path should fail clap parsing"
    189     );
    190 
    191     let output = run_myc(
    192         &env_path,
    193         &[
    194             "persistence",
    195             "backup",
    196             "--out",
    197             backup_dir.to_str().expect("backup dir str"),
    198         ],
    199     );
    200     assert!(output.status.success(), "{:?}", output);
    201 
    202     let parsed: Value = serde_json::from_slice(&output.stdout).expect("backup json");
    203     assert_eq!(parsed["signer_identity_reference"]["copied_file_count"], 2);
    204     assert_eq!(parsed["user_identity_reference"]["copied_file_count"], 2);
    205     assert_eq!(
    206         parsed["discovery_app_identity_reference"],
    207         Value::Null,
    208         "default config reuses signer identity and should not emit a dedicated discovery backup"
    209     );
    210     assert!(backup_dir.join("manifest.json").is_file());
    211     assert!(
    212         backup_dir
    213             .join("state")
    214             .join("signer-state.sqlite")
    215             .is_file()
    216     );
    217     assert!(
    218         backup_dir
    219             .join("state")
    220             .join("delivery-outbox.sqlite")
    221             .is_file()
    222     );
    223     assert!(
    224         backup_dir
    225             .join("state")
    226             .join("audit")
    227             .join("operations.sqlite")
    228             .is_file()
    229     );
    230     assert!(
    231         backup_dir
    232             .join("identity-references")
    233             .join("signer")
    234             .join("path")
    235             .is_file()
    236     );
    237     assert!(
    238         backup_dir
    239             .join("identity-references")
    240             .join("signer")
    241             .join("encrypted-key-path")
    242             .is_file()
    243     );
    244     assert!(
    245         backup_dir
    246             .join("identity-references")
    247             .join("user")
    248             .join("path")
    249             .is_file()
    250     );
    251     assert!(
    252         backup_dir
    253             .join("identity-references")
    254             .join("user")
    255             .join("encrypted-key-path")
    256             .is_file()
    257     );
    258 }
    259 
    260 #[test]
    261 fn persistence_backup_cli_rejects_destination_inside_state_dir() {
    262     let source = tempfile::tempdir().expect("source tempdir");
    263     let sqlite_config = migrate_to_sqlite(&source);
    264     let env_path = source.path().join("sqlite.env");
    265     write_env(&env_path, &sqlite_config);
    266     let nested_backup_dir = sqlite_config.paths.state_dir.join("backup");
    267 
    268     let output = run_myc(
    269         &env_path,
    270         &[
    271             "persistence",
    272             "backup",
    273             "--out",
    274             nested_backup_dir.to_str().expect("nested backup dir str"),
    275         ],
    276     );
    277 
    278     assert!(!output.status.success(), "{:?}", output);
    279     let stderr = String::from_utf8(output.stderr).expect("backup stderr");
    280     assert!(stderr.contains("cannot copy"));
    281 }
    282 
    283 #[test]
    284 fn persistence_restore_cli_restores_backup_and_verify_restore_passes() {
    285     let source = tempfile::tempdir().expect("source tempdir");
    286     let sqlite_config = migrate_to_sqlite(&source);
    287     let sqlite_env = source.path().join("sqlite.env");
    288     write_env(&sqlite_env, &sqlite_config);
    289     let backup_dir = source.path().join("backup");
    290     let backup = run_myc(
    291         &sqlite_env,
    292         &[
    293             "persistence",
    294             "backup",
    295             "--out",
    296             backup_dir.to_str().expect("backup dir str"),
    297         ],
    298     );
    299     assert!(backup.status.success(), "{:?}", backup);
    300 
    301     let restored = tempfile::tempdir().expect("restored tempdir");
    302     let restored_signer = restored.path().join("signer.json");
    303     let restored_user = restored.path().join("user.json");
    304 
    305     let mut restored_config = sqlite_config.clone();
    306     restored_config.paths.state_dir = restored.path().join("state");
    307     restored_config.paths.signer_identity_path = restored_signer;
    308     restored_config.paths.user_identity_path = restored_user;
    309     let restored_env = restored.path().join("restored.env");
    310     write_env(&restored_env, &restored_config);
    311 
    312     let restore = run_myc(
    313         &restored_env,
    314         &[
    315             "persistence",
    316             "restore",
    317             "--from",
    318             backup_dir.to_str().expect("backup dir str"),
    319         ],
    320     );
    321     assert!(restore.status.success(), "{:?}", restore);
    322 
    323     let restore_json: Value = serde_json::from_slice(&restore.stdout).expect("restore json");
    324     assert_eq!(
    325         restore_json["signer_identity_reference"]["restored_file_count"],
    326         2
    327     );
    328     assert_eq!(
    329         restore_json["user_identity_reference"]["restored_file_count"],
    330         2
    331     );
    332     assert!(
    333         restored_config
    334             .paths
    335             .state_dir
    336             .join("signer-state.sqlite")
    337             .is_file()
    338     );
    339     assert!(restored_config.paths.signer_identity_path.is_file());
    340     assert!(restored_config.paths.user_identity_path.is_file());
    341     assert!(
    342         myc::identity_files::encrypted_identity_wrapping_key_path(
    343             &restored_config.paths.signer_identity_path
    344         )
    345         .is_file()
    346     );
    347     assert!(
    348         myc::identity_files::encrypted_identity_wrapping_key_path(
    349             &restored_config.paths.user_identity_path
    350         )
    351         .is_file()
    352     );
    353 
    354     let output = run_myc(&restored_env, &["persistence", "verify-restore"]);
    355 
    356     assert!(output.status.success(), "{:?}", output);
    357 
    358     let parsed: Value = serde_json::from_slice(&output.stdout).expect("verify restore json");
    359     assert_eq!(parsed["signer_state"]["backend"], "sqlite");
    360     assert_eq!(parsed["signer_state"]["connection_count"], 1);
    361     assert_eq!(parsed["runtime_audit"]["backend"], "sqlite");
    362     assert_eq!(parsed["runtime_audit"]["record_count"], 1);
    363     assert_eq!(parsed["delivery_outbox"]["queued_job_count"], 0);
    364     assert_eq!(parsed["delivery_outbox"]["unfinished_job_count"], 0);
    365     assert!(
    366         parsed["delivery_outbox"]["path"]
    367             .as_str()
    368             .expect("delivery outbox path")
    369             .ends_with("delivery-outbox.sqlite")
    370     );
    371 }
    372 
    373 #[test]
    374 fn persistence_verify_restore_cli_rejects_missing_outbox_file() {
    375     let source = tempfile::tempdir().expect("source tempdir");
    376     let sqlite_config = migrate_to_sqlite(&source);
    377 
    378     let restored = tempfile::tempdir().expect("restored tempdir");
    379     let restored_state_dir = restored.path().join("state");
    380     copy_dir_recursive(&sqlite_config.paths.state_dir, &restored_state_dir);
    381     let restored_signer = restored.path().join("signer.json");
    382     let restored_user = restored.path().join("user.json");
    383     std::fs::copy(&sqlite_config.paths.signer_identity_path, &restored_signer)
    384         .expect("copy signer identity");
    385     std::fs::copy(&sqlite_config.paths.user_identity_path, &restored_user)
    386         .expect("copy user identity");
    387     std::fs::remove_file(restored_state_dir.join("delivery-outbox.sqlite"))
    388         .expect("remove restored outbox");
    389 
    390     let mut restored_config = sqlite_config.clone();
    391     restored_config.paths.state_dir = restored_state_dir;
    392     restored_config.paths.signer_identity_path = restored_signer;
    393     restored_config.paths.user_identity_path = restored_user;
    394     let restored_env = restored.path().join("restored.env");
    395     std::fs::write(
    396         &restored_env,
    397         restored_config
    398             .to_env_string()
    399             .expect("render restored env"),
    400     )
    401     .expect("write restored env");
    402 
    403     let output = Command::new(env!("CARGO_BIN_EXE_myc"))
    404         .arg("--env-file")
    405         .arg(&restored_env)
    406         .arg("persistence")
    407         .arg("verify-restore")
    408         .output()
    409         .expect("run verify restore");
    410 
    411     assert!(!output.status.success(), "{:?}", output);
    412     let stderr = String::from_utf8(output.stderr).expect("verify restore stderr");
    413     assert!(
    414         stderr.contains("persistence verify-restore requires an existing delivery outbox file")
    415     );
    416 }
    417 
    418 #[test]
    419 fn persistence_restore_cli_rejects_non_empty_destination() {
    420     let source = tempfile::tempdir().expect("source tempdir");
    421     let sqlite_config = migrate_to_sqlite(&source);
    422     let sqlite_env = source.path().join("sqlite.env");
    423     write_env(&sqlite_env, &sqlite_config);
    424     let backup_dir = source.path().join("backup");
    425     let backup = run_myc(
    426         &sqlite_env,
    427         &[
    428             "persistence",
    429             "backup",
    430             "--out",
    431             backup_dir.to_str().expect("backup dir str"),
    432         ],
    433     );
    434     assert!(backup.status.success(), "{:?}", backup);
    435 
    436     let restored = tempfile::tempdir().expect("restored tempdir");
    437     let mut restored_config = sqlite_config.clone();
    438     restored_config.paths.state_dir = restored.path().join("state");
    439     restored_config.paths.signer_identity_path = restored.path().join("signer.json");
    440     restored_config.paths.user_identity_path = restored.path().join("user.json");
    441     std::fs::create_dir_all(&restored_config.paths.state_dir).expect("create restored state dir");
    442     std::fs::write(
    443         restored_config.paths.state_dir.join("existing.txt"),
    444         "occupied",
    445     )
    446     .expect("write occupied marker");
    447     let restored_env = restored.path().join("restored.env");
    448     write_env(&restored_env, &restored_config);
    449 
    450     let restore = run_myc(
    451         &restored_env,
    452         &[
    453             "persistence",
    454             "restore",
    455             "--from",
    456             backup_dir.to_str().expect("backup dir str"),
    457         ],
    458     );
    459 
    460     assert!(!restore.status.success(), "{:?}", restore);
    461     let stderr = String::from_utf8(restore.stderr).expect("restore stderr");
    462     assert!(stderr.contains("restore state directory"));
    463 }
    464 
    465 #[test]
    466 fn persistence_verify_restore_cli_rejects_signer_identity_mismatch() {
    467     let source = tempfile::tempdir().expect("source tempdir");
    468     let sqlite_config = migrate_to_sqlite(&source);
    469 
    470     let restored = tempfile::tempdir().expect("restored tempdir");
    471     let restored_state_dir = restored.path().join("state");
    472     copy_dir_recursive(&sqlite_config.paths.state_dir, &restored_state_dir);
    473     let restored_signer = restored.path().join("other-signer.json");
    474     let restored_user = restored.path().join("user.json");
    475     write_identity(
    476         &restored_signer,
    477         "3333333333333333333333333333333333333333333333333333333333333333",
    478     );
    479     std::fs::copy(&sqlite_config.paths.user_identity_path, &restored_user)
    480         .expect("copy user identity");
    481     std::fs::copy(
    482         myc::identity_files::encrypted_identity_wrapping_key_path(
    483             &sqlite_config.paths.user_identity_path,
    484         ),
    485         myc::identity_files::encrypted_identity_wrapping_key_path(&restored_user),
    486     )
    487     .expect("copy user identity wrapping key");
    488 
    489     let mut restored_config = sqlite_config.clone();
    490     restored_config.paths.state_dir = restored_state_dir;
    491     restored_config.paths.signer_identity_path = restored_signer;
    492     restored_config.paths.user_identity_path = restored_user;
    493     let restored_env = restored.path().join("restored.env");
    494     std::fs::write(
    495         &restored_env,
    496         restored_config
    497             .to_env_string()
    498             .expect("render restored env"),
    499     )
    500     .expect("write restored env");
    501 
    502     let output = Command::new(env!("CARGO_BIN_EXE_myc"))
    503         .arg("--env-file")
    504         .arg(&restored_env)
    505         .arg("persistence")
    506         .arg("verify-restore")
    507         .output()
    508         .expect("run verify restore");
    509 
    510     assert!(!output.status.success(), "{:?}", output);
    511     let stderr = String::from_utf8(output.stderr).expect("verify restore stderr");
    512     assert!(stderr.contains("does not match persisted signer identity"));
    513 }