feat(notgraph): heatmap coloring, symbol counts, per-crate module graphs, cycle callouts

This commit is contained in:
Joseph O'Brien
2026-04-02 20:53:07 -04:00
parent 5c14230935
commit b5a27108d0
28 changed files with 490 additions and 167 deletions

View File

@@ -1,10 +1,7 @@
use crate::error::AgeError;
use crate::format::{header_bytes_up_to_footer, parse_header};
use crate::identities::{FileKey, Identity};
use chacha20poly1305::{
aead::Aead,
ChaCha20Poly1305, Key, KeyInit, Nonce,
};
use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce, aead::Aead};
use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use sha2::Sha256;
@@ -86,9 +83,9 @@ fn derive_payload_key(file_key: &FileKey, nonce: &[u8; 16]) -> Result<[u8; 32],
#[cfg(test)]
mod tests {
use super::*;
use crate::Encryptor;
use crate::identities::x25519::X25519Identity;
use crate::recipients::x25519::X25519Recipient;
use crate::Encryptor;
use rand::rngs::OsRng;
use x25519_dalek::{PublicKey, StaticSecret};
@@ -116,11 +113,8 @@ mod tests {
fn decryptor_roundtrip_multiple_recipients() {
let (identity1, recipient1) = make_x25519_pair();
let (identity2, recipient2) = make_x25519_pair();
let encryptor = Encryptor::with_recipients(vec![
Box::new(recipient1),
Box::new(recipient2),
])
.unwrap();
let encryptor =
Encryptor::with_recipients(vec![Box::new(recipient1), Box::new(recipient2)]).unwrap();
let plaintext = b"multi-recipient test";
let ciphertext = encryptor.encrypt(plaintext).unwrap();
let decryptor1 = Decryptor::with_identities(vec![Box::new(identity1)]);

View File

@@ -2,10 +2,7 @@ use crate::error::AgeError;
use crate::format::serialize_header;
use crate::identities::{FileKey, Header};
use crate::recipients::Recipient;
use chacha20poly1305::{
aead::Aead,
ChaCha20Poly1305, Key, KeyInit, Nonce,
};
use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce, aead::Aead};
use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use rand::RngCore;
@@ -129,7 +126,9 @@ mod tests {
Box::new(X25519Recipient::from_public_key(public));
let encryptor = Encryptor::with_recipients(vec![recipient]).unwrap();
let plaintext = b"hello, age!";
let ciphertext = encryptor.encrypt(plaintext).expect("encrypt should succeed");
let ciphertext = encryptor
.encrypt(plaintext)
.expect("encrypt should succeed");
assert!(
ciphertext.starts_with(b"age-encryption.org/v1\n"),
"output must start with age version line"

View File

@@ -11,9 +11,7 @@ const FOOTER_PREFIX: &[u8] = b"--- ";
/// index into `input` where the payload (nonce + ciphertext) begins.
pub fn parse_header(input: &[u8]) -> Result<(Header, usize), AgeError> {
if !input.starts_with(VERSION_LINE) {
return Err(AgeError::ParseError(
"missing age version line".to_string(),
));
return Err(AgeError::ParseError("missing age version line".to_string()));
}
let mut pos = VERSION_LINE.len();
@@ -176,11 +174,15 @@ mod tests {
assert_eq!(header.recipients[0].args.len(), 1);
assert!(header.recipients[0].body.is_empty());
// payload offset should point to the 32 bytes at the end
assert_eq!(&input[payload_offset..], {
let mut v = vec![0x01u8; 16];
v.extend_from_slice(&[0x02u8; 16]);
v
}.as_slice());
assert_eq!(
&input[payload_offset..],
{
let mut v = vec![0x01u8; 16];
v.extend_from_slice(&[0x02u8; 16]);
v
}
.as_slice()
);
}
#[test]
@@ -208,7 +210,10 @@ mod tests {
fn parse_missing_version_line_returns_error() {
let input = b"not-age-encryption\n-> X25519 arg\n\n--- AAAA\n";
let result = parse_header(input);
assert!(result.is_err(), "expected ParseError for missing version line");
assert!(
result.is_err(),
"expected ParseError for missing version line"
);
}
#[test]
@@ -233,7 +238,10 @@ mod tests {
args: vec!["salt".to_string(), "18".to_string()],
body: body.clone(),
};
let header = Header { recipients: vec![stanza], mac: vec![0u8; 32] };
let header = Header {
recipients: vec![stanza],
mac: vec![0u8; 32],
};
let serialized = serialize_header(&header);
let mut with_payload = serialized;
with_payload.extend_from_slice(&[0u8; 32]);

View File

@@ -36,7 +36,7 @@ impl Identity for EncryptedIdentity {
Err(_) => {
return Some(Err(AgeError::ParseError(
"decrypted identity file is not valid UTF-8".to_string(),
)))
)));
}
};
// Parse inner identity — only X25519 supported for now

View File

@@ -39,9 +39,9 @@ impl FileKey {
impl TryFrom<&[u8]> for FileKey {
type Error = AgeError;
fn try_from(b: &[u8]) -> Result<Self, AgeError> {
b.try_into()
.map(Self)
.map_err(|_| AgeError::ParseError(format!("file key must be 16 bytes, got {}", b.len())))
b.try_into().map(Self).map_err(|_| {
AgeError::ParseError(format!("file key must be 16 bytes, got {}", b.len()))
})
}
}

View File

@@ -63,8 +63,8 @@ pub(crate) fn derive_scrypt_key(
mod tests {
use super::*;
use crate::identities::FileKey;
use crate::recipients::scrypt::ScryptRecipient;
use crate::recipients::Recipient;
use crate::recipients::scrypt::ScryptRecipient;
#[test]
fn scrypt_wrap_unwrap_roundtrip() {
@@ -73,7 +73,9 @@ mod tests {
let identity = ScryptIdentity::new(passphrase);
let file_key = FileKey::new([0x11u8; 16]);
let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed");
let stanza = recipient
.wrap_file_key(&file_key)
.expect("wrap should succeed");
assert_eq!(stanza.tag, "scrypt");
assert_eq!(stanza.args.len(), 2);

View File

@@ -46,7 +46,11 @@ impl Identity for SshEd25519Identity {
}
let fp_bytes = match STANDARD_NO_PAD.decode(&stanza.args[0]) {
Ok(b) => b,
Err(e) => return Some(Err(AgeError::ParseError(format!("fingerprint base64: {e}")))),
Err(e) => {
return Some(Err(AgeError::ParseError(format!(
"fingerprint base64: {e}"
))));
}
};
if fp_bytes.as_slice() != self.fingerprint() {
return None;
@@ -68,8 +72,11 @@ fn unwrap(stanza: &Stanza, identity: &SshEd25519Identity) -> Result<FileKey, Age
let shared = x25519_secret.diffie_hellman(&ephemeral_pub);
let x25519_pub = X25519PublicKey::from(&x25519_secret);
let wrap_key =
derive_wrap_key(shared.as_bytes(), ephemeral_pub.as_bytes(), x25519_pub.as_bytes())?;
let wrap_key = derive_wrap_key(
shared.as_bytes(),
ephemeral_pub.as_bytes(),
x25519_pub.as_bytes(),
)?;
let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key));
let nonce = Nonce::default();
@@ -104,8 +111,8 @@ pub(crate) fn derive_wrap_key(
mod tests {
use super::*;
use crate::identities::FileKey;
use crate::recipients::ssh_ed25519::SshEd25519Recipient;
use crate::recipients::Recipient;
use crate::recipients::ssh_ed25519::SshEd25519Recipient;
fn test_signing_key() -> SigningKey {
use rand::rngs::OsRng;
@@ -123,7 +130,9 @@ mod tests {
let identity = SshEd25519Identity::from_signing_key(signing_key);
let file_key = FileKey::new([0x33u8; 16]);
let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed");
let stanza = recipient
.wrap_file_key(&file_key)
.expect("wrap should succeed");
assert_eq!(stanza.tag, "ssh-ed25519");
assert_eq!(stanza.args.len(), 2);

View File

@@ -32,7 +32,11 @@ impl Identity for SshRsaIdentity {
}
let fp = match STANDARD_NO_PAD.decode(&stanza.args[0]) {
Ok(b) => b,
Err(e) => return Some(Err(AgeError::ParseError(format!("fingerprint base64: {e}")))),
Err(e) => {
return Some(Err(AgeError::ParseError(format!(
"fingerprint base64: {e}"
))));
}
};
if fp.as_slice() != self.fingerprint() {
return None;
@@ -62,8 +66,8 @@ pub(crate) fn rsa_pubkey_fingerprint(public_key: &RsaPublicKey) -> [u8; 4] {
#[cfg(test)]
mod tests {
use super::*;
use crate::recipients::ssh_rsa::SshRsaRecipient;
use crate::recipients::Recipient;
use crate::recipients::ssh_rsa::SshRsaRecipient;
use rand::rngs::OsRng;
fn test_rsa_keypair() -> (RsaPrivateKey, RsaPublicKey) {
@@ -79,7 +83,9 @@ mod tests {
let identity = SshRsaIdentity::from_private_key(private_key);
let file_key = FileKey::new([0x55u8; 16]);
let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed");
let stanza = recipient
.wrap_file_key(&file_key)
.expect("wrap should succeed");
assert_eq!(stanza.tag, "ssh-rsa");
assert_eq!(stanza.args.len(), 1);

View File

@@ -119,7 +119,9 @@ mod tests {
let recipient = X25519Recipient::from_public_key(public);
let identity = X25519Identity::from_static_secret(StaticSecret::from(secret_bytes));
let file_key = FileKey::new([0x42u8; 16]);
let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed");
let stanza = recipient
.wrap_file_key(&file_key)
.expect("wrap should succeed");
assert_eq!(stanza.tag, "X25519");
assert_eq!(stanza.args.len(), 1);
let unwrapped = identity
@@ -148,7 +150,8 @@ mod tests {
let public = PublicKey::from(&secret);
let recipient_str = X25519Recipient::from_public_key(public).to_bech32();
assert!(recipient_str.starts_with("age1"));
let identity_str = X25519Identity::from_static_secret(StaticSecret::from(secret_bytes)).to_bech32();
let identity_str =
X25519Identity::from_static_secret(StaticSecret::from(secret_bytes)).to_bech32();
assert!(identity_str.starts_with("AGE-SECRET-KEY-1"));
}

View File

@@ -90,10 +90,8 @@ mod tests {
#[test]
fn resolve_identities_partial_success_returns_loaded() {
let sources: Vec<Box<dyn IdentitySource>> = vec![
Box::new(AlwaysFailSource),
Box::new(StaticX25519Source),
];
let sources: Vec<Box<dyn IdentitySource>> =
vec![Box::new(AlwaysFailSource), Box::new(StaticX25519Source)];
let ids = resolve_identities(sources).expect("at least one source succeeded");
assert_eq!(ids.len(), 1);
}

View File

@@ -1,6 +1,6 @@
use crate::error::AgeError;
use crate::identities::{FileKey, Stanza};
use crate::identities::scrypt::derive_scrypt_key;
use crate::identities::{FileKey, Stanza};
use crate::recipients::Recipient;
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce, aead::Aead};
@@ -17,7 +17,10 @@ pub struct ScryptRecipient {
impl ScryptRecipient {
/// `work_factor` is log2(N). Use 18 in production, 14 in tests.
pub fn new(passphrase: String, work_factor: u8) -> Self {
Self { passphrase, work_factor }
Self {
passphrase,
work_factor,
}
}
}
@@ -35,10 +38,7 @@ impl Recipient for ScryptRecipient {
Ok(Stanza {
tag: TAG.to_string(),
args: vec![
STANDARD_NO_PAD.encode(salt),
self.work_factor.to_string(),
],
args: vec![STANDARD_NO_PAD.encode(salt), self.work_factor.to_string()],
body: wrapped,
})
}

View File

@@ -1,6 +1,6 @@
use crate::error::AgeError;
use crate::identities::{FileKey, Stanza};
use crate::identities::ssh_rsa::rsa_pubkey_fingerprint;
use crate::identities::{FileKey, Stanza};
use crate::recipients::Recipient;
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
use rsa::{Oaep, RsaPublicKey};

View File

@@ -1,6 +1,6 @@
use crate::error::AgeError;
use crate::identities::{FileKey, Stanza};
use crate::identities::x25519::derive_wrap_key;
use crate::identities::{FileKey, Stanza};
use crate::recipients::Recipient;
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
use bech32::{Bech32, Hrp};

View File

@@ -10,7 +10,9 @@ pub struct BitwardenSource {
impl BitwardenSource {
pub fn new(item_name: impl Into<String>) -> Self {
Self { item_name: item_name.into() }
Self {
item_name: item_name.into(),
}
}
fn retrieve_key(&self) -> Result<String, AgeError> {
@@ -28,10 +30,12 @@ impl BitwardenSource {
let session = std::env::var("BW_SESSION").unwrap_or_default();
let session = if session.is_empty() {
let password = rpassword::prompt_password("Bitwarden master password: ")
.map_err(|e| AgeError::SourceError {
name: self.name().to_string(),
source: anyhow::anyhow!("could not read password: {e}"),
let password =
rpassword::prompt_password("Bitwarden master password: ").map_err(|e| {
AgeError::SourceError {
name: self.name().to_string(),
source: anyhow::anyhow!("could not read password: {e}"),
}
})?;
let output = Command::new("bw")
.args(["unlock", "--raw", &password])
@@ -101,10 +105,11 @@ impl IdentitySource for BitwardenSource {
fn load(&self) -> Result<Box<dyn Identity>, AgeError> {
let key = self.retrieve_key()?;
let identity = X25519Identity::from_bech32(key.trim()).map_err(|e| AgeError::SourceError {
name: self.name().to_string(),
source: anyhow::anyhow!("key is not a valid AGE-SECRET-KEY-1: {e}"),
})?;
let identity =
X25519Identity::from_bech32(key.trim()).map_err(|e| AgeError::SourceError {
name: self.name().to_string(),
source: anyhow::anyhow!("key is not a valid AGE-SECRET-KEY-1: {e}"),
})?;
Ok(Box::new(identity))
}
}

View File

@@ -1,7 +1,7 @@
use crate::error::AgeError;
use crate::identities::Identity;
use crate::identities::encrypted::EncryptedIdentity;
use crate::identities::x25519::X25519Identity;
use crate::identities::Identity;
use crate::sources::IdentitySource;
use std::path::PathBuf;
@@ -33,19 +33,19 @@ impl IdentitySource for FileSource {
source: anyhow::anyhow!("key file UTF-8: {e}"),
})?
.trim();
let identity = X25519Identity::from_bech32(key_str).map_err(|e| AgeError::SourceError {
name: self.name().to_string(),
source: anyhow::anyhow!("invalid AGE-SECRET-KEY-1: {e}"),
})?;
let identity =
X25519Identity::from_bech32(key_str).map_err(|e| AgeError::SourceError {
name: self.name().to_string(),
source: anyhow::anyhow!("invalid AGE-SECRET-KEY-1: {e}"),
})?;
Ok(Box::new(identity))
} else if content.starts_with(b"age-encryption.org/v1") {
let passphrase = rpassword::prompt_password(
"Enter passphrase for encrypted identity file: ",
)
.map_err(|e| AgeError::SourceError {
name: self.name().to_string(),
source: anyhow::anyhow!("could not read passphrase: {e}"),
})?;
let passphrase =
rpassword::prompt_password("Enter passphrase for encrypted identity file: ")
.map_err(|e| AgeError::SourceError {
name: self.name().to_string(),
source: anyhow::anyhow!("could not read passphrase: {e}"),
})?;
Ok(Box::new(EncryptedIdentity::new(content, passphrase)))
} else {
Err(AgeError::UnsupportedKeyType(format!(

View File

@@ -1,6 +1,6 @@
use crate::error::AgeError;
use crate::identities::scrypt::ScryptIdentity;
use crate::identities::Identity;
use crate::identities::scrypt::ScryptIdentity;
use crate::sources::IdentitySource;
pub struct PromptSource;