Skip to main content

rightkit_daemon/
record.rs

1use crate::suite::Suite;
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5
6/// Bumped on any incompatible change to discovery files, frames or the
7/// allowlist semantics. A client rejects a different major.
8pub const PROTOCOL_MAJOR: u32 = 1;
9pub const PROTOCOL_MINOR: u32 = 0;
10
11/// Rank of a record or takeover request that carries none (HeardRight's
12/// `service.json` / `takeover.json` name only a `role`). Never wins a takeover:
13/// a host resolves it through [`crate::ServiceConfig::known_roles`] or treats
14/// the incumbent as unbeatable.
15pub const RANK_UNKNOWN: u8 = u8::MAX;
16
17fn is_zero(v: &u64) -> bool {
18    *v == 0
19}
20
21fn rank_unknown() -> u8 {
22    RANK_UNKNOWN
23}
24
25/// `<suite>/runtime/<service>/service.json`.
26///
27/// A superset of HeardRight's `ServiceRecord` (`protocol_major`, `protocol_minor`,
28/// `role`, `pid`, `endpoint`, `engine_version`): the fields HeardRight does not
29/// write default on read, and `engine_version` is written alongside `version`
30/// so an unchanged HeardRight client still parses a record written here.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct ServiceRecord {
33    pub protocol_major: u32,
34    pub protocol_minor: u32,
35    #[serde(default)]
36    pub service: String,
37    /// Product label of the host (HeardRight: `heard_right_shell`, ...).
38    pub role: String,
39    /// Lower rank wins the service. [`RANK_UNKNOWN`] when the writer gave none.
40    #[serde(default = "rank_unknown")]
41    pub rank: u8,
42    pub pid: u32,
43    /// Unix socket path or named-pipe name.
44    pub endpoint: String,
45    #[serde(default)]
46    pub version: String,
47    /// Publication time (ms since epoch) of this record. HeardRight-compatible
48    /// meaning; never used to validate the PID.
49    #[serde(default)]
50    pub started_ms: u64,
51    /// Birth time of the owning process (ms since epoch), when the writer knows
52    /// it. Only when present is it compared against the live process to reject a
53    /// reused PID; records without it fall back to plain PID liveness.
54    #[serde(default, skip_serializing_if = "is_zero")]
55    pub process_started_ms: u64,
56    /// HeardRight's name for `version`; mirrored so either reader works.
57    #[serde(default)]
58    pub engine_version: String,
59}
60
61impl ServiceRecord {
62    /// The host version, from whichever field the writer used.
63    pub fn host_version(&self) -> &str {
64        if self.version.is_empty() {
65            &self.engine_version
66        } else {
67            &self.version
68        }
69    }
70}
71
72/// `<suite>/runtime/<service>/takeover.json`: written by a would-be host that
73/// outranks the incumbent; deleted by the requester once hosting.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct TakeoverRequest {
76    pub role: String,
77    /// HeardRight's `takeover.json` has no rank; [`RANK_UNKNOWN`] then.
78    #[serde(default = "rank_unknown")]
79    pub rank: u8,
80    pub pid: u32,
81    pub requested_at_ms: u64,
82}
83
84pub fn record_path(suite: &Suite, service: &str) -> PathBuf {
85    suite.service_dir(service).join("service.json")
86}
87pub fn takeover_path(suite: &Suite, service: &str) -> PathBuf {
88    suite.service_dir(service).join("takeover.json")
89}
90
91/// Read and parse one discovery file. `Ok(None)` when the file is absent (`NotFound`) or was read
92/// but does not parse (not a record this crate or HeardRight wrote). Any other read error is
93/// returned: a file another process is replacing can be refused for an instant (Windows
94/// `ERROR_ACCESS_DENIED` or `ERROR_SHARING_VIOLATION`), and a permission or I/O error says nothing
95/// about whether the file, or the service, exists.
96fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> std::io::Result<Option<T>> {
97    match rightkit_fs::read_replaced(path) {
98        Ok(bytes) => Ok(serde_json::from_slice(&bytes).ok()),
99        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
100        Err(e) => Err(e),
101    }
102}
103
104/// The published record, telling "absent" from "unreadable".
105///
106/// `Ok(None)`: there is no `service.json` (or it holds no record). `Err`: it could not be read
107/// right now, so whether a service exists is unknown. Treat that as "try again", never as "no
108/// service". A refused open (Windows access denied or sharing violation) is retried first by `rightkit_fs::read_replaced`.
109pub fn try_read_record(suite: &Suite, service: &str) -> std::io::Result<Option<ServiceRecord>> {
110    read_json(&record_path(suite, service))
111}
112
113/// The pending takeover request, telling "absent" from "unreadable" as [`try_read_record`] does.
114/// `Err` means whether a request exists is unknown: a host must not conclude there is none.
115pub fn try_read_takeover(suite: &Suite, service: &str) -> std::io::Result<Option<TakeoverRequest>> {
116    read_json(&takeover_path(suite, service))
117}
118
119/// The published record. A record that is absent, unparseable or unreadable is `None`: use
120/// [`try_read_record`] where "unreadable" must not be taken for "no service".
121pub fn read_record(suite: &Suite, service: &str) -> Option<ServiceRecord> {
122    try_read_record(suite, service).ok().flatten()
123}
124/// The pending takeover request. Absent, unparseable and unreadable are all `None`: use
125/// [`try_read_takeover`] where "unreadable" must not be taken for "no request".
126pub fn read_takeover(suite: &Suite, service: &str) -> Option<TakeoverRequest> {
127    try_read_takeover(suite, service).ok().flatten()
128}
129
130/// The record only when its owner process is alive (HeardRight's staleness
131/// rule: a dead pid means the record is stale). Does not probe the endpoint;
132/// connecting does that and reports `service_unavailable`. Unreadable is `None`; see
133/// [`try_read_live_record`].
134pub fn read_live_record(suite: &Suite, service: &str) -> Option<ServiceRecord> {
135    try_read_live_record(suite, service).ok().flatten()
136}
137
138/// [`read_live_record`] with `Err` for a record that could not be read.
139pub fn try_read_live_record(
140    suite: &Suite,
141    service: &str,
142) -> std::io::Result<Option<ServiceRecord>> {
143    Ok(try_read_record(suite, service)?.filter(crate::discovery::record_process_matches))
144}
145
146/// Publish a takeover request (atomic, owner-only).
147///
148/// A host in another process finds the request on its next check of `takeover.json`, at most
149/// about a second later. A host in this process is woken at once.
150pub fn write_takeover(suite: &Suite, service: &str, req: &TakeoverRequest) -> std::io::Result<()> {
151    crate::suite::make_private_dir(&suite.service_dir(service)).map_err(std::io::Error::other)?;
152    let path = takeover_path(suite, service);
153    write_atomic(&path, &serde_json::to_vec_pretty(req).unwrap_or_default())?;
154    crate::takeover_watch::poke(&path);
155    Ok(())
156}
157
158/// Remove `takeover.json` only if `pid` wrote it.
159pub fn clear_takeover_if_owner(suite: &Suite, service: &str, pid: u32) {
160    if read_takeover(suite, service).is_some_and(|t| t.pid == pid) {
161        let _ = std::fs::remove_file(takeover_path(suite, service));
162    }
163}
164
165/// Atomic, durable, owner-only replace (`rightkit_fs::atomic_write_private`: unique temp file in
166/// the same directory, `fsync`, rename, directory `fsync`). The parent directory must exist.
167pub fn write_atomic(path: &Path, body: &[u8]) -> std::io::Result<()> {
168    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
169    loop {
170        match rightkit_fs::atomic_write_private(path, body) {
171            Ok(()) => return Ok(()),
172            // Windows refuses to rename over a file another process has open without delete
173            // sharing (the incumbent host and clients poll these records), failing transiently
174            // with "Access is denied". Retry briefly; elsewhere rename never fails that way.
175            Err(e)
176                if cfg!(windows)
177                    && e.kind() == Some(std::io::ErrorKind::PermissionDenied)
178                    && std::time::Instant::now() < deadline =>
179            {
180                std::thread::sleep(std::time::Duration::from_millis(10));
181            }
182            Err(rightkit_fs::FsError::Io { source, .. }) => return Err(source),
183            Err(e) => return Err(std::io::Error::other(e)),
184        }
185    }
186}
187
188/// Which methods each calling app may use. `"*"` as an app key is the default
189/// for apps with no entry of their own; `"*"` as a method allows every method
190/// for that app. `ping` is always allowed. An empty allowlist denies everyone.
191#[derive(Debug, Clone, Default, PartialEq, Eq)]
192pub struct Allowlist {
193    apps: BTreeMap<String, BTreeSet<String>>,
194}
195
196impl Allowlist {
197    pub fn new() -> Self {
198        Self::default()
199    }
200    pub fn allow<I, S>(mut self, app: &str, methods: I) -> Self
201    where
202        I: IntoIterator<Item = S>,
203        S: Into<String>,
204    {
205        self.apps
206            .entry(app.to_string())
207            .or_default()
208            .extend(methods.into_iter().map(Into::into));
209        self
210    }
211    fn entry(&self, app: &str) -> Option<&BTreeSet<String>> {
212        self.apps.get(app).or_else(|| self.apps.get("*"))
213    }
214    pub fn app_known(&self, app: &str) -> bool {
215        self.entry(app).is_some()
216    }
217    pub fn permits(&self, app: &str, method: &str) -> bool {
218        method == "ping"
219            || self
220                .entry(app)
221                .is_some_and(|m| m.contains(method) || m.contains("*"))
222    }
223    pub fn methods_for(&self, app: &str) -> Vec<String> {
224        self.entry(app)
225            .map(|m| m.iter().cloned().collect())
226            .unwrap_or_default()
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    fn suite() -> (tempfile::TempDir, Suite) {
235        let d = tempfile::tempdir().unwrap();
236        let s = Suite::at(d.path().join("suite")).unwrap();
237        (d, s)
238    }
239
240    /// A directory at the path makes the read fail with an error that is not `NotFound`
241    /// (`IsADirectory` on Unix, `PermissionDenied` on Windows) on every platform.
242    fn unreadable(path: &Path) {
243        std::fs::create_dir_all(path).unwrap();
244    }
245
246    #[test]
247    fn absent_files_are_none_not_errors() {
248        let (_d, s) = suite();
249        assert!(try_read_record(&s, "demo").unwrap().is_none());
250        assert!(try_read_takeover(&s, "demo").unwrap().is_none());
251        assert!(try_read_live_record(&s, "demo").unwrap().is_none());
252        assert!(read_record(&s, "demo").is_none() && read_takeover(&s, "demo").is_none());
253    }
254
255    #[test]
256    fn unparseable_files_stay_none() {
257        let (_d, s) = suite();
258        std::fs::create_dir_all(s.service_dir("demo")).unwrap();
259        std::fs::write(record_path(&s, "demo"), b"not json").unwrap();
260        std::fs::write(takeover_path(&s, "demo"), b"{").unwrap();
261        assert!(try_read_record(&s, "demo").unwrap().is_none());
262        assert!(try_read_takeover(&s, "demo").unwrap().is_none());
263    }
264
265    #[test]
266    fn unreadable_files_are_errors_in_the_try_readers() {
267        let (_d, s) = suite();
268        unreadable(&record_path(&s, "demo"));
269        unreadable(&takeover_path(&s, "demo"));
270        let e = try_read_record(&s, "demo").unwrap_err();
271        assert_ne!(e.kind(), std::io::ErrorKind::NotFound);
272        let e = try_read_takeover(&s, "demo").unwrap_err();
273        assert_ne!(e.kind(), std::io::ErrorKind::NotFound);
274        assert!(try_read_live_record(&s, "demo").is_err());
275        // The infallible readers keep their old shape.
276        assert!(read_record(&s, "demo").is_none() && read_takeover(&s, "demo").is_none());
277        // Unknown ownership: the request is left alone.
278        clear_takeover_if_owner(&s, "demo", std::process::id());
279        assert!(takeover_path(&s, "demo").is_dir());
280    }
281}