Skip to main content

runner_manager_platform/
service_request.rs

1//! Asking the running service to do something this process cannot.
2//!
3//! The service shares its state directory with the account that installed
4//! it, so a request left there is a channel both sides can use with no
5//! privilege and no socket. The asking command leaves a request under an
6//! identifier it chose; the service takes the request by deleting it, which is
7//! how the command knows somebody is listening; and the service answers under
8//! the same identifier, which is how the command knows the answer is to this
9//! request.
10//!
11//! [`GITHUB_DISCOVERY`] gives every request its own pair of files under
12//! `service-requests/`, so commands asking at once do not displace each other.
13//! [`CACHE_PRUNE`] keeps the single pair of files it was introduced with, which
14//! a service from that release still reads.
15
16use std::fs;
17use std::io;
18use std::path::{Path, PathBuf};
19use std::time::{Duration, Instant};
20
21use serde::de::DeserializeOwned;
22use serde::{Deserialize, Serialize};
23
24/// How often the service looks for a request.
25pub const POLL: Duration = Duration::from_secs(2);
26
27/// The directory under the state directory per-request channels use.
28const REQUESTS_DIR: &str = "service-requests";
29
30/// One kind of request and its answers.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct Channel {
33    slots: Slots,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37enum Slots {
38    /// One request file and one result file, shared by every asker.
39    One {
40        request: &'static str,
41        result: &'static str,
42    },
43    /// `<prefix>.<id>.request` and `<prefix>.<id>.result` per request.
44    PerRequest(&'static str),
45}
46
47/// `host cache prune` for a cache root this account cannot write: the root of
48/// a service running as another account (LocalSystem on Windows, root
49/// elsewhere).
50pub const CACHE_PRUNE: Channel = Channel {
51    slots: Slots::One {
52        request: crate::dependency_cache::PRUNE_REQUEST_FILE,
53        result: crate::dependency_cache::PRUNE_RESULT_FILE,
54    },
55};
56
57/// What the stored GitHub credential reaches, asked of the service by a
58/// command that cannot read the credential itself.
59///
60/// The reason is a login-mode Mac reached over SSH: the credential is in the
61/// login keychain, which an SSH session cannot unlock, while the service runs
62/// in the desktop session and reads it every poll. `repo add`, `org add` and
63/// `auth status` used to fail there with the keychain's error. The answer
64/// holds what GitHub said about the installations, never the credential.
65pub const GITHUB_DISCOVERY: Channel = Channel {
66    slots: Slots::PerRequest("github-discovery"),
67};
68
69/// How long a command waits on the service, and how often it looks.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct Wait {
72    /// For the service to take the request: several of its polls, so a
73    /// running service always answers inside it.
74    pub taken: Duration,
75    /// For the answer, once the service has taken the request.
76    pub finished: Duration,
77    pub poll: Duration,
78}
79
80impl Wait {
81    /// What a command waiting on a GitHub request uses.
82    pub const GITHUB: Self = Self {
83        taken: Duration::from_secs(4 * POLL.as_secs()),
84        finished: Duration::from_secs(90),
85        poll: Duration::from_millis(250),
86    };
87}
88
89/// Why a command got no answer.
90#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
91pub enum AskError {
92    #[error("the request could not be left for the service: {0}")]
93    NotSent(String),
94    #[error("no running service took the request within {0} seconds")]
95    NotTaken(u64),
96    #[error("the service took the request but gave no answer within {0} seconds")]
97    NoAnswer(u64),
98    #[error("the service could not answer: {0}")]
99    Refused(String),
100}
101
102#[derive(Debug, Serialize, Deserialize)]
103struct Answer<T> {
104    request: String,
105    outcome: Result<T, String>,
106}
107
108/// The longest request identifier taken, and the characters it may hold. An
109/// identifier is chosen by the asking command (a timestamp and a process id)
110/// and copied into the answer, so the service takes nothing else.
111const MAX_ID: usize = 64;
112
113fn valid_id(id: &str) -> bool {
114    !id.is_empty()
115        && id.len() <= MAX_ID
116        && id
117            .chars()
118            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_'))
119}
120
121/// An answer nobody collected is removed after this long.
122const UNCOLLECTED: Duration = Duration::from_secs(10 * 60);
123
124/// Reads a single-slot request without following a link and without reading
125/// more than an identifier's worth.
126///
127/// The service may run as root or LocalSystem while the state directory
128/// belongs to the account that installed it, so the request path is under that
129/// account's control. Following a link there would have the service read a
130/// file of the link's choosing, and copy it into an answer that account can
131/// read; a FIFO or `/dev/zero` would hold the service's loop.
132fn read_request(path: &Path) -> Option<String> {
133    use std::io::Read as _;
134    let mut options = fs::OpenOptions::new();
135    options.read(true);
136    #[cfg(unix)]
137    std::os::unix::fs::OpenOptionsExt::custom_flags(
138        &mut options,
139        libc::O_NOFOLLOW | libc::O_NONBLOCK,
140    );
141    #[cfg(windows)]
142    std::os::windows::fs::OpenOptionsExt::custom_flags(
143        &mut options,
144        // FILE_FLAG_OPEN_REPARSE_POINT: open a link as itself.
145        0x0020_0000,
146    );
147    let file = options.open(path).ok()?;
148    if !file.metadata().ok()?.is_file() {
149        return None;
150    }
151    let mut text = String::new();
152    file.take(MAX_ID as u64 + 2)
153        .read_to_string(&mut text)
154        .ok()?;
155    let id = text.trim();
156    valid_id(id).then(|| id.to_owned())
157}
158
159/// The answer file is the service's to write and the asking account's to read,
160/// whichever account the service runs as. It holds no secret.
161fn readable_by_the_asker(path: &Path) -> io::Result<()> {
162    #[cfg(unix)]
163    {
164        use std::os::unix::fs::PermissionsExt as _;
165        fs::set_permissions(path, fs::Permissions::from_mode(0o644))
166    }
167    #[cfg(not(unix))]
168    {
169        let _ = path;
170        Ok(())
171    }
172}
173
174impl Channel {
175    /// Where a single-slot channel's request lives.
176    #[must_use]
177    pub fn request_path(&self, state_dir: &Path) -> PathBuf {
178        match self.slots {
179            Slots::One { request, .. } => state_dir.join(request),
180            Slots::PerRequest(prefix) => state_dir.join(REQUESTS_DIR).join(prefix),
181        }
182    }
183
184    fn request_file(&self, state_dir: &Path, id: &str) -> PathBuf {
185        match self.slots {
186            Slots::One { request, .. } => state_dir.join(request),
187            Slots::PerRequest(prefix) => state_dir
188                .join(REQUESTS_DIR)
189                .join(format!("{prefix}.{id}.request")),
190        }
191    }
192
193    fn result_file(&self, state_dir: &Path, id: &str) -> PathBuf {
194        match self.slots {
195            Slots::One { result, .. } => state_dir.join(result),
196            Slots::PerRequest(prefix) => state_dir
197                .join(REQUESTS_DIR)
198                .join(format!("{prefix}.{id}.result")),
199        }
200    }
201
202    /// Takes the pending requests and returns their identifiers. Taking one is
203    /// deleting it, so a request is answered once.
204    #[must_use]
205    pub fn take(&self, state_dir: &Path) -> Vec<String> {
206        match self.slots {
207            Slots::One { request, .. } => {
208                let path = state_dir.join(request);
209                if fs::symlink_metadata(&path).is_err() {
210                    return Vec::new();
211                }
212                let id = read_request(&path);
213                // Removed whatever it held: an unreadable or malformed request
214                // is not going to become a good one.
215                if fs::remove_file(&path).is_err() {
216                    return Vec::new();
217                }
218                id.into_iter().collect()
219            }
220            Slots::PerRequest(prefix) => {
221                let Ok(entries) = fs::read_dir(state_dir.join(REQUESTS_DIR)) else {
222                    return Vec::new();
223                };
224                let mut taken = Vec::new();
225                for entry in entries.flatten() {
226                    let name = entry.file_name();
227                    let Some(name) = name.to_str() else { continue };
228                    let Some(rest) = name.strip_prefix(prefix).and_then(|r| r.strip_prefix('.'))
229                    else {
230                        continue;
231                    };
232                    if let Some(id) = rest.strip_suffix(".request") {
233                        // The identifier is the file's name, so its contents
234                        // are never read, and a link is removed as a link.
235                        if fs::remove_file(entry.path()).is_ok() && valid_id(id) {
236                            taken.push(id.to_owned());
237                        }
238                    } else if rest.ends_with(".result")
239                        && entry
240                            .metadata()
241                            .and_then(|meta| meta.modified())
242                            .ok()
243                            .and_then(|at| at.elapsed().ok())
244                            .is_some_and(|age| age >= UNCOLLECTED)
245                    {
246                        let _ = fs::remove_file(entry.path());
247                    }
248                }
249                taken
250            }
251        }
252    }
253
254    /// The service's answer to `request`.
255    ///
256    /// # Errors
257    /// The answer could not be encoded or written.
258    pub fn answer<T: Serialize>(
259        &self,
260        state_dir: &Path,
261        request: &str,
262        outcome: Result<T, String>,
263    ) -> io::Result<()> {
264        let answer = Answer {
265            request: request.to_owned(),
266            outcome,
267        };
268        let json = serde_json::to_vec_pretty(&answer).map_err(io::Error::other)?;
269        let path = self.result_file(state_dir, request);
270        crate::host_fitness::write_atomically(&path, &json)?;
271        readable_by_the_asker(&path)
272    }
273
274    /// The service's answer to `request`, once it has given one.
275    #[must_use]
276    pub fn read_answer<T: DeserializeOwned>(
277        &self,
278        state_dir: &Path,
279        request: &str,
280    ) -> Option<Result<T, String>> {
281        fs::read(self.result_file(state_dir, request))
282            .ok()
283            .and_then(|bytes| serde_json::from_slice::<Answer<T>>(&bytes).ok())
284            .filter(|answer| answer.request == request)
285            .map(|answer| answer.outcome)
286    }
287
288    /// Asks the service and waits for its answer.
289    ///
290    /// # Errors
291    /// [`AskError`], naming which half of the exchange did not happen.
292    pub fn ask<T: DeserializeOwned>(&self, state_dir: &Path, wait: Wait) -> Result<T, AskError> {
293        self.ask_with(state_dir, wait, || {})
294    }
295
296    /// [`Self::ask`], calling `sent` once the request is in place, so the
297    /// command can say it is waiting.
298    ///
299    /// # Errors
300    /// As [`Self::ask`].
301    pub fn ask_with<T: DeserializeOwned>(
302        &self,
303        state_dir: &Path,
304        wait: Wait,
305        sent: impl FnOnce(),
306    ) -> Result<T, AskError> {
307        // Unique within this process as well as across processes: two threads
308        // can read the same instant where the clock is coarse (macOS).
309        static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
310        let id = format!(
311            "{}-{}-{}",
312            chrono::Utc::now().format("%Y%m%dT%H%M%S%.9fZ"),
313            std::process::id(),
314            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
315        );
316        let request = self.request_file(state_dir, &id);
317        crate::host_fitness::write_atomically(&request, id.as_bytes())
318            .map_err(|error| AskError::NotSent(format!("{}: {error}", request.display())))?;
319        sent();
320
321        let started = Instant::now();
322        let pending = |request: &Path| match self.slots {
323            Slots::One { .. } => read_request(request).as_deref() == Some(id.as_str()),
324            Slots::PerRequest(_) => fs::symlink_metadata(request).is_ok(),
325        };
326        while pending(&request) {
327            if started.elapsed() >= wait.taken {
328                // Withdrawn, so a service that starts later does not answer a
329                // command that is no longer waiting: only this command's own
330                // request, which in a single slot another command may have
331                // replaced. Gone already means it was taken after all.
332                match fs::remove_file(&request) {
333                    Err(error) if error.kind() == io::ErrorKind::NotFound => break,
334                    _ => return Err(AskError::NotTaken(wait.taken.as_secs())),
335                }
336            }
337            std::thread::sleep(wait.poll);
338        }
339
340        let taken = Instant::now();
341        loop {
342            if let Some(outcome) = self.read_answer(state_dir, &id) {
343                if matches!(self.slots, Slots::PerRequest(_)) {
344                    let _ = fs::remove_file(self.result_file(state_dir, &id));
345                }
346                return outcome.map_err(AskError::Refused);
347            }
348            if taken.elapsed() >= wait.finished {
349                return Err(AskError::NoAnswer(wait.finished.as_secs()));
350            }
351            std::thread::sleep(wait.poll);
352        }
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    const QUICK: Wait = Wait {
361        taken: Duration::from_millis(600),
362        finished: Duration::from_secs(5),
363        poll: Duration::from_millis(20),
364    };
365
366    /// A stand-in service: takes one request and answers it with `outcome`.
367    fn service(
368        state_dir: PathBuf,
369        outcome: Result<u32, String>,
370    ) -> std::thread::JoinHandle<String> {
371        std::thread::spawn(move || {
372            loop {
373                if let Some(request) = GITHUB_DISCOVERY.take(&state_dir).pop() {
374                    GITHUB_DISCOVERY
375                        .answer(&state_dir, &request, outcome)
376                        .unwrap();
377                    return request;
378                }
379                std::thread::sleep(Duration::from_millis(10));
380            }
381        })
382    }
383
384    #[test]
385    fn a_running_service_takes_the_request_once_and_answers_it() {
386        let dir = tempfile::tempdir().unwrap();
387        let answering = service(dir.path().to_path_buf(), Ok(7));
388        assert_eq!(GITHUB_DISCOVERY.ask::<u32>(dir.path(), QUICK), Ok(7));
389        answering.join().unwrap();
390        assert!(
391            GITHUB_DISCOVERY.take(dir.path()).is_empty(),
392            "answered once"
393        );
394    }
395
396    #[test]
397    fn a_service_that_cannot_answer_says_why() {
398        let dir = tempfile::tempdir().unwrap();
399        let answering = service(dir.path().to_path_buf(), Err("no credential".into()));
400        assert_eq!(
401            GITHUB_DISCOVERY.ask::<u32>(dir.path(), QUICK),
402            Err(AskError::Refused("no credential".into()))
403        );
404        answering.join().unwrap();
405    }
406
407    #[test]
408    fn with_no_service_listening_the_request_is_withdrawn() {
409        let dir = tempfile::tempdir().unwrap();
410        assert!(matches!(
411            GITHUB_DISCOVERY.ask::<u32>(dir.path(), QUICK),
412            Err(AskError::NotTaken(_))
413        ));
414        assert!(
415            GITHUB_DISCOVERY.take(dir.path()).is_empty(),
416            "a later service must not answer a command that stopped waiting"
417        );
418    }
419
420    #[test]
421    fn an_answer_to_another_request_is_not_taken_for_this_one() {
422        let dir = tempfile::tempdir().unwrap();
423        GITHUB_DISCOVERY
424            .answer(dir.path(), "an-earlier-request", Ok(1_u32))
425            .unwrap();
426        let state_dir = dir.path().to_path_buf();
427        // Takes the request and never answers it.
428        let silent = std::thread::spawn(move || {
429            while GITHUB_DISCOVERY.take(&state_dir).is_empty() {
430                std::thread::sleep(Duration::from_millis(10));
431            }
432        });
433        let wait = Wait {
434            finished: Duration::from_millis(300),
435            ..QUICK
436        };
437        assert!(matches!(
438            GITHUB_DISCOVERY.ask::<u32>(dir.path(), wait),
439            Err(AskError::NoAnswer(_))
440        ));
441        silent.join().unwrap();
442    }
443
444    /// Two commands asking at once each get their own answer: neither
445    /// replaces the other's request.
446    #[test]
447    fn commands_asking_at_once_each_get_their_own_answer() {
448        let dir = tempfile::tempdir().unwrap();
449        let state_dir = dir.path().to_path_buf();
450        let service = std::thread::spawn(move || {
451            let mut answered = 0;
452            let deadline = Instant::now() + Duration::from_secs(10);
453            while answered < 2 && Instant::now() < deadline {
454                for request in GITHUB_DISCOVERY.take(&state_dir) {
455                    GITHUB_DISCOVERY
456                        .answer(&state_dir, &request, Ok(request.len()))
457                        .unwrap();
458                    answered += 1;
459                }
460                std::thread::sleep(Duration::from_millis(10));
461            }
462            answered
463        });
464        let asking =
465            |dir: PathBuf| std::thread::spawn(move || GITHUB_DISCOVERY.ask::<usize>(&dir, QUICK));
466        let first = asking(dir.path().to_path_buf());
467        let second = asking(dir.path().to_path_buf());
468        assert!(first.join().unwrap().is_ok());
469        assert!(second.join().unwrap().is_ok());
470        assert_eq!(service.join().unwrap(), 2);
471    }
472
473    /// The service never follows a link left at a request path, and never
474    /// takes an identifier it would not have chosen.
475    #[cfg(unix)]
476    #[test]
477    fn a_request_that_is_a_link_or_not_an_identifier_is_dropped_unread() {
478        let dir = tempfile::tempdir().unwrap();
479        let secret = dir.path().join("only-the-service-may-read");
480        fs::write(&secret, "the contents of somebody else's file").unwrap();
481        let request = CACHE_PRUNE.request_path(dir.path());
482        std::os::unix::fs::symlink(&secret, &request).unwrap();
483        assert!(CACHE_PRUNE.take(dir.path()).is_empty());
484        assert!(
485            fs::symlink_metadata(&request).is_err(),
486            "the link is removed"
487        );
488        assert!(secret.exists(), "and only the link");
489
490        fs::write(&request, "not an identifier\nat all").unwrap();
491        assert!(CACHE_PRUNE.take(dir.path()).is_empty());
492        fs::write(&request, "20261007T221210.123456789Z-4242\n").unwrap();
493        assert_eq!(
494            CACHE_PRUNE.take(dir.path()),
495            ["20261007T221210.123456789Z-4242"]
496        );
497    }
498}