metrale_bench/
serve_identity.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: What makes one `met serve` "the same server" as another: the
4//! bytes of the binary, the arguments it was started with, and its
5//! `METRALE_*` serve levers.
6//!
7//! A benchmark that reuses a server somebody else started must know it is
8//! measuring what it would have started itself, or its record names a config
9//! it never ran. The server publishes the identity as digests
10//! (`GET /serve-config`), never as the arguments themselves, because an argv
11//! can carry `--auth-token`. Both sides compute the digests with the
12//! functions here.
13//!
14//! The environment is part of the identity because levers are not recipe
15//! keys: two servers with different levers can have byte-identical argv. The
16//! digest is `serve_env::fingerprint` over `serve_env::process_levers()`,
17//! which covers every `METRALE_*` variable except the harness class. The
18//! harness computes the expected digest from the lever set the recipe
19//! declares. The reuse check errs toward refusing: refusing a reusable server
20//! costs one server start, while reusing a wrong one corrupts a record.
21//!
22//! Owner: bench (serve identity).
23//! Invariants:
24//! - Every digest this module computes is 64 lowercase hex characters;
25//!   `this_process` reports `unavailable: …` as `binary_sha256` when the
26//!   executable cannot be read.
27
28use std::path::Path;
29
30use anyhow::{Context, Result};
31use sha2::{Digest, Sha256};
32
33/// 2026-09-26: The SHA-256 of a server's arguments (everything after the
34/// program name), each NUL-terminated so `["a b"]` and `["a", "b"]` differ.
35#[must_use]
36pub fn argv_fingerprint(args_after_program: &[String]) -> String {
37    let mut h = Sha256::new();
38    for a in args_after_program {
39        h.update(a.as_bytes());
40        h.update([0u8]);
41    }
42    metrale_closure::hex_lower(&h.finalize())
43}
44
45/// 2026-09-26: The SHA-256 of a file's bytes; used as a binary's identity.
46pub fn file_sha256(path: &Path) -> Result<String> {
47    let mut f = std::fs::File::open(path).with_context(|| format!("opening {}", path.display()))?;
48    let mut h = Sha256::new();
49    // 2026-09-30: Read in chunks; sha2 0.11's hasher is not an `io::Write`, so
50    // `io::copy` into it no longer compiles.
51    let mut buf = vec![0u8; 1 << 16];
52    loop {
53        match std::io::Read::read(&mut f, &mut buf) {
54            Ok(0) => break,
55            Ok(n) => h.update(&buf[..n]),
56            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
57            Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
58        }
59    }
60    Ok(metrale_closure::hex_lower(&h.finalize()))
61}
62
63/// 2026-09-26: The identity of this process, computed once: the digests of
64/// its executable, its arguments after the program name and its levers, and
65/// its pid.
66pub fn this_process() -> &'static ServeIdentity {
67    static ID: std::sync::OnceLock<ServeIdentity> = std::sync::OnceLock::new();
68    ID.get_or_init(|| {
69        let args: Vec<String> = std::env::args().skip(1).collect();
70        ServeIdentity {
71            argv_sha256: argv_fingerprint(&args),
72            binary_sha256: std::env::current_exe()
73                .context("current_exe")
74                .and_then(|p| file_sha256(&p))
75                .unwrap_or_else(|e| format!("unavailable: {e:#}")),
76            env_sha256: crate::serve_env::fingerprint(&crate::serve_env::process_levers()),
77            pid: std::process::id(),
78        }
79    })
80}
81
82/// 2026-09-26: What `GET /serve-config` answers.
83#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
84pub struct ServeIdentity {
85    pub argv_sha256: String,
86    pub binary_sha256: String,
87    /// 2026-09-26: The digest of the `METRALE_*` serve levers in this
88    /// server's environment (`serve_env::fingerprint` over
89    /// `serve_env::process_levers()`). The argv says which recipe rendering
90    /// the server runs; this says which levers it reads beside it.
91    ///
92    /// `serde(default)`, so a report without the field still parses, as the
93    /// empty string, which [`env_is_unknown`] treats as unknown and the reuse
94    /// check refuses: "cannot tell" must not read as "matches".
95    #[serde(default)]
96    pub env_sha256: String,
97    pub pid: u32,
98}
99
100/// 2026-09-26: Whether a reported lever digest carries no information: the
101/// empty string, which a report without [`ServeIdentity::env_sha256`] parses
102/// to. A real digest is 64 hex characters even for an empty lever set.
103#[must_use]
104pub fn env_is_unknown(reported: &str) -> bool {
105    reported.is_empty()
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn the_fingerprint_separates_arguments_and_orders_them() {
114        let a = argv_fingerprint(&["serve".into(), "m".into(), "--port".into(), "1".into()]);
115        let b = argv_fingerprint(&["serve".into(), "m".into(), "--port".into(), "2".into()]);
116        let c = argv_fingerprint(&["serve m".into(), "--port".into(), "1".into()]);
117        assert_ne!(a, b, "a different port is a different server");
118        assert_ne!(a, c, "joined arguments are not the same argv");
119        assert_eq!(
120            a,
121            argv_fingerprint(&["serve".into(), "m".into(), "--port".into(), "1".into()])
122        );
123        assert_eq!(a.len(), 64);
124    }
125
126    /// 2026-09-26: The identity carries the lever digest, and a report
127    /// without the field still parses, with a digest that never equals a
128    /// real one.
129    #[test]
130    fn the_identity_carries_the_lever_digest_and_an_older_server_reports_none() {
131        let id = this_process();
132        assert_eq!(
133            id.env_sha256,
134            crate::serve_env::fingerprint(&crate::serve_env::process_levers())
135        );
136        assert_eq!(id.env_sha256.len(), 64);
137        let old: ServeIdentity =
138            serde_json::from_str(r#"{"argv_sha256":"a","binary_sha256":"b","pid":1}"#).unwrap();
139        assert_eq!(old.env_sha256, "");
140        assert_ne!(
141            old.env_sha256,
142            crate::serve_env::fingerprint(&Default::default()),
143            "even an empty lever set has a digest an old server cannot claim"
144        );
145    }
146
147    #[test]
148    fn a_file_digest_is_its_bytes() {
149        let dir = std::env::temp_dir().join(format!("serve-identity-{}", std::process::id()));
150        std::fs::create_dir_all(&dir).unwrap();
151        let p = dir.join("bin");
152        std::fs::write(&p, b"hello").unwrap();
153        assert_eq!(
154            file_sha256(&p).unwrap(),
155            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
156        );
157        std::fs::write(&p, b"hellO").unwrap();
158        assert_ne!(
159            file_sha256(&p).unwrap(),
160            "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
161        );
162        // 2026-09-30: Several read chunks (64 KiB each) hash as the bytes do in one call.
163        let big: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
164        std::fs::write(&p, &big).unwrap();
165        assert_eq!(
166            file_sha256(&p).unwrap(),
167            metrale_closure::hex_lower(&Sha256::digest(&big))
168        );
169        let _ = std::fs::remove_dir_all(&dir);
170    }
171}