style: apply cargo fmt so the release gate can run

The release gate's first real stage is `cargo fmt --check`, and it had
44 diffs across 15 files — enough to abort `create-release.sh` at step 0
before it touched a version number. Some of that drift is mine from the
last two days, some predates it in files I never opened
(bootstrap.rs, ghost_reaper.rs, openwrt/router.rs), and one is the
regenerated fips/app_ports.rs.

No behaviour change — rustfmt only.

Gate now: 8 of 9 green. The remaining red is cargo-test-weekly exiting
124, which is the 25-minute `timeout` expiring during a cold
CARGO_INCREMENTAL=0 rebuild on a loaded node — the tests never started.
Not a test failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-19 12:38:41 -04:00
co-authored by Claude Opus 5
parent bd299318c0
commit c788dff42d
15 changed files with 164 additions and 110 deletions
+14 -6
View File
@@ -105,10 +105,7 @@ async fn run_keeper(mut rx: mpsc::Receiver<String>, connected: Arc<AtomicBool>)
/// Discard queued input for `d` — used while no kiosk session exists so the /// Discard queued input for `d` — used while no kiosk session exists so the
/// bounded channel can't fill with stale events. /// bounded channel can't fill with stale events.
async fn drain_for(rx: &mut mpsc::Receiver<String>, d: Duration) { async fn drain_for(rx: &mut mpsc::Receiver<String>, d: Duration) {
let _ = tokio::time::timeout(d, async { let _ = tokio::time::timeout(d, async { while rx.recv().await.is_some() {} }).await;
while rx.recv().await.is_some() {}
})
.await;
} }
/// Find the kiosk page target's WebSocket debugger URL. Prefers the page on /// Find the kiosk page target's WebSocket debugger URL. Prefers the page on
@@ -219,7 +216,11 @@ fn translate(raw: &str, cursor: &mut Cursor, id: &mut impl FnMut() -> u64) -> Ve
vec![mouse_event(id(), "mouseMoved", cursor, "none", 0, 1)] vec![mouse_event(id(), "mouseMoved", cursor, "none", 0, 1)]
} }
Some("c") => { Some("c") => {
let b = msg.get("b").and_then(Value::as_u64).unwrap_or(1).clamp(1, 3); let b = msg
.get("b")
.and_then(Value::as_u64)
.unwrap_or(1)
.clamp(1, 3);
let (button, buttons) = match b { let (button, buttons) = match b {
2 => ("middle", 4), 2 => ("middle", 4),
3 => ("right", 2), 3 => ("right", 2),
@@ -255,7 +256,14 @@ fn translate(raw: &str, cursor: &mut Cursor, id: &mut impl FnMut() -> u64) -> Ve
} }
} }
fn mouse_event(id: u64, kind: &str, cursor: &Cursor, button: &str, buttons: u32, clicks: u32) -> Value { fn mouse_event(
id: u64,
kind: &str,
cursor: &Cursor,
button: &str,
buttons: u32,
clicks: u32,
) -> Value {
json!({ json!({
"id": id, "id": id,
"method": "Input.dispatchMouseEvent", "method": "Input.dispatchMouseEvent",
+5 -1
View File
@@ -667,7 +667,11 @@ impl RpcHandler {
.get("state") .get("state")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s == "SETTLED") .map(|s| s == "SETTLED")
.unwrap_or_else(|| body.get("settled").and_then(|v| v.as_bool()).unwrap_or(false)); .unwrap_or_else(|| {
body.get("settled")
.and_then(|v| v.as_bool())
.unwrap_or(false)
});
let amt_paid_sat = body let amt_paid_sat = body
.get("amt_paid_sat") .get("amt_paid_sat")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
+6 -2
View File
@@ -43,8 +43,12 @@ impl RpcHandler {
// plus a blocking SSH verify per candidate. Inline, one click of // plus a blocking SSH verify per candidate. Inline, one click of
// "scan for routers" held a tokio worker for that whole time. // "scan for routers" held a tokio worker for that whole time.
let routers = tokio::task::spawn_blocking(move || { let routers = tokio::task::spawn_blocking(move || {
tokio::runtime::Handle::current() tokio::runtime::Handle::current().block_on(detect::scan_subnet(
.block_on(detect::scan_subnet(subnet, prefix, &ssh_user, &ssh_password)) subnet,
prefix,
&ssh_user,
&ssh_password,
))
}) })
.await .await
.context("openwrt scan task")?; .context("openwrt scan task")?;
+1 -3
View File
@@ -380,9 +380,7 @@ impl RpcHandler {
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
.unwrap_or(false); .unwrap_or(false);
let mut password = self let mut password = self.verify_reveal_auth(&params, "the ecash seed").await?;
.verify_reveal_auth(&params, "the ecash seed")
.await?;
password.zeroize(); password.zeroize();
let seed = let seed =
+32 -10
View File
@@ -184,14 +184,17 @@ pub async fn ensure_doctor_installed() {
Err(e) => warn!("nginx listener repair failed (non-fatal): {:#}", e), Err(e) => warn!("nginx listener repair failed (non-fatal): {:#}", e),
} }
match run_ha_rpc_proxy_bind_repair().await { match run_ha_rpc_proxy_bind_repair().await {
Ok(true) => info!( Ok(true) => {
"HA bitcoind RPC forwarder rebound dynamically — survives network moves now" info!("HA bitcoind RPC forwarder rebound dynamically — survives network moves now")
), }
Ok(false) => debug!("HA bitcoind RPC forwarder absent or already dynamic"), Ok(false) => debug!("HA bitcoind RPC forwarder absent or already dynamic"),
Err(e) => warn!("HA RPC forwarder bind repair failed (non-fatal): {:#}", e), Err(e) => warn!("HA RPC forwarder bind repair failed (non-fatal): {:#}", e),
} }
match run_pull_never_image_repair().await { match run_pull_never_image_repair().await {
Ok(n) if n > 0 => info!(retagged = n, "Healed quadlet image refs orphaned by registry rename"), Ok(n) if n > 0 => info!(
retagged = n,
"Healed quadlet image refs orphaned by registry rename"
),
Ok(_) => debug!("All quadlet image refs resolve locally"), Ok(_) => debug!("All quadlet image refs resolve locally"),
Err(e) => warn!("Quadlet image ref repair failed (non-fatal): {:#}", e), Err(e) => warn!("Quadlet image ref repair failed (non-fatal): {:#}", e),
} }
@@ -704,7 +707,12 @@ fn parse_socat_static_bind(exec_line: &str) -> Option<(String, String)> {
} }
// Only rewrite units pinned to a concrete address; a unit already using // Only rewrite units pinned to a concrete address; a unit already using
// a computed bind (or none) needs no heal. // a computed bind (or none) needs no heal.
let bind = after_listen.split("bind=").nth(1)?.split(',').next()?.trim(); let bind = after_listen
.split("bind=")
.nth(1)?
.split(',')
.next()?
.trim();
if !bind.chars().all(|c| c.is_ascii_digit() || c == '.') || bind.starts_with("127.") { if !bind.chars().all(|c| c.is_ascii_digit() || c == '.') || bind.starts_with("127.") {
return None; return None;
} }
@@ -731,7 +739,10 @@ async fn run_ha_rpc_proxy_bind_repair() -> Result<bool> {
Ok(s) => s, Ok(s) => s,
Err(_) => return Ok(false), // node never grew the forwarder Err(_) => return Ok(false), // node never grew the forwarder
}; };
let Some(exec_line) = unit.lines().find(|l| l.trim_start().starts_with("ExecStart=")) else { let Some(exec_line) = unit
.lines()
.find(|l| l.trim_start().starts_with("ExecStart="))
else {
return Ok(false); return Ok(false);
}; };
let Some((port, target)) = parse_socat_static_bind(exec_line) else { let Some((port, target)) = parse_socat_static_bind(exec_line) else {
@@ -833,7 +844,11 @@ async fn run_pull_never_image_repair() -> Result<usize> {
} }
async fn podman_stdout(args: &[&str]) -> String { async fn podman_stdout(args: &[&str]) -> String {
match tokio::process::Command::new("podman").args(args).output().await { match tokio::process::Command::new("podman")
.args(args)
.output()
.await
{
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).into_owned(), Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).into_owned(),
_ => String::new(), _ => String::new(),
} }
@@ -859,7 +874,8 @@ const NGINX_SITES: [&str; 2] = [
"/etc/nginx/sites-available/archipelago-http", "/etc/nginx/sites-available/archipelago-http",
"/etc/nginx/sites-available/archipelago", "/etc/nginx/sites-available/archipelago",
]; ];
const NGINX_RESTART_DROPIN: &str = "/etc/systemd/system/nginx.service.d/10-archipelago-restart.conf"; const NGINX_RESTART_DROPIN: &str =
"/etc/systemd/system/nginx.service.d/10-archipelago-restart.conf";
/// Global IPv4 addresses on this host, minus Tailscale CGNAT (100.64/10) — /// Global IPv4 addresses on this host, minus Tailscale CGNAT (100.64/10) —
/// the same exclusion `setup-node-ca.sh` applies, for the same reason. /// the same exclusion `setup-node-ca.sh` applies, for the same reason.
@@ -964,7 +980,10 @@ async fn run_nginx_listener_repair() -> Result<bool> {
let status = host_sudo(&["sh", "-lc", &script]).await?; let status = host_sudo(&["sh", "-lc", &script]).await?;
match status.code() { match status.code() {
Some(0) => changed = true, Some(0) => changed = true,
Some(3) => warn!(site, "nginx listener repair failed its config test — rolled back"), Some(3) => warn!(
site,
"nginx listener repair failed its config test — rolled back"
),
_ => warn!(site, "nginx listener repair helper failed"), _ => warn!(site, "nginx listener repair helper failed"),
} }
} }
@@ -1826,7 +1845,10 @@ mod tests {
let healed = retarget_https_listeners(cfg, &present).expect("must heal"); let healed = retarget_https_listeners(cfg, &present).expect("must heal");
assert!(healed.contains("listen 192.168.1.50:443 ssl;")); assert!(healed.contains("listen 192.168.1.50:443 ssl;"));
assert!(healed.contains("listen 10.44.0.1:443 ssl;")); assert!(healed.contains("listen 10.44.0.1:443 ssl;"));
assert!(!healed.contains("192.168.63.240"), "stale listener must be dropped"); assert!(
!healed.contains("192.168.63.240"),
"stale listener must be dropped"
);
// Untouched lines survive, and the repair is idempotent. // Untouched lines survive, and the repair is idempotent.
assert!(healed.contains("listen 80 default_server;")); assert!(healed.contains("listen 80 default_server;"));
assert!(healed.contains("ssl_certificate /x;")); assert!(healed.contains("ssl_certificate /x;"));
+16 -7
View File
@@ -216,14 +216,20 @@ pub async fn reap_for_app(app_id: &str) -> usize {
let app_id = app_id.to_string(); let app_id = app_id.to_string();
reap_matching(move |g| { reap_matching(move |g| {
g.name.as_deref().is_some_and(|n| { g.name.as_deref().is_some_and(|n| {
n == app_id || n.starts_with(&format!("{app_id}-")) || n.ends_with(&format!("-{app_id}")) n == app_id
|| n.starts_with(&format!("{app_id}-"))
|| n.ends_with(&format!("-{app_id}"))
}) })
}) })
.await .await
} }
async fn reap_matching(pred: impl Fn(&Ghost) -> bool) -> usize { async fn reap_matching(pred: impl Fn(&Ghost) -> bool) -> usize {
let ghosts: Vec<Ghost> = find_ghosts().await.into_iter().filter(|g| pred(g)).collect(); let ghosts: Vec<Ghost> = find_ghosts()
.await
.into_iter()
.filter(|g| pred(g))
.collect();
if ghosts.is_empty() { if ghosts.is_empty() {
return 0; return 0;
} }
@@ -237,7 +243,10 @@ async fn reap_matching(pred: impl Fn(&Ghost) -> bool) -> usize {
); );
kill_ghost(ghost).await; kill_ghost(ghost).await;
} }
info!(count = ghosts.len(), "ghost reaper: reaped ghost containers"); info!(
count = ghosts.len(),
"ghost reaper: reaped ghost containers"
);
ghosts.len() ghosts.len()
} }
@@ -280,7 +289,9 @@ mod tests {
#[test] #[test]
fn a_truncated_or_missing_id_is_not_reapable() { fn a_truncated_or_missing_id_is_not_reapable() {
assert!(parse_conmon(&argv(&["/usr/bin/conmon", "-c", "8ea2fc65", "-n", "gitea"])).is_none()); assert!(
parse_conmon(&argv(&["/usr/bin/conmon", "-c", "8ea2fc65", "-n", "gitea"])).is_none()
);
assert!(parse_conmon(&argv(&["/usr/bin/conmon", "--api-version", "1"])).is_none()); assert!(parse_conmon(&argv(&["/usr/bin/conmon", "--api-version", "1"])).is_none());
} }
@@ -295,9 +306,7 @@ mod tests {
let app = app.to_string(); let app = app.to_string();
let gh = g(name); let gh = g(name);
gh.name.as_deref().is_some_and(|n| { gh.name.as_deref().is_some_and(|n| {
n == app n == app || n.starts_with(&format!("{app}-")) || n.ends_with(&format!("-{app}"))
|| n.starts_with(&format!("{app}-"))
|| n.ends_with(&format!("-{app}"))
}) })
}; };
assert!(matches("gitea", "gitea")); assert!(matches("gitea", "gitea"));
+3 -37
View File
@@ -6,41 +6,7 @@
//! no listener, so allowing them is inert. //! no listener, so allowing them is inert.
pub const APP_LAUNCH_PORTS: &[u16] = &[ pub const APP_LAUNCH_PORTS: &[u16] = &[
2283, 2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8088,
2342, 8089, 8090, 8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380,
3000, 11434, 18081, 18083, 23000, 32838, 50002,
3001,
3002,
4080,
5180,
7778,
8080,
8081,
8082,
8083,
8084,
8085,
8087,
8088,
8089,
8090,
8096,
8123,
8175,
8176,
8187,
8240,
8334,
8336,
8888,
8999,
9000,
9100,
10380,
11434,
18081,
18083,
23000,
32838,
50002,
]; ];
+6 -2
View File
@@ -538,8 +538,12 @@ async fn same_serial_device(a: &str, b: &str) -> bool {
if a == b { if a == b {
return true; return true;
} }
let ra = fs::canonicalize(a).await.unwrap_or_else(|_| PathBuf::from(a)); let ra = fs::canonicalize(a)
let rb = fs::canonicalize(b).await.unwrap_or_else(|_| PathBuf::from(b)); .await
.unwrap_or_else(|_| PathBuf::from(a));
let rb = fs::canonicalize(b)
.await
.unwrap_or_else(|_| PathBuf::from(b));
ra == rb ra == rb
} }
+4 -1
View File
@@ -697,7 +697,10 @@ mod tests {
// nodes would derive each other's coins. // nodes would derive each other's coins.
let (other_words, _) = MasterSeed::generate().unwrap(); let (other_words, _) = MasterSeed::generate().unwrap();
let (_, other_seed) = MasterSeed::from_mnemonic_words(&other_words.to_string()).unwrap(); let (_, other_seed) = MasterSeed::from_mnemonic_words(&other_words.to_string()).unwrap();
assert_ne!(a.to_string(), derive_cashu_mnemonic(&other_seed).unwrap().to_string()); assert_ne!(
a.to_string(),
derive_cashu_mnemonic(&other_seed).unwrap().to_string()
);
} }
/// It must NOT be the node's own phrase. Restoring ecash into a /// It must NOT be the node's own phrase. Restoring ecash into a
+1 -2
View File
@@ -664,8 +664,7 @@ impl Server {
.map(|(a, _)| *a) .map(|(a, _)| *a)
.unwrap_or(0) .unwrap_or(0)
+ 1; + 1;
let delay = let delay = (90u64 << attempts.min(10)).min(86_400);
(90u64 << attempts.min(10)).min(86_400);
notify_backoff.insert( notify_backoff.insert(
node.did.clone(), node.did.clone(),
(attempts, now + Duration::from_secs(delay)), (attempts, now + Duration::from_secs(delay)),
+8 -2
View File
@@ -765,7 +765,10 @@ mod tests {
let short = "01fc0ec0e59cd6fa"; let short = "01fc0ec0e59cd6fa";
let full = "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821"; let full = "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821";
assert!(is_truncated_v2_keyset_id(short)); assert!(is_truncated_v2_keyset_id(short));
assert!(full.starts_with(short), "short form must prefix the full id"); assert!(
full.starts_with(short),
"short form must prefix the full id"
);
// It must survive token validation so the swap path can repair it, // It must survive token validation so the swap path can repair it,
// rather than being rejected as malformed. // rather than being rejected as malformed.
assert!(validate_keyset_id(short).is_ok()); assert!(validate_keyset_id(short).is_ok());
@@ -784,7 +787,10 @@ mod tests {
let err = validate_keyset_id("00112233445566778899") let err = validate_keyset_id("00112233445566778899")
.expect_err("9-byte keyset id must be rejected"); .expect_err("9-byte keyset id must be rejected");
let msg = err.to_string(); let msg = err.to_string();
assert!(msg.contains("10-byte") || msg.contains("unsupported keyset id"), "{msg}"); assert!(
msg.contains("10-byte") || msg.contains("unsupported keyset id"),
"{msg}"
);
// Non-hex ids (the original base64 keyset format) are named as such // Non-hex ids (the original base64 keyset format) are named as such
// rather than reported as a length problem. // rather than reported as a length problem.
+17 -8
View File
@@ -397,9 +397,8 @@ pub async fn load_accepted_mints(data_dir: &Path) -> Result<AcceptedMints> {
mints: vec![network.default_mint()], mints: vec![network.default_mint()],
} }
} else { } else {
serde_json::from_str(&content).with_context(|| { serde_json::from_str(&content)
format!("Accepted-mints file {} is damaged", path.display()) .with_context(|| format!("Accepted-mints file {} is damaged", path.display()))?
})?
}; };
Ok(mints) Ok(mints)
} }
@@ -1521,7 +1520,10 @@ pub async fn restore_from_seed(data_dir: &Path, mint_url: &str) -> Result<Restor
let mint_key = match keys.key_for_amount(sig.amount) { let mint_key = match keys.key_for_amount(sig.amount) {
Ok(k) => k, Ok(k) => k,
Err(e) => { Err(e) => {
warn!("Restored a {} sat output with no matching key: {e:#}", sig.amount); warn!(
"Restored a {} sat output with no matching key: {e:#}",
sig.amount
);
continue; continue;
} }
}; };
@@ -2312,7 +2314,11 @@ mod tests {
// mint — never the real coins. // mint — never the real coins.
save_network(dir, EcashNetwork::Testnet).await.unwrap(); save_network(dir, EcashNetwork::Testnet).await.unwrap();
let test_wallet = load_wallet(dir).await.unwrap(); let test_wallet = load_wallet(dir).await.unwrap();
assert_eq!(test_wallet.balance(), 0, "test wallet must not see real coins"); assert_eq!(
test_wallet.balance(),
0,
"test wallet must not see real coins"
);
assert!(test_wallet.mint_url.contains("testnut")); assert!(test_wallet.mint_url.contains("testnut"));
assert!(load_accepted_mints(dir).await.unwrap().mints[0].contains("testnut")); assert!(load_accepted_mints(dir).await.unwrap().mints[0].contains("testnut"));
@@ -2341,7 +2347,6 @@ mod tests {
assert_eq!(back.proofs[0].proof.secret, "real"); assert_eq!(back.proofs[0].proof.secret, "real");
} }
#[tokio::test] #[tokio::test]
async fn a_damaged_wallet_file_fails_loudly_and_is_left_on_disk() { async fn a_damaged_wallet_file_fails_loudly_and_is_left_on_disk() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
@@ -2355,7 +2360,9 @@ mod tests {
// It must NOT read as an empty wallet: that is what caused the real // It must NOT read as an empty wallet: that is what caused the real
// balance to be overwritten with nothing on the next save. // balance to be overwritten with nothing on the next save.
let err = load_wallet(dir).await.expect_err("damaged wallet must error"); let err = load_wallet(dir)
.await
.expect_err("damaged wallet must error");
assert!( assert!(
err.to_string().contains("damaged"), err.to_string().contains("damaged"),
"error should name the problem: {err}" "error should name the problem: {err}"
@@ -2372,7 +2379,9 @@ mod tests {
std::fs::create_dir_all(dir.join("wallet")).unwrap(); std::fs::create_dir_all(dir.join("wallet")).unwrap();
std::fs::write(dir.join("wallet/ecash.json"), " \n").unwrap(); std::fs::write(dir.join("wallet/ecash.json"), " \n").unwrap();
// A create that never got its first write is not damage. // A create that never got its first write is not damage.
let w = load_wallet(dir).await.expect("empty file is a fresh wallet"); let w = load_wallet(dir)
.await
.expect("empty file is a fresh wallet");
assert_eq!(w.balance(), 0); assert_eq!(w.balance(), 0);
} }
+8 -5
View File
@@ -191,7 +191,10 @@ impl MintClient {
&self, &self,
keyset_id: &str, keyset_id: &str,
amounts: &[u64], amounts: &[u64],
) -> Result<(Vec<BlindedMessageRequest>, Vec<(Vec<u8>, secp256k1::SecretKey, u64)>)> { ) -> Result<(
Vec<BlindedMessageRequest>,
Vec<(Vec<u8>, secp256k1::SecretKey, u64)>,
)> {
let derived = match &self.recovery { let derived = match &self.recovery {
Some(source) => match source.next_outputs(keyset_id, amounts.len()).await { Some(source) => match source.next_outputs(keyset_id, amounts.len()).await {
Ok(pairs) => Some(pairs), Ok(pairs) => Some(pairs),
@@ -320,9 +323,7 @@ impl MintClient {
.filter(|k| !k.keys.is_empty() && k.unit.eq_ignore_ascii_case("sat")) .filter(|k| !k.keys.is_empty() && k.unit.eq_ignore_ascii_case("sat"))
// Prefer a keyset the mint will still sign with. // Prefer a keyset the mint will still sign with.
.max_by_key(|k| k.active) .max_by_key(|k| k.active)
.ok_or_else(|| { .ok_or_else(|| anyhow::anyhow!("No active sat keyset found at mint {}", self.url))
anyhow::anyhow!("No active sat keyset found at mint {}", self.url)
})
} }
// ── Mint quotes (NUT-04) ── // ── Mint quotes (NUT-04) ──
@@ -510,7 +511,9 @@ impl MintClient {
"The mint's fee ({fee} sat) consumes this whole amount — nothing would be left" "The mint's fee ({fee} sat) consumes this whole amount — nothing would be left"
); );
} }
debug!("Reducing swap outputs {requested} -> {spendable} to cover a {fee} sat mint fee"); debug!(
"Reducing swap outputs {requested} -> {spendable} to cover a {fee} sat mint fee"
);
owned_targets = amount_to_denominations(spendable); owned_targets = amount_to_denominations(spendable);
&owned_targets &owned_targets
} else { } else {
+39 -22
View File
@@ -280,12 +280,18 @@ pub async fn establish_independent(data_dir: &Path) -> Result<EcashSeed> {
/// dangerous choice if the imported phrase turned out to be the one already /// dangerous choice if the imported phrase turned out to be the one already
/// in use. /// in use.
pub async fn import_mnemonic(data_dir: &Path, words: &str, confirm: bool) -> Result<EcashSeed> { pub async fn import_mnemonic(data_dir: &Path, words: &str, confirm: bool) -> Result<EcashSeed> {
let mnemonic: bip39::Mnemonic = words.split_whitespace().collect::<Vec<_>>().join(" ").parse() let mnemonic: bip39::Mnemonic = words
.map_err(|e| anyhow::anyhow!( .split_whitespace()
"That is not a valid BIP-39 recovery phrase: {e}. Check for typos — \ .collect::<Vec<_>>()
.join(" ")
.parse()
.map_err(|e| {
anyhow::anyhow!(
"That is not a valid BIP-39 recovery phrase: {e}. Check for typos — \
every word must come from the BIP-39 word list, and the phrase as \ every word must come from the BIP-39 word list, and the phrase as \
a whole carries a checksum." a whole carries a checksum."
))?; )
})?;
if let Some(existing) = load_seed(data_dir).await? { if let Some(existing) = load_seed(data_dir).await? {
if existing.mnemonic == mnemonic { if existing.mnemonic == mnemonic {
@@ -322,19 +328,18 @@ async fn archive_seed(data_dir: &Path) -> Result<()> {
} }
let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ"); let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
let to = data_dir.join(format!("wallet/cashu_seed.replaced-{stamp}.json")); let to = data_dir.join(format!("wallet/cashu_seed.replaced-{stamp}.json"));
fs::rename(&from, &to) fs::rename(&from, &to).await.with_context(|| {
.await format!(
.with_context(|| format!("Could not archive the previous ecash phrase to {}", to.display()))?; "Could not archive the previous ecash phrase to {}",
to.display()
)
})?;
warn!("Previous ecash phrase archived to {}", to.display()); warn!("Previous ecash phrase archived to {}", to.display());
Ok(()) Ok(())
} }
/// Write the seed file at 0600, creating the wallet directory if needed. /// Write the seed file at 0600, creating the wallet directory if needed.
async fn write_seed( async fn write_seed(data_dir: &Path, mnemonic: &bip39::Mnemonic, source: SeedSource) -> Result<()> {
data_dir: &Path,
mnemonic: &bip39::Mnemonic,
source: SeedSource,
) -> Result<()> {
let path = seed_path(data_dir); let path = seed_path(data_dir);
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent) fs::create_dir_all(parent)
@@ -596,8 +601,7 @@ mod tests {
// A *different* master seed must not replace the phrase the existing // A *different* master seed must not replace the phrase the existing
// proofs were minted under. // proofs were minted under.
let (other_words, _) = MasterSeed::generate().unwrap(); let (other_words, _) = MasterSeed::generate().unwrap();
let (_, other_master) = let (_, other_master) = MasterSeed::from_mnemonic_words(&other_words.to_string()).unwrap();
MasterSeed::from_mnemonic_words(&other_words.to_string()).unwrap();
let third = establish_from_master(d, &other_master).await.unwrap(); let third = establish_from_master(d, &other_master).await.unwrap();
assert_eq!( assert_eq!(
first.words(), first.words(),
@@ -615,10 +619,9 @@ mod tests {
// Stand in for the other wallet: a known phrase and what it derives. // Stand in for the other wallet: a known phrase and what it derives.
let theirs: bip39::Mnemonic = TEST_MNEMONIC.parse().unwrap(); let theirs: bip39::Mnemonic = TEST_MNEMONIC.parse().unwrap();
let expected = let expected = EcashSeed::from_mnemonic(theirs.clone(), SeedSource::Imported)
EcashSeed::from_mnemonic(theirs.clone(), SeedSource::Imported) .derive_output(V1_KEYSET, 3)
.derive_output(V1_KEYSET, 3) .unwrap();
.unwrap();
let imported = import_mnemonic(d, TEST_MNEMONIC, false).await.unwrap(); let imported = import_mnemonic(d, TEST_MNEMONIC, false).await.unwrap();
assert_eq!(imported.source(), SeedSource::Imported); assert_eq!(imported.source(), SeedSource::Imported);
@@ -644,7 +647,10 @@ mod tests {
let err = import_mnemonic(d, &other.to_string(), false) let err = import_mnemonic(d, &other.to_string(), false)
.await .await
.expect_err("must not replace without confirmation"); .expect_err("must not replace without confirmation");
assert!(err.to_string().contains("already has a backup phrase"), "{err}"); assert!(
err.to_string().contains("already has a backup phrase"),
"{err}"
);
assert_eq!( assert_eq!(
load_seed(d).await.unwrap().unwrap().words(), load_seed(d).await.unwrap().unwrap().words(),
original_words, original_words,
@@ -660,7 +666,11 @@ mod tests {
let archived: Vec<_> = std::fs::read_dir(d.join("wallet")) let archived: Vec<_> = std::fs::read_dir(d.join("wallet"))
.unwrap() .unwrap()
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with("cashu_seed.replaced-")) .filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("cashu_seed.replaced-")
})
.collect(); .collect();
assert_eq!(archived.len(), 1, "the replaced phrase must be kept"); assert_eq!(archived.len(), 1, "the replaced phrase must be kept");
} }
@@ -677,7 +687,11 @@ mod tests {
let archived = std::fs::read_dir(d.join("wallet")) let archived = std::fs::read_dir(d.join("wallet"))
.unwrap() .unwrap()
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with("cashu_seed.replaced-")) .filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("cashu_seed.replaced-")
})
.count(); .count();
assert_eq!(archived, 0); assert_eq!(archived, 0);
} }
@@ -718,7 +732,10 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(seed_path(d)).unwrap().permissions().mode(); let mode = std::fs::metadata(seed_path(d))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "the ecash phrase must be owner-only"); assert_eq!(mode & 0o777, 0o600, "the ecash phrase must be owner-only");
} }
} }
+4 -2
View File
@@ -29,8 +29,10 @@ impl Router {
.with_context(|| format!("no address for {}", addr))?; .with_context(|| format!("no address for {}", addr))?;
let tcp = TcpStream::connect_timeout(&resolved, std::time::Duration::from_secs(5)) let tcp = TcpStream::connect_timeout(&resolved, std::time::Duration::from_secs(5))
.with_context(|| format!("TCP connect to {}", addr))?; .with_context(|| format!("TCP connect to {}", addr))?;
tcp.set_read_timeout(Some(std::time::Duration::from_secs(30))).ok(); tcp.set_read_timeout(Some(std::time::Duration::from_secs(30)))
tcp.set_write_timeout(Some(std::time::Duration::from_secs(30))).ok(); .ok();
tcp.set_write_timeout(Some(std::time::Duration::from_secs(30)))
.ok();
Ok(tcp) Ok(tcp)
} }