hotl_platform/lib.rs
1//! Platform seams: one capability trait per concern, one adapter per platform.
2//!
3//! `ARCHITECTURE.md` says core crates sit behind platform traits so the seam
4//! stays clean. This crate is that seam. A second OS is what makes the
5//! abstraction pay for itself, and the native Windows port (plan 0027) is what
6//! built it out from the original `Clock` + `SecretStore` stub.
7//!
8//! # The rules every adapter here follows
9//!
10//! 1. **One adapter per capability per platform**, named `<Platform><Capability>`,
11//! living in `src/<capability>/{mod,unix,windows}.rs`. `mod.rs` holds the
12//! trait and everything platform-free.
13//! 2. **Static dispatch.** Each capability exports a `#[cfg]`-selected type
14//! alias and a unit const, so a call site writes
15//! `hotl_platform::PRIVATE_FS.create_dir(p)?` and pays nothing. There is
16//! exactly one implementation in any build; the point is that the contract
17//! is named, documented and testable, not that it is swappable at runtime.
18//! The one `dyn` in this crate is [`SecretStore`], whose set genuinely is
19//! heterogeneous at runtime (env → keychain → prompt).
20//! 3. **Traits are sealed** — see [`sealed`].
21//! 4. **Capability-narrow by construction.** Before adding a method, ask what
22//! it would let a caller do that the module exists to forbid. A general
23//! `Fs` trait with `open(&Path)` would demote `fsguard`'s structural
24//! one-door invariant to a discipline invariant; [`DirHandle`] instead
25//! exposes only relative-to-handle, one-component-at-a-time operations.
26//! 5. **Totality: no silent no-ops.** Where a platform genuinely lacks a
27//! capability the method returns [`Unsupported`], never `Ok(())`.
28//! 6. **Adapters are thin, and policy never lives in one.** An adapter
29//! translates one contract into one platform's syscalls. The moment it
30//! makes a *decision*, two platforms have begun to diverge silently.
31//! 7. **The doc comment carries the contract, including what differs.**
32//! 8. **Parity tests are generic over the trait**, instantiated on the active
33//! adapter, so one test body runs on every OS.
34//! 9. **Test adapters live behind the `testing` feature**, never the default
35//! build.
36//! 10. **Adding a platform means implementing the traits, not editing call
37//! sites.** If a future WASM or arm64-Windows port has to touch anything
38//! outside `src/*/`, the seam leaked. That is the acceptance test.
39
40use std::time::{SystemTime, UNIX_EPOCH};
41
42pub mod console;
43pub mod entropy;
44pub mod ipc;
45pub mod openat;
46pub mod paths;
47pub mod privatefs;
48pub mod process;
49pub mod sealed;
50
51pub use console::{ActiveConsoleControl, ConsoleControl, HandlerContract};
52pub use entropy::{ActiveEntropy, Entropy};
53pub use ipc::{ActiveIpc, Ipc, IpcListener, Liveness, PeerReject};
54pub use openat::{ActiveDirHandle, DirHandle, Excl, GuardIo, NodeId, NodeKind, OpenMode};
55pub use paths::{ActiveKnownPaths, KnownPaths};
56pub use privatefs::{ActivePrivateFs, EffectiveAccess, PrivateFs, Writes};
57pub use process::{ActiveProcessControl, ProcessControl, TreeReaper};
58
59/// The active adapters. Call sites use these rather than naming a platform
60/// type, which is what keeps rule 10 checkable.
61pub const PRIVATE_FS: ActivePrivateFs = ActivePrivateFs::new();
62pub const KNOWN_PATHS: ActiveKnownPaths = ActiveKnownPaths::new();
63pub const ENTROPY: ActiveEntropy = ActiveEntropy::new();
64pub const PROCESS_CONTROL: ActiveProcessControl = ActiveProcessControl::new();
65pub const IPC: ActiveIpc = ActiveIpc::new();
66pub const CONSOLE: ActiveConsoleControl = ActiveConsoleControl::new();
67
68/// Serializes tests that touch process-global env: the paths tests flip
69/// `XDG_DATA_HOME` with `set_var`, and `data()` readers (the ipc socket test)
70/// derive real paths from it mid-run. Without this lock a parallel harness
71/// races and reads a half-changed env, landing the socket under a root that
72/// does not exist (ENOENT on bind or connect).
73#[cfg(test)]
74pub(crate) static ENV_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
75
76/// A capability this platform does not have.
77///
78/// Adapters return this rather than quietly succeeding: a no-op adapter is
79/// exactly how a security control becomes a rubber stamp, and it reads as
80/// "working" in every test that only checks the `Result`. `because` is rendered
81/// by `hotl doctor`, so write it for a human.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct Unsupported {
84 pub capability: &'static str,
85 pub platform: &'static str,
86 pub because: &'static str,
87}
88
89impl Unsupported {
90 pub const fn new(capability: &'static str, because: &'static str) -> Self {
91 Self {
92 capability,
93 platform: std::env::consts::OS,
94 because,
95 }
96 }
97}
98
99impl std::fmt::Display for Unsupported {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 write!(
102 f,
103 "{} is not available on {}: {}",
104 self.capability, self.platform, self.because
105 )
106 }
107}
108
109impl std::error::Error for Unsupported {}
110
111impl From<Unsupported> for std::io::Error {
112 fn from(u: Unsupported) -> Self {
113 std::io::Error::new(std::io::ErrorKind::Unsupported, u.to_string())
114 }
115}
116
117pub trait Clock: Send + Sync {
118 fn now_ms(&self) -> u64;
119}
120
121#[derive(Debug, Clone, Copy, Default)]
122pub struct SystemClock;
123
124impl Clock for SystemClock {
125 fn now_ms(&self) -> u64 {
126 SystemTime::now()
127 .duration_since(UNIX_EPOCH)
128 .map(|d| d.as_millis() as u64)
129 .unwrap_or(0)
130 }
131}
132
133/// Resolution order: env var → SecretStore → prompt.
134///
135/// The one deliberately `dyn` seam in this crate (rule 2): the implementations
136/// really are chosen at runtime and really are heterogeneous.
137pub trait SecretStore: Send + Sync {
138 fn get(&self, name: &str) -> Option<String>;
139}
140
141#[derive(Debug, Clone, Copy, Default)]
142pub struct EnvSecrets;
143
144impl SecretStore for EnvSecrets {
145 fn get(&self, name: &str) -> Option<String> {
146 std::env::var(name).ok().filter(|v| !v.is_empty())
147 }
148}