Skip to main content

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}