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 boot_id: String,
58 pub ipc_endpoint: Endpoint,
60 pub started_at_unix_ms: u64,
62 pub idle_timeout_secs: Option<u32>,
64}
65
66impl DaemonProcess {
67 pub fn current_process(
76 ipc_endpoint: Endpoint,
77 idle_timeout_secs: Option<u32>,
78 ) -> Result<Self, IdentityError> {
79 let exe_path = std::env::current_exe().map_err(IdentityError::CurrentExe)?;
80 let exe_hash = executable_hash_file(&exe_path)?;
81 Ok(Self {
82 pid: std::process::id(),
83 exe_path,
84 exe_hash,
85 boot_id: host_identity::current().boot_id,
86 ipc_endpoint,
87 started_at_unix_ms: unix_now_ms(),
88 idle_timeout_secs,
89 })
90 }
91
92 pub fn to_proto(&self) -> protocol::DaemonProcess {
97 protocol::DaemonProcess {
98 pid: self.pid,
99 exe_path: self.exe_path.to_string_lossy().into_owned(),
100 exe_hash_algorithm: EXECUTABLE_HASH_ALGORITHM.to_owned(),
101 exe_hash: self.exe_hash.to_vec(),
102 ipc_endpoint: Some(self.ipc_endpoint.clone()),
103 started_at_unix_ms: self.started_at_unix_ms,
104 boot_id: self.boot_id.clone(),
105 idle_timeout_secs: self.idle_timeout_secs,
106 }
107 }
108
109 pub fn from_manifest_current_daemon(
115 manifest: &CacheManifest,
116 ) -> Result<Option<Self>, IdentityError> {
117 manifest
118 .current_daemon
119 .clone()
120 .map(Self::try_from)
121 .transpose()
122 }
123}
124
125impl TryFrom<protocol::DaemonProcess> for DaemonProcess {
126 type Error = IdentityError;
127
128 fn try_from(value: protocol::DaemonProcess) -> Result<Self, Self::Error> {
129 let ipc_endpoint = value.ipc_endpoint.ok_or(IdentityError::MissingEndpoint)?;
130 if value.exe_hash_algorithm != EXECUTABLE_HASH_ALGORITHM {
131 return Err(IdentityError::UnsupportedExecutableHashAlgorithm(
132 value.exe_hash_algorithm,
133 ));
134 }
135 let exe_hash =
136 vec_to_hash(value.exe_hash).map_err(IdentityError::InvalidExecutableHashLength)?;
137 Ok(Self {
138 pid: value.pid,
139 exe_path: PathBuf::from(value.exe_path),
140 exe_hash,
141 boot_id: value.boot_id,
142 ipc_endpoint,
143 started_at_unix_ms: value.started_at_unix_ms,
144 idle_timeout_secs: value.idle_timeout_secs,
145 })
146 }
147}
148
149impl From<&DaemonProcess> for protocol::DaemonProcess {
150 fn from(value: &DaemonProcess) -> Self {
151 value.to_proto()
152 }
153}
154
155impl Serialize for DaemonProcess {
156 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
157 where
158 S: Serializer,
159 {
160 DaemonProcessSerde::from(self).serialize(serializer)
161 }
162}
163
164impl<'de> Deserialize<'de> for DaemonProcess {
165 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
166 where
167 D: Deserializer<'de>,
168 {
169 let value = DaemonProcessSerde::deserialize(deserializer)?;
170 if value.exe_hash_algorithm != EXECUTABLE_HASH_ALGORITHM {
171 return Err(<D::Error as serde::de::Error>::custom(format!(
172 "unsupported daemon executable hash algorithm {:?}; expected blake3",
173 value.exe_hash_algorithm
174 )));
175 }
176 Ok(Self {
177 pid: value.pid,
178 exe_path: value.exe_path,
179 exe_hash: value.exe_hash,
180 boot_id: value.boot_id,
181 ipc_endpoint: value.ipc_endpoint.into(),
182 started_at_unix_ms: value.started_at_unix_ms,
183 idle_timeout_secs: value.idle_timeout_secs,
184 })
185 }
186}
187
188#[derive(Debug, thiserror::Error)]
190pub enum IdentityError {
191 #[error("daemon process is missing ipc_endpoint")]
193 MissingEndpoint,
194 #[error("unsupported daemon executable hash algorithm {0:?}; expected blake3")]
196 UnsupportedExecutableHashAlgorithm(String),
197 #[error("daemon process exe_hash must be 32 bytes, got {0}")]
199 InvalidExecutableHashLength(usize),
200 #[error("failed to resolve current executable: {0}")]
202 CurrentExe(io::Error),
203 #[error("failed to hash executable: {0}")]
205 Io(#[from] io::Error),
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209struct DaemonProcessSerde {
210 pid: u32,
211 exe_path: PathBuf,
212 exe_hash_algorithm: String,
213 exe_hash: [u8; 32],
214 boot_id: String,
215 ipc_endpoint: EndpointSerde,
216 started_at_unix_ms: u64,
217 idle_timeout_secs: Option<u32>,
218}
219
220impl From<&DaemonProcess> for DaemonProcessSerde {
221 fn from(value: &DaemonProcess) -> Self {
222 Self {
223 pid: value.pid,
224 exe_path: value.exe_path.clone(),
225 exe_hash_algorithm: EXECUTABLE_HASH_ALGORITHM.to_owned(),
226 exe_hash: value.exe_hash,
227 boot_id: value.boot_id.clone(),
228 ipc_endpoint: EndpointSerde::from(&value.ipc_endpoint),
229 started_at_unix_ms: value.started_at_unix_ms,
230 idle_timeout_secs: value.idle_timeout_secs,
231 }
232 }
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
236struct EndpointSerde {
237 namespace_id: String,
238 path: String,
239}
240
241impl From<&Endpoint> for EndpointSerde {
242 fn from(value: &Endpoint) -> Self {
243 Self {
244 namespace_id: value.namespace_id.clone(),
245 path: value.path.clone(),
246 }
247 }
248}
249
250impl From<EndpointSerde> for Endpoint {
251 fn from(value: EndpointSerde) -> Self {
252 Endpoint {
253 namespace_id: value.namespace_id,
254 path: value.path,
255 }
256 }
257}
258
259pub fn executable_hash_file(path: &Path) -> Result<[u8; 32], io::Error> {
261 crate::content_hash::blake3_file(path).map(|hash| *hash.as_bytes())
262}
263
264pub fn sha256_file(path: &Path) -> Result<[u8; 32], io::Error> {
266 let bytes = fs::read(path)?;
267 let digest = Sha256::digest(&bytes);
268 let mut out = [0_u8; 32];
269 out.copy_from_slice(&digest);
270 Ok(out)
271}
272
273fn vec_to_hash(bytes: Vec<u8>) -> Result<[u8; 32], usize> {
274 let len = bytes.len();
275 let Ok(out) = bytes.try_into() else {
276 return Err(len);
277 };
278 Ok(out)
279}
280
281const EXECUTABLE_HASH_ALGORITHM: &str = "blake3";
282
283fn unix_now_ms() -> u64 {
284 SystemTime::now()
285 .duration_since(UNIX_EPOCH)
286 .map(|duration| duration.as_millis() as u64)
287 .unwrap_or(0)
288}
289
290#[cfg(test)]
291mod broker_dance_identity_tests {
292 use super::*;
301
302 #[test]
303 fn executable_identity_hash_uses_blake3() {
304 let path =
305 std::env::temp_dir().join(format!("running-process-946-hash-{}", std::process::id()));
306 std::fs::write(&path, b"daemon image bytes").expect("write fixture");
307 let actual = executable_hash_file(&path).expect("hash fixture");
308 std::fs::remove_file(&path).ok();
309
310 assert_eq!(actual, *blake3::hash(b"daemon image bytes").as_bytes());
311 }
312
313 fn endpoint(path: &str) -> Endpoint {
314 Endpoint {
315 namespace_id: "ns".to_owned(),
316 path: path.to_owned(),
317 }
318 }
319
320 fn identity(exe_hash: [u8; 32]) -> DaemonProcess {
321 DaemonProcess {
324 pid: 1234,
325 exe_path: PathBuf::from("runtime/soldr-self/v0.8.44-deadbeef/soldr.exe"),
326 exe_hash,
327 boot_id: "boot-1".to_owned(),
328 ipc_endpoint: endpoint("rpb-v2-soldr-daemon-0123456789abcdef-0"),
329 started_at_unix_ms: 1,
330 idle_timeout_secs: Some(600),
331 }
332 }
333
334 #[test]
335 fn distinct_builds_get_distinct_identities() {
336 let a = identity([0xAA; 32]);
337
338 let mut rebuilt = [0xAA; 32];
340 rebuilt[0] = 0xBB;
341 let b = identity(rebuilt);
342
343 assert_ne!(
344 a, b,
345 "a different executable hash must produce a distinct daemon identity, \
346 so the broker can never conflate two builds (no stale-version war)"
347 );
348
349 assert_eq!(
352 a,
353 identity([0xAA; 32]),
354 "identical executable bytes must yield the same identity"
355 );
356 }
357
358 #[test]
359 fn exe_sha256_survives_the_manifest_wire_round_trip() {
360 let original = identity([0x42; 32]);
364 let proto = original.to_proto();
365 assert_eq!(proto.exe_hash_algorithm, "blake3");
366 assert_eq!(
367 proto.exe_hash.len(),
368 32,
369 "the wire form must carry the full 32-byte BLAKE3 hash"
370 );
371 let restored = DaemonProcess::try_from(proto).expect("identity round-trips");
372 assert_eq!(
373 restored, original,
374 "identity (including exe_hash) must survive the manifest round-trip"
375 );
376 }
377
378 #[test]
379 fn legacy_sha256_wire_identity_is_rejected_actionably() {
380 let legacy = protocol::DaemonProcess {
385 pid: 1234,
386 exe_path: "legacy-daemon".to_owned(),
387 exe_hash_algorithm: String::new(),
388 exe_hash: Vec::new(),
389 ipc_endpoint: Some(endpoint("legacy.sock")),
390 started_at_unix_ms: 1,
391 boot_id: "boot-1".to_owned(),
392 idle_timeout_secs: None,
393 };
394
395 let error = DaemonProcess::try_from(legacy).expect_err("legacy SHA-256 must be fenced");
396 assert!(matches!(
397 error,
398 IdentityError::UnsupportedExecutableHashAlgorithm(ref algorithm)
399 if algorithm.is_empty()
400 ));
401 assert!(error.to_string().contains("expected blake3"));
402 }
403}