running_process/broker/backend_lifecycle/
identity.rs1use std::convert::TryFrom;
10use std::fs;
11use std::io;
12use std::path::{Path, PathBuf};
13use std::time::{SystemTime, UNIX_EPOCH};
14
15use serde::{Deserialize, Deserializer, Serialize, Serializer};
16use sha2::{Digest, Sha256};
17
18use crate::broker::host_identity;
19use crate::broker::protocol::{self, CacheManifest, Endpoint};
20
21#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct DaemonProcess {
50 pub pid: u32,
52 pub exe_path: PathBuf,
54 pub exe_hash: [u8; 32],
56 pub legacy_exe_sha256: [u8; 32],
58 pub boot_id: String,
60 pub ipc_endpoint: Endpoint,
62 pub started_at_unix_ms: u64,
64 pub idle_timeout_secs: Option<u32>,
66}
67
68impl DaemonProcess {
69 pub fn current_process(
78 ipc_endpoint: Endpoint,
79 idle_timeout_secs: Option<u32>,
80 ) -> Result<Self, IdentityError> {
81 let exe_path = std::env::current_exe().map_err(IdentityError::CurrentExe)?;
82 let exe_hash = executable_hash_file(&exe_path)?;
83 let legacy_exe_sha256 = sha256_file(&exe_path)?;
84 Ok(Self {
85 pid: std::process::id(),
86 exe_path,
87 exe_hash,
88 legacy_exe_sha256,
89 boot_id: host_identity::current().boot_id,
90 ipc_endpoint,
91 started_at_unix_ms: unix_now_ms(),
92 idle_timeout_secs,
93 })
94 }
95
96 pub fn to_proto(&self) -> protocol::DaemonProcess {
101 protocol::DaemonProcess {
102 pid: self.pid,
103 exe_path: self.exe_path.to_string_lossy().into_owned(),
104 exe_hash_algorithm: EXECUTABLE_HASH_ALGORITHM.to_owned(),
105 exe_hash: self.exe_hash.to_vec(),
106 ipc_endpoint: Some(self.ipc_endpoint.clone()),
107 started_at_unix_ms: self.started_at_unix_ms,
108 boot_id: self.boot_id.clone(),
109 idle_timeout_secs: self.idle_timeout_secs,
110 }
111 }
112
113 pub fn encode_probe_identity(&self, output: &mut Vec<u8>) -> Result<(), prost::EncodeError> {
120 use prost::Message;
121
122 self.to_proto().encode(output)?;
123 output.push(0x1a); output.push(32); output.extend_from_slice(&self.legacy_exe_sha256);
126 Ok(())
127 }
128
129 pub fn from_manifest_current_daemon(
135 manifest: &CacheManifest,
136 ) -> Result<Option<Self>, IdentityError> {
137 manifest
138 .current_daemon
139 .clone()
140 .map(Self::try_from)
141 .transpose()
142 }
143}
144
145impl TryFrom<protocol::DaemonProcess> for DaemonProcess {
146 type Error = IdentityError;
147
148 fn try_from(value: protocol::DaemonProcess) -> Result<Self, Self::Error> {
149 let ipc_endpoint = value.ipc_endpoint.ok_or(IdentityError::MissingEndpoint)?;
150 if value.exe_hash_algorithm != EXECUTABLE_HASH_ALGORITHM {
151 return Err(IdentityError::UnsupportedExecutableHashAlgorithm(
152 value.exe_hash_algorithm,
153 ));
154 }
155 let exe_hash =
156 vec_to_hash(value.exe_hash).map_err(IdentityError::InvalidExecutableHashLength)?;
157 Ok(Self {
158 pid: value.pid,
159 exe_path: PathBuf::from(value.exe_path),
160 exe_hash,
161 legacy_exe_sha256: [0; 32],
165 boot_id: value.boot_id,
166 ipc_endpoint,
167 started_at_unix_ms: value.started_at_unix_ms,
168 idle_timeout_secs: value.idle_timeout_secs,
169 })
170 }
171}
172
173impl From<&DaemonProcess> for protocol::DaemonProcess {
174 fn from(value: &DaemonProcess) -> Self {
175 value.to_proto()
176 }
177}
178
179impl Serialize for DaemonProcess {
180 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
181 where
182 S: Serializer,
183 {
184 DaemonProcessSerde::from(self).serialize(serializer)
185 }
186}
187
188impl<'de> Deserialize<'de> for DaemonProcess {
189 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
190 where
191 D: Deserializer<'de>,
192 {
193 let value = DaemonProcessSerde::deserialize(deserializer)?;
194 if value.exe_hash_algorithm != EXECUTABLE_HASH_ALGORITHM {
195 return Err(<D::Error as serde::de::Error>::custom(format!(
196 "unsupported daemon executable hash algorithm {:?}; expected blake3",
197 value.exe_hash_algorithm
198 )));
199 }
200 Ok(Self {
201 pid: value.pid,
202 exe_path: value.exe_path,
203 exe_hash: value.exe_hash,
204 legacy_exe_sha256: value.legacy_exe_sha256,
205 boot_id: value.boot_id,
206 ipc_endpoint: value.ipc_endpoint.into(),
207 started_at_unix_ms: value.started_at_unix_ms,
208 idle_timeout_secs: value.idle_timeout_secs,
209 })
210 }
211}
212
213#[derive(Debug, thiserror::Error)]
215pub enum IdentityError {
216 #[error("daemon process is missing ipc_endpoint")]
218 MissingEndpoint,
219 #[error("unsupported daemon executable hash algorithm {0:?}; expected blake3")]
221 UnsupportedExecutableHashAlgorithm(String),
222 #[error("daemon process exe_hash must be 32 bytes, got {0}")]
224 InvalidExecutableHashLength(usize),
225 #[error("failed to resolve current executable: {0}")]
227 CurrentExe(io::Error),
228 #[error("failed to hash executable: {0}")]
230 Io(#[from] io::Error),
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
234struct DaemonProcessSerde {
235 pid: u32,
236 exe_path: PathBuf,
237 exe_hash_algorithm: String,
238 exe_hash: [u8; 32],
239 legacy_exe_sha256: [u8; 32],
240 boot_id: String,
241 ipc_endpoint: EndpointSerde,
242 started_at_unix_ms: u64,
243 idle_timeout_secs: Option<u32>,
244}
245
246impl From<&DaemonProcess> for DaemonProcessSerde {
247 fn from(value: &DaemonProcess) -> Self {
248 Self {
249 pid: value.pid,
250 exe_path: value.exe_path.clone(),
251 exe_hash_algorithm: EXECUTABLE_HASH_ALGORITHM.to_owned(),
252 exe_hash: value.exe_hash,
253 legacy_exe_sha256: value.legacy_exe_sha256,
254 boot_id: value.boot_id.clone(),
255 ipc_endpoint: EndpointSerde::from(&value.ipc_endpoint),
256 started_at_unix_ms: value.started_at_unix_ms,
257 idle_timeout_secs: value.idle_timeout_secs,
258 }
259 }
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
263struct EndpointSerde {
264 namespace_id: String,
265 path: String,
266}
267
268impl From<&Endpoint> for EndpointSerde {
269 fn from(value: &Endpoint) -> Self {
270 Self {
271 namespace_id: value.namespace_id.clone(),
272 path: value.path.clone(),
273 }
274 }
275}
276
277impl From<EndpointSerde> for Endpoint {
278 fn from(value: EndpointSerde) -> Self {
279 Endpoint {
280 namespace_id: value.namespace_id,
281 path: value.path,
282 }
283 }
284}
285
286pub fn executable_hash_file(path: &Path) -> Result<[u8; 32], io::Error> {
288 crate::content_hash::blake3_file(path).map(|hash| *hash.as_bytes())
289}
290
291pub fn sha256_file(path: &Path) -> Result<[u8; 32], io::Error> {
293 let bytes = fs::read(path)?;
294 let digest = Sha256::digest(&bytes);
295 let mut out = [0_u8; 32];
296 out.copy_from_slice(&digest);
297 Ok(out)
298}
299
300fn vec_to_hash(bytes: Vec<u8>) -> Result<[u8; 32], usize> {
301 let len = bytes.len();
302 let Ok(out) = bytes.try_into() else {
303 return Err(len);
304 };
305 Ok(out)
306}
307
308const EXECUTABLE_HASH_ALGORITHM: &str = "blake3";
309
310fn unix_now_ms() -> u64 {
311 SystemTime::now()
312 .duration_since(UNIX_EPOCH)
313 .map(|duration| duration.as_millis() as u64)
314 .unwrap_or(0)
315}
316
317#[cfg(test)]
318mod broker_dance_identity_tests {
319 use super::*;
328 use prost::Message;
329
330 #[derive(Clone, PartialEq, Message)]
332 struct LegacyDaemonProcess {
333 #[prost(uint32, tag = "1")]
334 pid: u32,
335 #[prost(string, tag = "2")]
336 exe_path: String,
337 #[prost(bytes = "vec", tag = "3")]
338 exe_sha256: Vec<u8>,
339 }
340
341 #[test]
342 fn executable_identity_hash_uses_blake3() {
343 let path =
344 std::env::temp_dir().join(format!("running-process-946-hash-{}", std::process::id()));
345 std::fs::write(&path, b"daemon image bytes").expect("write fixture");
346 let actual = executable_hash_file(&path).expect("hash fixture");
347 std::fs::remove_file(&path).ok();
348
349 assert_eq!(actual, *blake3::hash(b"daemon image bytes").as_bytes());
350 }
351
352 #[test]
353 fn blake3_identity_dual_writes_a_legacy_sha256_for_stable_brokers() {
354 let identity = DaemonProcess::current_process(endpoint("compat.sock"), None)
355 .expect("current daemon identity");
356 let current = identity.to_proto();
357 let mut encoded = Vec::new();
358 identity
359 .encode_probe_identity(&mut encoded)
360 .expect("encode compatibility identity");
361 let legacy = LegacyDaemonProcess::decode(encoded.as_slice())
362 .expect("pre-blake3 broker decodes current identity");
363
364 assert_eq!(current.exe_hash_algorithm, EXECUTABLE_HASH_ALGORITHM);
365 assert_eq!(legacy.exe_sha256.len(), 32);
366 assert_eq!(
367 legacy.exe_sha256,
368 sha256_file(&identity.exe_path)
369 .expect("sha256 executable")
370 .to_vec(),
371 "the legacy wire field remains verifiable by a stable broker"
372 );
373 }
374
375 fn endpoint(path: &str) -> Endpoint {
376 Endpoint {
377 namespace_id: "ns".to_owned(),
378 path: path.to_owned(),
379 }
380 }
381
382 fn identity(exe_hash: [u8; 32]) -> DaemonProcess {
383 DaemonProcess {
386 pid: 1234,
387 exe_path: PathBuf::from("runtime/soldr-self/v0.8.44-deadbeef/soldr.exe"),
388 exe_hash,
389 legacy_exe_sha256: [0x24; 32],
390 boot_id: "boot-1".to_owned(),
391 ipc_endpoint: endpoint("rpb-v2-soldr-daemon-0123456789abcdef-0"),
392 started_at_unix_ms: 1,
393 idle_timeout_secs: Some(600),
394 }
395 }
396
397 #[test]
398 fn distinct_builds_get_distinct_identities() {
399 let a = identity([0xAA; 32]);
400
401 let mut rebuilt = [0xAA; 32];
403 rebuilt[0] = 0xBB;
404 let b = identity(rebuilt);
405
406 assert_ne!(
407 a, b,
408 "a different executable hash must produce a distinct daemon identity, \
409 so the broker can never conflate two builds (no stale-version war)"
410 );
411
412 assert_eq!(
415 a,
416 identity([0xAA; 32]),
417 "identical executable bytes must yield the same identity"
418 );
419 }
420
421 #[test]
422 fn exe_sha256_survives_the_manifest_wire_round_trip() {
423 let original = identity([0x42; 32]);
427 let proto = original.to_proto();
428 assert_eq!(proto.exe_hash_algorithm, "blake3");
429 assert_eq!(
430 proto.exe_hash.len(),
431 32,
432 "the wire form must carry the full 32-byte BLAKE3 hash"
433 );
434 let restored = DaemonProcess::try_from(proto).expect("identity round-trips");
435 let mut expected = original;
436 expected.legacy_exe_sha256 = [0; 32];
437 assert_eq!(
438 restored, expected,
439 "the canonical BLAKE3 identity must survive the manifest round-trip; the legacy probe field is not persisted"
440 );
441 }
442
443 #[test]
444 fn legacy_sha256_wire_identity_is_rejected_actionably() {
445 let legacy = protocol::DaemonProcess {
450 pid: 1234,
451 exe_path: "legacy-daemon".to_owned(),
452 exe_hash_algorithm: String::new(),
453 exe_hash: Vec::new(),
454 ipc_endpoint: Some(endpoint("legacy.sock")),
455 started_at_unix_ms: 1,
456 boot_id: "boot-1".to_owned(),
457 idle_timeout_secs: None,
458 };
459
460 let error = DaemonProcess::try_from(legacy).expect_err("legacy SHA-256 must be fenced");
461 assert!(matches!(
462 error,
463 IdentityError::UnsupportedExecutableHashAlgorithm(ref algorithm)
464 if algorithm.is_empty()
465 ));
466 assert!(error.to_string().contains("expected blake3"));
467 }
468}