1use 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
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
75pub enum DaemonIdentityHashPolicy {
76 #[default]
78 LegacyCompatible,
79 Blake3Only,
81}
82
83impl DaemonProcess {
84 pub fn current_process(
93 ipc_endpoint: Endpoint,
94 idle_timeout_secs: Option<u32>,
95 ) -> Result<Self, IdentityError> {
96 Self::current_process_with_hash_policy(
97 ipc_endpoint,
98 idle_timeout_secs,
99 DaemonIdentityHashPolicy::LegacyCompatible,
100 )
101 }
102
103 pub fn current_process_with_hash_policy(
110 ipc_endpoint: Endpoint,
111 idle_timeout_secs: Option<u32>,
112 hash_policy: DaemonIdentityHashPolicy,
113 ) -> Result<Self, IdentityError> {
114 let exe_path = std::env::current_exe().map_err(IdentityError::CurrentExe)?;
115 let exe_hash = executable_hash_file(&exe_path)?;
116 let legacy_exe_sha256 = match hash_policy {
117 DaemonIdentityHashPolicy::LegacyCompatible => sha256_file(&exe_path)?,
118 DaemonIdentityHashPolicy::Blake3Only => [0; 32],
119 };
120 Ok(Self {
121 pid: std::process::id(),
122 exe_path,
123 exe_hash,
124 legacy_exe_sha256,
125 boot_id: host_identity::current().boot_id,
126 ipc_endpoint,
127 started_at_unix_ms: unix_now_ms(),
128 idle_timeout_secs,
129 })
130 }
131
132 pub fn to_proto(&self) -> protocol::DaemonProcess {
137 protocol::DaemonProcess {
138 pid: self.pid,
139 exe_path: self.exe_path.to_string_lossy().into_owned(),
140 exe_hash_algorithm: EXECUTABLE_HASH_ALGORITHM.to_owned(),
141 exe_hash: self.exe_hash.to_vec(),
142 ipc_endpoint: Some(self.ipc_endpoint.clone()),
143 started_at_unix_ms: self.started_at_unix_ms,
144 boot_id: self.boot_id.clone(),
145 idle_timeout_secs: self.idle_timeout_secs,
146 }
147 }
148
149 pub fn encode_probe_identity(&self, output: &mut Vec<u8>) -> Result<(), prost::EncodeError> {
156 use prost::Message;
157
158 self.to_proto().encode(output)?;
159 output.push(0x1a); output.push(32); output.extend_from_slice(&self.legacy_exe_sha256);
162 Ok(())
163 }
164
165 pub fn from_manifest_current_daemon(
171 manifest: &CacheManifest,
172 ) -> Result<Option<Self>, IdentityError> {
173 manifest
174 .current_daemon
175 .clone()
176 .map(Self::try_from)
177 .transpose()
178 }
179}
180
181impl TryFrom<protocol::DaemonProcess> for DaemonProcess {
182 type Error = IdentityError;
183
184 fn try_from(value: protocol::DaemonProcess) -> Result<Self, Self::Error> {
185 let ipc_endpoint = value.ipc_endpoint.ok_or(IdentityError::MissingEndpoint)?;
186 if value.exe_hash_algorithm != EXECUTABLE_HASH_ALGORITHM {
187 return Err(IdentityError::UnsupportedExecutableHashAlgorithm(
188 value.exe_hash_algorithm,
189 ));
190 }
191 let exe_hash =
192 vec_to_hash(value.exe_hash).map_err(IdentityError::InvalidExecutableHashLength)?;
193 Ok(Self {
194 pid: value.pid,
195 exe_path: PathBuf::from(value.exe_path),
196 exe_hash,
197 legacy_exe_sha256: [0; 32],
201 boot_id: value.boot_id,
202 ipc_endpoint,
203 started_at_unix_ms: value.started_at_unix_ms,
204 idle_timeout_secs: value.idle_timeout_secs,
205 })
206 }
207}
208
209impl From<&DaemonProcess> for protocol::DaemonProcess {
210 fn from(value: &DaemonProcess) -> Self {
211 value.to_proto()
212 }
213}
214
215impl Serialize for DaemonProcess {
216 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
217 where
218 S: Serializer,
219 {
220 DaemonProcessSerde::from(self).serialize(serializer)
221 }
222}
223
224impl<'de> Deserialize<'de> for DaemonProcess {
225 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
226 where
227 D: Deserializer<'de>,
228 {
229 let value = DaemonProcessSerde::deserialize(deserializer)?;
230 if value.exe_hash_algorithm != EXECUTABLE_HASH_ALGORITHM {
231 return Err(<D::Error as serde::de::Error>::custom(format!(
232 "unsupported daemon executable hash algorithm {:?}; expected blake3",
233 value.exe_hash_algorithm
234 )));
235 }
236 Ok(Self {
237 pid: value.pid,
238 exe_path: value.exe_path,
239 exe_hash: value.exe_hash,
240 legacy_exe_sha256: value.legacy_exe_sha256,
241 boot_id: value.boot_id,
242 ipc_endpoint: value.ipc_endpoint.into(),
243 started_at_unix_ms: value.started_at_unix_ms,
244 idle_timeout_secs: value.idle_timeout_secs,
245 })
246 }
247}
248
249#[derive(Debug, thiserror::Error)]
251pub enum IdentityError {
252 #[error("daemon process is missing ipc_endpoint")]
254 MissingEndpoint,
255 #[error("unsupported daemon executable hash algorithm {0:?}; expected blake3")]
257 UnsupportedExecutableHashAlgorithm(String),
258 #[error("daemon process exe_hash must be 32 bytes, got {0}")]
260 InvalidExecutableHashLength(usize),
261 #[error("failed to resolve current executable: {0}")]
263 CurrentExe(io::Error),
264 #[error("failed to hash executable: {0}")]
266 Io(#[from] io::Error),
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
270struct DaemonProcessSerde {
271 pid: u32,
272 exe_path: PathBuf,
273 exe_hash_algorithm: String,
274 exe_hash: [u8; 32],
275 #[serde(default)]
276 legacy_exe_sha256: [u8; 32],
277 boot_id: String,
278 ipc_endpoint: EndpointSerde,
279 started_at_unix_ms: u64,
280 idle_timeout_secs: Option<u32>,
281}
282
283impl From<&DaemonProcess> for DaemonProcessSerde {
284 fn from(value: &DaemonProcess) -> Self {
285 Self {
286 pid: value.pid,
287 exe_path: value.exe_path.clone(),
288 exe_hash_algorithm: EXECUTABLE_HASH_ALGORITHM.to_owned(),
289 exe_hash: value.exe_hash,
290 legacy_exe_sha256: value.legacy_exe_sha256,
291 boot_id: value.boot_id.clone(),
292 ipc_endpoint: EndpointSerde::from(&value.ipc_endpoint),
293 started_at_unix_ms: value.started_at_unix_ms,
294 idle_timeout_secs: value.idle_timeout_secs,
295 }
296 }
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize)]
300struct EndpointSerde {
301 namespace_id: String,
302 path: String,
303}
304
305impl From<&Endpoint> for EndpointSerde {
306 fn from(value: &Endpoint) -> Self {
307 Self {
308 namespace_id: value.namespace_id.clone(),
309 path: value.path.clone(),
310 }
311 }
312}
313
314impl From<EndpointSerde> for Endpoint {
315 fn from(value: EndpointSerde) -> Self {
316 Endpoint {
317 namespace_id: value.namespace_id,
318 path: value.path,
319 }
320 }
321}
322
323pub fn executable_hash_file(path: &Path) -> Result<[u8; 32], io::Error> {
325 crate::content_hash::blake3_file(path).map(|hash| *hash.as_bytes())
326}
327
328pub fn sha256_file(path: &Path) -> Result<[u8; 32], io::Error> {
330 let bytes = fs::read(path)?;
331 let digest = Sha256::digest(&bytes);
332 let mut out = [0_u8; 32];
333 out.copy_from_slice(&digest);
334 Ok(out)
335}
336
337fn vec_to_hash(bytes: Vec<u8>) -> Result<[u8; 32], usize> {
338 let len = bytes.len();
339 let Ok(out) = bytes.try_into() else {
340 return Err(len);
341 };
342 Ok(out)
343}
344
345const EXECUTABLE_HASH_ALGORITHM: &str = "blake3";
346
347fn unix_now_ms() -> u64 {
348 SystemTime::now()
349 .duration_since(UNIX_EPOCH)
350 .map(|duration| duration.as_millis() as u64)
351 .unwrap_or(0)
352}
353
354#[cfg(test)]
355mod broker_dance_identity_tests {
356 use super::*;
365 use prost::Message;
366
367 #[derive(Clone, PartialEq, Message)]
369 struct LegacyDaemonProcess {
370 #[prost(uint32, tag = "1")]
371 pid: u32,
372 #[prost(string, tag = "2")]
373 exe_path: String,
374 #[prost(bytes = "vec", tag = "3")]
375 exe_sha256: Vec<u8>,
376 }
377
378 #[test]
379 fn executable_identity_hash_uses_blake3() {
380 let path =
381 std::env::temp_dir().join(format!("running-process-946-hash-{}", std::process::id()));
382 std::fs::write(&path, b"daemon image bytes").expect("write fixture");
383 let actual = executable_hash_file(&path).expect("hash fixture");
384 std::fs::remove_file(&path).ok();
385
386 assert_eq!(actual, *blake3::hash(b"daemon image bytes").as_bytes());
387 }
388
389 #[test]
390 fn blake3_identity_dual_writes_a_legacy_sha256_for_stable_brokers() {
391 let identity = DaemonProcess::current_process(endpoint("compat.sock"), None)
392 .expect("current daemon identity");
393 let current = identity.to_proto();
394 let mut encoded = Vec::new();
395 identity
396 .encode_probe_identity(&mut encoded)
397 .expect("encode compatibility identity");
398 let legacy = LegacyDaemonProcess::decode(encoded.as_slice())
399 .expect("pre-blake3 broker decodes current identity");
400
401 assert_eq!(current.exe_hash_algorithm, EXECUTABLE_HASH_ALGORITHM);
402 assert_eq!(legacy.exe_sha256.len(), 32);
403 assert_eq!(
404 legacy.exe_sha256,
405 sha256_file(&identity.exe_path)
406 .expect("sha256 executable")
407 .to_vec(),
408 "the legacy wire field remains verifiable by a stable broker"
409 );
410 }
411
412 #[test]
413 fn blake3_only_identity_skips_the_legacy_sha256_pass_and_keeps_tag_three_zeroed() {
414 let identity = DaemonProcess::current_process_with_hash_policy(
415 endpoint("blake3-only.sock"),
416 None,
417 DaemonIdentityHashPolicy::Blake3Only,
418 )
419 .expect("current daemon identity");
420 let mut encoded = Vec::new();
421 identity
422 .encode_probe_identity(&mut encoded)
423 .expect("encode compatibility identity");
424 let legacy = LegacyDaemonProcess::decode(encoded.as_slice())
425 .expect("pre-blake3 broker decodes current identity");
426
427 assert_eq!(identity.legacy_exe_sha256, [0; 32]);
428 assert_eq!(legacy.exe_sha256, [0; 32]);
429 assert_eq!(
430 identity.exe_hash,
431 executable_hash_file(&identity.exe_path).expect("blake3 executable")
432 );
433 }
434
435 #[test]
436 fn blake3_only_identity_sidecar_records_a_zero_legacy_digest() {
437 let dir = tempfile::tempdir().expect("tempdir");
438 let path = dir.path().join("identity.json");
439 let identity = DaemonProcess::current_process_with_hash_policy(
440 endpoint("blake3-only-sidecar.sock"),
441 None,
442 DaemonIdentityHashPolicy::Blake3Only,
443 )
444 .expect("current daemon identity");
445
446 crate::broker::backend_sdk::write_daemon_identity_file(&path, &identity)
447 .expect("write sidecar");
448 let json: serde_json::Value =
449 serde_json::from_slice(&std::fs::read(&path).expect("read sidecar"))
450 .expect("parse sidecar");
451
452 let legacy = json
453 .get("legacy_exe_sha256")
454 .and_then(serde_json::Value::as_array)
455 .expect("legacy digest array");
456 assert_eq!(legacy.len(), 32);
457 assert!(legacy.iter().all(|byte| byte == &serde_json::json!(0)));
458 }
459
460 fn endpoint(path: &str) -> Endpoint {
461 Endpoint {
462 namespace_id: "ns".to_owned(),
463 path: path.to_owned(),
464 }
465 }
466
467 fn identity(exe_hash: [u8; 32]) -> DaemonProcess {
468 DaemonProcess {
471 pid: 1234,
472 exe_path: PathBuf::from("runtime/soldr-self/v0.8.44-deadbeef/soldr.exe"),
473 exe_hash,
474 legacy_exe_sha256: [0x24; 32],
475 boot_id: "boot-1".to_owned(),
476 ipc_endpoint: endpoint("rpb-v2-soldr-daemon-0123456789abcdef-0"),
477 started_at_unix_ms: 1,
478 idle_timeout_secs: Some(600),
479 }
480 }
481
482 #[test]
483 fn distinct_builds_get_distinct_identities() {
484 let a = identity([0xAA; 32]);
485
486 let mut rebuilt = [0xAA; 32];
488 rebuilt[0] = 0xBB;
489 let b = identity(rebuilt);
490
491 assert_ne!(
492 a, b,
493 "a different executable hash must produce a distinct daemon identity, \
494 so the broker can never conflate two builds (no stale-version war)"
495 );
496
497 assert_eq!(
500 a,
501 identity([0xAA; 32]),
502 "identical executable bytes must yield the same identity"
503 );
504 }
505
506 #[test]
507 fn pre_4_10_4_json_defaults_the_missing_legacy_sha256() {
508 let original = identity([0x42; 32]);
509 let mut legacy_json = serde_json::to_value(&original).expect("serialize identity");
510 let object = legacy_json.as_object_mut().expect("identity JSON object");
511 assert!(object.remove("legacy_exe_sha256").is_some());
512
513 let restored: DaemonProcess =
514 serde_json::from_value(legacy_json).expect("read pre-4.10.4 identity JSON");
515 let mut expected = original;
516 expected.legacy_exe_sha256 = [0; 32];
517 assert_eq!(restored, expected);
518 }
519
520 #[test]
521 fn exe_sha256_survives_the_manifest_wire_round_trip() {
522 let original = identity([0x42; 32]);
526 let proto = original.to_proto();
527 assert_eq!(proto.exe_hash_algorithm, "blake3");
528 assert_eq!(
529 proto.exe_hash.len(),
530 32,
531 "the wire form must carry the full 32-byte BLAKE3 hash"
532 );
533 let restored = DaemonProcess::try_from(proto).expect("identity round-trips");
534 let mut expected = original;
535 expected.legacy_exe_sha256 = [0; 32];
536 assert_eq!(
537 restored, expected,
538 "the canonical BLAKE3 identity must survive the manifest round-trip; the legacy probe field is not persisted"
539 );
540 }
541
542 #[test]
543 fn legacy_sha256_wire_identity_is_rejected_actionably() {
544 let legacy = protocol::DaemonProcess {
549 pid: 1234,
550 exe_path: "legacy-daemon".to_owned(),
551 exe_hash_algorithm: String::new(),
552 exe_hash: Vec::new(),
553 ipc_endpoint: Some(endpoint("legacy.sock")),
554 started_at_unix_ms: 1,
555 boot_id: "boot-1".to_owned(),
556 idle_timeout_secs: None,
557 };
558
559 let error = DaemonProcess::try_from(legacy).expect_err("legacy SHA-256 must be fenced");
560 assert!(matches!(
561 error,
562 IdentityError::UnsupportedExecutableHashAlgorithm(ref algorithm)
563 if algorithm.is_empty()
564 ));
565 assert!(error.to_string().contains("expected blake3"));
566 }
567}