toolkit_zero/dependency-graph/capture.rs
1//! Runtime reader for the `ironprint.json` fingerprint embedded in the binary.
2//!
3//! The companion `build` module (feature `dependency-graph-build`) writes
4//! `ironprint.json` to `$OUT_DIR` at compile time. The downstream binary
5//! embeds it with:
6//!
7//! ```rust,ignore
8//! const IRONPRINT: &str = include_str!(concat!(env!("OUT_DIR"), "/ironprint.json"));
9//! ```
10//!
11//! ## Functions
12//!
13//! * [`parse`] — deserialises the embedded JSON into a typed [`IronprintData`]
14//! struct. Returns [`CaptureError`] if the JSON is malformed or a required
15//! section is absent.
16//! * [`as_bytes`] — returns the raw JSON bytes. Because the JSON is normalised
17//! and sorted at build time, the returned bytes are stable and deterministic
18//! across equivalent builds.
19//!
20//! ## Concerns
21//!
22//! * The data is a **read-only snapshot** fixed at compile time. It reflects the
23//! build environment at the time of compilation, not the current runtime state.
24//! * The fingerprint resides as plain text in the binary's read-only data
25//! section. It is neither encrypted nor obfuscated and is visible to anyone
26//! with access to the binary.
27
28use std::collections::BTreeMap;
29
30use serde_json::Value;
31
32// ─── error ────────────────────────────────────────────────────────────────────
33
34/// Errors that can occur while reading a captured ironprint.
35#[derive(Debug)]
36pub enum CaptureError {
37 /// The embedded JSON is not valid.
38 InvalidJson(String),
39 /// A required top-level section is missing.
40 MissingSection(&'static str),
41}
42
43impl std::fmt::Display for CaptureError {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 match self {
46 Self::InvalidJson(e) => write!(f, "ironprint JSON is invalid: {e}"),
47 Self::MissingSection(key) => write!(f, "ironprint is missing section: {key}"),
48 }
49 }
50}
51
52impl std::error::Error for CaptureError {}
53
54// ─── typed data ───────────────────────────────────────────────────────────────
55
56/// Parsed contents of `ironprint.json`.
57///
58/// All fields map directly onto the sections produced by
59/// [`build::generate_ironprint`](super::build::generate_ironprint).
60#[derive(Debug, Clone)]
61pub struct IronprintData {
62 /// Package name and version.
63 pub package: PackageInfo,
64 /// Build-environment snapshot.
65 pub build: BuildInfo,
66 /// SHA-256 hex digest of `Cargo.lock` (comments stripped).
67 pub cargo_lock_sha256: String,
68 /// Normalised `cargo metadata` dependency graph (raw JSON value).
69 pub deps: Value,
70 /// Per-file SHA-256 digests of every `.rs` file under `src/`.
71 pub source: BTreeMap<String, String>,
72}
73
74/// Package identity section.
75#[derive(Debug, Clone)]
76pub struct PackageInfo {
77 pub name: String,
78 pub version: String,
79}
80
81/// Build environment section.
82#[derive(Debug, Clone)]
83pub struct BuildInfo {
84 /// Active cargo feature names, sorted.
85 pub features: Vec<String>,
86 /// Optimisation level (`"0"` / `"1"` / `"2"` / `"3"` / `"s"` / `"z"`).
87 pub opt_level: String,
88 /// Cargo profile (`"debug"` / `"release"` / …).
89 pub profile: String,
90 /// Full `rustc --version` string.
91 pub rustc_version: String,
92 /// Target triple, e.g. `"x86_64-apple-darwin"`.
93 pub target: String,
94}
95
96// ─── public API ───────────────────────────────────────────────────────────────
97
98/// Parse the embedded ironprint JSON into a typed [`IronprintData`].
99///
100/// Pass the `&str` produced by
101/// `include_str!(concat!(env!("OUT_DIR"), "/ironprint.json"))`.
102pub fn parse(json: &str) -> Result<IronprintData, CaptureError> {
103 let root: Value = serde_json::from_str(json)
104 .map_err(|e| CaptureError::InvalidJson(e.to_string()))?;
105
106 let obj = root.as_object()
107 .ok_or(CaptureError::InvalidJson("root is not an object".into()))?;
108
109 // ── package ───────────────────────────────────────────────────────────────
110 let pkg = obj.get("package")
111 .and_then(|v| v.as_object())
112 .ok_or(CaptureError::MissingSection("package"))?;
113
114 let package = PackageInfo {
115 name: pkg.get("name").and_then(|v| v.as_str()).unwrap_or("").to_owned(),
116 version: pkg.get("version").and_then(|v| v.as_str()).unwrap_or("").to_owned(),
117 };
118
119 // ── build ─────────────────────────────────────────────────────────────────
120 let bld = obj.get("build")
121 .and_then(|v| v.as_object())
122 .ok_or(CaptureError::MissingSection("build"))?;
123
124 let features = bld.get("features")
125 .and_then(|v| v.as_array())
126 .map(|arr| {
127 arr.iter()
128 .filter_map(|v| v.as_str())
129 .map(str::to_owned)
130 .collect()
131 })
132 .unwrap_or_default();
133
134 let build = BuildInfo {
135 features,
136 opt_level: bld.get("opt_level").and_then(|v| v.as_str()).unwrap_or("").to_owned(),
137 profile: bld.get("profile").and_then(|v| v.as_str()).unwrap_or("").to_owned(),
138 rustc_version: bld.get("rustc_version").and_then(|v| v.as_str()).unwrap_or("").to_owned(),
139 target: bld.get("target").and_then(|v| v.as_str()).unwrap_or("").to_owned(),
140 };
141
142 // ── cargo_lock_sha256 ─────────────────────────────────────────────────────
143 let cargo_lock_sha256 = obj.get("cargo_lock_sha256")
144 .and_then(|v| v.as_str())
145 .ok_or(CaptureError::MissingSection("cargo_lock_sha256"))?
146 .to_owned();
147
148 // ── deps ──────────────────────────────────────────────────────────────────
149 let deps = obj.get("deps")
150 .ok_or(CaptureError::MissingSection("deps"))?
151 .clone();
152
153 // ── source ────────────────────────────────────────────────────────────────
154 let source = obj.get("source")
155 .and_then(|v| v.as_object())
156 .map(|map| {
157 map.iter()
158 .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_owned()))
159 .collect()
160 })
161 .unwrap_or_default();
162
163 Ok(IronprintData { package, build, cargo_lock_sha256, deps, source })
164}
165
166/// Return the raw bytes of the ironprint JSON string.
167///
168/// Because `ironprint.json` is already normalised (sorted keys, no whitespace,
169/// no absolute paths) at build time, the returned bytes are stable and
170/// deterministic across equivalent builds.
171///
172/// ```rust,ignore
173/// const IRONPRINT: &str = include_str!(concat!(env!("OUT_DIR"), "/ironprint.json"));
174///
175/// let bytes: &[u8] = toolkit_zero::dependency_graph::capture::as_bytes(IRONPRINT);
176/// ```
177pub fn as_bytes(json: &str) -> &[u8] {
178 json.as_bytes()
179}