diff --git a/packages/cultmesh-rs/Cargo.lock b/packages/cultmesh-rs/Cargo.lock index 8dfc33ed..d056b52e 100644 --- a/packages/cultmesh-rs/Cargo.lock +++ b/packages/cultmesh-rs/Cargo.lock @@ -52,6 +52,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -174,7 +183,7 @@ dependencies = [ [[package]] name = "cultcache-rs" -version = "0.2.0" +version = "0.3.1" dependencies = [ "anyhow", "chrono", @@ -185,13 +194,15 @@ dependencies = [ "serde", "serde_bytes", "serde_json", + "serde_stacker", + "stacker", "uuid", "windows-sys 0.59.0", ] [[package]] name = "cultcache-rs-derive" -version = "0.1.0" +version = "0.2.0" dependencies = [ "proc-macro2", "quote", @@ -213,7 +224,7 @@ dependencies = [ [[package]] name = "cultnet-rs" -version = "0.2.0" +version = "0.3.0" dependencies = [ "aes-gcm", "anyhow", @@ -221,7 +232,9 @@ dependencies = [ "chrono", "cultcache-rs", "ed25519-dalek", + "fs2", "hmac", + "libc", "rand", "rmp-serde", "rmpv", @@ -521,6 +534,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -589,6 +611,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "quote" version = "1.0.47" @@ -774,6 +806,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_stacker" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4936375d50c4be7eff22293a9344f8e46f323ed2b3c243e52f89138d9bb0f4a" +dependencies = [ + "serde", + "serde_core", + "stacker", +] + [[package]] name = "sha2" version = "0.10.9" @@ -826,6 +869,19 @@ dependencies = [ "der", ] +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/packages/cultnet-rs/CHANGELOG.md b/packages/cultnet-rs/CHANGELOG.md index d12dc3ed..c2600756 100644 --- a/packages/cultnet-rs/CHANGELOG.md +++ b/packages/cultnet-rs/CHANGELOG.md @@ -6,6 +6,12 @@ only in git history. ## Unreleased +### Added + +- The provider half of `gamecult.runtime_presence_health.v2` for Idunn-managed Rust services, in one module that builds and runs on every platform an Idunn driver launches (unix under Idunn's systemd driver, Windows under its host driver). `IdunnRuntimeAuthority::open(target, health_contract, credentials)` reads the runtime bundle, the candidate bind and the write lease from the launch environment Idunn set (no caller supplies a path) and checks every binding Idunn expects (target and health contract, activation bound to Expected, both signing credentials, the write-lease and candidate-bind environment; the candidate bind must equal Expected's route exactly). The credentials' delivery decides the custody the bundle is held to: `IdunnRuntimeCredentials::from_systemd_descriptors` (unix; found by name per sd_listen_fds_with_names(3), `LISTEN_PID` or the `PrivatePIDs` pid-1 rule, root-owned 0400 single-link read-only files, close-on-exec, `LISTEN_*` removed from the environment) carries root custody (bundle directory, `expected.cc`, `activation.cc`, their `.lock` siblings and every parent root-owned, not group- or world-writable, no symlinks, and the same for the write lease), while `from_host_environment` (the host driver's files; the provider identity is opened before the one-shot activation credential is read and removed, and a refusal leaves the credential in place) carries profile custody on every platform (the bundle is a real directory of real files, never a link: std's `is_symlink` refuses symlinks and, on Windows, junctions). No refusal names a path; an unreadable store is reported by its failure kind alone (not decodable, unsupported format, not found, permission denied, I/O error). `IdunnRuntimePresenceSigner` takes the authority by value, owns the reported health (`RuntimePresenceState`, starting Warming; `report_health`), signs validated presence records with `sign` / `sign_at` (returning `SignedRuntimePresence`: record, raw CultNet document and canonical digest), holds Idunn's process write lease through a `ProcessWriteLeaseGuard` (unix only; `try_acquire_write_lease`; the guard holds the lease's `.lock` shared and `require_current` detects replacement; a runtime whose Expected requires a lease is refused at `open` elsewhere with `write_lease_required`), keeps signing Failed, without a lease digest, after the lease is lost, and answers Idunn's route challenge from local authority with `answer_route_observation`. The module owns no network. No wire bytes change: its output passes `authenticate_runtime_presence_claim` and `correlate_runtime_presence_claim`. New unix-only dependencies: `libc`, `fs2`. +- The launch-contract names both sides of an Idunn launch read: `IDUNN_RUNTIME_BUNDLE_ENVIRONMENT`, `IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT`, `IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT`, `IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT`, `RUNTIME_PRESENCE_IDENTITY_BINDING` and `RUNTIME_PRESENCE_IDENTITY_FD_NAME`. +- Version 0.3.0: the unreleased `register` and `CultMeshCdnChunkRef` breaking changes below follow the 0.y.z minor lane. + ### Breaking - `CultNetDocumentRegistry::register` returns `Result<&mut Self>` where it returned `&mut Self`. A binding that claims a document type or a schema id another binding already holds is refused with `cultcache_rs::SchemaConflictError` (naming the new binding's schema id and both document types) and nothing is bound; binding a type to the schema id it already holds still replaces that binding. Callers chain `register(...)?`. diff --git a/packages/cultnet-rs/Cargo.toml b/packages/cultnet-rs/Cargo.toml index 13d87867..77f368d4 100644 --- a/packages/cultnet-rs/Cargo.toml +++ b/packages/cultnet-rs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cultnet-rs" -version = "0.2.0" +version = "0.3.0" edition = "2024" publish = false license = "MIT" @@ -29,6 +29,10 @@ zeroize = "1" socket2 = "0.5" uuid = { version = "1", features = ["v4"] } +[target.'cfg(unix)'.dependencies] +fs2 = "0.4" +libc = "0.2" + [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security_Cryptography"] } diff --git a/packages/cultnet-rs/src/idunn_runtime_provider.rs b/packages/cultnet-rs/src/idunn_runtime_provider.rs new file mode 100644 index 00000000..6e4173ed --- /dev/null +++ b/packages/cultnet-rs/src/idunn_runtime_provider.rs @@ -0,0 +1,1404 @@ +//! The provider half of `gamecult.runtime_presence_health.v2` for Rust runtimes. +//! +//! An Idunn-managed service attests its own health: it opens the root-provisioned +//! runtime bundle with the two signing credentials Idunn hands it, reports its +//! health as it changes, and signs presence records that Odin can authenticate +//! and Idunn's route challenge can read. This module owns that provider half and +//! owns no network; publishing the signed records is a separate concern that +//! consumes [`IdunnRuntimePresenceSigner::sign`]. +//! +//! The module compiles and runs on every platform an Idunn driver launches: +//! Idunn's systemd driver (unix) delivers the credentials as inherited +//! descriptors under root custody, its host driver (Windows) as files in the +//! user's profile. Only the descriptor delivery, root custody and the write-lease +//! lock are unix-only. +//! +//! ```no_run +//! use cultnet_rs::{ +//! GameCultRuntimeCapability, IdunnRuntimeAuthority, IdunnRuntimeCredentials, +//! IdunnRuntimePresenceSigner, RuntimePresenceState, +//! }; +//! +//! # fn main() -> anyhow::Result<()> { +//! // The host driver's delivery. Under Idunn's systemd driver (unix) use +//! // `IdunnRuntimeCredentials::from_systemd_descriptors()` instead. +//! let credentials = IdunnRuntimeCredentials::from_host_environment()?; +//! // The runtime bundle, the candidate bind and the write lease are read from the +//! // launch environment Idunn set. +//! let authority = IdunnRuntimeAuthority::open( +//! "my-service", +//! "my-service.runtime-health.v1", +//! credentials, +//! )?; +//! authority.require_candidate_bind("127.0.0.1:14103")?; +//! let mut signer = IdunnRuntimePresenceSigner::new( +//! authority, +//! vec![GameCultRuntimeCapability { +//! capability: "my.capability".into(), +//! schema: "my.schema.v1".into(), +//! compatibility: "v1".into(), +//! capacity: 1, +//! }], +//! ); +//! let warming = signer.sign("starting")?; // the signer starts Warming +//! signer.report_health(RuntimePresenceState::Active); +//! let active = signer.sign("ready")?; +//! # let _ = (warming, active); +//! # Ok(()) +//! # } +//! ``` + +use std::fs::File; +#[cfg(unix)] +use std::fs::OpenOptions; +use std::io::Read; +#[cfg(unix)] +use std::os::fd::{AsRawFd, FromRawFd, RawFd}; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, anyhow, bail, ensure}; +use cultcache_rs::{CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore}; +use zeroize::Zeroizing; + +#[cfg(unix)] +use crate::IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME; +#[cfg(unix)] +use crate::RUNTIME_PRESENCE_IDENTITY_FD_NAME; +use crate::{ + CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, + GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA, GameCultProviderHealthIdentity, + GameCultRuntimeCapability, GameCultRuntimePresenceHealthPurpose, + GameCultRuntimePresenceHealthRecord, IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT, + IDUNN_EXPECTED_INCARNATION_SCHEMA, IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT, + IDUNN_RUNTIME_ACTIVATION_SCHEMA, IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, + IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, IdunnExpectedIncarnationRecord, + IdunnRuntimeActivationRecord, IdunnRuntimeActivationSigner, RUNTIME_PRESENCE_IDENTITY_BINDING, + ServiceIdentitySigner, open_service_identity_credential_reader, +}; +#[cfg(unix)] +use crate::{IDUNN_PROCESS_WRITE_LEASE_SCHEMA, IdunnProcessWriteLeaseRecord}; + +#[cfg(unix)] +const LISTEN_PID_ENVIRONMENT: &str = "LISTEN_PID"; +#[cfg(unix)] +const LISTEN_FDS_ENVIRONMENT: &str = "LISTEN_FDS"; +#[cfg(unix)] +const LISTEN_FDNAMES_ENVIRONMENT: &str = "LISTEN_FDNAMES"; +/// `SD_LISTEN_FDS_START` in sd_listen_fds(3). +#[cfg(unix)] +const LISTEN_FDS_START: RawFd = 3; +const MAX_RECENT_WARMING_PROOFS: usize = 64; +const MAX_DETAIL_BYTES: usize = 512; +const DOCUMENT_ROLE: &str = "runtime-presence-health-publisher"; +const DOCUMENT_TAGS: [&str; 2] = ["cultnet.transport.rudp.v0", "runtime-presence"]; + +// ------------------------------------------------------------------ host port + +/// The process facts the provider reads: environment, pid and inherited +/// descriptors. Production reads the real process; tests supply a fake, so the +/// rules run without mutating the test process's own environment. +trait LaunchHost { + fn var(&self, name: &str) -> Option; + fn remove_var(&mut self, name: &str); + fn process_id(&self) -> u32; + /// Takes sole ownership of an inherited descriptor, or refuses if it is not open. + #[cfg(unix)] + fn adopt_descriptor(&mut self, descriptor: RawFd) -> Result; +} + +struct ProcessHost; + +impl LaunchHost for ProcessHost { + fn var(&self, name: &str) -> Option { + std::env::var(name).ok() + } + + fn remove_var(&mut self, name: &str) { + // SAFETY: the credential helpers document that they run during start-up + // before the service spawns threads, which is the contract + // `std::env::remove_var` needs. + unsafe { std::env::remove_var(name) } + } + + fn process_id(&self) -> u32 { + std::process::id() + } + + #[cfg(unix)] + fn adopt_descriptor(&mut self, descriptor: RawFd) -> Result { + // SAFETY: F_GETFD takes an integer descriptor and dereferences nothing. + if unsafe { libc::fcntl(descriptor, libc::F_GETFD) } == -1 { + return Err(std::io::Error::last_os_error()) + .with_context(|| format!("inherited descriptor {descriptor} is not open")); + } + // SAFETY: the caller proved the descriptor is open, names it exactly once + // and has not adopted it before, so this is its only owner. + Ok(unsafe { File::from_raw_fd(descriptor) }) + } +} + +fn optional_environment(host: &impl LaunchHost, name: &str) -> Result> { + match host.var(name) { + None => Ok(None), + Some(value) if !value.is_empty() && value.trim() == value => Ok(Some(value)), + Some(_) => bail!("{name} is empty or has surrounding whitespace"), + } +} + +fn required_environment(host: &impl LaunchHost, name: &str) -> Result { + optional_environment(host, name)? + .ok_or_else(|| anyhow!("{name} is required for an Idunn-managed runtime")) +} + +// -------------------------------------------------------------------- custody + +/// What a path is, read without following links. std counts symlinks and, on +/// Windows, junctions as links; Profile custody accepts only the real thing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FileKind { + Regular, + Directory, + Symlink, + Other, +} + +impl FileKind { + fn of(metadata: &std::fs::Metadata) -> Self { + let file_type = metadata.file_type(); + if file_type.is_symlink() { + Self::Symlink + } else if file_type.is_dir() { + Self::Directory + } else if file_type.is_file() { + Self::Regular + } else { + Self::Other + } + } + + fn noun(self) -> &'static str { + match self { + Self::Directory => "a directory", + _ => "a regular file", + } + } +} + +/// What a custody-checked path must be. +#[derive(Clone, Copy, Debug)] +enum Rule { + /// A record or lock Idunn provisions read-only. + ReadOnlyFile, + /// The runtime bundle directory. + ReadOnlyDirectory, + /// The write-lease record and its lock. + ControlledFile, +} + +impl Rule { + fn kind(self) -> FileKind { + match self { + Self::ReadOnlyFile | Self::ControlledFile => FileKind::Regular, + Self::ReadOnlyDirectory => FileKind::Directory, + } + } + + #[cfg(unix)] + fn root_description(self) -> &'static str { + match self { + Self::ReadOnlyFile => "a root-owned read-only regular file", + Self::ReadOnlyDirectory => "a root-owned read-only directory", + Self::ControlledFile => "a root-controlled regular file", + } + } +} + +/// Profile custody trusts the user's profile and checks only that the path is the +/// real thing: no owner or ACL, because Idunn's host driver sets none. +fn profile_accepts(kind: FileKind, rule: Rule) -> bool { + kind == rule.kind() +} + +/// The unix facts a root-custody rule reads. Rules are pure predicates over +/// these, so the whole policy is testable without owning root. +#[cfg(unix)] +#[derive(Clone, Copy, Debug)] +struct Facts { + kind: FileKind, + uid: u32, + gid: u32, + mode: u32, + nlink: u64, + len: u64, +} + +#[cfg(unix)] +impl Facts { + fn of(metadata: &std::fs::Metadata) -> Self { + Self { + kind: FileKind::of(metadata), + uid: metadata.uid(), + gid: metadata.gid(), + mode: metadata.mode() & 0o7777, + nlink: metadata.nlink(), + len: metadata.len(), + } + } + + fn of_path(path: &Path, label: &str) -> Result { + let metadata = + std::fs::symlink_metadata(path).with_context(|| format!("inspecting {label}"))?; + Ok(Self::of(&metadata)) + } + + fn satisfies(&self, rule: Rule) -> bool { + match rule { + Rule::ReadOnlyFile => self.is_root_read_only_file(), + Rule::ReadOnlyDirectory => self.is_root_read_only_directory(), + Rule::ControlledFile => self.is_root_controlled_file(), + } + } + + /// Root-owned regular file nobody can write, not even root's group. + fn is_root_read_only_file(&self) -> bool { + self.kind == FileKind::Regular && self.uid == 0 && self.mode & 0o222 == 0 + } + + fn is_root_read_only_directory(&self) -> bool { + self.kind == FileKind::Directory && self.uid == 0 && self.mode & 0o222 == 0 + } + + /// Root-owned regular file that group and world cannot write. + fn is_root_controlled_file(&self) -> bool { + self.kind == FileKind::Regular && self.uid == 0 && self.mode & 0o022 == 0 + } + + fn is_root_controlled_directory(&self) -> bool { + self.kind == FileKind::Directory && self.uid == 0 && self.mode & 0o022 == 0 + } + + /// An inherited signer: root-owned, mode exactly 0400, one link, not empty. + fn is_signer_descriptor(&self) -> bool { + self.kind == FileKind::Regular + && self.uid == 0 + && self.gid == 0 + && self.mode == 0o400 + && self.nlink == 1 + && self.len > 0 + } +} + +/// How much of the filesystem the provider trusts, derived from how the +/// credentials were delivered and never chosen by a caller. Idunn's systemd +/// driver provisions root-owned material (`Root`); its host driver writes into the +/// user's profile (`Profile`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Custody { + #[cfg(unix)] + Root, + Profile, +} + +impl Custody { + fn require_file(self, path: &Path, label: &str, rule: Rule) -> Result<()> { + ensure!(path.is_absolute(), "{label} path is not absolute"); + let metadata = + std::fs::symlink_metadata(path).with_context(|| format!("inspecting {label}"))?; + match self { + Self::Profile => ensure!( + profile_accepts(FileKind::of(&metadata), rule), + "{label} is not {} (a link is refused)", + rule.kind().noun() + ), + #[cfg(unix)] + Self::Root => ensure!( + Facts::of(&metadata).satisfies(rule), + "{label} is not {}", + rule.root_description() + ), + } + Ok(()) + } + + /// The sibling `.lock` of a record. Only root custody inspects it. + #[cfg(unix)] + fn require_lock(self, path: &Path, label: &str, rule: Rule) -> Result<()> { + match self { + Self::Profile => Ok(()), + Self::Root => self.require_file(path, label, rule), + } + } + + /// Every directory from `path` up to the filesystem root is root-controlled. + /// Only root custody walks the chain. + #[cfg(unix)] + fn require_directory_chain(self, path: &Path, label: &str) -> Result<()> { + if self == Self::Profile { + return Ok(()); + } + ensure!(path.is_absolute(), "{label} path is not absolute"); + for directory in path.ancestors() { + let facts = Facts::of_path(directory, label)?; + ensure!( + facts.is_root_controlled_directory(), + "{label} is not entirely root-controlled" + ); + } + Ok(()) + } + + fn require_bundle(self, bundle: &Path) -> Result<()> { + self.require_file(bundle, "Idunn runtime bundle", Rule::ReadOnlyDirectory)?; + #[cfg(unix)] + { + let parent = bundle.parent().context("runtime bundle has no parent")?; + self.require_directory_chain(parent, "Idunn runtime bundle parent")?; + } + Ok(()) + } +} + +#[cfg(unix)] +fn sibling_lock_path(path: &Path) -> Result { + let mut name = path + .file_name() + .context("CultCache authority path has no file name")? + .to_os_string(); + name.push(".lock"); + Ok(path.with_file_name(name)) +} + +// ---------------------------------------------------------------- credentials + +/// The two signing credentials Idunn issues a launch: the one-launch activation +/// seed and the stable provider identity. Idunn's systemd driver delivers them +/// as OpenFile descriptors and its host driver as files named in the +/// environment; `from_systemd_descriptors` (unix only) and +/// [`from_host_environment`](Self::from_host_environment) read each form. +/// +/// The delivery decides how much of the filesystem the authority trusts: systemd +/// descriptors carry root custody, the host files carry profile custody. A +/// caller never chooses it. +/// +/// The raw constructor is not public, so a caller cannot choose custody: +/// +/// ```compile_fail,E0624 +/// let _ = cultnet_rs::IdunnRuntimeCredentials::new(std::io::empty(), std::io::empty()); +/// ``` +pub struct IdunnRuntimeCredentials { + activation: Box, + provider: Box, + custody: Custody, +} + +impl IdunnRuntimeCredentials { + /// Wraps the host driver's activation seed and provider identity credential + /// (profile custody). Crate-private: custody follows delivery, so a caller + /// never picks it and uses a `from_*` constructor. + pub(crate) fn new(activation: impl Read + 'static, provider: impl Read + 'static) -> Self { + Self::delivered(activation, provider, Custody::Profile) + } + + fn delivered( + activation: impl Read + 'static, + provider: impl Read + 'static, + custody: Custody, + ) -> Self { + Self { + activation: Box::new(activation), + provider: Box::new(provider), + custody, + } + } + + /// Takes the credentials from systemd's inherited descriptors, following + /// sd_listen_fds_with_names(3). `LISTEN_PID` must be this process (or, under + /// `PrivatePIDs`, this process must be pid 1 while `LISTEN_PID` names a host + /// pid above 1); the descriptors are found by name and extra descriptors are + /// tolerated; each must be a root-owned 0400 single-link read-only regular + /// file and is marked close-on-exec. `LISTEN_PID`, `LISTEN_FDS` and + /// `LISTEN_FDNAMES` are removed from the environment so no child inherits + /// them. + /// + /// Call this during start-up, before the service spawns threads: it edits the + /// process environment and takes ownership of descriptors 3 and above. + #[cfg(unix)] + pub fn from_systemd_descriptors() -> Result { + Self::from_systemd_descriptors_in(&mut ProcessHost) + } + + #[cfg(unix)] + fn from_systemd_descriptors_in(host: &mut impl LaunchHost) -> Result { + let (activation, provider) = take_systemd_descriptors(host, Custody::Root)?; + Ok(Self::delivered(activation, provider, Custody::Root)) + } + + /// Takes the credentials from the files Idunn's host driver names in + /// [`IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT`] and + /// [`RUNTIME_PRESENCE_IDENTITY_BINDING`]. The provider identity file is opened + /// first; a missing, unreadable or non-regular one refuses and leaves the + /// activation credential in place. The activation credential is one-shot: it is read + /// into memory, its handle closed and the file removed, so the seed lives only + /// in this process afterwards. A refusal names the variable and never the path. + pub fn from_host_environment() -> Result { + Self::from_host_environment_in(&ProcessHost) + } + + fn from_host_environment_in(host: &impl LaunchHost) -> Result { + let activation_path = PathBuf::from(required_environment( + host, + IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT, + )?); + let provider_path = PathBuf::from(required_environment( + host, + RUNTIME_PRESENCE_IDENTITY_BINDING, + )?); + ensure!( + activation_path.is_absolute() && provider_path.is_absolute(), + "host credential paths are not absolute" + ); + let provider = File::open(&provider_path).with_context(|| { + format!("{RUNTIME_PRESENCE_IDENTITY_BINDING}: opening provider identity credential") + })?; + ensure!( + provider + .metadata() + .with_context(|| { + format!("{RUNTIME_PRESENCE_IDENTITY_BINDING}: inspecting provider identity") + })? + .is_file(), + "{RUNTIME_PRESENCE_IDENTITY_BINDING}: provider identity credential is not a regular file" + ); + let mut seed = Zeroizing::new(Vec::new()); + { + let mut file = File::open(&activation_path).with_context(|| { + format!("{IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT}: opening activation credential") + })?; + file.read_to_end(&mut seed).with_context(|| { + format!("{IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT}: reading activation credential") + })?; + } + std::fs::remove_file(&activation_path).with_context(|| { + format!("{IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT}: retiring activation credential") + })?; + Ok(Self::new(SeedReader { seed, position: 0 }, provider)) + } +} + +/// Reads an in-memory seed and wipes it when dropped. +struct SeedReader { + seed: Zeroizing>, + position: usize, +} + +impl Read for SeedReader { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + let remaining = &self.seed[self.position..]; + let count = remaining.len().min(buffer.len()); + buffer[..count].copy_from_slice(&remaining[..count]); + self.position += count; + Ok(count) + } +} + +/// systemd's `LISTEN_PID` is a host pid. `PrivatePIDs=yes` makes the main process +/// pid 1 in a private namespace, where it cannot see that host pid; a descendant +/// has a pid above 1 and must not reuse the inherited set. +#[cfg(unix)] +fn listen_pid_matches(listen_pid: &str, process_id: u32) -> bool { + match listen_pid.parse::() { + Ok(declared) if declared.to_string() == listen_pid => { + declared == process_id || (process_id == 1 && declared > 1) + } + _ => false, + } +} + +#[cfg(unix)] +fn take_systemd_descriptors(host: &mut impl LaunchHost, custody: Custody) -> Result<(File, File)> { + let listen_pid = required_environment(&*host, LISTEN_PID_ENVIRONMENT); + let listen_fds = required_environment(&*host, LISTEN_FDS_ENVIRONMENT); + let listen_fd_names = required_environment(&*host, LISTEN_FDNAMES_ENVIRONMENT); + // Whatever the verdict, no child of this process inherits the claim. + for name in [ + LISTEN_PID_ENVIRONMENT, + LISTEN_FDS_ENVIRONMENT, + LISTEN_FDNAMES_ENVIRONMENT, + ] { + host.remove_var(name); + } + let (listen_pid, listen_fds, listen_fd_names) = (listen_pid?, listen_fds?, listen_fd_names?); + ensure!( + listen_pid_matches(&listen_pid, host.process_id()), + "systemd signer descriptors belong to a different process" + ); + let count = listen_fds + .parse::() + .ok() + .filter(|count| count.to_string() == listen_fds) + .context("parsing systemd LISTEN_FDS")?; + let names = listen_fd_names.split(':').collect::>(); + ensure!( + names.len() == count, + "systemd LISTEN_FDNAMES does not name every inherited descriptor" + ); + let activation = find_descriptor(&names, IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME)?; + let provider = find_descriptor(&names, RUNTIME_PRESENCE_IDENTITY_FD_NAME)?; + let activation = host.adopt_descriptor(activation)?; + let activation = protect_descriptor(activation, "runtime activation signer", custody)?; + let provider = host.adopt_descriptor(provider)?; + let provider = protect_descriptor(provider, "stable provider signer", custody)?; + Ok((activation, provider)) +} + +/// The descriptor number of the one descriptor called `name`. +#[cfg(unix)] +fn find_descriptor(names: &[&str], name: &str) -> Result { + let mut matches = names + .iter() + .enumerate() + .filter(|(_, candidate)| **candidate == name) + .map(|(index, _)| LISTEN_FDS_START + index as RawFd); + let descriptor = matches + .next() + .ok_or_else(|| anyhow!("systemd did not pass the {name} descriptor"))?; + ensure!( + matches.next().is_none(), + "systemd passed the {name} descriptor more than once" + ); + Ok(descriptor) +} + +#[cfg(unix)] +fn protect_descriptor(file: File, label: &str, custody: Custody) -> Result { + // OpenFile descriptors arrive without close-on-exec; a child must never + // inherit a signer, even on a path that refuses it below. + // SAFETY: fcntl reads and sets flags of the descriptor `file` owns. + let flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFD) }; + if flags == -1 + // SAFETY: as above. + || unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETFD, flags | libc::FD_CLOEXEC) } == -1 + { + return Err(std::io::Error::last_os_error()) + .with_context(|| format!("protecting {label} descriptor from child inheritance")); + } + // SAFETY: F_GETFL reads the flags of the descriptor `file` owns. + let status = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFL) }; + if status == -1 { + return Err(std::io::Error::last_os_error()) + .with_context(|| format!("reading {label} access mode")); + } + ensure!( + status & libc::O_ACCMODE == libc::O_RDONLY, + "{label} descriptor is not read-only" + ); + if custody == Custody::Root { + let facts = Facts::of( + &file + .metadata() + .with_context(|| format!("inspecting {label}"))?, + ); + ensure!( + facts.is_signer_descriptor(), + "{label} descriptor is not a root-owned 0400 singly-linked regular file" + ); + } + Ok(file) +} + +// ------------------------------------------------------------------ authority + +/// What the authority and every lease guard share: the verified records and the +/// custody policy their files are read under. +struct Records { + expected: IdunnExpectedIncarnationRecord, + expected_sha256: String, + activation: IdunnRuntimeActivationRecord, + activation_sha256: String, + /// Set exactly when Expected requires a process write lease (unix only: no + /// other platform can hold the lease lock, so `open` refuses the requirement). + #[cfg(unix)] + lease_path: Option, + #[cfg(unix)] + custody: Custody, +} + +/// One launch's verified Idunn authority: the Expected incarnation, the +/// activation witness bound to it, and the two signing keys Idunn issued. +/// +/// Opening checks every binding the provider owes Idunn. The Expected target and +/// health contract equal the caller's; the activation binds Expected's canonical +/// digest and runtime id; the activation credential is the activation's signer; +/// the provider identity is the signer Expected selected; the write-lease +/// environment is present exactly when Expected requires a lease; and the +/// candidate-bind environment names Expected's route. The launch environment is +/// read here and nowhere else. +pub struct IdunnRuntimeAuthority { + records: Arc, + activation_signer: IdunnRuntimeActivationSigner, + provider_signer: ServiceIdentitySigner, + candidate_bind: Option, +} + +impl std::fmt::Debug for IdunnRuntimeAuthority { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("IdunnRuntimeAuthority") + .field("target", &self.records.expected.target) + .field("runtime_id", &self.records.expected.runtime_id) + .finish_non_exhaustive() + } +} + +impl IdunnRuntimeAuthority { + /// Opens the runtime bundle Idunn launched this process with, for the target + /// `target` and the health contract `health_contract`, with the credentials + /// Idunn issued. The bundle directory comes from + /// [`IDUNN_RUNTIME_BUNDLE_ENVIRONMENT`], the candidate bind from + /// [`IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT`] and the write lease from + /// [`IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT`]; no caller supplies a path. + /// + /// The credentials' delivery decides the custody the bundle is held to. Under + /// systemd descriptors (root custody, unix) the bundle directory, its + /// `expected.cc` and `activation.cc`, their `.lock` siblings and every parent + /// directory must be root-owned and not writable by others, and none may be a + /// symlink; the write lease, if Expected requires one, is held to the same + /// custody. Under any other delivery (profile custody, every platform) the + /// bundle directory and the two records must be a real directory and real + /// regular files, never a link. A runtime that requires a + /// write lease can only be opened on unix, where the lease lock exists. + pub fn open( + target: &str, + health_contract: &str, + credentials: IdunnRuntimeCredentials, + ) -> Result { + Self::open_in(&ProcessHost, target, health_contract, credentials) + } + + fn open_in( + host: &impl LaunchHost, + target: &str, + health_contract: &str, + credentials: IdunnRuntimeCredentials, + ) -> Result { + let custody = credentials.custody; + let bundle = PathBuf::from(required_environment( + host, + IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, + )?); + ensure!( + bundle.is_absolute(), + "{IDUNN_RUNTIME_BUNDLE_ENVIRONMENT} is not an absolute path" + ); + custody.require_bundle(&bundle)?; + let (key, payload) = read_authority_record( + &bundle.join("expected.cc"), + IdunnExpectedIncarnationRecord::TYPE, + IDUNN_EXPECTED_INCARNATION_SCHEMA, + custody, + )?; + let expected = IdunnExpectedIncarnationRecord::decode_canonical(&payload)?; + ensure!( + key == expected.target, + "Expected record key is not its target" + ); + ensure!( + expected.target == target && expected.health_contract == health_contract, + "Idunn Expected target or health contract does not match this runtime" + ); + let (key, payload) = read_authority_record( + &bundle.join("activation.cc"), + IdunnRuntimeActivationRecord::TYPE, + IDUNN_RUNTIME_ACTIVATION_SCHEMA, + custody, + )?; + let activation = IdunnRuntimeActivationRecord::decode_canonical(&payload)?; + ensure!( + key == expected.target, + "activation record key is not the Expected target" + ); + let expected_sha256 = expected.canonical_sha256()?; + ensure!( + activation.expected_projection_sha256 == expected_sha256 + && activation.runtime_id == expected.runtime_id, + "runtime activation does not bind the bundled Expected projection" + ); + + // The lease lock exists only on unix; a requirement no process here can + // hold is refused rather than signed around. + #[cfg(not(unix))] + ensure!( + !expected.write_lease_required, + "write_lease_required: Expected requires a process write lease, which this platform cannot hold" + ); + let lease_path = optional_environment(host, IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT)?; + ensure!( + lease_path.is_some() == expected.write_lease_required, + "Idunn process write-lease environment does not match Expected" + ); + #[cfg(unix)] + let lease_path = lease_path.map(PathBuf::from); + #[cfg(unix)] + if let Some(path) = &lease_path { + ensure!( + path.is_absolute(), + "Idunn process write-lease path is not absolute" + ); + } + let candidate_bind = optional_environment(host, IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT)?; + match (&expected.route, &candidate_bind) { + (Some(route), Some(bind)) => ensure!( + route.candidate_endpoint == format!("{}://{bind}", route.transport), + "Idunn candidate bind differs from the Expected route" + ), + (None, None) => {} + _ => bail!("Idunn candidate bind environment does not match Expected's route"), + } + + let IdunnRuntimeCredentials { + activation: activation_credential, + provider: provider_credential, + .. + } = credentials; + let activation_signer = + IdunnRuntimeActivationSigner::from_credential_reader(activation_credential)?; + ensure!( + activation_signer.identity_id() == activation.activation_signer_identity_id + && activation_signer.public_key() == activation.activation_signer_public_key, + "runtime activation credential does not belong to the Idunn activation record" + ); + // The shared reader owns profile validation, platform binding, seed + // unprotection and domain-separated signing; this module never parses or + // exports seed bytes. + let provider_signer = open_service_identity_credential_reader::< + GameCultProviderHealthIdentity, + >(provider_credential)?; + ensure!( + provider_signer.entry().identity_id == expected.expected_signer_identity_id, + "provider signer is not the identity selected by Expected" + ); + + Ok(Self { + records: Arc::new(Records { + activation_sha256: activation.canonical_sha256()?, + expected_sha256, + expected, + activation, + #[cfg(unix)] + lease_path, + #[cfg(unix)] + custody, + }), + activation_signer, + provider_signer, + candidate_bind, + }) + } + + /// Idunn's admitted description of this incarnation. + pub fn expected(&self) -> &IdunnExpectedIncarnationRecord { + &self.records.expected + } + + /// The activation witness Idunn issued for this launch. + pub fn activation(&self) -> &IdunnRuntimeActivationRecord { + &self.records.activation + } + + /// The endpoint every presence states as bound: Expected's candidate route, + /// confirmed at open against Idunn's candidate-bind environment. `None` when + /// the incarnation has no route. + pub fn bound_endpoint(&self) -> Option<&str> { + self.records + .expected + .route + .as_ref() + .map(|route| route.candidate_endpoint.as_str()) + } + + /// Checks that `bind` (a `host:port`) is the address Idunn will challenge: + /// the candidate bind Idunn put in [`IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT`], + /// which open already tied to Expected's route. Call it with the address the + /// service actually binds, before it listens. + pub fn require_candidate_bind(&self, bind: &str) -> Result<()> { + let route = self + .records + .expected + .route + .as_ref() + .context("Expected has no admitted route")?; + ensure!( + self.candidate_bind.as_deref() == Some(bind) + && route.candidate_endpoint == format!("{}://{bind}", route.transport), + "candidate bind differs from Idunn's candidate bind and the Expected route" + ); + Ok(()) + } +} + +fn read_authority_record( + path: &Path, + record_type: &str, + schema: &str, + custody: Custody, +) -> Result<(String, Vec)> { + custody.require_file(path, "runtime authority record", Rule::ReadOnlyFile)?; + #[cfg(unix)] + custody.require_lock( + &sibling_lock_path(path)?, + "runtime authority lock", + Rule::ReadOnlyFile, + )?; + read_snapshot(path, "runtime authority record", |entries| { + let [envelope] = entries.as_slice() else { + bail!("runtime authority store must contain exactly one record"); + }; + ensure!( + envelope.r#type == record_type && envelope.schema_id.as_deref() == Some(schema), + "runtime authority store has the wrong typed envelope" + ); + Ok((envelope.key.clone(), envelope.payload.clone())) + }) +} + +/// Reads `path` as a CultCache store under its shared lock and hands the +/// envelopes to `read`. CultCache's own errors carry the store's path, which +/// under the host driver lies in the user's profile, so a failure of the store +/// itself is reported by `label` and a path-free kind alone +/// ([`store_failure_kind`]); `read`'s refusals pass through. +fn read_snapshot( + path: &Path, + label: &str, + read: impl FnOnce(Vec) -> Result, +) -> Result { + let mut outcome = None; + let store = + SingleFileMessagePackBackingStore::new(path).with_read_only_shared_snapshot(|entries| { + outcome = Some(read(entries)); + Ok(()) + }); + match (store, outcome) { + (Ok(()), Some(outcome)) => outcome, + (Err(error), _) => bail!( + "{label} could not be read as a CultCache store: {}", + store_failure_kind(&error) + ), + (Ok(()), None) => bail!("{label} could not be read as a CultCache store"), + } +} + +/// What kept a store from being read, as a label that names no path: CultCache's +/// verdict on the bytes, else the kind of the I/O failure in the chain. +fn store_failure_kind(error: &anyhow::Error) -> &'static str { + use cultcache_rs::{CultCacheStoreUnreadable, CultCacheStoreUnreadableKind}; + if let Some(unreadable) = error.downcast_ref::() { + return match unreadable.kind { + CultCacheStoreUnreadableKind::Undecodable => "not decodable", + CultCacheStoreUnreadableKind::UnsupportedFormat => { + "in a format this runtime does not read" + } + _ => "unreadable", + }; + } + match error + .chain() + .find_map(|cause| cause.downcast_ref::()) + .map(std::io::Error::kind) + { + Some(std::io::ErrorKind::NotFound) => "not found", + Some(std::io::ErrorKind::PermissionDenied) => "permission denied", + Some(_) => "I/O error", + None => "unreadable", + } +} + +// ---------------------------------------------------------------- write lease + +/// Proof that this process holds Idunn's process write lease. It holds the +/// lease's sibling `.lock` shared for as long as any clone lives, which fences +/// Idunn's lease replacement and every other writer that takes the lock +/// exclusively. Gate state writes on [`require_current`](Self::require_current). +/// +/// The lease lock exists only on unix. The type exists everywhere so consumer code +/// needs no `cfg`, but on other platforms no guard can be constructed: `open` +/// refuses a runtime that requires a lease. +#[derive(Clone)] +pub struct ProcessWriteLeaseGuard { + held: Arc, +} + +#[cfg(unix)] +struct HeldLease { + _lock: File, + records: Arc, + warming_proofs: Vec, + lease_sha256: String, +} + +/// No platform without the lease lock can hold a lease. +#[cfg(not(unix))] +enum HeldLease {} + +impl HeldLease { + #[cfg(unix)] + fn lease_sha256(&self) -> &str { + &self.lease_sha256 + } + + #[cfg(not(unix))] + fn lease_sha256(&self) -> &str { + match *self {} + } + + #[cfg(unix)] + fn require_current(&self) -> Result<()> { + let observed = load_lease(&self.records, &self.warming_proofs)?; + ensure!( + observed.as_deref() == Some(self.lease_sha256()), + "process-write lease is no longer the admitted lifetime grant" + ); + Ok(()) + } + + #[cfg(not(unix))] + fn require_current(&self) -> Result<()> { + match *self {} + } +} + +impl std::fmt::Debug for ProcessWriteLeaseGuard { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ProcessWriteLeaseGuard") + .field("lease_sha256", &self.lease_sha256()) + .finish_non_exhaustive() + } +} + +impl ProcessWriteLeaseGuard { + /// The canonical digest of the lease record this guard was admitted under. + pub fn lease_sha256(&self) -> &str { + self.held.lease_sha256() + } + + /// Fails unless the lease record on disk is still the admitted lifetime grant. + pub fn require_current(&self) -> Result<()> { + self.held.require_current() + } +} + +/// The digest of the lease on disk if it exists and binds this incarnation and +/// one of `warming_proofs`; `None` while the record is absent or empty. +#[cfg(unix)] +fn load_lease(records: &Records, warming_proofs: &[String]) -> Result> { + let Some(path) = present_lease_path(records)? else { + return Ok(None); + }; + read_snapshot(path, "process-write-lease record", |entries| { + let [envelope] = entries.as_slice() else { + if entries.is_empty() { + return Ok(None); + } + bail!("process-write-lease store is ambiguous"); + }; + ensure!( + envelope.r#type == IdunnProcessWriteLeaseRecord::TYPE + && envelope.schema_id.as_deref() == Some(IDUNN_PROCESS_WRITE_LEASE_SCHEMA), + "process-write-lease store has the wrong typed envelope" + ); + let lease = IdunnProcessWriteLeaseRecord::decode_canonical(&envelope.payload)?; + ensure!( + envelope.key == lease.target, + "process-write-lease key is not its target" + ); + exact_lease_sha256(&lease, records, warming_proofs).map(Some) + }) +} + +/// The lease record's path once the record exists and its files pass custody; +/// `None` while Idunn has not written it. +#[cfg(unix)] +fn present_lease_path(records: &Records) -> Result> { + let path = records + .lease_path + .as_deref() + .context("Expected does not require a process write lease")?; + match std::fs::symlink_metadata(path) { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + } + require_lease_custody(records.custody, path)?; + Ok(Some(path)) +} + +#[cfg(unix)] +fn require_lease_custody(custody: Custody, path: &Path) -> Result<()> { + custody.require_file(path, "process-write-lease record", Rule::ControlledFile)?; + custody.require_lock( + &sibling_lock_path(path)?, + "process-write-lease lock", + Rule::ControlledFile, + )?; + custody.require_directory_chain( + path.parent().context("process-write lease has no parent")?, + "process-write-lease parent", + ) +} + +#[cfg(unix)] +fn exact_lease_sha256( + lease: &IdunnProcessWriteLeaseRecord, + records: &Records, + warming_proofs: &[String], +) -> Result { + lease.validate()?; + let expected = &records.expected; + ensure!( + lease.target == expected.target + && lease.expected_projection_sha256 == records.expected_sha256 + && lease.plan_id == expected.plan_id + && lease.incarnation_id == expected.incarnation_id + && lease.sealed_release_id == expected.sealed_release_id + && lease.activation_witness_sha256 == records.activation_sha256 + && Some(&lease.state_schema_generation) == expected.state_schema_generation.as_ref() + && Some(&lease.state_contract_sha256) == expected.state_contract_sha256.as_ref() + && lease.runtime_id == expected.runtime_id + && lease.runtime_instance_id == records.activation.runtime_instance_id + && warming_proofs + .iter() + .any(|proof| proof == &lease.warming_presence_sha256), + "process-write lease does not bind a provider-owned Warming proof for this incarnation" + ); + lease.canonical_sha256() +} + +/// Takes the lease's shared lock and admits the record under it. `None` while +/// the record is absent. +#[cfg(unix)] +fn acquire_lease( + records: &Arc, + warming_proofs: &[String], +) -> Result> { + let Some(path) = present_lease_path(records)? else { + return Ok(None); + }; + let lock_path = sibling_lock_path(path)?; + let lock = OpenOptions::new() + .read(true) + .open(&lock_path) + .context("opening process-write-lease lock")?; + fs2::FileExt::lock_shared(&lock).context("locking process-write-lease lock")?; + // The first shared lock stays held while the snapshot is decoded; the + // backing-store read takes its own nested shared lock so the canonical + // CultCache decoder is the one reading the record. + let Some(lease_sha256) = load_lease(records, warming_proofs)? else { + return Ok(None); + }; + Ok(Some(ProcessWriteLeaseGuard { + held: Arc::new(HeldLease { + _lock: lock, + records: Arc::clone(records), + warming_proofs: warming_proofs.to_vec(), + lease_sha256, + }), + })) +} + +/// `open` refuses a lease requirement on this platform, so nothing reaches here. +#[cfg(not(unix))] +fn acquire_lease( + _records: &Arc, + _warming_proofs: &[String], +) -> Result> { + bail!("write_lease_required: this platform cannot hold a process write lease") +} + +// --------------------------------------------------------------------- signer + +/// The health an app reports about itself. Every presence the signer signs +/// states the last one reported. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RuntimePresenceState { + /// Starting, or waiting for Idunn's write lease. The signer begins here. + Warming, + /// Serving. + Active, + /// Serving with reduced ability. + Degraded, + /// Not able to serve. + Failed, +} + +impl RuntimePresenceState { + /// The state string the presence record carries. + pub fn as_str(self) -> &'static str { + match self { + Self::Warming => "warming", + Self::Active => "active", + Self::Degraded => "degraded", + Self::Failed => "failed", + } + } +} + +/// One signed presence: the validated record, the raw CultNet document that +/// carries it, and the record's canonical digest (what a write lease names when +/// the presence is Warming). +#[derive(Clone, Debug)] +pub struct SignedRuntimePresence { + pub record: GameCultRuntimePresenceHealthRecord, + pub document: CultNetRawDocumentRecord, + pub canonical_sha256: String, +} + +/// Signs this launch's runtime presence and answers Idunn's route challenge from +/// local authority, with no network. +/// +/// The signer is the one owner of reported health: it starts Warming, the app +/// moves it with [`report_health`](Self::report_health), and every presence it +/// signs, however it is carried, states that health. It takes the authority by +/// value, so one signer per authority is a fact of the types: a second signer +/// would restart the publisher sequence under the same runtime instance. +pub struct IdunnRuntimePresenceSigner { + authority: IdunnRuntimeAuthority, + capabilities: Vec, + health: RuntimePresenceState, + sequence: u64, + warming_proofs: Vec, + lease: Option, +} + +impl IdunnRuntimePresenceSigner { + /// A Warming signer over `authority`, stating `capabilities` in every + /// presence. Capabilities are validated when a presence is signed. + pub fn new( + authority: IdunnRuntimeAuthority, + capabilities: Vec, + ) -> Self { + Self { + authority, + capabilities, + health: RuntimePresenceState::Warming, + sequence: 0, + warming_proofs: Vec::new(), + lease: None, + } + } + + /// The authority this signer owns. + pub fn authority(&self) -> &IdunnRuntimeAuthority { + &self.authority + } + + /// Records the app's health. Until the first call the signer reports Warming. + pub fn report_health(&mut self, state: RuntimePresenceState) { + self.health = state; + } + + /// The health the next presence will state. + pub fn health(&self) -> RuntimePresenceState { + self.health + } + + /// Signs one presence now, in the reported health. Every call takes the next + /// publisher sequence. A Warming presence never carries a lease and becomes a + /// valid write-lease target; Active on a lease-required Expected is refused + /// unless the signer holds a current [`ProcessWriteLeaseGuard`]; a held guard + /// that is no longer current refuses Active and Degraded, while Failed still + /// signs, without a lease digest, so a runtime that lost its lease can say so. + pub fn sign(&mut self, detail: &str) -> Result { + self.sign_at(detail, unix_millis()?) + } + + /// [`sign`](Self::sign) with an explicit observation time. + pub fn sign_at( + &mut self, + detail: &str, + observed_at_unix_millis: u64, + ) -> Result { + let records = Arc::clone(&self.authority.records); + let state = self.health; + let write_lease_sha256 = match (state, &self.lease) { + (RuntimePresenceState::Warming, _) | (RuntimePresenceState::Failed, None) => None, + // A runtime that lost its lease must still be able to say it + // failed; the correlation admits a not-ready presence without a + // lease digest. + (RuntimePresenceState::Failed, Some(guard)) => guard + .require_current() + .ok() + .map(|()| guard.lease_sha256().to_owned()), + (_, Some(guard)) => { + guard.require_current()?; + Some(guard.lease_sha256().to_owned()) + } + (_, None) if records.expected.write_lease_required => { + bail!( + "{} stateful presence lacks its process-write lease", + state.as_str() + ) + } + (_, None) => None, + }; + self.sequence = self + .sequence + .checked_add(1) + .ok_or_else(|| anyhow!("runtime presence publisher sequence overflow"))?; + let expected = &records.expected; + let mut record = GameCultRuntimePresenceHealthRecord { + schema_version: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into(), + target: expected.target.clone(), + expected_projection_sha256: records.expected_sha256.clone(), + plan_id: expected.plan_id.clone(), + incarnation_id: expected.incarnation_id.clone(), + sealed_release_id: expected.sealed_release_id.clone(), + activation_witness_sha256: records.activation_sha256.clone(), + state_schema_generation: expected.state_schema_generation.clone(), + state_contract_sha256: expected.state_contract_sha256.clone(), + runtime_id: expected.runtime_id.clone(), + runtime_instance_id: records.activation.runtime_instance_id.clone(), + bound_endpoint: self.authority.bound_endpoint().map(str::to_owned), + capabilities: self.capabilities.clone(), + health_contract: expected.health_contract.clone(), + state: state.as_str().into(), + detail: truncate_utf8(detail, MAX_DETAIL_BYTES).into(), + write_lease_sha256, + signer_identity_id: self.authority.provider_signer.entry().identity_id.clone(), + publisher_sequence: self.sequence, + observed_at_unix_millis, + signature_algorithm: "ed25519".into(), + signature: Vec::new(), + activation_signer_identity_id: records.activation.activation_signer_identity_id.clone(), + activation_signature: Vec::new(), + }; + let proof_payload = record.canonical_proof_payload()?; + let provider_proof = self + .authority + .provider_signer + .sign::(&proof_payload); + ensure!( + provider_proof.identity_id == record.signer_identity_id, + "provider signer identity changed after runtime admission" + ); + record.signature = provider_proof.signature; + record.activation_signature = self + .authority + .activation_signer + .sign_presence_proof(&record)?; + record.validate()?; + let payload = rmp_serde::to_vec(&record)?; + ensure!( + rmp_serde::from_slice::(&payload)? == record, + "runtime presence did not round-trip canonically" + ); + let canonical_sha256 = record.canonical_sha256()?; + let document = CultNetRawDocumentRecord { + schema_id: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into(), + record_key: expected.target.clone(), + stored_at: chrono::DateTime::from_timestamp_millis(i64::try_from( + observed_at_unix_millis, + )?) + .context("runtime presence observation time is invalid")? + .to_rfc3339(), + payload_encoding: CultNetRawPayloadEncoding::Messagepack, + payload, + source_runtime_id: Some(expected.runtime_id.clone()), + source_agent_id: Some(record.signer_identity_id.clone()), + source_role: Some(DOCUMENT_ROLE.into()), + tags: Some(DOCUMENT_TAGS.iter().map(|tag| (*tag).into()).collect()), + }; + if state == RuntimePresenceState::Warming { + self.warming_proofs.push(canonical_sha256.clone()); + if self.warming_proofs.len() > MAX_RECENT_WARMING_PROOFS { + self.warming_proofs.remove(0); + } + } + Ok(SignedRuntimePresence { + record, + document, + canonical_sha256, + }) + } + + /// Looks for Idunn's process write lease. `Ok(None)` while Idunn has not + /// written it; an error when a record exists but does not bind this + /// incarnation and a Warming presence this signer signed within its last 64. + /// The guard it returns is kept: later non-Warming presences carry its lease + /// digest. The caller waits, sleeping between calls and signing Warming + /// heartbeats, as its own loop decides. + pub fn try_acquire_write_lease(&mut self) -> Result> { + ensure!( + self.authority.records.expected.write_lease_required, + "Expected does not require a process write lease" + ); + if let Some(guard) = &self.lease { + guard.require_current()?; + return Ok(Some(guard.clone())); + } + let guard = acquire_lease(&self.authority.records, &self.warming_proofs)?; + self.lease = guard.clone(); + Ok(guard) + } + + /// Answers Idunn's route challenge: a `SnapshotResponseRaw` carrying a fresh + /// presence in the reported health. Only a plain snapshot request for this + /// runtime's presence schema and target is answered; another schema, another + /// record key, a shard-addressed request, a request of any other kind or an + /// empty or untrimmed message id is refused. + pub fn answer_route_observation(&mut self, request: &CultNetMessage) -> Result { + let CultNetMessage::SnapshotRequest { + message_id, + schema_ids, + record_keys, + } = request + else { + bail!("Idunn route observation is not a plain snapshot request"); + }; + ensure!( + !message_id.is_empty() && message_id.trim() == message_id, + "Idunn route observation requires a message id" + ); + let target = &self.authority.records.expected.target; + ensure!( + schema_ids.as_ref().is_none_or(|schemas| schemas + .iter() + .any(|schema| schema == GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA)) + && record_keys + .as_ref() + .is_none_or(|keys| keys.iter().any(|key| key == target)), + "Idunn route observation does not request this runtime presence document" + ); + let signed = self.sign(&format!("route-observation:{message_id}"))?; + Ok(CultNetMessage::SnapshotResponseRaw { + message_id: message_id.clone(), + documents: vec![signed.document], + }) + } +} + +fn truncate_utf8(text: &str, max_bytes: usize) -> &str { + if text.len() <= max_bytes { + return text; + } + let mut end = max_bytes; + while !text.is_char_boundary(end) { + end -= 1; + } + &text[..end] +} + +fn unix_millis() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before the Unix epoch")? + .as_millis() + .try_into() + .context("Unix time exceeds u64") +} + +#[cfg(test)] +mod tests; diff --git a/packages/cultnet-rs/src/idunn_runtime_provider/tests.rs b/packages/cultnet-rs/src/idunn_runtime_provider/tests.rs new file mode 100644 index 00000000..9f2d53ec --- /dev/null +++ b/packages/cultnet-rs/src/idunn_runtime_provider/tests.rs @@ -0,0 +1,2120 @@ +use std::collections::BTreeMap; +use std::io::Cursor; + +use cultcache_rs::{CacheBackingStore, CultCacheEnvelope}; + +use super::*; +use crate::{ + IdunnExpectedCapability, IdunnExpectedRoute, IdunnRuntimeActivationLaunch, + IdunnServiceIdentity, RUNTIME_PRESENCE_IDENTITY_FD_NAME, RuntimePresenceAuthenticationContext, + VerifiedRuntimeAuthority, authenticate_runtime_presence_claim, + correlate_runtime_presence_claim, enroll_service_identity_at, verify_runtime_authority, +}; + +const TARGET: &str = "ghostlight"; +const HEALTH_CONTRACT: &str = "ghostlight.runtime-health.v1"; +const BIND: &str = "127.0.0.1:14103"; +const ENDPOINT: &str = "tcp://127.0.0.1:14103"; +#[cfg(unix)] +const ACTIVATION_NAME: &str = IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME; +#[cfg(unix)] +const PROVIDER_NAME: &str = RUNTIME_PRESENCE_IDENTITY_FD_NAME; + +fn digest(byte: char) -> String { + format!("sha256-{}", byte.to_string().repeat(64)) +} + +fn refusal(result: Result) -> String { + match result { + Ok(_) => panic!("expected a refusal"), + Err(error) => format!("{error:#}"), + } +} + +fn capabilities() -> Vec { + ["conversation", "state"] + .into_iter() + .map(|name| GameCultRuntimeCapability { + capability: name.into(), + schema: format!("{name}.v1"), + compatibility: "v1".into(), + capacity: 1, + }) + .collect() +} + +fn write_record(path: &Path, key: &str, ty: &str, schema: &str, payload: Vec) -> Result<()> { + SingleFileMessagePackBackingStore::new(path).push(&CultCacheEnvelope { + key: key.into(), + r#type: ty.into(), + payload, + stored_at: "2026-10-29T00:00:00Z".into(), + schema_id: Some(schema.into()), + }) +} + +// ------------------------------------------------------------------ fake host + +struct FakeHost { + vars: BTreeMap, + pid: u32, + #[cfg(unix)] + descriptors: BTreeMap, +} + +impl FakeHost { + fn new() -> Self { + Self { + vars: BTreeMap::new(), + pid: 4242, + #[cfg(unix)] + descriptors: BTreeMap::new(), + } + } + + fn with(mut self, name: &str, value: &str) -> Self { + self.vars.insert(name.into(), value.into()); + self + } +} + +impl LaunchHost for FakeHost { + fn var(&self, name: &str) -> Option { + self.vars.get(name).cloned() + } + + fn remove_var(&mut self, name: &str) { + self.vars.remove(name); + } + + fn process_id(&self) -> u32 { + self.pid + } + + #[cfg(unix)] + fn adopt_descriptor(&mut self, descriptor: RawFd) -> Result { + self.descriptors + .remove(&descriptor) + .with_context(|| format!("descriptor {descriptor} is not open")) + } +} + +// ------------------------------------------------------------------------ rig + +/// One launch's worth of Idunn material written under a temp directory. +struct Rig { + dir: tempfile::TempDir, + expected: IdunnExpectedIncarnationRecord, + activation: IdunnRuntimeActivationRecord, + activation_seed: Vec, + provider_credential: Vec, + idunn: ServiceIdentitySigner, + provider_public_key: Vec, +} + +impl Rig { + fn new(write_lease_required: bool) -> Result { + let dir = tempfile::tempdir()?; + let provider_path = dir.path().join("provider.cc"); + let provider = + enroll_service_identity_at::(&provider_path)?; + let idunn = + enroll_service_identity_at::(&dir.path().join("idunn.cc"))?; + let provider_anchor = provider.trust_anchor()?; + let expected = IdunnExpectedIncarnationRecord { + schema_version: IDUNN_EXPECTED_INCARNATION_SCHEMA.into(), + target: TARGET.into(), + plan_id: digest('2'), + incarnation_id: "ghostlight/2026-09-02/1".into(), + sealed_release_id: digest('3'), + source_repository: "github.com/GameCult/Ghostlight".into(), + source_revision: "4".repeat(40), + recipe_sha256: digest('5'), + runtime_id: "ghostlight-yggdrasil-1".into(), + expected_signer_identity_id: provider_anchor.identity_id.clone(), + health_contract: HEALTH_CONTRACT.into(), + artifact_sha256: digest('6'), + state_schema_generation: Some("world-v2".into()), + state_contract_sha256: Some(digest('7')), + write_lease_required, + route: Some(IdunnExpectedRoute { + route_id: "ghostlight-public".into(), + transport: "tcp".into(), + stable_endpoint: "tcp://127.0.0.1:14100".into(), + candidate_endpoint: ENDPOINT.into(), + }), + capabilities: ["conversation", "state"] + .into_iter() + .map(|name| IdunnExpectedCapability { + capability: name.into(), + schema: format!("{name}.v1"), + compatibility: "v1".into(), + minimum_capacity: 1, + }) + .collect(), + dependencies: Vec::new(), + }; + let (activation, activation_seed) = Self::issue(&expected, &idunn)?; + let rig = Self { + provider_credential: std::fs::read(&provider_path)?, + provider_public_key: provider_anchor.public_key, + activation, + activation_seed, + expected, + idunn, + dir, + }; + std::fs::create_dir(rig.bundle())?; + rig.write_bundle(&rig.expected, &rig.activation)?; + Ok(rig) + } + + fn issue( + expected: &IdunnExpectedIncarnationRecord, + idunn: &ServiceIdentitySigner, + ) -> Result<(IdunnRuntimeActivationRecord, Vec)> { + let launch = IdunnRuntimeActivationLaunch::issue(expected, digest('8'), 99, idunn)?; + let mut seed = Vec::new(); + let activation = launch.write_credential(&mut seed)?; + Ok((activation, seed)) + } + + fn write_bundle( + &self, + expected: &IdunnExpectedIncarnationRecord, + activation: &IdunnRuntimeActivationRecord, + ) -> Result<()> { + write_record( + &self.bundle().join("expected.cc"), + &expected.target, + IdunnExpectedIncarnationRecord::TYPE, + IDUNN_EXPECTED_INCARNATION_SCHEMA, + expected.canonical_bytes()?, + )?; + write_record( + &self.bundle().join("activation.cc"), + &expected.target, + IdunnRuntimeActivationRecord::TYPE, + IDUNN_RUNTIME_ACTIVATION_SCHEMA, + activation.canonical_bytes()?, + ) + } + + fn bundle(&self) -> PathBuf { + self.dir.path().join("bundle") + } + + fn lease_path(&self) -> PathBuf { + self.dir.path().join("lease.cc") + } + + #[cfg(unix)] + fn lock_path(&self) -> PathBuf { + sibling_lock_path(&self.lease_path()).unwrap() + } + + fn credentials(&self) -> IdunnRuntimeCredentials { + IdunnRuntimeCredentials::new( + Cursor::new(self.activation_seed.clone()), + Cursor::new(self.provider_credential.clone()), + ) + } + + /// A launch environment that names only the bundle. + fn bare_host(&self) -> FakeHost { + FakeHost::new().with( + IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, + self.bundle().to_str().unwrap(), + ) + } + + /// The whole launch environment Idunn sets for this rig. + fn host(&self) -> FakeHost { + let host = self + .bare_host() + .with(IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, BIND); + if self.expected.write_lease_required { + host.with( + IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT, + self.lease_path().to_str().unwrap(), + ) + } else { + host + } + } + + fn open_with( + &self, + host: &FakeHost, + target: &str, + health_contract: &str, + credentials: IdunnRuntimeCredentials, + ) -> Result { + IdunnRuntimeAuthority::open_in(host, target, health_contract, credentials) + } + + fn open(&self, host: &FakeHost) -> Result { + self.open_with(host, TARGET, HEALTH_CONTRACT, self.credentials()) + } + + fn authority(&self) -> Result { + self.open(&self.host()) + } + + fn signer(&self) -> Result { + Ok(IdunnRuntimePresenceSigner::new( + self.authority()?, + capabilities(), + )) + } + + fn verified(&self) -> Result { + verify_runtime_authority( + &self.expected, + &self.activation, + &self.idunn.trust_anchor()?, + &self.provider_public_key, + ) + } + + /// Idunn's lease record at `path`, naming `warming_sha256`. + #[cfg(unix)] + fn write_lease_at(&self, path: &Path, warming_sha256: &str, epoch: u64) -> Result<()> { + let lease = IdunnProcessWriteLeaseRecord { + schema_version: IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into(), + target: TARGET.into(), + expected_projection_sha256: self.expected.canonical_sha256()?, + plan_id: self.expected.plan_id.clone(), + incarnation_id: self.expected.incarnation_id.clone(), + sealed_release_id: self.expected.sealed_release_id.clone(), + activation_witness_sha256: self.activation.canonical_sha256()?, + state_schema_generation: "world-v2".into(), + state_contract_sha256: digest('7'), + runtime_id: self.expected.runtime_id.clone(), + runtime_instance_id: self.activation.runtime_instance_id.clone(), + warming_presence_sha256: warming_sha256.into(), + lease_epoch: epoch, + issued_at_unix_millis: 101, + }; + write_record( + path, + TARGET, + IdunnProcessWriteLeaseRecord::TYPE, + IDUNN_PROCESS_WRITE_LEASE_SCHEMA, + lease.canonical_bytes()?, + ) + } + + #[cfg(unix)] + fn write_lease(&self, warming_sha256: &str, epoch: u64) -> Result<()> { + self.write_lease_at(&self.lease_path(), warming_sha256, epoch) + } +} + +/// Runs `presence` through the shared verifier exactly as Odin would. +fn assert_odin_accepts(rig: &Rig, presence: &SignedRuntimePresence, now: u64) -> Result<()> { + let authority = rig.verified()?; + let claim = authenticate_runtime_presence_claim( + &presence.document.payload, + &authority, + RuntimePresenceAuthenticationContext { + trusted_received_at_unix_millis: now, + maximum_age_millis: 100, + maximum_future_skew_millis: 5, + }, + )?; + assert_eq!(claim.record(), &presence.record); + let verified = + correlate_runtime_presence_claim(claim, &authority)?.into_undisputed_present()?; + assert_eq!(verified.signed_presence_sha256(), presence.canonical_sha256); + Ok(()) +} + +// ---------------------------------------------------------------- signer tests + +#[test] +fn signed_presence_authenticates_and_correlates() -> Result<()> { + let rig = Rig::new(false)?; + let mut signer = rig.signer()?; + let warming = signer.sign_at("starting", 1_000)?; + signer.report_health(RuntimePresenceState::Active); + let active = signer.sign_at("ready", 1_001)?; + + for (presence, state, detail) in [ + (&warming, "warming", "starting"), + (&active, "active", "ready"), + ] { + assert_odin_accepts(&rig, presence, 1_010)?; + assert_eq!(presence.record.state, state); + assert_eq!(presence.record.detail, detail); + assert_eq!(presence.record.bound_endpoint.as_deref(), Some(ENDPOINT)); + assert_eq!( + presence.document.payload, + rmp_serde::to_vec(&presence.record)? + ); + assert_eq!( + presence.canonical_sha256, + presence.record.canonical_sha256()? + ); + assert_eq!( + presence.document.schema_id, + GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA + ); + assert_eq!(presence.document.record_key, TARGET); + assert_eq!( + presence.document.source_runtime_id.as_deref(), + Some("ghostlight-yggdrasil-1") + ); + assert_eq!( + presence.document.source_agent_id.as_deref(), + Some(presence.record.signer_identity_id.as_str()) + ); + assert_eq!( + presence.document.source_role.as_deref(), + Some(DOCUMENT_ROLE) + ); + assert_eq!( + presence.document.tags.as_deref(), + Some( + &[ + "cultnet.transport.rudp.v0".to_owned(), + "runtime-presence".to_owned() + ][..] + ) + ); + } + assert_eq!(warming.document.stored_at, "1970-01-01T00:00:01+00:00"); + assert_eq!(signer.authority().expected().target, TARGET); + assert_eq!(signer.authority().activation(), &rig.activation); + assert_eq!(signer.authority().bound_endpoint(), Some(ENDPOINT)); + Ok(()) +} + +#[test] +fn sequence_strictly_increases_and_health_is_signer_owned() -> Result<()> { + let rig = Rig::new(false)?; + let mut signer = rig.signer()?; + assert_eq!(signer.health(), RuntimePresenceState::Warming); + let first = signer.sign_at("a", 1_000)?; + assert_eq!( + (first.record.state.as_str(), first.record.publisher_sequence), + ("warming", 1) + ); + let mut sequence = 1; + for (health, text) in [ + (RuntimePresenceState::Active, "active"), + (RuntimePresenceState::Degraded, "degraded"), + (RuntimePresenceState::Failed, "failed"), + (RuntimePresenceState::Warming, "warming"), + ] { + signer.report_health(health); + assert_eq!(signer.health(), health); + let signed = signer.sign_at("b", 1_000)?; + sequence += 1; + assert_eq!(signed.record.state, text); + assert_eq!(signed.record.publisher_sequence, sequence); + assert_odin_accepts(&rig, &signed, 1_010)?; + } + Ok(()) +} + +#[test] +fn detail_is_cut_to_512_bytes_on_a_character_boundary() -> Result<()> { + let rig = Rig::new(false)?; + let mut signer = rig.signer()?; + let exact = "x".repeat(512); + assert_eq!(signer.sign_at(&exact, 1_000)?.record.detail, exact); + let over = "x".repeat(513); + assert_eq!(signer.sign_at(&over, 1_000)?.record.detail, exact); + let wide = "é".repeat(400); + assert_eq!(signer.sign_at(&wide, 1_000)?.record.detail, "é".repeat(256)); + let odd = format!("x{}", "é".repeat(400)); + assert_eq!( + signer.sign_at(&odd, 1_000)?.record.detail, + format!("x{}", "é".repeat(255)) + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn warming_never_carries_lease_and_active_requires_held_lease() -> Result<()> { + let rig = Rig::new(true)?; + let mut signer = rig.signer()?; + let warming = signer.sign_at("awaiting-process-write-lease", 1_000)?; + assert_eq!(warming.record.write_lease_sha256, None); + + signer.report_health(RuntimePresenceState::Active); + let refused = refusal(signer.sign_at("ready", 1_001)); + assert!( + refused.contains("lacks its process-write lease"), + "{refused}" + ); + // Degraded needs the lease as Active does, whatever the history. + signer.report_health(RuntimePresenceState::Degraded); + let refused = refusal(signer.sign_at("serving reduced", 1_001)); + assert!( + refused.contains("degraded stateful presence lacks its process-write lease"), + "{refused}" + ); + // Failed is the honest report and needs none. + signer.report_health(RuntimePresenceState::Failed); + assert_eq!( + signer + .sign_at("no lease yet", 1_001)? + .record + .write_lease_sha256, + None + ); + + rig.write_lease(&warming.canonical_sha256, 1)?; + let guard = signer + .try_acquire_write_lease()? + .expect("the lease binds a warming proof"); + signer.report_health(RuntimePresenceState::Active); + let active = signer.sign_at("ready", 1_002)?; + assert_eq!( + active.record.write_lease_sha256.as_deref(), + Some(guard.lease_sha256()) + ); + assert_odin_accepts(&rig, &active, 1_010)?; + + signer.report_health(RuntimePresenceState::Degraded); + assert_eq!( + signer + .sign_at("slow", 1_003)? + .record + .write_lease_sha256 + .as_deref(), + Some(guard.lease_sha256()) + ); + signer.report_health(RuntimePresenceState::Warming); + assert_eq!( + signer.sign_at("again", 1_004)?.record.write_lease_sha256, + None + ); + Ok(()) +} + +#[test] +fn a_runtime_without_a_lease_requirement_signs_active_and_refuses_a_lease() -> Result<()> { + let rig = Rig::new(false)?; + let mut signer = rig.signer()?; + signer.report_health(RuntimePresenceState::Active); + assert_eq!( + signer.sign_at("ready", 1_000)?.record.write_lease_sha256, + None + ); + let refused = refusal(signer.try_acquire_write_lease()); + assert!(refused.contains("does not require"), "{refused}"); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn lease_must_bind_a_recent_warming_proof_of_this_signer() -> Result<()> { + let rig = Rig::new(true)?; + let mut signer = rig.signer()?; + assert!(signer.try_acquire_write_lease()?.is_none(), "no record yet"); + + let mut warming = Vec::new(); + for index in 0..65 { + warming.push( + signer + .sign_at(&format!("heartbeat {index}"), 1_000)? + .canonical_sha256, + ); + } + signer.report_health(RuntimePresenceState::Failed); + let not_warming = signer.sign_at("failed", 1_000)?.canonical_sha256; + + // A proof some other signer signed, a presence that was never Warming and a + // Warming proof pushed out of the last 64 are all refused. + for (epoch, sha) in [(1, digest('d')), (2, not_warming), (3, warming[0].clone())] { + rig.write_lease(&sha, epoch)?; + let refused = refusal(signer.try_acquire_write_lease()); + assert!( + refused.contains("provider-owned Warming proof"), + "{refused}" + ); + } + + // The oldest proof still inside the window is accepted. + rig.write_lease(&warming[1], 4)?; + assert!(signer.try_acquire_write_lease()?.is_some()); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn a_lease_for_another_runtime_instance_is_refused() -> Result<()> { + let rig = Rig::new(true)?; + let mut signer = rig.signer()?; + let warming = signer.sign_at("w", 1_000)?; + rig.write_lease(&warming.canonical_sha256, 1)?; + let stored = SingleFileMessagePackBackingStore::new(rig.lease_path()) + .pull_all()? + .remove(0); + let mut lease = IdunnProcessWriteLeaseRecord::decode_canonical(&stored.payload)?; + lease.runtime_instance_id = digest('1'); + write_record( + &rig.lease_path(), + TARGET, + IdunnProcessWriteLeaseRecord::TYPE, + IDUNN_PROCESS_WRITE_LEASE_SCHEMA, + lease.canonical_bytes()?, + )?; + let refused = refusal(signer.try_acquire_write_lease()); + assert!( + refused.contains("provider-owned Warming proof"), + "{refused}" + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn lease_guard_holds_shared_lock_and_detects_replacement() -> Result<()> { + let rig = Rig::new(true)?; + let mut signer = rig.signer()?; + let warming = signer.sign_at("w", 1_000)?; + rig.write_lease(&warming.canonical_sha256, 1)?; + let guard = signer.try_acquire_write_lease()?.unwrap(); + assert_eq!( + signer.try_acquire_write_lease()?.unwrap().lease_sha256(), + guard.lease_sha256(), + "acquiring again returns the held lease" + ); + guard.require_current()?; + + let probe = OpenOptions::new().read(true).open(rig.lock_path())?; + assert!( + fs2::FileExt::try_lock_exclusive(&probe).is_err(), + "a guard holds the lock shared" + ); + fs2::FileExt::try_lock_shared(&probe)?; + fs2::FileExt::unlock(&probe)?; + + // The lock outlives every clone but the last. + let clone = guard.clone(); + drop(guard); + assert!(fs2::FileExt::try_lock_exclusive(&probe).is_err()); + + // A writer that renames a new record into place is detected. + let replacement = rig.dir.path().join("replacement.cc"); + rig.write_lease_at(&replacement, &warming.canonical_sha256, 2)?; + std::fs::rename(&replacement, rig.lease_path())?; + let stale = refusal(clone.require_current()); + assert!(stale.contains("no longer the admitted"), "{stale}"); + signer.report_health(RuntimePresenceState::Active); + assert!( + signer.sign_at("ready", 1_001).is_err(), + "a stale lease signs nothing but Warming" + ); + + drop(clone); + drop(signer); + fs2::FileExt::try_lock_exclusive(&probe)?; + Ok(()) +} + +#[cfg(unix)] +#[test] +fn a_removed_lease_record_is_not_current() -> Result<()> { + let rig = Rig::new(true)?; + let mut signer = rig.signer()?; + let warming = signer.sign_at("w", 1_000)?; + rig.write_lease(&warming.canonical_sha256, 1)?; + let guard = signer.try_acquire_write_lease()?.unwrap(); + std::fs::remove_file(rig.lease_path())?; + let stale = refusal(guard.require_current()); + assert!(stale.contains("no longer the admitted"), "{stale}"); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn a_lost_lease_still_signs_failed() -> Result<()> { + let rig = Rig::new(true)?; + let mut signer = rig.signer()?; + let warming = signer.sign_at("warming", 1_000)?; + rig.write_lease(&warming.canonical_sha256, 1)?; + let guard = signer.try_acquire_write_lease()?.expect("the lease binds"); + signer.report_health(RuntimePresenceState::Active); + let held = signer.sign_at("held", 1_001)?; + assert_eq!( + held.record.write_lease_sha256.as_deref(), + Some(guard.lease_sha256()) + ); + // While the lease is current, Failed carries its digest like any other state. + signer.report_health(RuntimePresenceState::Failed); + assert_eq!( + signer + .sign_at("failing", 1_001)? + .record + .write_lease_sha256 + .as_deref(), + Some(guard.lease_sha256()) + ); + + // Idunn withdraws the lease: Active and Degraded can no longer be claimed. + std::fs::remove_file(rig.lease_path())?; + for state in [RuntimePresenceState::Active, RuntimePresenceState::Degraded] { + signer.report_health(state); + let refused = refusal(signer.sign_at("lost", 1_002)); + assert!( + refused.contains("no longer the admitted"), + "{state:?}: {refused}" + ); + } + // Failed is the honest report, and it carries no lease digest. + signer.report_health(RuntimePresenceState::Failed); + let failed = signer.sign_at("lease lost", 1_003)?; + assert_eq!(failed.record.state, "failed"); + assert_eq!(failed.record.write_lease_sha256, None); + let authority = rig.verified()?; + let claim = authenticate_runtime_presence_claim( + &failed.document.payload, + &authority, + RuntimePresenceAuthenticationContext { + trusted_received_at_unix_millis: 1_010, + maximum_age_millis: 100, + maximum_future_skew_millis: 5, + }, + )?; + correlate_runtime_presence_claim(claim, &authority)?; + // Warming still signs, as before. + signer.report_health(RuntimePresenceState::Warming); + signer.sign_at("again", 1_004)?; + Ok(()) +} + +// ------------------------------------------------------------- authority tests + +#[test] +fn authority_open_refuses_mismatches() -> Result<()> { + let rig = Rig::new(false)?; + rig.authority()?; + + let refused = refusal(rig.open_with(&rig.host(), "other", HEALTH_CONTRACT, rig.credentials())); + assert!(refused.contains("target or health contract"), "{refused}"); + let refused = refusal(rig.open_with(&rig.host(), TARGET, "other.v1", rig.credentials())); + assert!(refused.contains("target or health contract"), "{refused}"); + + let mut wrong_seed = rig.activation_seed.clone(); + wrong_seed[0] ^= 1; + let credentials = IdunnRuntimeCredentials::new( + Cursor::new(wrong_seed), + Cursor::new(rig.provider_credential.clone()), + ); + let refused = refusal(rig.open_with(&rig.host(), TARGET, HEALTH_CONTRACT, credentials)); + assert!( + refused.contains("activation credential does not belong"), + "{refused}" + ); + + let other = tempfile::tempdir()?; + enroll_service_identity_at::(&other.path().join("p.cc"))?; + let credentials = IdunnRuntimeCredentials::new( + Cursor::new(rig.activation_seed.clone()), + Cursor::new(std::fs::read(other.path().join("p.cc"))?), + ); + let refused = refusal(rig.open_with(&rig.host(), TARGET, HEALTH_CONTRACT, credentials)); + assert!( + refused.contains("not the identity selected by Expected"), + "{refused}" + ); + + // A lease environment on an incarnation that needs none is refused. + let lease_env = rig + .host() + .with(IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT, "/run/lease.cc"); + let refused = refusal(rig.open(&lease_env)); + assert!( + refused.contains("write-lease environment does not match"), + "{refused}" + ); + let empty = rig.host().with(IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT, ""); + assert!(refusal(rig.open(&empty)).contains("empty or has surrounding whitespace")); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn a_lease_requirement_needs_the_lease_environment() -> Result<()> { + let rig = Rig::new(true)?; + rig.authority()?; + let no_lease_env = rig + .bare_host() + .with(IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, BIND); + let refused = refusal(rig.open(&no_lease_env)); + assert!( + refused.contains("write-lease environment does not match"), + "{refused}" + ); + Ok(()) +} + +#[cfg(not(unix))] +#[test] +fn non_unix_open_refuses_a_write_lease_requirement() -> Result<()> { + let rig = Rig::new(true)?; + let refused = refusal(rig.authority()); + assert!(refused.contains("write_lease_required"), "{refused}"); + assert!( + !refused.contains(rig.dir.path().to_str().unwrap()), + "{refused}" + ); + Ok(()) +} + +#[test] +fn open_reads_the_whole_launch_environment() -> Result<()> { + let rig = Rig::new(false)?; + // The bundle comes only from the launch environment: with it missing, open has + // nowhere to look, whatever else is set. + let no_bundle = FakeHost::new().with(IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, BIND); + let refused = refusal(rig.open(&no_bundle)); + assert!( + refused.contains(IDUNN_RUNTIME_BUNDLE_ENVIRONMENT), + "{refused}" + ); + let relative = rig + .host() + .with(IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, "relative/bundle"); + let refused = refusal(rig.open(&relative)); + assert!(refused.contains("not an absolute path"), "{refused}"); + // The bundle, the candidate bind and (below, unix) the write lease are all + // read through the one host port. + let authority = rig.open(&rig.host())?; + authority.require_candidate_bind(BIND)?; + Ok(()) +} + +#[test] +fn the_launch_contract_names_are_the_public_spellings() { + assert_eq!( + IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, + "GAMECULT_IDUNN_RUNTIME_BUNDLE" + ); + assert_eq!( + IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, + "GAMECULT_IDUNN_CANDIDATE_BIND" + ); + assert_eq!( + IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT, + "GAMECULT_IDUNN_PROCESS_WRITE_LEASE" + ); + assert_eq!( + IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT, + "GAMECULT_IDUNN_ACTIVATION_CREDENTIAL" + ); + assert_eq!( + RUNTIME_PRESENCE_IDENTITY_BINDING, + "GAMECULT_RUNTIME_PRESENCE_IDENTITY" + ); + assert_eq!( + RUNTIME_PRESENCE_IDENTITY_FD_NAME, + "gamecult-runtime-presence-identity" + ); +} + +#[test] +fn authority_open_refuses_an_activation_for_another_expected() -> Result<()> { + let rig = Rig::new(false)?; + let mut other_expected = rig.expected.clone(); + other_expected.plan_id = digest('9'); + let (foreign, _) = Rig::issue(&other_expected, &rig.idunn)?; + write_record( + &rig.bundle().join("activation.cc"), + TARGET, + IdunnRuntimeActivationRecord::TYPE, + IDUNN_RUNTIME_ACTIVATION_SCHEMA, + foreign.canonical_bytes()?, + )?; + let refused = refusal(rig.authority()); + assert!( + refused.contains("does not bind the bundled Expected"), + "{refused}" + ); + Ok(()) +} + +#[test] +fn authority_open_refuses_a_substituted_record_key() -> Result<()> { + let rig = Rig::new(false)?; + let substitute = rig.dir.path().join("substitute.cc"); + write_record( + &substitute, + "someone-else", + IdunnExpectedIncarnationRecord::TYPE, + IDUNN_EXPECTED_INCARNATION_SCHEMA, + rig.expected.canonical_bytes()?, + )?; + std::fs::rename(&substitute, rig.bundle().join("expected.cc"))?; + let refused = refusal(rig.authority()); + assert!(refused.contains("key is not its target"), "{refused}"); + Ok(()) +} + +#[test] +fn candidate_bind_must_equal_expected_route() -> Result<()> { + let rig = Rig::new(false)?; + let authority = rig.authority()?; + authority.require_candidate_bind(BIND)?; + assert!(authority.require_candidate_bind("127.0.0.1:14104").is_err()); + assert!(authority.require_candidate_bind(ENDPOINT).is_err()); + assert!(authority.require_candidate_bind("").is_err()); + assert_eq!(authority.bound_endpoint(), Some(ENDPOINT)); + + // Idunn's environment must itself name Expected's route. + let bind = |value: &str| { + rig.bare_host() + .with(IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, value) + }; + assert!(refusal(rig.open(&bind("127.0.0.1:14104"))).contains("candidate bind differs")); + let missing = rig.bare_host(); + assert!(refusal(rig.open(&missing)).contains("candidate bind environment")); + assert!(refusal(rig.open(&bind(" 127.0.0.1:14103"))).contains("surrounding whitespace")); + Ok(()) +} + +#[test] +fn candidate_bind_suffix_is_refused_at_open() -> Result<()> { + let rig = Rig::new(false)?; + // Idunn passes the bare host:port; only the whole of Expected's endpoint + // after `tcp://` is that bind. A suffix or prefix of it is not. + for wrong in [ + "0.0.1:14103", + "1:14103", + "127.0.0.1:1410", + "tcp://127.0.0.1:14103", + ] { + let host = rig + .bare_host() + .with(IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, wrong); + let refused = refusal(rig.open(&host)); + assert!( + refused.contains("candidate bind differs"), + "{wrong}: {refused}" + ); + } + let authority = rig.authority()?; + assert!(authority.require_candidate_bind("0.0.1:14103").is_err()); + Ok(()) +} + +#[test] +fn a_route_less_incarnation_has_no_bind_to_check() -> Result<()> { + let mut rig = Rig::new(false)?; + rig.expected.route = None; + let (activation, seed) = Rig::issue(&rig.expected, &rig.idunn)?; + rig.activation = activation; + rig.activation_seed = seed; + rig.write_bundle(&rig.expected, &rig.activation)?; + let authority = rig.open(&rig.bare_host())?; + assert_eq!(authority.bound_endpoint(), None); + assert!(authority.require_candidate_bind(BIND).is_err()); + assert!(refusal(rig.open(&rig.host())).contains("candidate bind environment")); + let mut signer = IdunnRuntimePresenceSigner::new(authority, capabilities()); + assert_eq!(signer.sign_at("w", 1_000)?.record.bound_endpoint, None); + Ok(()) +} + +// --------------------------------------------------------------- route answer + +fn strings(items: &[&str]) -> Vec { + items.iter().map(|item| (*item).to_owned()).collect() +} + +fn request(id: &str, schemas: Option<&[&str]>, keys: Option<&[&str]>) -> CultNetMessage { + CultNetMessage::SnapshotRequest { + message_id: id.into(), + schema_ids: schemas.map(strings), + record_keys: keys.map(strings), + } +} + +fn presence_in(answer: CultNetMessage) -> GameCultRuntimePresenceHealthRecord { + let CultNetMessage::SnapshotResponseRaw { documents, .. } = answer else { + panic!("a snapshot response"); + }; + let [document] = documents.as_slice() else { + panic!("one presence document"); + }; + rmp_serde::from_slice(&document.payload).unwrap() +} + +#[test] +fn route_observation_refuses_foreign_requests() -> Result<()> { + let rig = Rig::new(false)?; + let mut signer = rig.signer()?; + signer.report_health(RuntimePresenceState::Degraded); + let schema = GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA; + + let answer = + signer.answer_route_observation(&request("m-1", Some(&[schema]), Some(&[TARGET])))?; + let CultNetMessage::SnapshotResponseRaw { message_id, .. } = &answer else { + panic!("a snapshot response"); + }; + assert_eq!(message_id, "m-1"); + let record = presence_in(answer); + assert_eq!( + record.state, "degraded", + "the answer states the reported health" + ); + assert_eq!(record.detail, "route-observation:m-1"); + assert_eq!(record.publisher_sequence, 1); + // An open request and a request listing more than this runtime are fine. + signer.answer_route_observation(&request("m-2", None, None))?; + signer.answer_route_observation(&request("m-3", Some(&["x", schema]), Some(&["y", TARGET])))?; + + for refused in [ + request("m", Some(&["other.schema.v1"]), None), + request("m", None, Some(&["another-target"])), + request("m", Some(&[schema]), Some(&["another-target"])), + request("m", Some(&["other.schema.v1"]), Some(&[TARGET])), + request("", None, None), + request(" m", None, None), + request("m ", None, None), + CultNetMessage::SnapshotResponseRaw { + message_id: "m".into(), + documents: Vec::new(), + }, + ] { + assert!( + signer.answer_route_observation(&refused).is_err(), + "{refused:?}" + ); + } + // Refusals sign nothing: the next answer takes the next sequence. + let record = presence_in(signer.answer_route_observation(&request("m-4", None, None))?); + assert_eq!(record.publisher_sequence, 4); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn route_observation_answers_in_lease_discipline() -> Result<()> { + let rig = Rig::new(true)?; + let mut signer = rig.signer()?; + signer.report_health(RuntimePresenceState::Active); + assert!( + signer + .answer_route_observation(&request("m", None, None)) + .is_err() + ); + Ok(()) +} + +// -------------------------------------------------------- descriptor contract + +#[cfg(unix)] +fn clear_cloexec(file: &File) { + // SAFETY: fcntl on a descriptor `file` owns. + assert_ne!( + unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETFD, 0) }, + -1 + ); +} + +#[cfg(unix)] +fn set_cloexec(file: &File) { + // SAFETY: fcntl on a descriptor `file` owns. + assert_ne!( + unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) }, + -1 + ); +} + +#[cfg(unix)] +fn is_cloexec(file: &File) -> bool { + // SAFETY: fcntl on a descriptor `file` owns. + let flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFD) }; + flags != -1 && flags & libc::FD_CLOEXEC != 0 +} + +#[cfg(unix)] +fn inherited(dir: &Path, name: &str, content: &[u8]) -> Result { + std::fs::write(dir.join(name), content)?; + let file = File::open(dir.join(name))?; + clear_cloexec(&file); + assert!(!is_cloexec(&file)); + Ok(file) +} + +#[cfg(unix)] +fn systemd_host(dir: &Path, listen_pid: &str, fds: &str, names: &str) -> Result { + let mut host = FakeHost::new() + .with(LISTEN_PID_ENVIRONMENT, listen_pid) + .with(LISTEN_FDS_ENVIRONMENT, fds) + .with(LISTEN_FDNAMES_ENVIRONMENT, names); + for (index, name) in names.split(':').enumerate() { + let descriptor = LISTEN_FDS_START + index as RawFd; + host.descriptors.insert( + descriptor, + inherited(dir, &format!("fd-{index}"), name.as_bytes())?, + ); + } + Ok(host) +} + +#[cfg(unix)] +fn contents(mut file: File) -> Result { + let mut text = String::new(); + file.read_to_string(&mut text)?; + Ok(text) +} + +#[cfg(unix)] +#[test] +fn systemd_descriptor_contract() -> Result<()> { + let dir = tempfile::tempdir()?; + let names = format!("socket:{ACTIVATION_NAME}:extra:{PROVIDER_NAME}:more"); + + // Found by name; extra descriptors are tolerated and left alone. + let mut host = systemd_host(dir.path(), "4242", "5", &names)?; + // A descriptor that already carries close-on-exec keeps it. + set_cloexec(&host.descriptors[&(LISTEN_FDS_START + 3)]); + let (activation, provider) = take_systemd_descriptors(&mut host, Custody::Profile)?; + assert!( + is_cloexec(&activation) && is_cloexec(&provider), + "signers never reach a child" + ); + assert_eq!(contents(activation)?, ACTIVATION_NAME); + assert_eq!(contents(provider)?, PROVIDER_NAME); + assert!(host.vars.is_empty(), "LISTEN_* leave the environment"); + assert_eq!(host.descriptors.len(), 3, "only the two signers were taken"); + + // Order does not matter. + let swapped = format!("{PROVIDER_NAME}:{ACTIVATION_NAME}"); + let mut host = systemd_host(dir.path(), "4242", "2", &swapped)?; + let (activation, provider) = take_systemd_descriptors(&mut host, Custody::Profile)?; + assert_eq!(contents(activation)?, ACTIVATION_NAME); + assert_eq!(contents(provider)?, PROVIDER_NAME); + + // A claim for another process is refused and still cleared from the environment. + let both = format!("{ACTIVATION_NAME}:{PROVIDER_NAME}"); + let mut host = systemd_host(dir.path(), "999", "2", &both)?; + assert!( + refusal(take_systemd_descriptors(&mut host, Custody::Profile)) + .contains("different process") + ); + assert!(host.vars.is_empty()); + + // PrivatePIDs: pid 1 accepts the host's LISTEN_PID, a descendant does not. + let mut host = systemd_host(dir.path(), "777", "2", &both)?; + host.pid = 1; + take_systemd_descriptors(&mut host, Custody::Profile)?; + let mut host = systemd_host(dir.path(), "777", "2", &both)?; + host.pid = 2; + assert!(take_systemd_descriptors(&mut host, Custody::Profile).is_err()); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn systemd_listen_pid_rule() { + assert!(listen_pid_matches("4242", 4242)); + assert!(!listen_pid_matches("4243", 4242)); + // PrivatePIDs: the main process is pid 1 and cannot see the host pid. + assert!(listen_pid_matches("777", 1)); + assert!(listen_pid_matches("1", 1)); + assert!( + !listen_pid_matches("777", 2), + "a descendant must not reuse the set" + ); + assert!(!listen_pid_matches("0", 1)); + // The text must be the canonical number. + assert!(!listen_pid_matches("04242", 4242)); + assert!(!listen_pid_matches("+4242", 4242)); + assert!(!listen_pid_matches("", 1)); + assert!(!listen_pid_matches("abc", 1)); +} + +#[cfg(unix)] +#[test] +fn systemd_descriptor_contract_refuses_malformed_claims() -> Result<()> { + let dir = tempfile::tempdir()?; + let both = format!("{ACTIVATION_NAME}:{PROVIDER_NAME}"); + let only_activation = ACTIVATION_NAME.to_owned(); + let doubled = format!("{ACTIVATION_NAME}:{PROVIDER_NAME}:{PROVIDER_NAME}"); + for (fds, names, expect) in [ + ( + "2", + only_activation.as_str(), + "LISTEN_FDNAMES does not name", + ), + ( + "1", + only_activation.as_str(), + "did not pass the gamecult-runtime-presence-identity", + ), + ("3", doubled.as_str(), "more than once"), + ("2", "a:b", "did not pass the gamecult-idunn"), + ("02", both.as_str(), "LISTEN_FDS"), + ("x", both.as_str(), "LISTEN_FDS"), + ("3", both.as_str(), "does not name every"), + ] { + let mut host = systemd_host(dir.path(), "4242", "2", &both)?; + host.vars.insert(LISTEN_FDS_ENVIRONMENT.into(), fds.into()); + host.vars + .insert(LISTEN_FDNAMES_ENVIRONMENT.into(), names.into()); + let refused = refusal(take_systemd_descriptors(&mut host, Custody::Profile)); + assert!(refused.contains(expect), "{names}: {refused}"); + assert!(host.vars.is_empty()); + } + for missing in [ + LISTEN_PID_ENVIRONMENT, + LISTEN_FDS_ENVIRONMENT, + LISTEN_FDNAMES_ENVIRONMENT, + ] { + let mut host = systemd_host(dir.path(), "4242", "2", &both)?; + host.vars.remove(missing); + assert!(refusal(take_systemd_descriptors(&mut host, Custody::Profile)).contains(missing)); + assert!(host.vars.is_empty(), "the rest of the claim is removed too"); + } + // A descriptor the process does not have open is refused. + let mut host = systemd_host(dir.path(), "4242", "2", &both)?; + host.descriptors.clear(); + assert!(refusal(take_systemd_descriptors(&mut host, Custody::Profile)).contains("not open")); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn systemd_descriptors_must_be_read_only() -> Result<()> { + let dir = tempfile::tempdir()?; + let both = format!("{ACTIVATION_NAME}:{PROVIDER_NAME}"); + let mut host = systemd_host(dir.path(), "4242", "2", &both)?; + let writable = OpenOptions::new() + .read(true) + .write(true) + .open(dir.path().join("fd-1"))?; + host.descriptors.insert(LISTEN_FDS_START + 1, writable); + let refused = refusal(take_systemd_descriptors(&mut host, Custody::Profile)); + assert!(refused.contains("not read-only"), "{refused}"); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn write_only_pipe_descriptor_is_not_read_only() -> Result<()> { + let mut fds = [0 as libc::c_int; 2]; + // SAFETY: pipe writes two descriptors into the array. + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0); + // SAFETY: both descriptors were just created and are owned exactly once. + let (read_end, write_end) = unsafe { (File::from_raw_fd(fds[0]), File::from_raw_fd(fds[1])) }; + // SAFETY: F_GETFL reads the flags of a descriptor `write_end` owns. + let status = unsafe { libc::fcntl(write_end.as_raw_fd(), libc::F_GETFL) }; + assert_eq!( + status & libc::O_ACCMODE, + libc::O_WRONLY, + "a valid status of 1" + ); + // F_GETFL succeeds here, so the refusal is the access mode and not a failed read. + let refused = refusal(protect_descriptor( + write_end, + "probe signer", + Custody::Profile, + )); + assert!( + refused.contains("probe signer descriptor is not read-only"), + "{refused}" + ); + assert!( + protect_descriptor(read_end, "probe signer", Custody::Profile).is_ok(), + "the read end of the same pipe is read-only" + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn systemd_descriptors_pass_the_root_custody_rule_or_are_refused() -> Result<()> { + let dir = tempfile::tempdir()?; + let both = format!("{ACTIVATION_NAME}:{PROVIDER_NAME}"); + // The temp files are mode 0644, never root's 0400. + let mut host = systemd_host(dir.path(), "4242", "2", &both)?; + let refused = refusal(take_systemd_descriptors(&mut host, Custody::Root)); + assert!(refused.contains("root-owned 0400"), "{refused}"); + // The public delivery is root custody, with no way to ask for less. + let mut host = systemd_host(dir.path(), "4242", "2", &both)?; + let refused = refusal(IdunnRuntimeCredentials::from_systemd_descriptors_in( + &mut host, + )); + assert!(refused.contains("root-owned 0400"), "{refused}"); + Ok(()) +} + +#[test] +fn custody_follows_delivery() -> Result<()> { + let rig = Rig::new(false)?; + assert_eq!(rig.credentials().custody, Custody::Profile, "new"); + + let dir = tempfile::tempdir()?; + let seed = dir.path().join("activation-key"); + let identity = dir.path().join("identity.cc"); + std::fs::write(&seed, &rig.activation_seed)?; + std::fs::write(&identity, &rig.provider_credential)?; + let host = FakeHost::new() + .with( + IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT, + seed.to_str().unwrap(), + ) + .with( + RUNTIME_PRESENCE_IDENTITY_BINDING, + identity.to_str().unwrap(), + ); + assert_eq!( + IdunnRuntimeCredentials::from_host_environment_in(&host)?.custody, + Custody::Profile, + "host files" + ); + + // Profile custody takes the real thing and nothing else, on every platform. + for (kind, rule, accepted) in [ + (FileKind::Directory, Rule::ReadOnlyDirectory, true), + (FileKind::Regular, Rule::ReadOnlyFile, true), + (FileKind::Regular, Rule::ControlledFile, true), + (FileKind::Symlink, Rule::ReadOnlyDirectory, false), + (FileKind::Symlink, Rule::ReadOnlyFile, false), + (FileKind::Symlink, Rule::ControlledFile, false), + (FileKind::Other, Rule::ReadOnlyFile, false), + (FileKind::Regular, Rule::ReadOnlyDirectory, false), + (FileKind::Directory, Rule::ReadOnlyFile, false), + ] { + assert_eq!(profile_accepts(kind, rule), accepted, "{kind:?} {rule:?}"); + } + Ok(()) +} + +#[test] +fn profile_custody_reads_the_real_filesystem() -> Result<()> { + let dir = tempfile::tempdir()?; + let file = dir.path().join("record.cc"); + std::fs::write(&file, b"x")?; + let custody = Custody::Profile; + custody.require_file(&file, "record", Rule::ReadOnlyFile)?; + custody.require_file(dir.path(), "dir", Rule::ReadOnlyDirectory)?; + // The wrong kind, an absent path and a relative path are refused. + let refused = refusal(custody.require_file(dir.path(), "dir", Rule::ReadOnlyFile)); + assert!(refused.contains("dir is not a regular file"), "{refused}"); + assert!( + custody + .require_file(&file, "record", Rule::ReadOnlyDirectory) + .is_err() + ); + assert!( + custody + .require_file(&dir.path().join("absent"), "absent", Rule::ReadOnlyFile) + .is_err() + ); + assert!( + custody + .require_file(Path::new("record.cc"), "record", Rule::ReadOnlyFile) + .is_err() + ); + assert_eq!( + FileKind::of(&std::fs::symlink_metadata(&file)?), + FileKind::Regular + ); + assert_eq!( + FileKind::of(&std::fs::symlink_metadata(dir.path())?), + FileKind::Directory + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn profile_custody_refuses_a_symlink() -> Result<()> { + let dir = tempfile::tempdir()?; + let file = dir.path().join("record.cc"); + std::fs::write(&file, b"x")?; + let link = dir.path().join("link.cc"); + std::os::unix::fs::symlink(&file, &link)?; + let refused = refusal(Custody::Profile.require_file(&link, "record", Rule::ReadOnlyFile)); + assert!(refused.contains("a link is refused"), "{refused}"); + assert_eq!( + FileKind::of(&std::fs::symlink_metadata(&link)?), + FileKind::Symlink + ); + let dir_link = dir.path().join("dir-link"); + std::os::unix::fs::symlink(dir.path(), &dir_link)?; + assert!( + Custody::Profile + .require_file(&dir_link, "dir", Rule::ReadOnlyDirectory) + .is_err() + ); + // A bundle that is a symlink is refused by open, not followed. + let rig = Rig::new(false)?; + let bundle_link = rig.dir.path().join("bundle-link"); + std::os::unix::fs::symlink(rig.bundle(), &bundle_link)?; + let host = rig.host().with( + IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, + bundle_link.to_str().unwrap(), + ); + let refused = refusal(rig.open(&host)); + assert!( + refused.contains("Idunn runtime bundle is not a directory"), + "{refused}" + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn credentials_helper_wires_the_systemd_signers() -> Result<()> { + let rig = Rig::new(false)?; + let dir = tempfile::tempdir()?; + let both = format!("{ACTIVATION_NAME}:{PROVIDER_NAME}"); + let mut host = systemd_host(dir.path(), "4242", "2", &both)?; + host.descriptors.insert( + LISTEN_FDS_START, + inherited(dir.path(), "seed", &rig.activation_seed)?, + ); + host.descriptors.insert( + LISTEN_FDS_START + 1, + inherited(dir.path(), "identity", &rig.provider_credential)?, + ); + // The temp files are not root's, so the test takes the descriptors under profile + // custody and wraps them as any two readers. + let (activation, provider) = take_systemd_descriptors(&mut host, Custody::Profile)?; + let credentials = IdunnRuntimeCredentials::new(activation, provider); + let authority = rig.open_with(&rig.host(), TARGET, HEALTH_CONTRACT, credentials)?; + assert_eq!(authority.expected().target, TARGET); + Ok(()) +} + +// ------------------------------------------------------------ host credentials + +#[test] +fn host_activation_credential_is_one_shot() -> Result<()> { + let rig = Rig::new(false)?; + let dir = tempfile::tempdir()?; + let seed_path = dir.path().join("activation-key"); + let identity_path = dir.path().join("identity.cc"); + std::fs::write(&seed_path, &rig.activation_seed)?; + std::fs::write(&identity_path, &rig.provider_credential)?; + let host = FakeHost::new() + .with( + IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT, + seed_path.to_str().unwrap(), + ) + .with( + RUNTIME_PRESENCE_IDENTITY_BINDING, + identity_path.to_str().unwrap(), + ); + + let credentials = IdunnRuntimeCredentials::from_host_environment_in(&host)?; + assert!( + !seed_path.exists(), + "the seed lives only in this process now" + ); + assert!( + identity_path.exists(), + "the provider identity is stable and stays" + ); + let authority = rig.open_with(&rig.host(), TARGET, HEALTH_CONTRACT, credentials)?; + assert_eq!(authority.activation(), &rig.activation); + // The file is gone, so a second launch from the same delivery cannot read it. + assert!(IdunnRuntimeCredentials::from_host_environment_in(&host).is_err()); + Ok(()) +} + +#[test] +fn host_credentials_refuse_missing_or_relative_paths() -> Result<()> { + let dir = tempfile::tempdir()?; + let seed = dir.path().join("seed"); + std::fs::write(&seed, [1u8; 32])?; + let identity = dir.path().join("identity.cc"); + std::fs::write(&identity, b"x")?; + let host = FakeHost::new().with( + IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT, + seed.to_str().unwrap(), + ); + assert!( + refusal(IdunnRuntimeCredentials::from_host_environment_in(&host)) + .contains(RUNTIME_PRESENCE_IDENTITY_BINDING) + ); + let host = FakeHost::new().with( + RUNTIME_PRESENCE_IDENTITY_BINDING, + identity.to_str().unwrap(), + ); + assert!( + refusal(IdunnRuntimeCredentials::from_host_environment_in(&host)) + .contains(IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT) + ); + let host = FakeHost::new() + .with(IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT, "relative-seed") + .with( + RUNTIME_PRESENCE_IDENTITY_BINDING, + identity.to_str().unwrap(), + ); + assert!( + refusal(IdunnRuntimeCredentials::from_host_environment_in(&host)).contains("not absolute") + ); + assert!(seed.exists(), "nothing was consumed by a refused delivery"); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn root_rules_accept_what_idunn_provisions_and_nothing_else() { + use FileKind::{Directory, Regular}; + // What Idunn's systemd driver provisions: 0555 bundle, 0444 records and locks, + // 0640 lease files. + assert!(facts(Directory, 0, 0o555).satisfies(Rule::ReadOnlyDirectory)); + assert!(facts(Regular, 0, 0o444).satisfies(Rule::ReadOnlyFile)); + assert!(facts(Regular, 0, 0o640).satisfies(Rule::ControlledFile)); + // Each rule keeps its own kind and owner. + assert!(!facts(Directory, 0, 0o555).satisfies(Rule::ReadOnlyFile)); + assert!(!facts(Regular, 0, 0o444).satisfies(Rule::ReadOnlyDirectory)); + assert!(!facts(Regular, 0, 0o640).satisfies(Rule::ReadOnlyFile)); + assert!(!facts(Regular, 1000, 0o640).satisfies(Rule::ControlledFile)); +} + +#[cfg(unix)] +#[test] +fn only_root_custody_inspects_a_lock() -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir()?; + let lock = dir.path().join("record.cc.lock"); + std::fs::write(&lock, b"")?; + std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o666))?; + // The profile trusts its own lock; root custody refuses a world-writable one. + Custody::Profile.require_lock(&lock, "lock", Rule::ControlledFile)?; + let refused = refusal(Custody::Root.require_lock(&lock, "lock", Rule::ControlledFile)); + assert!( + refused.contains("lock is not a root-controlled regular file"), + "{refused}" + ); + Ok(()) +} + +/// A directory junction is a reparse point: profile custody refuses it as a bundle. +#[cfg(windows)] +#[test] +fn profile_custody_refuses_a_junction() -> Result<()> { + let rig = Rig::new(false)?; + let junction = rig.dir.path().join("bundle-junction"); + let made = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&junction) + .arg(rig.bundle()) + .output()?; + assert!(made.status.success(), "{made:?}"); + assert_eq!( + FileKind::of(&std::fs::symlink_metadata(&junction)?), + FileKind::Symlink + ); + let host = rig + .host() + .with(IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, junction.to_str().unwrap()); + let refused = refusal(rig.open(&host)); + assert!( + refused.contains("Idunn runtime bundle is not a directory"), + "{refused}" + ); + Ok(()) +} + +fn host_delivery(seed: &Path, identity: &Path) -> FakeHost { + FakeHost::new() + .with( + IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT, + seed.to_str().unwrap(), + ) + .with( + RUNTIME_PRESENCE_IDENTITY_BINDING, + identity.to_str().unwrap(), + ) +} + +#[test] +fn host_delivery_signs_on_every_platform() -> Result<()> { + let rig = Rig::new(false)?; + let dir = tempfile::tempdir()?; + let seed = dir.path().join("activation-key"); + let identity = dir.path().join("identity.cc"); + std::fs::write(&seed, &rig.activation_seed)?; + std::fs::write(&identity, &rig.provider_credential)?; + let credentials = + IdunnRuntimeCredentials::from_host_environment_in(&host_delivery(&seed, &identity))?; + let authority = rig.open_with(&rig.host(), TARGET, HEALTH_CONTRACT, credentials)?; + let mut signer = IdunnRuntimePresenceSigner::new(authority, capabilities()); + let warming = signer.sign_at("starting", 1_000)?; + signer.report_health(RuntimePresenceState::Active); + let active = signer.sign_at("ready", 1_001)?; + assert_odin_accepts(&rig, &warming, 1_010)?; + assert_odin_accepts(&rig, &active, 1_010)?; + Ok(()) +} + +#[test] +fn host_credentials_keep_the_seed_when_the_provider_is_missing() -> Result<()> { + let rig = Rig::new(false)?; + let dir = tempfile::tempdir()?; + let seed = dir.path().join("activation-key"); + std::fs::write(&seed, &rig.activation_seed)?; + let absent = dir.path().join("absent-identity.cc"); + let refused = refusal(IdunnRuntimeCredentials::from_host_environment_in( + &host_delivery(&seed, &absent), + )); + assert!( + refused.contains(RUNTIME_PRESENCE_IDENTITY_BINDING), + "{refused}" + ); + assert!(seed.exists(), "a refused delivery leaves the one-shot seed"); + // The next launch attempt, with the identity in place, still has its seed. + let identity = dir.path().join("identity.cc"); + std::fs::write(&identity, &rig.provider_credential)?; + IdunnRuntimeCredentials::from_host_environment_in(&host_delivery(&seed, &identity))?; + assert!(!seed.exists()); + Ok(()) +} + +#[test] +fn host_credential_errors_never_echo_a_path() -> Result<()> { + let rig = Rig::new(false)?; + let root = tempfile::tempdir()?; + let canary = root.path().join("canary-6e1f0c9a"); + std::fs::create_dir(&canary)?; + let seed = canary.join("activation-key"); + let identity = canary.join("identity.cc"); + std::fs::write(&identity, &rig.provider_credential)?; + let directory = canary.join("a-directory"); + std::fs::create_dir(&directory)?; + let mut refusals = vec![ + // The activation credential is absent. + refusal(IdunnRuntimeCredentials::from_host_environment_in( + &host_delivery(&seed, &identity), + )), + // The provider identity is absent. + refusal(IdunnRuntimeCredentials::from_host_environment_in( + &host_delivery(&directory.join("seed-in-a-dir"), &canary.join("absent.cc")), + )), + // The activation credential is a directory: it cannot be read or retired. + refusal(IdunnRuntimeCredentials::from_host_environment_in( + &host_delivery(&directory, &identity), + )), + ]; + // Bundle and record refusals: a missing bundle and a bundle of the wrong kind. + let missing_bundle = rig.bare_host().with( + IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, + canary.join("no-bundle").to_str().unwrap(), + ); + refusals.push(refusal(rig.open(&missing_bundle))); + let file_bundle = rig + .bare_host() + .with(IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, identity.to_str().unwrap()); + refusals.push(refusal(rig.open(&file_bundle))); + let record_missing = tempfile::tempdir()?; + let host = rig.bare_host().with( + IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, + record_missing.path().to_str().unwrap(), + ); + refusals.push(refusal(rig.open(&host))); + for refused in &refusals { + assert!(!refused.contains("canary-6e1f0c9a"), "{refused}"); + assert!( + !refused.contains(root.path().to_str().unwrap()), + "{refused}" + ); + assert!( + !refused.contains(record_missing.path().to_str().unwrap()), + "{refused}" + ); + } + assert!( + refusals[0].contains(IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT), + "{}", + refusals[0] + ); + assert!( + refusals[1].contains(RUNTIME_PRESENCE_IDENTITY_BINDING), + "{}", + refusals[1] + ); + Ok(()) +} + +// --------------------------------------------------------------------- custody + +#[cfg(unix)] +fn facts(kind: FileKind, uid: u32, mode: u32) -> Facts { + Facts { + kind, + uid, + gid: 0, + mode, + nlink: 1, + len: 32, + } +} + +#[cfg(unix)] +#[test] +fn custody_predicates() { + use FileKind::{Directory, Other, Regular, Symlink}; + // Root-owned read-only files (bundle records and locks). + assert!(facts(Regular, 0, 0o444).is_root_read_only_file()); + assert!(facts(Regular, 0, 0o400).is_root_read_only_file()); + assert!( + !facts(Regular, 0, 0o644).is_root_read_only_file(), + "owner-writable" + ); + assert!( + !facts(Regular, 0, 0o464).is_root_read_only_file(), + "group-writable" + ); + assert!( + !facts(Regular, 0, 0o446).is_root_read_only_file(), + "world-writable" + ); + assert!( + !facts(Regular, 1000, 0o444).is_root_read_only_file(), + "not root" + ); + assert!( + !facts(Symlink, 0, 0o444).is_root_read_only_file(), + "symlink" + ); + assert!(!facts(Directory, 0, 0o444).is_root_read_only_file()); + assert!(!facts(Other, 0, 0o444).is_root_read_only_file()); + // The bundle directory. + assert!(facts(Directory, 0, 0o555).is_root_read_only_directory()); + assert!(!facts(Directory, 0, 0o755).is_root_read_only_directory()); + assert!(!facts(Directory, 1000, 0o555).is_root_read_only_directory()); + assert!(!facts(Symlink, 0, 0o555).is_root_read_only_directory()); + assert!(!facts(Regular, 0, 0o555).is_root_read_only_directory()); + // Root-controlled files and directories (the lease and its parents). + assert!(facts(Regular, 0, 0o644).is_root_controlled_file()); + assert!( + !facts(Regular, 0, 0o664).is_root_controlled_file(), + "group-writable" + ); + assert!( + !facts(Regular, 0, 0o646).is_root_controlled_file(), + "world-writable" + ); + assert!(!facts(Regular, 1000, 0o644).is_root_controlled_file()); + assert!(!facts(Symlink, 0, 0o644).is_root_controlled_file()); + assert!(facts(Directory, 0, 0o755).is_root_controlled_directory()); + assert!(!facts(Directory, 0, 0o775).is_root_controlled_directory()); + assert!(!facts(Directory, 0, 0o757).is_root_controlled_directory()); + assert!( + !facts(Directory, 0, 0o1777).is_root_controlled_directory(), + "sticky tmp" + ); + assert!(!facts(Directory, 1000, 0o755).is_root_controlled_directory()); + assert!(!facts(Symlink, 0, 0o755).is_root_controlled_directory()); + // Inherited signers. + assert!(facts(Regular, 0, 0o400).is_signer_descriptor()); + assert!(!facts(Regular, 0, 0o440).is_signer_descriptor()); + assert!(!facts(Regular, 0, 0o600).is_signer_descriptor()); + assert!(!facts(Regular, 0, 0o4400).is_signer_descriptor(), "setuid"); + assert!(!facts(Regular, 1000, 0o400).is_signer_descriptor()); + assert!( + !Facts { + gid: 1000, + ..facts(Regular, 0, 0o400) + } + .is_signer_descriptor() + ); + assert!( + !Facts { + nlink: 2, + ..facts(Regular, 0, 0o400) + } + .is_signer_descriptor() + ); + assert!( + !Facts { + len: 0, + ..facts(Regular, 0, 0o400) + } + .is_signer_descriptor() + ); + assert!(!facts(Directory, 0, 0o400).is_signer_descriptor()); +} + +#[cfg(unix)] +#[test] +fn root_custody_reads_the_real_filesystem_and_refuses() -> Result<()> { + use std::os::unix::fs::{PermissionsExt, symlink}; + let dir = tempfile::tempdir()?; + let file = dir.path().join("record.cc"); + std::fs::write(&file, b"x")?; + let require = + |custody: Custody, path: &Path| custody.require_file(path, "record", Rule::ControlledFile); + + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o666))?; + assert!( + refusal(require(Custody::Root, &file)) + .contains("record is not a root-controlled regular file") + ); + let link = dir.path().join("link.cc"); + symlink(&file, &link)?; + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644))?; + assert!( + require(Custody::Root, &link).is_err(), + "a symlink is never custody" + ); + assert!(require(Custody::Root, Path::new("record.cc")).is_err()); + assert!(require(Custody::Root, &dir.path().join("absent")).is_err()); + assert!( + require(Custody::Profile, &link).is_err(), + "profile custody refuses a link too" + ); + assert!(require(Custody::Profile, &file).is_ok()); + + assert!( + Custody::Root + .require_directory_chain(dir.path(), "dir") + .is_err() + ); + assert!( + Custody::Root + .require_directory_chain(Path::new("relative"), "dir") + .is_err() + ); + assert!( + Custody::Profile + .require_directory_chain(dir.path(), "dir") + .is_ok() + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn the_default_open_path_enforces_root_custody() -> Result<()> { + // The rig's bundle is an ordinary writable temp directory. + let rig = Rig::new(false)?; + let mut credentials = rig.credentials(); + credentials.custody = Custody::Root; + let refused = refusal(rig.open_with(&rig.host(), TARGET, HEALTH_CONTRACT, credentials)); + assert!( + refused.contains("Idunn runtime bundle is not a root-owned read-only directory"), + "{refused}" + ); + Ok(()) +} + +#[test] +fn debug_output_names_the_target_and_never_signing_material() -> Result<()> { + let rig = Rig::new(false)?; + let shown = format!("{:?}", rig.authority()?); + assert!(shown.contains("IdunnRuntimeAuthority") && shown.contains(TARGET)); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn a_lease_guard_debug_names_its_digest() -> Result<()> { + let lease_rig = Rig::new(true)?; + let mut signer = lease_rig.signer()?; + let warming = signer.sign_at("w", 1_000)?; + lease_rig.write_lease(&warming.canonical_sha256, 1)?; + let guard = signer.try_acquire_write_lease()?.unwrap(); + assert!(format!("{guard:?}").contains(guard.lease_sha256())); + Ok(()) +} + +// ----------------------------------------------------- the real process, in small + +/// A descriptor number far above anything a parallel test is likely to open. +#[cfg(unix)] +fn high_duplicate(file: &File) -> RawFd { + // SAFETY: F_DUPFD duplicates a descriptor `file` owns into a fresh number. + let descriptor = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_DUPFD, 700) }; + assert!(descriptor >= 700); + descriptor +} + +#[test] +fn the_process_host_reads_the_real_process() -> Result<()> { + let mut host = ProcessHost; + assert_eq!(host.process_id(), std::process::id()); + + let name = "CULTNET_RS_PROVIDER_TEST_PROCESS_HOST"; + assert_eq!(host.var(name), None); + // SAFETY: a name no other test or thread reads or writes. + unsafe { std::env::set_var(name, "a value") }; + assert_eq!(host.var(name).as_deref(), Some("a value")); + host.remove_var(name); + assert_eq!(host.var(name), None); + assert!( + std::env::var(name).is_err(), + "removed from the real environment" + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn the_process_host_adopts_real_descriptors() -> Result<()> { + let mut host = ProcessHost; + let dir = tempfile::tempdir()?; + std::fs::write(dir.path().join("adopted"), b"adopted bytes")?; + let source = File::open(dir.path().join("adopted"))?; + let descriptor = high_duplicate(&source); + let adopted = host.adopt_descriptor(descriptor)?; + assert_eq!(adopted.as_raw_fd(), descriptor); + assert_eq!(contents(adopted.try_clone()?)?, "adopted bytes"); + adopted.sync_all()?; + + let closed = high_duplicate(&source); + // SAFETY: closes the duplicate made on the previous line. + assert_eq!(unsafe { libc::close(closed) }, 0); + assert!(refusal(host.adopt_descriptor(closed)).contains("is not open")); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn facts_read_the_real_metadata() -> Result<()> { + use std::os::unix::fs::{PermissionsExt, symlink}; + let dir = tempfile::tempdir()?; + let file = dir.path().join("file"); + std::fs::write(&file, b"hello")?; + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o640))?; + let observed = Facts::of(&std::fs::symlink_metadata(&file)?); + assert_eq!(observed.kind, FileKind::Regular); + assert_eq!( + observed.mode, 0o640, + "permission bits only, not the file type" + ); + assert_eq!((observed.len, observed.nlink), (5, 1)); + // SAFETY: geteuid takes no arguments and cannot fail. + assert_eq!(observed.uid, unsafe { libc::geteuid() }); + + assert_eq!(Facts::of_path(dir.path(), "dir")?.kind, FileKind::Directory); + let link = dir.path().join("link"); + symlink(&file, &link)?; + assert_eq!(Facts::of_path(&link, "link")?.kind, FileKind::Symlink); + let socket = dir.path().join("socket"); + let _listener = std::os::unix::net::UnixListener::bind(&socket)?; + assert_eq!(Facts::of_path(&socket, "socket")?.kind, FileKind::Other); + assert!(Facts::of_path(&dir.path().join("absent"), "absent").is_err()); + Ok(()) +} + +// --------------------------------------------------------- lease failure shapes + +#[cfg(unix)] +#[test] +fn a_lease_path_that_cannot_be_inspected_is_an_error_not_an_absent_lease() -> Result<()> { + let rig = Rig::new(true)?; + // The parent is a regular file, so the lease path is ENOTDIR, not "not found". + let impossible = rig.dir.path().join("provider.cc").join("lease.cc"); + let host = rig + .bare_host() + .with(IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, BIND) + .with( + IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT, + impossible.to_str().unwrap(), + ); + let mut signer = IdunnRuntimePresenceSigner::new(rig.open(&host)?, capabilities()); + assert!(signer.try_acquire_write_lease().is_err()); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn the_write_lease_is_held_to_root_custody() -> Result<()> { + let rig = Rig::new(true)?; + let mut authority = rig.authority()?; + Arc::get_mut(&mut authority.records).unwrap().custody = Custody::Root; + let mut signer = IdunnRuntimePresenceSigner::new(authority, capabilities()); + let warming = signer.sign_at("w", 1_000)?; + assert!( + signer.try_acquire_write_lease()?.is_none(), + "an absent record is not yet a custody question" + ); + rig.write_lease(&warming.canonical_sha256, 1)?; + let refused = refusal(signer.try_acquire_write_lease()); + assert!(refused.contains("process-write-lease"), "{refused}"); + Ok(()) +} + +#[test] +fn sign_stamps_the_current_time() -> Result<()> { + let rig = Rig::new(false)?; + let mut signer = rig.signer()?; + let before = unix_millis()?; + let signed = signer.sign("now")?; + let after = unix_millis()?; + assert!( + (before..=after).contains(&signed.record.observed_at_unix_millis), + "{before} <= {} <= {after}", + signed.record.observed_at_unix_millis + ); + assert!(before > 1_700_000_000_000, "unix_millis is the wall clock"); + Ok(()) +} + +// ------------------------------------------------------ s2 repairs, attempt 3 + +/// A lease-required runtime may claim Degraded only under a current lease, with +/// no lease ever held and after one was lost alike. +#[cfg(unix)] +#[test] +fn degraded_needs_a_current_lease_whatever_the_history() -> Result<()> { + let rig = Rig::new(true)?; + let mut signer = rig.signer()?; + let warming = signer.sign_at("w", 1_000)?; + signer.report_health(RuntimePresenceState::Degraded); + assert!(signer.sign_at("never held", 1_001).is_err()); + rig.write_lease(&warming.canonical_sha256, 1)?; + signer.try_acquire_write_lease()?.expect("binds"); + assert!(signer.sign_at("held", 1_002).is_ok()); + std::fs::remove_file(rig.lease_path())?; + assert!(signer.sign_at("lost", 1_003).is_err()); + Ok(()) +} + +/// A runtime that never needed a lease reports Degraded and Active without one. +#[test] +fn degraded_and_active_sign_without_a_lease_when_none_is_required() -> Result<()> { + let rig = Rig::new(false)?; + let mut signer = rig.signer()?; + for state in [RuntimePresenceState::Degraded, RuntimePresenceState::Active] { + signer.report_health(state); + assert_eq!(signer.sign_at("up", 1_000)?.record.write_lease_sha256, None); + } + Ok(()) +} + +fn assert_no_path(refused: &str, rig: &Rig) { + assert!( + !refused.contains(rig.dir.path().to_str().unwrap()), + "{refused}" + ); + assert!( + !refused.contains(rig.dir.path().file_name().unwrap().to_str().unwrap()), + "{refused}" + ); +} + +#[test] +fn a_missing_record_lock_names_no_path() -> Result<()> { + let rig = Rig::new(false)?; + let mut lock = rig.bundle().join("expected.cc").into_os_string(); + lock.push(".lock"); + std::fs::remove_file(&lock)?; + let refused = refusal(rig.open(&rig.host())); + assert!( + refused.contains("could not be read as a CultCache store: not found"), + "{refused}" + ); + assert_no_path(&refused, &rig); + Ok(()) +} + +#[test] +fn an_undecodable_record_names_no_path() -> Result<()> { + let rig = Rig::new(false)?; + std::fs::write( + rig.bundle().join("activation.cc"), + [0xc1_u8, 0xc1, b'g', b'a'], + )?; + let refused = refusal(rig.open(&rig.host())); + assert!( + refused.contains("could not be read as a CultCache store: not decodable"), + "{refused}" + ); + assert_no_path(&refused, &rig); + Ok(()) +} + +#[test] +fn a_record_in_a_newer_store_format_says_so_and_names_no_path() -> Result<()> { + let rig = Rig::new(false)?; + // A store header this runtime does not read; the format is judged from the + // header alone, so the cut-short store is still a newer one. + let mut newer = vec![0x93_u8, 0xb2]; + newer.extend_from_slice(b"cultcache.store.v9"); + std::fs::write(rig.bundle().join("activation.cc"), newer)?; + let refused = refusal(rig.open(&rig.host())); + assert!( + refused.contains( + "could not be read as a CultCache store: in a format this runtime does not read" + ), + "{refused}" + ); + assert_no_path(&refused, &rig); + Ok(()) +} + +#[test] +fn an_io_failure_is_labelled_by_its_kind_alone() { + use std::io::{Error, ErrorKind}; + let path = "/profile/secret-dir/store.cc"; + for (kind, label) in [ + (ErrorKind::PermissionDenied, "permission denied"), + (ErrorKind::NotFound, "not found"), + (ErrorKind::TimedOut, "I/O error"), + ] { + let error = anyhow::Error::new(Error::new(kind, path)) + .context(format!("failed to open pre-created {path}.lock")); + assert_eq!(store_failure_kind(&error), label); + } + assert_eq!(store_failure_kind(&anyhow!("opaque")), "unreadable"); +} + +/// The provider's own refusals about a record still say what is wrong. +#[test] +fn a_store_of_two_records_is_still_refused_by_its_own_message() -> Result<()> { + let rig = Rig::new(false)?; + write_record( + &rig.bundle().join("expected.cc"), + "other", + IdunnExpectedIncarnationRecord::TYPE, + IDUNN_EXPECTED_INCARNATION_SCHEMA, + rig.expected.canonical_bytes()?, + )?; + let refused = refusal(rig.open(&rig.host())); + assert!(refused.contains("exactly one record"), "{refused}"); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn an_undecodable_lease_names_no_path() -> Result<()> { + let rig = Rig::new(true)?; + let mut signer = rig.signer()?; + let warming = signer.sign_at("w", 1_000)?; + rig.write_lease(&warming.canonical_sha256, 1)?; + signer.try_acquire_write_lease()?.expect("binds"); + std::fs::write(rig.lease_path(), [0xc1_u8, 0xc1, b'g', b'a'])?; + signer.report_health(RuntimePresenceState::Active); + let refused = refusal(signer.sign_at("a", 1_001)); + assert!(refused.contains("could not be read"), "{refused}"); + assert_no_path(&refused, &rig); + Ok(()) +} + +/// A provider identity that is not a regular file refuses before the one-shot +/// seed is read, so the next launch attempt still has it. +#[test] +fn host_credentials_keep_the_seed_when_the_provider_is_a_directory() -> Result<()> { + let rig = Rig::new(false)?; + let dir = tempfile::tempdir()?; + let seed = dir.path().join("activation-key"); + std::fs::write(&seed, &rig.activation_seed)?; + let provider = dir.path().join("provider-is-a-dir"); + std::fs::create_dir(&provider)?; + let refused = refusal(IdunnRuntimeCredentials::from_host_environment_in( + &host_delivery(&seed, &provider), + )); + assert!( + refused.contains(RUNTIME_PRESENCE_IDENTITY_BINDING), + "{refused}" + ); + assert!(!refused.contains(dir.path().to_str().unwrap()), "{refused}"); + assert!(seed.exists(), "a refused delivery leaves the one-shot seed"); + Ok(()) +} diff --git a/packages/cultnet-rs/src/lib.rs b/packages/cultnet-rs/src/lib.rs index 621150ba..b35bd701 100644 --- a/packages/cultnet-rs/src/lib.rs +++ b/packages/cultnet-rs/src/lib.rs @@ -7,6 +7,7 @@ mod media_stream_contracts; mod media_stream_wire; mod provider_health_contracts; mod runtime_authority_contracts; +mod idunn_runtime_provider; mod service_identity; mod snapshot_query; mod cultmesh; @@ -30,6 +31,7 @@ pub use media_stream_contracts::*; pub use media_stream_wire::*; pub use provider_health_contracts::*; pub use runtime_authority_contracts::*; +pub use idunn_runtime_provider::*; pub use service_identity::*; pub use snapshot_query::*; pub use cultmesh::*; diff --git a/packages/cultnet-rs/src/runtime_authority_contracts.rs b/packages/cultnet-rs/src/runtime_authority_contracts.rs index 0549a5c1..08cdbcf1 100644 --- a/packages/cultnet-rs/src/runtime_authority_contracts.rs +++ b/packages/cultnet-rs/src/runtime_authority_contracts.rs @@ -21,6 +21,22 @@ pub const ODIN_RUNTIME_TOPOLOGY_CORRELATION_SCHEMA: &str = "odin.runtime_topolog pub const ODIN_RUNTIME_TOPOLOGY_CORRELATION_SIGNING_PURPOSE: &str = "odin.runtime_topology_correlation.v2"; +/// Names the runtime bundle directory Idunn launched this process with. Set by +/// both Idunn drivers; read by `IdunnRuntimeAuthority::open`. +pub const IDUNN_RUNTIME_BUNDLE_ENVIRONMENT: &str = "GAMECULT_IDUNN_RUNTIME_BUNDLE"; +/// Names the `host:port` Idunn expects the candidate to bind. Set by Idunn's +/// systemd driver for routed targets. +pub const IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT: &str = "GAMECULT_IDUNN_CANDIDATE_BIND"; +/// Names the process write-lease record of a stateful target. Set by Idunn's +/// systemd driver exactly when Expected requires a lease. +pub const IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT: &str = "GAMECULT_IDUNN_PROCESS_WRITE_LEASE"; +/// Names the one-shot activation credential file. Set by Idunn's host driver. +pub const IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT: &str = "GAMECULT_IDUNN_ACTIVATION_CREDENTIAL"; +/// Names the stable provider identity credential file. Set by Idunn's host driver. +pub const RUNTIME_PRESENCE_IDENTITY_BINDING: &str = "GAMECULT_RUNTIME_PRESENCE_IDENTITY"; +/// The name Idunn's systemd driver gives the stable provider identity descriptor. +pub const RUNTIME_PRESENCE_IDENTITY_FD_NAME: &str = "gamecult-runtime-presence-identity"; + const MAX_RUNTIME_CAPABILITIES: usize = 256; const MAX_RUNTIME_DEPENDENCIES: usize = 256; const MAX_TOPOLOGY_DISAGREEMENTS: usize = 256;