Skip to main content

running_process/broker/backend_lifecycle/
identity.rs

1//! Normalized daemon identity carried by `BackendHandle`.
2//!
3//! `DaemonProcess` is the typed form of `CacheManifest.current_daemon`. It is
4//! deliberately more specific than the generated protobuf message: paths are
5//! `PathBuf`s, executable hashes are fixed 32-byte arrays, and the IPC endpoint
6//! is required. That keeps malformed manifests out of the `BackendHandle` probe
7//! path.
8
9use 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/// A backend daemon identity with fixed-width fields suitable for verification.
22///
23/// This mirrors `CacheManifest.current_daemon`, but normalizes protobuf strings
24/// and byte vectors into path and digest types that are harder to misuse.
25///
26/// Persist this value only after the daemon has selected its final IPC endpoint
27/// and executable. Later consumers can pass the same identity to
28/// [`crate::broker::backend_handle::BackendHandle::probe`] or store it as
29/// `CacheManifest.current_daemon`.
30///
31/// ```no_run
32/// use running_process::broker::backend_handle::DaemonProcess;
33/// use running_process::broker::protocol::{CacheManifest, Endpoint};
34///
35/// # fn example(mut manifest: CacheManifest)
36/// #     -> Result<CacheManifest, running_process::broker::backend_lifecycle::identity::IdentityError>
37/// # {
38/// let endpoint = Endpoint {
39///     namespace_id: "host-namespace".to_owned(),
40///     path: "running-process-backend.sock".to_owned(),
41/// };
42/// let daemon = DaemonProcess::current_process(endpoint, Some(600))?;
43///
44/// manifest.current_daemon = Some(daemon.to_proto());
45/// # Ok(manifest)
46/// # }
47/// ```
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct DaemonProcess {
50    /// Operating-system process ID.
51    pub pid: u32,
52    /// Executable path recorded when the daemon identity was written.
53    pub exe_path: PathBuf,
54    /// BLAKE3 content hash of the daemon executable.
55    pub exe_hash: [u8; 32],
56    /// Host boot ID observed when the daemon started.
57    pub boot_id: String,
58    /// IPC endpoint used to connect to the daemon.
59    pub ipc_endpoint: Endpoint,
60    /// Daemon start timestamp in Unix milliseconds.
61    pub started_at_unix_ms: u64,
62    /// Optional idle timeout advertised by the daemon.
63    pub idle_timeout_secs: Option<u32>,
64}
65
66impl DaemonProcess {
67    /// Build a daemon identity for the current process.
68    ///
69    /// This is primarily useful for tests and direct-daemon consumers that have
70    /// just spawned a backend and need to persist a manifest entry.
71    ///
72    /// The executable digest is taken from `std::env::current_exe()` at the time
73    /// this method runs. If a daemon relocates or replaces its executable after
74    /// startup, record the final identity after relocation instead.
75    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    /// Convert this identity into the protobuf form stored in `CacheManifest`.
93    ///
94    /// The conversion preserves the fixed-width BLAKE3 value as bytes and
95    /// names its algorithm explicitly on the wire.
96    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    /// Read and normalize `CacheManifest.current_daemon`.
110    ///
111    /// Returns `Ok(None)` when the manifest has no daemon entry. Malformed
112    /// entries, such as a missing endpoint or non-32-byte executable digest,
113    /// return an [`IdentityError`].
114    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/// Errors returned while normalizing daemon identity.
189#[derive(Debug, thiserror::Error)]
190pub enum IdentityError {
191    /// The protobuf daemon identity did not include an IPC endpoint.
192    #[error("daemon process is missing ipc_endpoint")]
193    MissingEndpoint,
194    /// The protobuf daemon identity used an unsupported or legacy hash contract.
195    #[error("unsupported daemon executable hash algorithm {0:?}; expected blake3")]
196    UnsupportedExecutableHashAlgorithm(String),
197    /// The protobuf daemon identity had an executable digest with the wrong size.
198    #[error("daemon process exe_hash must be 32 bytes, got {0}")]
199    InvalidExecutableHashLength(usize),
200    /// The current executable path could not be read.
201    #[error("failed to resolve current executable: {0}")]
202    CurrentExe(io::Error),
203    /// A filesystem operation failed while hashing the executable.
204    #[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
259/// BLAKE3 content hash used by the daemon identity wire contract.
260pub 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
264/// SHA-256 helper retained for the independent process-probe wire contract.
265pub 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    //! The broker owns the daemon "dance" (relocation / identity / lifecycle),
293    //! and it keys a backend on its `DaemonProcess` identity — whose distinctive
294    //! field is `exe_hash`, the content hash of the (relocated) executable.
295    //!
296    //! These pin the invariant that lets the broker keep two *builds* apart
297    //! instead of letting them displace each other as "stale-version" — the
298    //! exact collision that spawn-stormed soldr's per-process self-managed
299    //! daemon when the identity did NOT carry the hash (zackees/soldr#2352).
300    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        // Everything else is held constant so `exe_hash` is the only variable:
322        // a distinct *build* of the same daemon differs only in its bytes.
323        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        // One byte of the binary changed => a rebuild.
339        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        // Same bytes => same identity: a client and the same-build daemon it
350        // spawns rendezvous on ONE identity, with no shared file or negotiation.
351        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        // The broker distinguishes backends off the `CacheManifest` on the wire,
361        // so the 32-byte content hash must round-trip intact — otherwise two
362        // builds could collapse to one identity in transit.
363        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        // Pre-#946 peers populated reserved field 3 and know nothing about the
381        // new algorithm/hash fields. Prost drops the unknown legacy field, so
382        // the explicit empty algorithm marker is what turns version skew into
383        // a contract error instead of a false executable mismatch.
384        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}