use super::*;
use alloy::primitives::{B256, keccak256};
use alloy::signers::{Signer, SignerSync};
use alloy::sol;
use alloy::sol_types::{SolStruct, eip712_domain};
use std::assert_matches;
use std::os::unix::fs::PermissionsExt;
use tempfile::TempDir;
const TEST_PASSWORD: &str = "hunter2";
fn make_data_dir() -> TempDir {
let tmp = TempDir::new().unwrap();
fs::set_permissions(tmp.path(), fs::Permissions::from_mode(0o700)).unwrap();
tmp
}
#[test]
fn password_sources_orders_env_then_file_then_prompt() {
let with_file = standard_sources(Some(PathBuf::from("/abs/pw.txt")), PasswordUse::Unlock);
assert_matches!(
with_file.as_slice(),
[
PasswordSource::Env(name),
PasswordSource::File(p),
PasswordSource::Prompt {
usage: PasswordUse::Unlock
},
] if *name == KEYSTORE_PASSWORD_ENV
&& p == Path::new("/abs/pw.txt"),
"got: {with_file:?}"
);
let without = standard_sources(None, PasswordUse::Create);
assert_matches!(
without.as_slice(),
[
PasswordSource::Env(_),
PasswordSource::Prompt {
usage: PasswordUse::Create
},
],
"got: {without:?}"
);
}
#[test]
fn generates_keystore_with_secure_perms() {
let tmp = make_data_dir();
let _addr = generate_and_persist(tmp.path(), TEST_PASSWORD, false).unwrap();
let path = keystore_path(tmp.path());
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "keystore file should be 0o600, was {mode:#o}");
let dir_mode = fs::metadata(tmp.path()).unwrap().permissions().mode() & 0o777;
assert_eq!(
dir_mode, 0o700,
"data_dir should be 0o700, was {dir_mode:#o}"
);
}
#[test]
fn skips_when_keystore_exists_and_force_false() {
let tmp = make_data_dir();
generate_and_persist(tmp.path(), TEST_PASSWORD, false).unwrap();
let err = generate_and_persist(tmp.path(), TEST_PASSWORD, false).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("already exists"), "got: {msg}");
assert!(msg.contains("--force"), "got: {msg}");
}
#[test]
fn force_overwrites_existing_keystore() {
let tmp = make_data_dir();
let first = generate_and_persist(tmp.path(), TEST_PASSWORD, false).unwrap();
let second = generate_and_persist(tmp.path(), TEST_PASSWORD, true).unwrap();
assert_ne!(
first, second,
"force should produce a fresh key with a different address"
);
}
#[test]
fn force_archives_old_keystore_to_bak() {
let tmp = make_data_dir();
generate_and_persist(tmp.path(), TEST_PASSWORD, false).unwrap();
let original = fs::read(keystore_path(tmp.path())).unwrap();
generate_and_persist(tmp.path(), TEST_PASSWORD, true).unwrap();
let mut bak: Option<PathBuf> = None;
for entry in fs::read_dir(tmp.path()).unwrap() {
let p = entry.unwrap().path();
let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
if name.starts_with("keystore.json.bak.") {
bak = Some(p);
break;
}
}
let bak = bak.expect("force should produce a `keystore.json.bak.<ts>` file");
let archived = fs::read(&bak).unwrap();
assert_eq!(
archived, original,
"bak file must hold the byte-for-byte prior keystore"
);
}
#[test]
fn round_trip_address_recovery() {
let tmp = make_data_dir();
let written = generate_and_persist(tmp.path(), TEST_PASSWORD, false).unwrap();
let signer = load_signer(&keystore_path(tmp.path()), TEST_PASSWORD).unwrap();
assert_eq!(
signer.address(),
written,
"loaded signer must derive to the address returned by generate"
);
}
#[test]
fn empty_password_round_trip() {
let tmp = make_data_dir();
let written = generate_and_persist(tmp.path(), "", false).unwrap();
let signer = load_signer(&keystore_path(tmp.path()), "").unwrap();
assert_eq!(
signer.address(),
written,
"an empty password must round-trip like any other"
);
}
#[test]
fn wrong_password_rejected_for_empty_keystore() {
let tmp = make_data_dir();
generate_and_persist(tmp.path(), "", false).unwrap();
let err = load_signer(&keystore_path(tmp.path()), TEST_PASSWORD).unwrap_err();
assert!(format!("{err:#}").contains("decrypt"), "got: {err:#}");
}
#[test]
fn wrong_password_rejected() {
let tmp = make_data_dir();
generate_and_persist(tmp.path(), TEST_PASSWORD, false).unwrap();
let err = load_signer(&keystore_path(tmp.path()), "definitely-wrong").unwrap_err();
let chain = format!("{err:#}");
assert!(
chain.contains("decrypt"),
"error should mention decrypt failure, got: {chain}"
);
assert!(
!chain.contains(TEST_PASSWORD),
"error must not echo the correct password, got: {chain}"
);
assert!(
!chain.contains("definitely-wrong"),
"error must not echo the attempted password, got: {chain}"
);
}
#[test]
fn missing_keystore_path_rejected() {
let tmp = make_data_dir();
let path = tmp.path().join("does-not-exist.json");
let err = load_signer(&path, TEST_PASSWORD).unwrap_err();
assert!(format!("{err:#}").contains("cannot access"), "got: {err:#}");
}
#[test]
fn malformed_json_rejected() {
let tmp = make_data_dir();
let path = keystore_path(tmp.path());
fs::write(&path, b"{not json").unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
let err = load_signer(&path, TEST_PASSWORD).unwrap_err();
assert!(format!("{err:#}").contains("decrypt"), "got: {err:#}");
}
#[test]
fn non_v3_keystore_rejected() {
let tmp = make_data_dir();
let path = keystore_path(tmp.path());
fs::write(&path, br#"{"version":1,"address":"0x0"}"#).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
let err = load_signer(&path, TEST_PASSWORD).unwrap_err();
assert!(format!("{err:#}").contains("decrypt"), "got: {err:#}");
}
#[test]
fn rejects_keystore_with_group_perms() {
let tmp = make_data_dir();
generate_and_persist(tmp.path(), TEST_PASSWORD, false).unwrap();
let path = keystore_path(tmp.path());
fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
let err = load_signer(&path, TEST_PASSWORD).unwrap_err();
assert!(
format!("{err:#}").contains("insecure permissions"),
"got: {err:#}"
);
}
#[test]
fn rejects_symlinked_keystore() {
let tmp = make_data_dir();
let real = tmp.path().join("real.json");
fs::write(&real, b"placeholder").unwrap();
fs::set_permissions(&real, fs::Permissions::from_mode(0o600)).unwrap();
let link = keystore_path(tmp.path());
std::os::unix::fs::symlink(&real, &link).unwrap();
let err = load_signer(&link, TEST_PASSWORD).unwrap_err();
assert!(
format!("{err:#}").contains("not a regular file"),
"got: {err:#}"
);
}
#[test]
fn first_matching_source_wins() {
let tmp = make_data_dir();
let first = tmp.path().join("first.txt");
let second = tmp.path().join("second.txt");
fs::write(&first, b"first-wins").unwrap();
fs::write(&second, b"second-loses").unwrap();
let pw = read_password(
&[PasswordSource::File(first), PasswordSource::File(second)],
"ignored",
)
.unwrap();
assert_eq!(pw.secret(), "first-wins");
}
#[test]
fn missing_file_source_falls_through() {
let tmp = make_data_dir();
let missing = tmp.path().join("not-there.txt");
let real = tmp.path().join("real.txt");
fs::write(&real, b"actual-pw").unwrap();
let pw = read_password(
&[PasswordSource::File(missing), PasswordSource::File(real)],
"ignored",
)
.unwrap();
assert_eq!(pw.secret(), "actual-pw");
}
#[test]
fn a_skipped_file_before_the_winner_warns_naming_the_path() {
let tmp = make_data_dir();
let missing = tmp.path().join("typo.txt");
let real = tmp.path().join("real.txt");
fs::write(&real, b"actual-pw").unwrap();
let resolved = read_password(
&[
PasswordSource::File(missing.clone()),
PasswordSource::File(real.clone()),
],
"ignored",
)
.unwrap();
assert_eq!(resolved.warnings().len(), 1, "{:?}", resolved.warnings());
let warning = resolved.warnings().first().unwrap();
assert!(
warning.contains(&missing.display().to_string()),
"{warning}"
);
assert!(warning.contains(&real.display().to_string()), "{warning}");
assert!(warning.contains("not found"), "{warning}");
assert!(
!warning.contains("actual-pw"),
"must not echo the password: {warning}"
);
}
#[test]
fn a_file_after_the_winner_warns_it_was_unused() {
let tmp = make_data_dir();
let winner = tmp.path().join("winner.txt");
let shadowed = tmp.path().join("shadowed.txt");
fs::write(&winner, b"winning-pw").unwrap();
fs::write(&shadowed, b"never-read").unwrap();
let resolved = read_password(
&[
PasswordSource::File(winner),
PasswordSource::File(shadowed.clone()),
],
"ignored",
)
.unwrap();
let warning = resolved.warnings().first().expect("one warning");
assert!(
warning.contains(&shadowed.display().to_string()),
"{warning}"
);
assert!(warning.contains("was not used"), "{warning}");
}
#[test]
fn a_clean_single_source_resolve_has_no_warnings() {
let tmp = make_data_dir();
let pw_file = tmp.path().join("pw.txt");
fs::write(&pw_file, b"hunter2").unwrap();
let resolved = read_password(&[PasswordSource::File(pw_file.clone())], "ignored").unwrap();
assert!(resolved.warnings().is_empty(), "{:?}", resolved.warnings());
assert_eq!(resolved.origin(), &PasswordOrigin::File(pw_file));
}
#[test]
fn password_file_trailing_newline_stripped() {
let tmp = make_data_dir();
let pw_file = tmp.path().join("pw.txt");
fs::write(&pw_file, b"hunter2\n").unwrap();
let pw = read_password(&[PasswordSource::File(pw_file)], "ignored").unwrap();
assert_eq!(pw.secret(), "hunter2");
}
#[test]
fn password_file_preserves_internal_newlines() {
let tmp = make_data_dir();
let pw_file = tmp.path().join("pw.txt");
fs::write(&pw_file, b"line1\nline2").unwrap();
let pw = read_password(&[PasswordSource::File(pw_file)], "ignored").unwrap();
assert_eq!(pw.secret(), "line1\nline2");
}
#[test]
fn password_file_strips_crlf() {
let tmp = make_data_dir();
let pw_file = tmp.path().join("pw.txt");
fs::write(&pw_file, b"hunter2\r\n").unwrap();
let pw = read_password(&[PasswordSource::File(pw_file)], "ignored").unwrap();
assert_eq!(pw.secret(), "hunter2");
}
#[test]
fn prompt_skipped_when_not_a_tty() {
let err = read_password(
&[PasswordSource::Prompt {
usage: PasswordUse::Unlock,
}],
"ignored",
)
.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("no keystore password source") && msg.contains("TTY"),
"got: {msg}"
);
}
#[test]
fn empty_password_file_is_an_empty_password() {
let tmp = make_data_dir();
let empty = tmp.path().join("empty.txt");
fs::write(&empty, b"").unwrap();
let pw = read_password(&[PasswordSource::File(empty)], "ignored").unwrap();
assert_eq!(pw.secret(), "");
}
#[test]
fn empty_password_file_beats_a_later_populated_source() {
let tmp = make_data_dir();
let empty = tmp.path().join("empty.txt");
let real = tmp.path().join("real.txt");
fs::write(&empty, b"").unwrap();
fs::write(&real, b"never-reached").unwrap();
let pw = read_password(
&[PasswordSource::File(empty), PasswordSource::File(real)],
"ignored",
)
.unwrap();
assert_eq!(pw.secret(), "");
}
#[test]
fn exhausted_sources_error_lists_every_skip_reason() {
const UNSET: &str = "DECDN_KEYSTORE_PASSWORD_ABSENT_IN_TESTS";
let tmp = make_data_dir();
let missing = tmp.path().join("nope.txt");
let err = read_password(
&[
PasswordSource::Env(UNSET),
PasswordSource::File(missing.clone()),
PasswordSource::Prompt {
usage: PasswordUse::Unlock,
},
],
"ignored",
)
.unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains(UNSET), "got: {msg}");
assert!(msg.contains(&missing.display().to_string()), "got: {msg}");
assert!(msg.contains("TTY"), "got: {msg}");
}
#[test]
fn missing_password_file_falls_through_and_names_the_path() {
let tmp = make_data_dir();
let missing = tmp.path().join("nope.txt");
let err = read_password(&[PasswordSource::File(missing.clone())], "ignored").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("no keystore password source"), "got: {msg}");
assert!(msg.contains(&missing.display().to_string()), "got: {msg}");
}
#[test]
fn password_file_with_only_a_newline_is_an_empty_password() {
let tmp = make_data_dir();
let pw_file = tmp.path().join("pw.txt");
fs::write(&pw_file, b"\n").unwrap();
let pw = read_password(&[PasswordSource::File(pw_file)], "ignored").unwrap();
assert_eq!(pw.secret(), "");
}
#[test]
fn unreadable_password_file_is_fatal() {
let tmp = make_data_dir();
let dir = tmp.path().join("a-directory");
fs::create_dir(&dir).unwrap();
let fallback = tmp.path().join("fallback.txt");
fs::write(&fallback, b"never-reached").unwrap();
let err = read_password(
&[
PasswordSource::File(dir.clone()),
PasswordSource::File(fallback),
],
"ignored",
)
.unwrap_err();
let chain = format!("{err:#}");
assert!(
chain.contains("failed to read password file"),
"got: {chain}"
);
assert!(
chain.contains(&dir.display().to_string()),
"error must name the offending path, got: {chain}"
);
assert!(
!chain.contains("no keystore password source"),
"an unreadable file must not fall through to the next source: {chain}"
);
}
sol! {
#[allow(missing_docs)]
struct BindNodeId {
bytes32 nodeId;
uint64 nonce;
}
}
#[test]
fn eip712_loopback_sign_and_recover() {
let tmp = make_data_dir();
let written = generate_and_persist(tmp.path(), TEST_PASSWORD, false).unwrap();
let signer = load_signer(&keystore_path(tmp.path()), TEST_PASSWORD)
.unwrap()
.with_chain_id(Some(421_614));
let domain = eip712_domain! {
name: "decdn-test",
version: "1",
chain_id: 421_614,
verifying_contract: Address::ZERO,
};
let binding = BindNodeId {
nodeId: B256::from([0xaau8; 32]),
nonce: 1,
};
let signing_hash: B256 = binding.eip712_signing_hash(&domain);
let sig = signer.sign_hash_sync(&signing_hash).unwrap();
let recovered = sig.recover_address_from_prehash(&signing_hash).unwrap();
assert_eq!(
recovered, written,
"recovered EIP-712 signer must match the keystore-derived address"
);
}
#[test]
fn bind_node_id_type_hash_matches_adr_003() {
let canonical: &[u8] = b"BindNodeId(bytes32 nodeId,uint64 nonce)";
let expected = keccak256(canonical);
let actual = keccak256(BindNodeId::eip712_root_type().as_bytes());
assert_eq!(
actual, expected,
"BindNodeId type hash drifted from ADR 003 §EIP-712 NodeId-to-Ethereum Binding"
);
}