feat(notgraph): heatmap coloring, symbol counts, per-crate module graphs, cycle callouts
This commit is contained in:
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(" -> ")))
|
.map(|c| format!("<p class=\"cycle-entry\">CYCLE: {}</p>", c.join(" → ")))
|
||||||
.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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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) {{
|
||||||
|
|||||||
@@ -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<_>>()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
59
crates/notsecrets/examples/age_smoke.rs
Normal file
59
crates/notsecrets/examples/age_smoke.rs
Normal 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");
|
||||||
|
}
|
||||||
@@ -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)]);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())))
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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}"),
|
||||||
})?;
|
})?;
|
||||||
|
|||||||
@@ -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}"),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user