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,6 +1,4 @@
use crate::types::{ use crate::types::{CrateGraph, FanStats, GraphStats, Hotspot, HotspotKind, ModStats, ModuleGraph};
CrateGraph, FanStats, GraphStats, Hotspot, HotspotKind, ModStats, ModuleGraph,
};
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
pub fn fan_stats(nodes: &[String], edges: &[(String, String)]) -> Vec<FanStats> { pub fn fan_stats(nodes: &[String], edges: &[(String, String)]) -> Vec<FanStats> {
@@ -10,11 +8,14 @@ pub fn fan_stats(nodes: &[String], edges: &[(String, String)]) -> Vec<FanStats>
*fan_out.entry(from.as_str()).or_insert(0) += 1; *fan_out.entry(from.as_str()).or_insert(0) += 1;
*fan_in.entry(to.as_str()).or_insert(0) += 1; *fan_in.entry(to.as_str()).or_insert(0) += 1;
} }
nodes.iter().map(|n| FanStats { nodes
.iter()
.map(|n| FanStats {
name: n.clone(), name: n.clone(),
fan_in: *fan_in.get(n.as_str()).unwrap_or(&0), fan_in: *fan_in.get(n.as_str()).unwrap_or(&0),
fan_out: *fan_out.get(n.as_str()).unwrap_or(&0), fan_out: *fan_out.get(n.as_str()).unwrap_or(&0),
}).collect() })
.collect()
} }
/// Kahn's algorithm. Returns cycle participants if graph is not a DAG. /// Kahn's algorithm. Returns cycle participants if graph is not a DAG.
@@ -30,7 +31,8 @@ pub fn detect_cycles(nodes: &[String], edges: &[(String, String)]) -> Vec<Vec<St
} }
} }
let mut queue: VecDeque<&str> = in_degree.iter() let mut queue: VecDeque<&str> = in_degree
.iter()
.filter(|&(_, &d)| d == 0) .filter(|&(_, &d)| d == 0)
.map(|(n, _)| *n) .map(|(n, _)| *n)
.collect(); .collect();
@@ -51,7 +53,8 @@ pub fn detect_cycles(nodes: &[String], edges: &[(String, String)]) -> Vec<Vec<St
return vec![]; return vec![];
} }
let cycle_nodes: Vec<String> = in_degree.iter() let cycle_nodes: Vec<String> = in_degree
.iter()
.filter(|&(_, &d)| d > 0) .filter(|&(_, &d)| d > 0)
.map(|(n, _)| n.to_string()) .map(|(n, _)| n.to_string())
.collect(); .collect();
@@ -66,7 +69,11 @@ pub fn hotspots(stats: &[FanStats], top_n: usize) -> Vec<Hotspot> {
by_fan_in.sort_by(|a, b| b.fan_in.cmp(&a.fan_in)); by_fan_in.sort_by(|a, b| b.fan_in.cmp(&a.fan_in));
for s in by_fan_in.iter().take(top_n) { for s in by_fan_in.iter().take(top_n) {
if s.fan_in > 0 { if s.fan_in > 0 {
result.push(Hotspot { name: s.name.clone(), kind: HotspotKind::FanIn, score: s.fan_in }); result.push(Hotspot {
name: s.name.clone(),
kind: HotspotKind::FanIn,
score: s.fan_in,
});
} }
} }
@@ -74,14 +81,22 @@ pub fn hotspots(stats: &[FanStats], top_n: usize) -> Vec<Hotspot> {
by_fan_out.sort_by(|a, b| b.fan_out.cmp(&a.fan_out)); by_fan_out.sort_by(|a, b| b.fan_out.cmp(&a.fan_out));
for s in by_fan_out.iter().take(top_n) { for s in by_fan_out.iter().take(top_n) {
if s.fan_out > 0 { if s.fan_out > 0 {
result.push(Hotspot { name: s.name.clone(), kind: HotspotKind::FanOut, score: s.fan_out }); result.push(Hotspot {
name: s.name.clone(),
kind: HotspotKind::FanOut,
score: s.fan_out,
});
} }
} }
result result
} }
pub fn analyse(crate_graph: &CrateGraph, module_graphs: &[ModuleGraph], top_n: usize) -> GraphStats { pub fn analyse(
crate_graph: &CrateGraph,
module_graphs: &[ModuleGraph],
top_n: usize,
) -> GraphStats {
let crate_stats = fan_stats(&crate_graph.nodes, &crate_graph.edges); let crate_stats = fan_stats(&crate_graph.nodes, &crate_graph.edges);
let crate_hotspots = hotspots(&crate_stats, top_n); let crate_hotspots = hotspots(&crate_stats, top_n);
@@ -92,10 +107,15 @@ pub fn analyse(crate_graph: &CrateGraph, module_graphs: &[ModuleGraph], top_n: u
let node_stats = fan_stats(&mg.nodes, &mg.edges); let node_stats = fan_stats(&mg.nodes, &mg.edges);
let cycles = detect_cycles(&mg.nodes, &mg.edges); let cycles = detect_cycles(&mg.nodes, &mg.edges);
all_cycles.extend(cycles.clone()); all_cycles.extend(cycles.clone());
mod_stats_list.push(ModStats { krate: mg.krate.clone(), nodes: node_stats, cycles }); mod_stats_list.push(ModStats {
krate: mg.krate.clone(),
nodes: node_stats,
cycles,
});
} }
let all_mod_fan: Vec<FanStats> = mod_stats_list.iter() let all_mod_fan: Vec<FanStats> = mod_stats_list
.iter()
.flat_map(|ms| ms.nodes.iter().cloned()) .flat_map(|ms| ms.nodes.iter().cloned())
.collect(); .collect();
let mod_hotspots = hotspots(&all_mod_fan, top_n); let mod_hotspots = hotspots(&all_mod_fan, top_n);
@@ -156,13 +176,28 @@ mod tests {
#[test] #[test]
fn hotspots_picks_top_n_by_fan_in_and_fan_out() { fn hotspots_picks_top_n_by_fan_in_and_fan_out() {
let stats = vec![ let stats = vec![
FanStats { name: "a".to_string(), fan_in: 5, fan_out: 1 }, FanStats {
FanStats { name: "b".to_string(), fan_in: 3, fan_out: 2 }, name: "a".to_string(),
FanStats { name: "c".to_string(), fan_in: 1, fan_out: 4 }, fan_in: 5,
fan_out: 1,
},
FanStats {
name: "b".to_string(),
fan_in: 3,
fan_out: 2,
},
FanStats {
name: "c".to_string(),
fan_in: 1,
fan_out: 4,
},
]; ];
let spots = hotspots(&stats, 1); let spots = hotspots(&stats, 1);
let fi = spots.iter().find(|h| h.kind == HotspotKind::FanIn).unwrap(); let fi = spots.iter().find(|h| h.kind == HotspotKind::FanIn).unwrap();
let fo = spots.iter().find(|h| h.kind == HotspotKind::FanOut).unwrap(); let fo = spots
.iter()
.find(|h| h.kind == HotspotKind::FanOut)
.unwrap();
assert_eq!(fi.name, "a"); assert_eq!(fi.name, "a");
assert_eq!(fo.name, "c"); assert_eq!(fo.name, "c");
} }

View File

@@ -17,7 +17,11 @@ pub fn build(manifest_path: &std::path::Path) -> Result<CrateGraph> {
let nodes: Vec<CrateName> = workspace_members.iter().cloned().collect(); let nodes: Vec<CrateName> = workspace_members.iter().cloned().collect();
let mut edges: Vec<(CrateName, CrateName)> = Vec::new(); let mut edges: Vec<(CrateName, CrateName)> = Vec::new();
for pkg in meta.packages.iter().filter(|p| workspace_members.contains(&p.name)) { for pkg in meta
.packages
.iter()
.filter(|p| workspace_members.contains(&p.name))
{
for dep in &pkg.dependencies { for dep in &pkg.dependencies {
if workspace_members.contains(&dep.name) { if workspace_members.contains(&dep.name) {
edges.push((pkg.name.clone(), dep.name.clone())); edges.push((pkg.name.clone(), dep.name.clone()));
@@ -35,15 +39,21 @@ mod tests {
#[test] #[test]
fn builds_crate_graph_for_workspace() { fn builds_crate_graph_for_workspace() {
let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent().unwrap() .parent()
.parent().unwrap() .unwrap()
.parent()
.unwrap()
.join("Cargo.toml"); .join("Cargo.toml");
let graph = build(&manifest).unwrap(); let graph = build(&manifest).unwrap();
assert!(graph.nodes.contains(&"notgraph".to_string())); assert!(graph.nodes.contains(&"notgraph".to_string()));
assert!(graph.nodes.contains(&"notcore".to_string())); assert!(graph.nodes.contains(&"notcore".to_string()));
assert!(graph.edges.contains(&("notstrap".to_string(), "notfiles".to_string()))); assert!(
graph
.edges
.contains(&("notstrap".to_string(), "notfiles".to_string()))
);
assert!(!graph.edges.iter().any(|(from, _)| from == "notcore")); assert!(!graph.edges.iter().any(|(from, _)| from == "notcore"));
} }
} }

View File

@@ -1,17 +1,18 @@
use crate::types::{CrateGraph, GraphStats, HotspotKind, SymbolKind, SymbolTable}; use crate::types::{CrateGraph, GraphStats, HotspotKind, ModuleGraph, SymbolKind, SymbolTable};
use anyhow::Result; use anyhow::Result;
use std::path::Path; use std::path::Path;
pub fn write_all( pub fn write_all(
output_dir: &Path, output_dir: &Path,
crate_graph: &CrateGraph, crate_graph: &CrateGraph,
module_graphs: &[ModuleGraph],
stats: &GraphStats, stats: &GraphStats,
symbol_tables: &[SymbolTable], symbol_tables: &[SymbolTable],
) -> Result<()> { ) -> Result<()> {
std::fs::create_dir_all(output_dir)?; std::fs::create_dir_all(output_dir)?;
write_json(output_dir, stats)?; write_json(output_dir, stats)?;
write_markdown(output_dir, stats, symbol_tables)?; write_markdown(output_dir, stats, symbol_tables)?;
write_html(output_dir, crate_graph, stats)?; write_html(output_dir, crate_graph, module_graphs, stats, symbol_tables)?;
Ok(()) Ok(())
} }
@@ -110,17 +111,54 @@ fn write_markdown(dir: &Path, stats: &GraphStats, symbol_tables: &[SymbolTable])
Ok(()) Ok(())
} }
fn write_html(dir: &Path, crate_graph: &CrateGraph, stats: &GraphStats) -> Result<()> { fn write_html(
dir: &Path,
crate_graph: &CrateGraph,
module_graphs: &[ModuleGraph],
stats: &GraphStats,
symbol_tables: &[SymbolTable],
) -> Result<()> {
let fan_map: std::collections::HashMap<&str, (usize, usize)> = stats let fan_map: std::collections::HashMap<&str, (usize, usize)> = stats
.crate_graph .crate_graph
.iter() .iter()
.map(|fs| (fs.name.as_str(), (fs.fan_in, fs.fan_out))) .map(|fs| (fs.name.as_str(), (fs.fan_in, fs.fan_out)))
.collect(); .collect();
// Classify nodes into subgraphs by topology: // Symbol counts per crate
// leaf = no outgoing edges to workspace crates (fan-out == 0) let sym_count: std::collections::HashMap<&str, usize> = symbol_tables
// root = no incoming edges from workspace crates (fan-in == 0) .iter()
// feature = everything else .map(|t| {
let count = t.symbols.iter().filter(|s| s.is_pub).count();
(t.krate.as_str(), count)
})
.collect();
// Nodes involved in any cycle (for red highlighting)
let cycle_nodes: std::collections::HashSet<&str> = stats
.cycles
.iter()
.flat_map(|c| c.iter().map(|n| n.as_str()))
.collect();
// Max fan-in for heatmap normalisation
let max_fan_in = stats
.crate_graph
.iter()
.map(|fs| fs.fan_in)
.max()
.unwrap_or(1)
.max(1);
// Heatmap: interpolate between dim blue and bright blue based on fan-in
let heatmap_color = |fan_in: usize| -> String {
let t = fan_in as f32 / max_fan_in as f32;
let r = (30.0 + t * 20.0) as u8;
let g = (58.0 + t * 80.0) as u8;
let b = (95.0 + t * 160.0) as u8;
format!("#{:02x}{:02x}{:02x}", r, g, b)
};
// Classify nodes into subgraphs by topology
let leaf_nodes: std::collections::HashSet<&str> = stats let leaf_nodes: std::collections::HashSet<&str> = stats
.crate_graph .crate_graph
.iter() .iter()
@@ -146,7 +184,6 @@ fn write_html(dir: &Path, crate_graph: &CrateGraph, stats: &GraphStats) -> Resul
.map(|n| n.as_str()) .map(|n| n.as_str())
.filter(|n| !leaf_nodes.contains(n) && !root_nodes.contains(n)) .filter(|n| !leaf_nodes.contains(n) && !root_nodes.contains(n))
.collect(); .collect();
// Nodes that are both leaf and root (isolated, e.g. notgraph) go into roots
let mut isolated: Vec<&str> = crate_graph.nodes.iter() let mut isolated: Vec<&str> = crate_graph.nodes.iter()
.map(|n| n.as_str()) .map(|n| n.as_str())
.filter(|n| leaf_nodes.contains(n) && root_nodes.contains(n)) .filter(|n| leaf_nodes.contains(n) && root_nodes.contains(n))
@@ -159,47 +196,114 @@ fn write_html(dir: &Path, crate_graph: &CrateGraph, stats: &GraphStats) -> Resul
let node_label = |n: &str| -> String { let node_label = |n: &str| -> String {
let (fi, fo) = fan_map.get(n).copied().unwrap_or((0, 0)); let (fi, fo) = fan_map.get(n).copied().unwrap_or((0, 0));
format!("{}[\"{}<br/>in:{} out:{}\"]", n, n, fi, fo) let syms = sym_count.get(n).copied().unwrap_or(0);
format!(
"{}[\"{}<br/>in:{} out:{} | {} pub\"]",
n, n, fi, fo, syms
)
}; };
// Mermaid flowchart: dependency -> dependent (LR) // ── Crate dependency graph ──────────────────────────────────────────────
let mut mermaid = String::from("flowchart LR\n"); let mut mermaid = String::from("flowchart LR\n");
if !leaves.is_empty() { if !leaves.is_empty() {
mermaid.push_str(" subgraph Core\n direction TB\n"); mermaid.push_str(" subgraph Core\n direction TB\n");
for n in &leaves { for n in &leaves { mermaid.push_str(&format!(" {}\n", node_label(n))); }
mermaid.push_str(&format!(" {}\n", node_label(n)));
}
mermaid.push_str(" end\n"); mermaid.push_str(" end\n");
} }
if !features.is_empty() { if !features.is_empty() {
mermaid.push_str(" subgraph Features\n direction TB\n"); mermaid.push_str(" subgraph Features\n direction TB\n");
for n in &features { for n in &features { mermaid.push_str(&format!(" {}\n", node_label(n))); }
mermaid.push_str(&format!(" {}\n", node_label(n)));
}
mermaid.push_str(" end\n"); mermaid.push_str(" end\n");
} }
if !roots.is_empty() { if !roots.is_empty() {
mermaid.push_str(" subgraph Tools\n direction TB\n"); mermaid.push_str(" subgraph Tools\n direction TB\n");
for n in &roots { for n in &roots { mermaid.push_str(&format!(" {}\n", node_label(n))); }
mermaid.push_str(&format!(" {}\n", node_label(n)));
}
mermaid.push_str(" end\n"); mermaid.push_str(" end\n");
} }
for (from, to) in &crate_graph.edges { for (from, to) in &crate_graph.edges {
// dependency -> dependent
mermaid.push_str(&format!(" {} --> {}\n", to, from)); mermaid.push_str(&format!(" {} --> {}\n", to, from));
} }
// Heatmap styles
for fs in &stats.crate_graph {
let color = heatmap_color(fs.fan_in);
mermaid.push_str(&format!(
" style {} fill:{},stroke:#4a9eff,color:#e0e0e0\n",
fs.name, color
));
}
// Cycle highlights (override heatmap)
for n in &cycle_nodes {
mermaid.push_str(&format!(
" style {} fill:#7a1a1a,stroke:#f44336,color:#ffffff\n",
n
));
}
let graph_diagram = mermaid; let graph_diagram = mermaid;
// Build cycle lookup from stats (ModuleGraph itself has no cycle data)
let mod_cycles: std::collections::HashMap<&str, &Vec<Vec<String>>> = stats
.module_graphs
.iter()
.map(|ms| (ms.krate.as_str(), &ms.cycles))
.collect();
// ── Per-crate module graphs ─────────────────────────────────────────────
let mut module_diagrams = String::new();
for mg in module_graphs {
// Collect cycle nodes for this crate
let empty: Vec<Vec<String>> = Vec::new();
let cycles = mod_cycles.get(mg.krate.as_str()).copied().unwrap_or(&empty);
let crate_cycle_nodes: std::collections::HashSet<&str> = cycles
.iter()
.flat_map(|c| c.iter().map(|n| n.as_str()))
.collect();
let mut d = format!("flowchart TD\n");
// Nodes: strip crate prefix for readable labels, skip ::tests modules
for node in &mg.nodes {
let short = node
.strip_prefix(&format!("{}::", mg.krate))
.unwrap_or(node.as_str());
// Sanitize id for Mermaid (replace :: with __)
let id = node.replace("::", "__");
d.push_str(&format!(" {}[\"{}\"]\n", id, short));
}
// Edges
for (from, to) in &mg.edges {
let from_id = from.replace("::", "__");
let to_id = to.replace("::", "__");
d.push_str(&format!(" {} --> {}\n", from_id, to_id));
}
// Cycle highlights
for n in &crate_cycle_nodes {
let id = n.replace("::", "__");
d.push_str(&format!(
" style {} fill:#7a1a1a,stroke:#f44336,color:#ffffff\n",
id
));
}
module_diagrams.push_str(&format!(
"<h3>{}</h3>\n<div class=\"graph-wrap\"><pre class=\"mermaid\">\n%%{{init: {{\"theme\":\"dark\"}}}}\n{}</pre></div>\n",
mg.krate, d
));
}
let cycle_html = if stats.cycles.is_empty() { let cycle_html = if stats.cycles.is_empty() {
"<p>No cycles detected.</p>".to_string() "<p>No cycles detected.</p>".to_string()
} else { } else {
stats stats
.cycles .cycles
.iter() .iter()
.map(|c| format!("<p>CYCLE: {}</p>", c.join(" -&gt; "))) .map(|c| format!("<p class=\"cycle-entry\">CYCLE: {}</p>", c.join(" &rarr; ")))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n") .join("\n")
}; };
@@ -207,18 +311,17 @@ fn write_html(dir: &Path, crate_graph: &CrateGraph, stats: &GraphStats) -> Resul
let hotspot_rows: String = stats let hotspot_rows: String = stats
.hotspots .hotspots
.iter() .iter()
.map(|h| { .map(|h| format!(
format!(
"<tr><td>{}</td><td>{}</td><td>{}</td></tr>", "<tr><td>{}</td><td>{}</td><td>{}</td></tr>",
h.name, h.kind, h.score h.name, h.kind, h.score
) ))
})
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
let html = format!( let html = format!(
include_str!("../templates/report.html.template"), include_str!("../templates/report.html.template"),
graph_diagram = graph_diagram, graph_diagram = graph_diagram,
module_diagrams = module_diagrams,
hotspot_rows = hotspot_rows, hotspot_rows = hotspot_rows,
cycle_html = cycle_html, cycle_html = cycle_html,
); );

View File

@@ -63,7 +63,7 @@ fn main() -> Result<()> {
workspace_root.join(&cli.output) workspace_root.join(&cli.output)
}; };
emit::write_all(&output_dir, &crate_g, &stats, &symbol_tables) emit::write_all(&output_dir, &crate_g, &module_graphs, &stats, &symbol_tables)
.context("failed to write reports")?; .context("failed to write reports")?;
println!("notgraph: reports written to {}", output_dir.display()); println!("notgraph: reports written to {}", output_dir.display());

View File

@@ -33,7 +33,6 @@ impl<'ast> Visit<'ast> for ModCollector {
} }
} }
pub fn build(krate: CrateName, src_dir: &Path) -> Result<ModuleGraph> { pub fn build(krate: CrateName, src_dir: &Path) -> Result<ModuleGraph> {
let mut all_nodes: Vec<ModPath> = Vec::new(); let mut all_nodes: Vec<ModPath> = Vec::new();
let mut all_edges: Vec<(ModPath, ModPath)> = Vec::new(); let mut all_edges: Vec<(ModPath, ModPath)> = Vec::new();
@@ -63,7 +62,11 @@ pub fn build(krate: CrateName, src_dir: &Path) -> Result<ModuleGraph> {
all_edges.sort(); all_edges.sort();
all_edges.dedup(); all_edges.dedup();
Ok(ModuleGraph { krate, nodes: all_nodes, edges: all_edges }) Ok(ModuleGraph {
krate,
nodes: all_nodes,
edges: all_edges,
})
} }
#[cfg(test)] #[cfg(test)]
@@ -72,8 +75,8 @@ mod tests {
#[test] #[test]
fn clean_fixture_has_expected_nodes() { fn clean_fixture_has_expected_nodes() {
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) let fixture =
.join("tests/fixtures/clean/src"); std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/clean/src");
let graph = build("clean".to_string(), &fixture).unwrap(); let graph = build("clean".to_string(), &fixture).unwrap();
assert!(graph.nodes.contains(&"clean".to_string())); assert!(graph.nodes.contains(&"clean".to_string()));
assert!(graph.nodes.contains(&"clean::alpha".to_string())); assert!(graph.nodes.contains(&"clean::alpha".to_string()));
@@ -82,10 +85,18 @@ mod tests {
#[test] #[test]
fn clean_fixture_has_edges_from_lib() { fn clean_fixture_has_edges_from_lib() {
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) let fixture =
.join("tests/fixtures/clean/src"); std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/clean/src");
let graph = build("clean".to_string(), &fixture).unwrap(); let graph = build("clean".to_string(), &fixture).unwrap();
assert!(graph.edges.contains(&("clean".to_string(), "clean::alpha".to_string()))); assert!(
assert!(graph.edges.contains(&("clean".to_string(), "clean::beta".to_string()))); graph
.edges
.contains(&("clean".to_string(), "clean::alpha".to_string()))
);
assert!(
graph
.edges
.contains(&("clean".to_string(), "clean::beta".to_string()))
);
} }
} }

View File

@@ -11,7 +11,10 @@ struct SymbolCollector {
impl SymbolCollector { impl SymbolCollector {
fn new(mod_path: ModPath) -> Self { fn new(mod_path: ModPath) -> Self {
Self { mod_path, symbols: Vec::new() } Self {
mod_path,
symbols: Vec::new(),
}
} }
fn is_pub(vis: &Visibility) -> bool { fn is_pub(vis: &Visibility) -> bool {
@@ -19,28 +22,57 @@ impl SymbolCollector {
} }
fn push(&mut self, kind: SymbolKind, name: String, is_pub: bool) { fn push(&mut self, kind: SymbolKind, name: String, is_pub: bool) {
self.symbols.push(Symbol { mod_path: self.mod_path.clone(), kind, name, is_pub }); self.symbols.push(Symbol {
mod_path: self.mod_path.clone(),
kind,
name,
is_pub,
});
} }
} }
impl<'ast> Visit<'ast> for SymbolCollector { impl<'ast> Visit<'ast> for SymbolCollector {
fn visit_item_struct(&mut self, node: &'ast syn::ItemStruct) { fn visit_item_struct(&mut self, node: &'ast syn::ItemStruct) {
self.push(SymbolKind::Struct, node.ident.to_string(), Self::is_pub(&node.vis)); self.push(
SymbolKind::Struct,
node.ident.to_string(),
Self::is_pub(&node.vis),
);
} }
fn visit_item_enum(&mut self, node: &'ast syn::ItemEnum) { fn visit_item_enum(&mut self, node: &'ast syn::ItemEnum) {
self.push(SymbolKind::Enum, node.ident.to_string(), Self::is_pub(&node.vis)); self.push(
SymbolKind::Enum,
node.ident.to_string(),
Self::is_pub(&node.vis),
);
} }
fn visit_item_trait(&mut self, node: &'ast syn::ItemTrait) { fn visit_item_trait(&mut self, node: &'ast syn::ItemTrait) {
self.push(SymbolKind::Trait, node.ident.to_string(), Self::is_pub(&node.vis)); self.push(
SymbolKind::Trait,
node.ident.to_string(),
Self::is_pub(&node.vis),
);
} }
fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) { fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
self.push(SymbolKind::Fn, node.sig.ident.to_string(), Self::is_pub(&node.vis)); self.push(
SymbolKind::Fn,
node.sig.ident.to_string(),
Self::is_pub(&node.vis),
);
} }
fn visit_item_type(&mut self, node: &'ast syn::ItemType) { fn visit_item_type(&mut self, node: &'ast syn::ItemType) {
self.push(SymbolKind::Type, node.ident.to_string(), Self::is_pub(&node.vis)); self.push(
SymbolKind::Type,
node.ident.to_string(),
Self::is_pub(&node.vis),
);
} }
fn visit_item_const(&mut self, node: &'ast syn::ItemConst) { fn visit_item_const(&mut self, node: &'ast syn::ItemConst) {
self.push(SymbolKind::Const, node.ident.to_string(), Self::is_pub(&node.vis)); self.push(
SymbolKind::Const,
node.ident.to_string(),
Self::is_pub(&node.vis),
);
} }
fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) { fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
let child_path = format!("{}::{}", self.mod_path, node.ident); let child_path = format!("{}::{}", self.mod_path, node.ident);
@@ -50,7 +82,6 @@ impl<'ast> Visit<'ast> for SymbolCollector {
} }
} }
pub fn build(krate: CrateName, src_dir: &Path) -> Result<SymbolTable> { pub fn build(krate: CrateName, src_dir: &Path) -> Result<SymbolTable> {
let mut symbols: Vec<Symbol> = Vec::new(); let mut symbols: Vec<Symbol> = Vec::new();
for entry in WalkDir::new(src_dir) for entry in WalkDir::new(src_dir)
@@ -75,45 +106,69 @@ mod tests {
use super::*; use super::*;
fn fixture_table() -> SymbolTable { fn fixture_table() -> SymbolTable {
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) let fixture =
.join("tests/fixtures/symbols/src"); std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/symbols/src");
build("symbols".to_string(), &fixture).unwrap() build("symbols".to_string(), &fixture).unwrap()
} }
#[test] #[test]
fn detects_pub_struct() { fn detects_pub_struct() {
let t = fixture_table(); let t = fixture_table();
assert!(t.symbols.iter().any(|s| s.name == "Foo" && s.kind == SymbolKind::Struct && s.is_pub)); assert!(
t.symbols
.iter()
.any(|s| s.name == "Foo" && s.kind == SymbolKind::Struct && s.is_pub)
);
} }
#[test] #[test]
fn detects_pub_enum() { fn detects_pub_enum() {
let t = fixture_table(); let t = fixture_table();
assert!(t.symbols.iter().any(|s| s.name == "Bar" && s.kind == SymbolKind::Enum && s.is_pub)); assert!(
t.symbols
.iter()
.any(|s| s.name == "Bar" && s.kind == SymbolKind::Enum && s.is_pub)
);
} }
#[test] #[test]
fn detects_pub_trait() { fn detects_pub_trait() {
let t = fixture_table(); let t = fixture_table();
assert!(t.symbols.iter().any(|s| s.name == "Baz" && s.kind == SymbolKind::Trait && s.is_pub)); assert!(
t.symbols
.iter()
.any(|s| s.name == "Baz" && s.kind == SymbolKind::Trait && s.is_pub)
);
} }
#[test] #[test]
fn detects_pub_fn() { fn detects_pub_fn() {
let t = fixture_table(); let t = fixture_table();
assert!(t.symbols.iter().any(|s| s.name == "qux" && s.kind == SymbolKind::Fn && s.is_pub)); assert!(
t.symbols
.iter()
.any(|s| s.name == "qux" && s.kind == SymbolKind::Fn && s.is_pub)
);
} }
#[test] #[test]
fn detects_pub_type_alias() { fn detects_pub_type_alias() {
let t = fixture_table(); let t = fixture_table();
assert!(t.symbols.iter().any(|s| s.name == "Alias" && s.kind == SymbolKind::Type && s.is_pub)); assert!(
t.symbols
.iter()
.any(|s| s.name == "Alias" && s.kind == SymbolKind::Type && s.is_pub)
);
} }
#[test] #[test]
fn detects_pub_const() { fn detects_pub_const() {
let t = fixture_table(); let t = fixture_table();
assert!(t.symbols.iter().any(|s| s.name == "VALUE" && s.kind == SymbolKind::Const && s.is_pub)); assert!(
t.symbols
.iter()
.any(|s| s.name == "VALUE" && s.kind == SymbolKind::Const && s.is_pub)
);
} }
#[test] #[test]

View File

@@ -13,12 +13,15 @@
--text: #e0e0e0; --text: #e0e0e0;
--accent: #4a9eff; --accent: #4a9eff;
--heading-border: #4a9eff; --heading-border: #4a9eff;
--cycle-bg: #3a1a1a;
--cycle-text: #f44336;
}} }}
* {{ box-sizing: border-box; }} * {{ box-sizing: border-box; }}
body {{ font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 2rem; max-width: 1200px; }} body {{ font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 2rem; max-width: 1200px; }}
h1 {{ color: var(--accent); margin-bottom: 0.5rem; }} h1 {{ color: var(--accent); margin-bottom: 0.5rem; }}
h2 {{ border-bottom: 2px solid var(--heading-border); padding-bottom: 0.3rem; color: var(--text); margin-top: 2rem; }} h2 {{ border-bottom: 2px solid var(--heading-border); padding-bottom: 0.3rem; color: var(--text); margin-top: 2.5rem; }}
.graph-wrap {{ background: var(--bg2); border: 1px solid var(--border); border-radius: 4px; padding: 1.5rem; margin-bottom: 2rem; overflow-x: auto; }} h3 {{ color: var(--accent); margin-top: 1.5rem; margin-bottom: 0.5rem; }}
.graph-wrap {{ background: var(--bg2); border: 1px solid var(--border); border-radius: 4px; padding: 1.5rem; margin-bottom: 1.5rem; overflow-x: auto; }}
table {{ border-collapse: collapse; width: 100%; margin-bottom: 2rem; }} table {{ border-collapse: collapse; width: 100%; margin-bottom: 2rem; }}
th, td {{ border: 1px solid var(--border); padding: 0.4rem 0.8rem; text-align: left; }} th, td {{ border: 1px solid var(--border); padding: 0.4rem 0.8rem; text-align: left; }}
th {{ background: var(--bg3); cursor: pointer; user-select: none; color: var(--accent); }} th {{ background: var(--bg3); cursor: pointer; user-select: none; color: var(--accent); }}
@@ -26,16 +29,30 @@
td {{ background: var(--bg2); }} td {{ background: var(--bg2); }}
tr:hover td {{ background: var(--bg3); }} tr:hover td {{ background: var(--bg3); }}
p {{ color: var(--text); }} p {{ color: var(--text); }}
.cycle-entry {{ background: var(--cycle-bg); color: var(--cycle-text); padding: 0.4rem 0.8rem; border-left: 3px solid var(--cycle-text); border-radius: 2px; font-family: monospace; }}
.legend {{ display: flex; gap: 1.5rem; margin-bottom: 1rem; font-size: 0.85rem; color: #888; }}
.legend-item {{ display: flex; align-items: center; gap: 0.4rem; }}
.legend-swatch {{ width: 14px; height: 14px; border-radius: 2px; border: 1px solid #4a9eff; }}
</style> </style>
</head> </head>
<body> <body>
<h1>notgraph Report</h1> <h1>notgraph Report</h1>
<h2>Crate Dependency Graph</h2> <h2>Crate Dependency Graph</h2>
<div class="legend">
<div class="legend-item"><div class="legend-swatch" style="background:#1e3a5f"></div> low fan-in</div>
<div class="legend-item"><div class="legend-swatch" style="background:#1e72ff"></div> high fan-in</div>
<div class="legend-item"><div class="legend-swatch" style="background:#7a1a1a;border-color:#f44336"></div> cycle</div>
</div>
<div class="graph-wrap"> <div class="graph-wrap">
<pre class="mermaid"> <pre class="mermaid">
%%{{init: {{'theme':'dark','themeVariables':{{'darkMode':true,'background':'#242424','primaryColor':'#1e3a5f','primaryBorderColor':'#4a9eff','primaryTextColor':'#e0e0e0','lineColor':'#4a9eff','edgeLabelBackground':'#242424'}}}}}}%% %%{{init: {{'theme':'dark','themeVariables':{{'darkMode':true,'background':'#242424','primaryColor':'#1e3a5f','primaryBorderColor':'#4a9eff','primaryTextColor':'#e0e0e0','lineColor':'#4a9eff','edgeLabelBackground':'#242424'}}}}}}%%
{graph_diagram}</pre> {graph_diagram}</pre>
</div> </div>
<h2>Module Graphs</h2>
{module_diagrams}
<h2>Hotspots</h2> <h2>Hotspots</h2>
<table id="hotspots"> <table id="hotspots">
<thead><tr> <thead><tr>
@@ -45,8 +62,10 @@
</tr></thead> </tr></thead>
<tbody>{hotspot_rows}</tbody> <tbody>{hotspot_rows}</tbody>
</table> </table>
<h2>Cycle Report</h2> <h2>Cycle Report</h2>
{cycle_html} {cycle_html}
<script> <script>
mermaid.initialize({{ startOnLoad: true }}); mermaid.initialize({{ startOnLoad: true }});
function sortTable(id, col, th) {{ function sortTable(id, col, th) {{

View File

@@ -14,7 +14,8 @@ fn clean_fixture_has_no_cycles() {
assert!( assert!(
analysis::detect_cycles(&mg.nodes, &mg.edges).is_empty(), analysis::detect_cycles(&mg.nodes, &mg.edges).is_empty(),
"expected no cycles; nodes={:?} edges={:?}", "expected no cycles; nodes={:?} edges={:?}",
mg.nodes, mg.edges mg.nodes,
mg.edges
); );
} }
@@ -38,8 +39,14 @@ fn symbols_fixture_has_six_public_symbols() {
let table = symbols::build("symbols".to_string(), &fixtures("symbols")).unwrap(); let table = symbols::build("symbols".to_string(), &fixtures("symbols")).unwrap();
let pub_count = table.symbols.iter().filter(|s| s.is_pub).count(); let pub_count = table.symbols.iter().filter(|s| s.is_pub).count();
assert_eq!( assert_eq!(
pub_count, 6, pub_count,
6,
"expected 6 public symbols (Foo, Bar, Baz, qux, Alias, VALUE), got: {:?}", "expected 6 public symbols (Foo, Bar, Baz, qux, Alias, VALUE), got: {:?}",
table.symbols.iter().filter(|s| s.is_pub).map(|s| &s.name).collect::<Vec<_>>() table
.symbols
.iter()
.filter(|s| s.is_pub)
.map(|s| &s.name)
.collect::<Vec<_>>()
); );
} }

View File

@@ -0,0 +1,59 @@
use notsecrets::{Decryptor, Encryptor, X25519Identity, X25519Recipient};
use rand::rngs::OsRng;
use x25519_dalek::{PublicKey, StaticSecret};
fn main() {
// 1. Generate a fresh keypair
let secret = StaticSecret::random_from_rng(OsRng);
let public = PublicKey::from(&secret);
let identity = X25519Identity::from_static_secret(secret);
let recipient = X25519Recipient::from_public_key(public);
println!("Recipient: {}", recipient.to_bech32());
println!("Identity: {}", identity.to_bech32());
// 2. Build plaintext from env
let plaintext = [
(
"ANTHROPIC_API_KEY",
std::env::var("ANTHROPIC_API_KEY").unwrap_or_default(),
),
(
"OPENAI_API_KEY",
std::env::var("OPENAI_API_KEY").unwrap_or_default(),
),
(
"GEMINI_API_KEY",
std::env::var("GEMINI_API_KEY").unwrap_or_default(),
),
]
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("\n");
let plaintext = plaintext.as_bytes();
// 3. Encrypt
let encryptor = Encryptor::with_recipients(vec![Box::new(recipient)]).unwrap();
let ciphertext = encryptor.encrypt(plaintext).unwrap();
println!("\nCiphertext: {} bytes", ciphertext.len());
println!(
"Header: {}",
std::str::from_utf8(&ciphertext[..22]).unwrap()
);
// 4. Write to file
let out_path = "/tmp/secrets.age";
std::fs::write(out_path, &ciphertext).unwrap();
println!("Written to: {out_path}");
// 5. Decrypt back
let decryptor = Decryptor::with_identities(vec![Box::new(identity)]);
let decrypted = decryptor.decrypt(&ciphertext).unwrap();
assert_eq!(decrypted, plaintext);
println!("\nDecrypted:\n{}", std::str::from_utf8(&decrypted).unwrap());
println!("\n✓ Round-trip success");
}

View File

@@ -1,10 +1,7 @@
use crate::error::AgeError; use crate::error::AgeError;
use crate::format::{header_bytes_up_to_footer, parse_header}; use crate::format::{header_bytes_up_to_footer, parse_header};
use crate::identities::{FileKey, Identity}; use crate::identities::{FileKey, Identity};
use chacha20poly1305::{ use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce, aead::Aead};
aead::Aead,
ChaCha20Poly1305, Key, KeyInit, Nonce,
};
use hkdf::Hkdf; use hkdf::Hkdf;
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
use sha2::Sha256; use sha2::Sha256;
@@ -86,9 +83,9 @@ fn derive_payload_key(file_key: &FileKey, nonce: &[u8; 16]) -> Result<[u8; 32],
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::Encryptor;
use crate::identities::x25519::X25519Identity; use crate::identities::x25519::X25519Identity;
use crate::recipients::x25519::X25519Recipient; use crate::recipients::x25519::X25519Recipient;
use crate::Encryptor;
use rand::rngs::OsRng; use rand::rngs::OsRng;
use x25519_dalek::{PublicKey, StaticSecret}; use x25519_dalek::{PublicKey, StaticSecret};
@@ -116,11 +113,8 @@ mod tests {
fn decryptor_roundtrip_multiple_recipients() { fn decryptor_roundtrip_multiple_recipients() {
let (identity1, recipient1) = make_x25519_pair(); let (identity1, recipient1) = make_x25519_pair();
let (identity2, recipient2) = make_x25519_pair(); let (identity2, recipient2) = make_x25519_pair();
let encryptor = Encryptor::with_recipients(vec![ let encryptor =
Box::new(recipient1), Encryptor::with_recipients(vec![Box::new(recipient1), Box::new(recipient2)]).unwrap();
Box::new(recipient2),
])
.unwrap();
let plaintext = b"multi-recipient test"; let plaintext = b"multi-recipient test";
let ciphertext = encryptor.encrypt(plaintext).unwrap(); let ciphertext = encryptor.encrypt(plaintext).unwrap();
let decryptor1 = Decryptor::with_identities(vec![Box::new(identity1)]); 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::format::serialize_header;
use crate::identities::{FileKey, Header}; use crate::identities::{FileKey, Header};
use crate::recipients::Recipient; use crate::recipients::Recipient;
use chacha20poly1305::{ use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce, aead::Aead};
aead::Aead,
ChaCha20Poly1305, Key, KeyInit, Nonce,
};
use hkdf::Hkdf; use hkdf::Hkdf;
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
use rand::RngCore; use rand::RngCore;
@@ -129,7 +126,9 @@ mod tests {
Box::new(X25519Recipient::from_public_key(public)); Box::new(X25519Recipient::from_public_key(public));
let encryptor = Encryptor::with_recipients(vec![recipient]).unwrap(); let encryptor = Encryptor::with_recipients(vec![recipient]).unwrap();
let plaintext = b"hello, age!"; let plaintext = b"hello, age!";
let ciphertext = encryptor.encrypt(plaintext).expect("encrypt should succeed"); let ciphertext = encryptor
.encrypt(plaintext)
.expect("encrypt should succeed");
assert!( assert!(
ciphertext.starts_with(b"age-encryption.org/v1\n"), ciphertext.starts_with(b"age-encryption.org/v1\n"),
"output must start with age version line" "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. /// index into `input` where the payload (nonce + ciphertext) begins.
pub fn parse_header(input: &[u8]) -> Result<(Header, usize), AgeError> { pub fn parse_header(input: &[u8]) -> Result<(Header, usize), AgeError> {
if !input.starts_with(VERSION_LINE) { if !input.starts_with(VERSION_LINE) {
return Err(AgeError::ParseError( return Err(AgeError::ParseError("missing age version line".to_string()));
"missing age version line".to_string(),
));
} }
let mut pos = VERSION_LINE.len(); let mut pos = VERSION_LINE.len();
@@ -176,11 +174,15 @@ mod tests {
assert_eq!(header.recipients[0].args.len(), 1); assert_eq!(header.recipients[0].args.len(), 1);
assert!(header.recipients[0].body.is_empty()); assert!(header.recipients[0].body.is_empty());
// payload offset should point to the 32 bytes at the end // payload offset should point to the 32 bytes at the end
assert_eq!(&input[payload_offset..], { assert_eq!(
&input[payload_offset..],
{
let mut v = vec![0x01u8; 16]; let mut v = vec![0x01u8; 16];
v.extend_from_slice(&[0x02u8; 16]); v.extend_from_slice(&[0x02u8; 16]);
v v
}.as_slice()); }
.as_slice()
);
} }
#[test] #[test]
@@ -208,7 +210,10 @@ mod tests {
fn parse_missing_version_line_returns_error() { fn parse_missing_version_line_returns_error() {
let input = b"not-age-encryption\n-> X25519 arg\n\n--- AAAA\n"; let input = b"not-age-encryption\n-> X25519 arg\n\n--- AAAA\n";
let result = parse_header(input); 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] #[test]
@@ -233,7 +238,10 @@ mod tests {
args: vec!["salt".to_string(), "18".to_string()], args: vec!["salt".to_string(), "18".to_string()],
body: body.clone(), 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 serialized = serialize_header(&header);
let mut with_payload = serialized; let mut with_payload = serialized;
with_payload.extend_from_slice(&[0u8; 32]); with_payload.extend_from_slice(&[0u8; 32]);

View File

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

View File

@@ -39,9 +39,9 @@ impl FileKey {
impl TryFrom<&[u8]> for FileKey { impl TryFrom<&[u8]> for FileKey {
type Error = AgeError; type Error = AgeError;
fn try_from(b: &[u8]) -> Result<Self, AgeError> { fn try_from(b: &[u8]) -> Result<Self, AgeError> {
b.try_into() b.try_into().map(Self).map_err(|_| {
.map(Self) AgeError::ParseError(format!("file key must be 16 bytes, got {}", b.len()))
.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 { mod tests {
use super::*; use super::*;
use crate::identities::FileKey; use crate::identities::FileKey;
use crate::recipients::scrypt::ScryptRecipient;
use crate::recipients::Recipient; use crate::recipients::Recipient;
use crate::recipients::scrypt::ScryptRecipient;
#[test] #[test]
fn scrypt_wrap_unwrap_roundtrip() { fn scrypt_wrap_unwrap_roundtrip() {
@@ -73,7 +73,9 @@ mod tests {
let identity = ScryptIdentity::new(passphrase); let identity = ScryptIdentity::new(passphrase);
let file_key = FileKey::new([0x11u8; 16]); 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.tag, "scrypt");
assert_eq!(stanza.args.len(), 2); 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]) { let fp_bytes = match STANDARD_NO_PAD.decode(&stanza.args[0]) {
Ok(b) => b, 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() { if fp_bytes.as_slice() != self.fingerprint() {
return None; return None;
@@ -68,8 +72,11 @@ fn unwrap(stanza: &Stanza, identity: &SshEd25519Identity) -> Result<FileKey, Age
let shared = x25519_secret.diffie_hellman(&ephemeral_pub); let shared = x25519_secret.diffie_hellman(&ephemeral_pub);
let x25519_pub = X25519PublicKey::from(&x25519_secret); let x25519_pub = X25519PublicKey::from(&x25519_secret);
let wrap_key = let wrap_key = derive_wrap_key(
derive_wrap_key(shared.as_bytes(), ephemeral_pub.as_bytes(), x25519_pub.as_bytes())?; shared.as_bytes(),
ephemeral_pub.as_bytes(),
x25519_pub.as_bytes(),
)?;
let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key)); let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key));
let nonce = Nonce::default(); let nonce = Nonce::default();
@@ -104,8 +111,8 @@ pub(crate) fn derive_wrap_key(
mod tests { mod tests {
use super::*; use super::*;
use crate::identities::FileKey; use crate::identities::FileKey;
use crate::recipients::ssh_ed25519::SshEd25519Recipient;
use crate::recipients::Recipient; use crate::recipients::Recipient;
use crate::recipients::ssh_ed25519::SshEd25519Recipient;
fn test_signing_key() -> SigningKey { fn test_signing_key() -> SigningKey {
use rand::rngs::OsRng; use rand::rngs::OsRng;
@@ -123,7 +130,9 @@ mod tests {
let identity = SshEd25519Identity::from_signing_key(signing_key); let identity = SshEd25519Identity::from_signing_key(signing_key);
let file_key = FileKey::new([0x33u8; 16]); 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.tag, "ssh-ed25519");
assert_eq!(stanza.args.len(), 2); 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]) { let fp = match STANDARD_NO_PAD.decode(&stanza.args[0]) {
Ok(b) => b, 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() { if fp.as_slice() != self.fingerprint() {
return None; return None;
@@ -62,8 +66,8 @@ pub(crate) fn rsa_pubkey_fingerprint(public_key: &RsaPublicKey) -> [u8; 4] {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::recipients::ssh_rsa::SshRsaRecipient;
use crate::recipients::Recipient; use crate::recipients::Recipient;
use crate::recipients::ssh_rsa::SshRsaRecipient;
use rand::rngs::OsRng; use rand::rngs::OsRng;
fn test_rsa_keypair() -> (RsaPrivateKey, RsaPublicKey) { fn test_rsa_keypair() -> (RsaPrivateKey, RsaPublicKey) {
@@ -79,7 +83,9 @@ mod tests {
let identity = SshRsaIdentity::from_private_key(private_key); let identity = SshRsaIdentity::from_private_key(private_key);
let file_key = FileKey::new([0x55u8; 16]); 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.tag, "ssh-rsa");
assert_eq!(stanza.args.len(), 1); assert_eq!(stanza.args.len(), 1);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,8 @@ use anyhow::{Context, Result};
use notcore::{HookPhase, Report, StepStatus}; use notcore::{HookPhase, Report, StepStatus};
use notfiles::{LinkOptions, link}; use notfiles::{LinkOptions, link};
use nothooks::{HookRunner, run_phase}; use nothooks::{HookRunner, run_phase};
use notsecrets::{BitwardenSource, FileSource, PromptSource, resolve_identities};
use notsecrets::sources::IdentitySource; use notsecrets::sources::IdentitySource;
use notsecrets::{BitwardenSource, FileSource, PromptSource, resolve_identities};
use serde::Deserialize; use serde::Deserialize;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};

View File

@@ -5,8 +5,8 @@ use tempfile::TempDir;
use notcore::{HookPhase, HookSpec}; use notcore::{HookPhase, HookSpec};
use notfiles::{LinkOptions, link}; use notfiles::{LinkOptions, link};
use nothooks::{HookResult, HookRunner}; use nothooks::{HookResult, HookRunner};
use notsecrets::{FileSource, resolve_identities};
use notsecrets::sources::IdentitySource; use notsecrets::sources::IdentitySource;
use notsecrets::{FileSource, resolve_identities};
/// Test that notsecrets and nothooks can each be used independently /// Test that notsecrets and nothooks can each be used independently
/// in the same integration boundary — resolving an age key from a file /// in the same integration boundary — resolving an age key from a file