rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

commit 432afc55df83633f43936c95dd5613c45a4e63c2
parent f194042783c39c7021a36963cd9698930e8c519d
Author: triesap <tyson@radroots.org>
Date:   Sun, 23 Aug 2026 19:47:00 +0000

runtime-paths: adopt sealed RHI instance context

- replace worker and ambient path authority with typed service-instance roots
- derive exact common and encrypted-identity artifacts from RuntimeContext
- remove public path reports, path-leaf overrides, and implicit identity generation
- advance the dependency set coherently to the reachable final Lib revision

Diffstat:
MCargo.lock | 28+++++++++++++++++++---------
MCargo.toml | 16+++++++++-------
MREADME | 26+++++++++++++++++++++++---
Mradroots.lib.source-lock.v1.toml | 8++++----
Dsrc/cli.rs | 30------------------------------
Msrc/cli_v1.rs | 35+++++++++--------------------------
Msrc/config.rs | 650++++++++++++-------------------------------------------------------------------
Msrc/features/trade_agreement_attestation.rs | 43+++----------------------------------------
Dsrc/host_paths/error.rs | 35-----------------------------------
Dsrc/host_paths/mod.rs | 11-----------
Dsrc/host_paths/namespace.rs | 148-------------------------------------------------------------------------------
Dsrc/host_paths/platform.rs | 179-------------------------------------------------------------------------------
Dsrc/host_paths/roots.rs | 315-------------------------------------------------------------------------------
Msrc/host_runtime.rs | 12------------
Msrc/identity_storage.rs | 17+++--------------
Msrc/lib.rs | 125++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Msrc/main.rs | 496+++++++++----------------------------------------------------------------------
Dsrc/paths.rs | 227-------------------------------------------------------------------------------
Asrc/runtime_context.rs | 183+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/services_hardening_cli.rs | 8++++----
Atests/services_hardening_runtime_context.rs | 290+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/source_guards.rs | 26+++++++++++++++-----------
22 files changed, 780 insertions(+), 2128 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1489,7 +1489,7 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "radroots_blossom" version = "0.1.0-alpha" -source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3" +source = "git+https://github.com/radrootslabs/lib?rev=7d7b454b4c9ed86569671993bd03ca868b676665#7d7b454b4c9ed86569671993bd03ca868b676665" dependencies = [ "mediatype", "serde", @@ -1501,7 +1501,7 @@ dependencies = [ [[package]] name = "radroots_core" version = "0.1.0-alpha" -source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3" +source = "git+https://github.com/radrootslabs/lib?rev=7d7b454b4c9ed86569671993bd03ca868b676665#7d7b454b4c9ed86569671993bd03ca868b676665" dependencies = [ "rust_decimal", "serde", @@ -1510,7 +1510,7 @@ dependencies = [ [[package]] name = "radroots_event" version = "0.1.0-alpha" -source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3" +source = "git+https://github.com/radrootslabs/lib?rev=7d7b454b4c9ed86569671993bd03ca868b676665#7d7b454b4c9ed86569671993bd03ca868b676665" dependencies = [ "hex", "jiff-tzdb", @@ -1528,7 +1528,7 @@ dependencies = [ [[package]] name = "radroots_event_codec" version = "0.1.0-alpha" -source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3" +source = "git+https://github.com/radrootslabs/lib?rev=7d7b454b4c9ed86569671993bd03ca868b676665#7d7b454b4c9ed86569671993bd03ca868b676665" dependencies = [ "hex", "radroots_blossom", @@ -1545,7 +1545,7 @@ dependencies = [ [[package]] name = "radroots_identity" version = "0.1.0-alpha" -source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3" +source = "git+https://github.com/radrootslabs/lib?rev=7d7b454b4c9ed86569671993bd03ca868b676665#7d7b454b4c9ed86569671993bd03ca868b676665" dependencies = [ "k256", "serde", @@ -1555,7 +1555,7 @@ dependencies = [ [[package]] name = "radroots_nostr" version = "0.1.0-alpha" -source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3" +source = "git+https://github.com/radrootslabs/lib?rev=7d7b454b4c9ed86569671993bd03ca868b676665#7d7b454b4c9ed86569671993bd03ca868b676665" dependencies = [ "nostr", "radroots_event", @@ -1569,15 +1569,24 @@ dependencies = [ [[package]] name = "radroots_protocol" version = "0.1.0-alpha" -source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3" +source = "git+https://github.com/radrootslabs/lib?rev=7d7b454b4c9ed86569671993bd03ca868b676665#7d7b454b4c9ed86569671993bd03ca868b676665" dependencies = [ "serde", ] [[package]] +name = "radroots_runtime_paths" +version = "0.1.0-alpha" +source = "git+https://github.com/radrootslabs/lib?rev=7d7b454b4c9ed86569671993bd03ca868b676665#7d7b454b4c9ed86569671993bd03ca868b676665" +dependencies = [ + "serde", + "thiserror 1.0.69", +] + +[[package]] name = "radroots_secrets" version = "0.1.0-alpha" -source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3" +source = "git+https://github.com/radrootslabs/lib?rev=7d7b454b4c9ed86569671993bd03ca868b676665#7d7b454b4c9ed86569671993bd03ca868b676665" dependencies = [ "chacha20poly1305", "serde", @@ -1589,7 +1598,7 @@ dependencies = [ [[package]] name = "radroots_trade" version = "0.1.0-alpha" -source = "git+https://github.com/radrootslabs/lib?rev=09065a610d95e57acdc895a14c07580fa099e7c3#09065a610d95e57acdc895a14c07580fa099e7c3" +source = "git+https://github.com/radrootslabs/lib?rev=7d7b454b4c9ed86569671993bd03ca868b676665#7d7b454b4c9ed86569671993bd03ca868b676665" dependencies = [ "hex", "radroots_core", @@ -1750,6 +1759,7 @@ dependencies = [ "radroots_identity", "radroots_nostr", "radroots_protocol", + "radroots_runtime_paths", "radroots_secrets", "radroots_trade", "rand 0.9.2", diff --git a/Cargo.toml b/Cargo.toml @@ -11,13 +11,14 @@ description = "Radroots trade agreement attestation worker" resolver = "3" [workspace.dependencies] -radroots_event = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" } -radroots_event_codec = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" } -radroots_identity = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" } -radroots_nostr = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" } -radroots_protocol = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" } -radroots_secrets = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" } -radroots_trade = { git = "https://github.com/radrootslabs/lib", rev = "09065a610d95e57acdc895a14c07580fa099e7c3", version = "=0.1.0-alpha" } +radroots_event = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha" } +radroots_event_codec = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha" } +radroots_identity = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha" } +radroots_nostr = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha" } +radroots_protocol = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha" } +radroots_runtime_paths = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha" } +radroots_secrets = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha" } +radroots_trade = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha" } [features] default = [] @@ -31,6 +32,7 @@ radroots_event_codec = { workspace = true, features = ["json"] } radroots_identity = { workspace = true } radroots_nostr = { workspace = true, features = ["events"] } radroots_protocol = { workspace = true } +radroots_runtime_paths = { workspace = true } radroots_secrets = { workspace = true } radroots_trade = { workspace = true } diff --git a/README b/README @@ -21,9 +21,8 @@ instance, repo-local root, and config-path selection are CLI concerns and are not document fields. The current runtime loader and root `config.toml` remain transitional until -their ordered replacement steps are complete. Existing environment and worker -selectors are prototype evidence, not authority to change or weaken the v1 -contract. +their ordered replacement steps are complete. Prototype environment and worker +selectors are removed and are not compatibility authority. `parse_rhi_config_v1` is the strict in-memory admission boundary for the target document. It bounds original bytes before parsing, rejects malformed duplicate @@ -57,6 +56,27 @@ database-path, worker, environment-file, or arbitrary path-leaf flag. Identity rekey and replacement are not commands; rotation is a create-new offline artifact plus governed configuration apply. +## Sealed service-instance paths + +One validated CLI invocation resolves through `RhiRuntimeContext`, which owns +the shared typed `RuntimeContext`, exact common artifacts, the validated +`service.identity.ncrypt` credential path, and the explicit or canonical +configuration selection. The service identity is fixed to `rhi`; the instance +comes only from `InstanceId`; profile and repo-local-root provenance are the +closed `bootstrap_cli` vocabulary. Callers cannot construct or mutate another +path set, override artifact names, or obtain a public path report. + +Service-host roots are exactly +`/etc/radroots/services/rhi/<instance>`, +`/var/lib/radroots/services/rhi/<instance>`, +`/var/cache/radroots/services/rhi/<instance>`, +`/var/log/radroots/services/rhi/<instance>`, +`/run/radroots/services/rhi/<instance>`, and +`/etc/radroots/secrets/services/rhi/<instance>`. Interactive roots consume the +injected host environment defined by `radroots_runtime_paths`; repo-local uses +one explicit absolute base and the same `services/rhi/<instance>` namespace. +Path resolution performs no directory creation or filesystem I/O. + Use the repository-owned Nix lanes for validation: ```text diff --git a/radroots.lib.source-lock.v1.toml b/radroots.lib.source-lock.v1.toml @@ -1,9 +1,9 @@ schema = "radroots.lib.source-lock.v1" repository = "https://github.com/radrootslabs/lib" -revision = "09065a610d95e57acdc895a14c07580fa099e7c3" +revision = "7d7b454b4c9ed86569671993bd03ca868b676665" architecture = "radroots.crates.release.v2" -workspace_catalog_sha256 = "8c27cebf6825f9ed74e122513c661f6dd31837dddb39d0988014121d8ab05e75" +workspace_catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4" version = "0.1.0-alpha" -source_archive_sha256 = "68badd1fb02d9396682d368e62dfe79969d8da48529760789c823fb4ab54aea3" +source_archive_sha256 = "b425371c134be96cce46b37f7035d6212f1efe8cff50bef366631ba5632991b0" lockfile = "Cargo.lock" -lockfile_sha256 = "3dda0cfcb08b133439343585c17429474cac1657dd97a7d46bfca3b04b3a8a87" +lockfile_sha256 = "42a3f375556257d05db86bbeaa59d3a96f040afaa85b4293f238a7da98d95fde" diff --git a/src/cli.rs b/src/cli.rs @@ -1,30 +0,0 @@ -use crate::host_runtime::ServiceCliArgs; -use clap::Parser; -use std::path::PathBuf; - -#[derive(Parser, Debug, Clone)] -#[command( - about = env!("CARGO_PKG_DESCRIPTION"), - author = env!("CARGO_PKG_AUTHORS"), - version = env!("CARGO_PKG_VERSION") -)] -pub struct Args { - #[command(subcommand)] - pub command: Option<Command>, - #[command(flatten)] - pub service: ServiceCliArgs, -} - -#[derive(clap::Subcommand, Debug, Clone)] -pub enum Command { - #[command( - name = "attestation-smoke", - about = "Run a release-product agreement attestation smoke request" - )] - AttestationSmoke { - #[arg(long)] - input: Option<PathBuf>, - #[arg(long)] - output: Option<PathBuf>, - }, -} diff --git a/src/cli_v1.rs b/src/cli_v1.rs @@ -6,9 +6,7 @@ use std::fmt; use std::path::{Component, Path, PathBuf}; use clap::{Parser, Subcommand, ValueEnum}; - -/// Maximum encoded length of an RHI instance identifier. -pub const RHI_INSTANCE_ID_MAX_BYTES: usize = 128; +use radroots_runtime_paths::InstanceId; /// The exact bootstrap profile selected by the operator. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -178,7 +176,7 @@ impl Error for RhiCliV1Error {} /// A validated one-pass RHI bootstrap and command selection. pub struct RhiCliInvocationV1 { profile: RhiBootstrapProfileV1, - instance: Box<str>, + instance: InstanceId, repo_local_root: Option<PathBuf>, config_path: Option<PathBuf>, output_mode: RhiCliOutputModeV1, @@ -194,7 +192,7 @@ impl RhiCliInvocationV1 { /// Returns the validated instance identifier. #[must_use] - pub fn instance(&self) -> &str { + pub const fn instance(&self) -> &InstanceId { &self.instance } @@ -262,7 +260,8 @@ where let instance = parsed .instance .ok_or_else(|| RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidArguments))?; - validate_instance(&instance)?; + let instance = InstanceId::new(instance) + .map_err(|_| RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidInstance))?; validate_bootstrap_paths( profile, parsed.repo_local_root.as_deref(), @@ -271,7 +270,7 @@ where Ok(RhiCliInvocationV1 { profile, - instance: instance.into_boxed_str(), + instance, repo_local_root: parsed.repo_local_root, config_path: parsed.config, output_mode: parsed.output.into(), @@ -279,22 +278,6 @@ where }) } -fn validate_instance(value: &str) -> Result<(), RhiCliV1Error> { - let bytes = value.as_bytes(); - let is_boundary = |byte: u8| byte.is_ascii_lowercase() || byte.is_ascii_digit(); - if bytes.is_empty() - || bytes.len() > RHI_INSTANCE_ID_MAX_BYTES - || !is_boundary(bytes[0]) - || !is_boundary(bytes[bytes.len() - 1]) - || !bytes - .iter() - .all(|byte| is_boundary(*byte) || matches!(*byte, b'-' | b'_')) - { - return Err(RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidInstance)); - } - Ok(()) -} - fn validate_bootstrap_paths( profile: RhiBootstrapProfileV1, repo_local_root: Option<&Path>, @@ -652,7 +635,7 @@ mod tests { "run", ]) .expect("host profile"); - assert_eq!(invocation.instance(), "north-01"); + assert_eq!(invocation.instance().as_str(), "north-01"); assert_eq!( invocation.config_path(), Some(Path::new("/etc/radroots/rhi.toml")) @@ -696,7 +679,7 @@ mod tests { assert_eq!(error.kind(), RhiCliV1ErrorKind::InvalidInstance); } - let exact = "a".repeat(RHI_INSTANCE_ID_MAX_BYTES); + let exact = "a".repeat(radroots_runtime_paths::INSTANCE_ID_MAX_BYTES); assert!( parse_rhi_cli_v1_from([ "rhi", @@ -708,7 +691,7 @@ mod tests { ]) .is_ok() ); - let overlong = "a".repeat(RHI_INSTANCE_ID_MAX_BYTES + 1); + let overlong = "a".repeat(radroots_runtime_paths::INSTANCE_ID_MAX_BYTES + 1); assert_eq!( parse_rhi_cli_v1_from([ "rhi", diff --git a/src/config.rs b/src/config.rs @@ -1,13 +1,14 @@ -use crate::host_nostr::Metadata; -use crate::host_runtime::{BackoffConfig, NostrServiceConfig}; +//! Transitional runtime settings materialization under the sealed path context. + +use std::path::{Path, PathBuf}; + use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; +use crate::RhiRuntimeContext; use crate::features::trade_agreement_attestation::TradeAgreementAttestationPolicy; -use crate::paths::{ - RhiRuntimePaths, default_subscriber_state_path_for_process, resolve_runtime_paths_with_resolver, -}; +use crate::host_nostr::Metadata; +use crate::host_runtime::{BackoffConfig, NostrServiceConfig}; fn default_replay_window_secs() -> u64 { 24 * 60 * 60 @@ -21,7 +22,7 @@ fn default_logging_filter() -> String { "info".to_owned() } -fn default_logging_stdout() -> bool { +const fn default_logging_stdout() -> bool { true } @@ -35,21 +36,19 @@ pub struct LoggingConfig { #[derive(Debug, Deserialize, Clone, Default)] #[serde(default, deny_unknown_fields)] struct RawLoggingConfig { - pub output_dir: Option<PathBuf>, - pub filter: Option<String>, - pub stdout: Option<bool>, + filter: Option<String>, + stdout: Option<bool>, } impl RawLoggingConfig { - fn into_logging_config(self, paths: &RhiRuntimePaths) -> Result<LoggingConfig> { + fn into_logging_config(self, context: &RhiRuntimeContext) -> Result<LoggingConfig> { let filter = self.filter.unwrap_or_else(default_logging_filter); let filter = filter.trim(); if filter.is_empty() { bail!("logging.filter must not be empty"); } - Ok(LoggingConfig { - output_dir: self.output_dir.unwrap_or_else(|| paths.logs_dir.clone()), + output_dir: context.context().paths().logs().to_path_buf(), filter: filter.to_owned(), stdout: self.stdout.unwrap_or_else(default_logging_stdout), }) @@ -59,27 +58,27 @@ impl RawLoggingConfig { #[derive(Debug, Deserialize, Clone, Default)] #[serde(default, deny_unknown_fields)] struct RawRelaysConfig { - pub urls: Vec<String>, + urls: Vec<String>, } #[derive(Debug, Deserialize, Clone, Default)] #[serde(default, deny_unknown_fields)] struct RawNostrConfig { - pub nip89: RawNip89Config, + nip89: RawNip89Config, } #[derive(Debug, Deserialize, Clone, Default)] #[serde(default, deny_unknown_fields)] struct RawNip89Config { - pub identifier: Option<String>, - pub extra_tags: Vec<Vec<String>>, + identifier: Option<String>, + extra_tags: Vec<Vec<String>>, } #[derive(Debug, Clone)] struct RawServiceConfig { - pub logging: LoggingConfig, - pub relays: RawRelaysConfig, - pub nostr: RawNostrConfig, + logging: LoggingConfig, + relays: RawRelaysConfig, + nostr: RawNostrConfig, } impl RawServiceConfig { @@ -98,34 +97,29 @@ pub struct Configuration { #[serde(flatten)] pub service: NostrServiceConfig, pub logging: LoggingConfig, - #[serde(default)] pub subscriber: SubscriberConfig, #[serde(default)] pub trade_agreement_attestation: TradeAgreementAttestationPolicy, } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct SubscriberConfig { - #[serde(default)] pub backoff: BackoffConfig, - #[serde(default)] pub state: SubscriberStateConfig, } #[derive(Debug, Deserialize, Clone, Default)] #[serde(default, deny_unknown_fields)] struct RawSubscriberConfig { - #[serde(default)] - pub backoff: BackoffConfig, - #[serde(default)] - pub state: RawSubscriberStateConfig, + backoff: BackoffConfig, + state: RawSubscriberStateConfig, } impl RawSubscriberConfig { - fn into_subscriber_config(self, paths: &RhiRuntimePaths) -> SubscriberConfig { + fn into_subscriber_config(self, context: &RhiRuntimeContext) -> SubscriberConfig { SubscriberConfig { backoff: self.backoff, - state: self.state.into_subscriber_state_config(paths), + state: self.state.into_subscriber_state_config(context), } } } @@ -140,18 +134,15 @@ pub struct SubscriberStateConfig { #[derive(Debug, Deserialize, Clone)] #[serde(deny_unknown_fields)] struct RawSubscriberStateConfig { - #[serde(default)] - pub path: Option<PathBuf>, #[serde(default = "default_replay_window_secs")] - pub replay_window_secs: u64, + replay_window_secs: u64, #[serde(default = "default_replay_overlap_secs")] - pub replay_overlap_secs: u64, + replay_overlap_secs: u64, } impl Default for RawSubscriberStateConfig { fn default() -> Self { Self { - path: None, replay_window_secs: default_replay_window_secs(), replay_overlap_secs: default_replay_overlap_secs(), } @@ -159,60 +150,51 @@ impl Default for RawSubscriberStateConfig { } impl RawSubscriberStateConfig { - fn into_subscriber_state_config(self, paths: &RhiRuntimePaths) -> SubscriberStateConfig { + fn into_subscriber_state_config(self, context: &RhiRuntimeContext) -> SubscriberStateConfig { SubscriberStateConfig { - path: self - .path - .unwrap_or_else(|| paths.subscriber_state_path.clone()), + path: context + .context() + .paths() + .state() + .join("trade-agreement-attestation") + .join("state.json"), replay_window_secs: self.replay_window_secs, replay_overlap_secs: self.replay_overlap_secs, } } } -impl Default for SubscriberStateConfig { - fn default() -> Self { - Self { - path: default_subscriber_state_path_for_process() - .expect("resolve canonical rhi subscriber state path"), - replay_window_secs: default_replay_window_secs(), - replay_overlap_secs: default_replay_overlap_secs(), - } - } -} - #[derive(Debug, Deserialize, Clone)] #[serde(deny_unknown_fields)] struct RawSettings { - pub metadata: Metadata, + metadata: Metadata, #[serde(default)] - pub logging: RawLoggingConfig, + logging: RawLoggingConfig, #[serde(default)] - pub relays: RawRelaysConfig, + relays: RawRelaysConfig, #[serde(default)] - pub nostr: RawNostrConfig, + nostr: RawNostrConfig, #[serde(default)] - pub subscriber: RawSubscriberConfig, + subscriber: RawSubscriberConfig, #[serde(default)] - pub trade_agreement_attestation: TradeAgreementAttestationPolicy, + trade_agreement_attestation: TradeAgreementAttestationPolicy, } impl RawSettings { - fn into_settings(self, paths: &RhiRuntimePaths) -> Result<Settings> { - let logging = self.logging.into_logging_config(paths)?; + fn into_settings(self, context: &RhiRuntimeContext) -> Result<Settings> { + let logging = self.logging.into_logging_config(context)?; let service = RawServiceConfig { logging: logging.clone(), relays: self.relays, nostr: self.nostr, } .into_service_config(); - Ok(Settings { metadata: self.metadata, config: Configuration { service, logging, - subscriber: self.subscriber.into_subscriber_config(paths), + subscriber: self.subscriber.into_subscriber_config(context), trade_agreement_attestation: self.trade_agreement_attestation, }, }) @@ -225,167 +207,62 @@ pub struct Settings { pub config: Configuration, } -fn load_settings_from_path_with_resolver( - path: &Path, - resolver: &crate::host_paths::RadrootsPathResolver, - profile: crate::host_paths::RadrootsPathProfile, - repo_local_root: Option<&Path>, -) -> Result<Settings> { - let paths = resolve_runtime_paths_with_resolver(resolver, profile, repo_local_root)?; +/// Loads transitional runtime settings with all paths supplied by one sealed context. +/// +/// The final versioned configuration parser is [`crate::parse_rhi_config_v1`]. +/// This adapter remains only until the legacy runtime is removed in Step 168; +/// it accepts no path overrides and performs no ambient environment selection. +pub fn load_settings_from_path(path: &Path, context: &RhiRuntimeContext) -> Result<Settings> { let raw = std::fs::read_to_string(path) .with_context(|| format!("read configuration from {}", path.display()))?; let settings: RawSettings = toml::from_str(&raw).with_context(|| format!("parse configuration {}", path.display()))?; - let settings = settings.into_settings(&paths)?; + let settings = settings.into_settings(context)?; settings.config.trade_agreement_attestation.validate()?; Ok(settings) } -pub fn load_settings_from_path(path: &Path) -> Result<Settings> { - let (profile, repo_local_root) = crate::paths::process_path_selection()?; - load_settings_from_path_with_resolver( - path, - &crate::host_paths::RadrootsPathResolver::current(), - profile, - repo_local_root.as_deref(), - ) -} - #[cfg(test)] mod tests { - use super::load_settings_from_path_with_resolver; - use crate::features::trade_agreement_attestation::TradeAgreementAttestationBackend; - use crate::host_paths::{ - RadrootsHostEnvironment, RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, - RadrootsPlatform, RadrootsRuntimeNamespace, - }; - use crate::paths::{ - default_subscriber_state_path_for_process, resolve_runtime_paths_with_resolver, - runtime_contract_with_resolver, - }; - use std::path::PathBuf; - - fn linux_resolver() -> RadrootsPathResolver { - RadrootsPathResolver::new( - RadrootsPlatform::Linux, - RadrootsHostEnvironment { - home_dir: Some(PathBuf::from("/home/treesap")), - ..RadrootsHostEnvironment::default() - }, - ) - } - - #[test] - fn worker_namespace_uses_canonical_interactive_roots() { - let namespace = RadrootsRuntimeNamespace::worker("rhi").expect("worker namespace"); - let namespaced = linux_resolver() - .resolve( - RadrootsPathProfile::InteractiveUser, - &RadrootsPathOverrides::default(), - ) - .expect("interactive_user roots") - .namespaced(&namespace); - - assert_eq!( - namespaced.config, - PathBuf::from("/home/treesap/.radroots/config/workers/rhi") - ); - assert_eq!( - namespaced.data, - PathBuf::from("/home/treesap/.radroots/data/workers/rhi") - ); - assert_eq!( - namespaced.logs, - PathBuf::from("/home/treesap/.radroots/logs/workers/rhi") - ); - assert_eq!( - namespaced.secrets, - PathBuf::from("/home/treesap/.radroots/secrets/workers/rhi") - ); - } - - #[test] - fn runtime_paths_follow_interactive_user_contract() { - let paths = resolve_runtime_paths_with_resolver( - &linux_resolver(), - RadrootsPathProfile::InteractiveUser, - None, - ) - .expect("interactive_user paths should resolve"); - - assert_eq!( - paths.config_path, - PathBuf::from("/home/treesap/.radroots/config/workers/rhi/config.toml") - ); - assert_eq!( - paths.logs_dir, - PathBuf::from("/home/treesap/.radroots/logs/workers/rhi") - ); - assert_eq!( - paths.identity_path, - PathBuf::from("/home/treesap/.radroots/secrets/workers/rhi/identity.secret.json") - ); - assert_eq!( - paths.subscriber_state_path, - PathBuf::from( - "/home/treesap/.radroots/data/workers/rhi/trade-agreement-attestation/state.json" - ) - ); - } - - #[test] - fn runtime_paths_follow_service_host_contract() { - let resolver = - RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); - let paths = - resolve_runtime_paths_with_resolver(&resolver, RadrootsPathProfile::ServiceHost, None) - .expect("service_host paths should resolve"); + use std::path::{Path, PathBuf}; - assert_eq!( - paths.config_path, - PathBuf::from("/etc/radroots/workers/rhi/config.toml") - ); - assert_eq!( - paths.logs_dir, - PathBuf::from("/var/log/radroots/workers/rhi") - ); - assert_eq!( - paths.identity_path, - PathBuf::from("/etc/radroots/secrets/workers/rhi/identity.secret.json") - ); - assert_eq!( - paths.subscriber_state_path, - PathBuf::from("/var/lib/radroots/workers/rhi/trade-agreement-attestation/state.json") - ); - } + use crate::{ + RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, parse_rhi_cli_v1_from, + resolve_rhi_runtime_context, + }; - #[test] - fn runtime_paths_follow_repo_local_contract() { - let repo_local_root = PathBuf::from("/repo/.local/radroots/dev/rhi"); - let paths = resolve_runtime_paths_with_resolver( - &linux_resolver(), - RadrootsPathProfile::RepoLocal, - Some(repo_local_root.as_path()), + use super::load_settings_from_path; + + fn context() -> crate::RhiRuntimeContext { + let invocation = parse_rhi_cli_v1_from([ + "rhi", + "--profile", + "interactive", + "--instance", + "default", + "run", + ]) + .expect("invocation"); + resolve_rhi_runtime_context( + &RadrootsPathResolver::new( + RadrootsPlatform::Linux, + RadrootsHostEnvironment { + home_dir: Some(PathBuf::from("/home/operator")), + xdg_config_home: Some(PathBuf::from("/xdg/config")), + xdg_data_home: Some(PathBuf::from("/xdg/data")), + xdg_state_home: Some(PathBuf::from("/xdg/state")), + xdg_cache_home: Some(PathBuf::from("/xdg/cache")), + xdg_runtime_dir: Some(PathBuf::from("/xdg/run")), + ..RadrootsHostEnvironment::default() + }, + ), + &invocation, ) - .expect("repo_local paths should resolve"); - - assert_eq!( - paths.config_path, - repo_local_root.join("config/workers/rhi/config.toml") - ); - assert_eq!(paths.logs_dir, repo_local_root.join("logs/workers/rhi")); - assert_eq!( - paths.identity_path, - repo_local_root.join("secrets/workers/rhi/identity.secret.json") - ); - assert_eq!( - paths.subscriber_state_path, - repo_local_root.join("data/workers/rhi/trade-agreement-attestation/state.json") - ); + .expect("context") } #[test] - fn load_settings_materializes_profile_defaults_when_paths_are_omitted() { + fn materializes_only_context_derived_paths() { let temp = tempfile::tempdir().expect("tempdir"); let config_path = temp.path().join("config.toml"); std::fs::write( @@ -397,378 +274,41 @@ name = "rhi-test" [relays] urls = ["wss://relay.example.com"] -[nostr.nip89] -identifier = "rhi" - [subscriber.state] replay_window_secs = 123 replay_overlap_secs = 45 "#, ) - .expect("write config"); - - let settings = load_settings_from_path_with_resolver( - &config_path, - &linux_resolver(), - RadrootsPathProfile::InteractiveUser, - None, - ) - .expect("load settings"); - - assert_eq!( - settings.config.service.logs_dir, - "/home/treesap/.radroots/logs/workers/rhi" - ); + .expect("config"); + let settings = load_settings_from_path(&config_path, &context()).expect("settings"); assert_eq!( settings.config.logging.output_dir, - PathBuf::from("/home/treesap/.radroots/logs/workers/rhi") - ); - assert_eq!(settings.config.logging.filter, "info"); - assert!(settings.config.logging.stdout); - assert_eq!( - settings.config.service.relays, - vec!["wss://relay.example.com"] - ); - assert_eq!( - settings.config.service.nip89_identifier.as_deref(), - Some("rhi") + Path::new("/xdg/state/radroots/logs/services/rhi/default") ); assert_eq!( settings.config.subscriber.state.path, - PathBuf::from( - "/home/treesap/.radroots/data/workers/rhi/trade-agreement-attestation/state.json" + Path::new( + "/xdg/data/radroots/services/rhi/default/trade-agreement-attestation/state.json" ) ); assert_eq!(settings.config.subscriber.state.replay_window_secs, 123); assert_eq!(settings.config.subscriber.state.replay_overlap_secs, 45); - assert_eq!( - settings.config.trade_agreement_attestation.backend, - TradeAgreementAttestationBackend::LocalStatementHash - ); - } - - #[test] - fn load_settings_parses_trade_agreement_attestation_policy() { - let temp = tempfile::tempdir().expect("tempdir"); - let config_path = temp.path().join("config.toml"); - std::fs::write( - &config_path, - r#" -[metadata] -name = "rhi-test" - -[logging] -output_dir = "logs/rhi" -filter = "warn" -stdout = false - -[relays] -urls = ["wss://relay.example.com"] - -[nostr.nip89] -identifier = "rhi" -extra_tags = [["t", "radroots"]] - -[subscriber.backoff] -base_ms = 10 -max_ms = 100 -factor = 3 -jitter_ms = 5 - -[subscriber.state] -path = "state/trade-agreement-attestation.json" - -[trade_agreement_attestation] -backend = "local_statement_hash" -expected_statement_contract_hash = "0x1111111111111111111111111111111111111111111111111111111111111111" -validator_set_addr = "30381:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:018f3d99-7d35-7c0c-8a0f-7f3b645abcde" -validator_set_event_id = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" -"#, - ) - .expect("write config"); - - let settings = load_settings_from_path_with_resolver( - &config_path, - &linux_resolver(), - RadrootsPathProfile::InteractiveUser, - None, - ) - .expect("load settings"); - - assert_eq!(settings.config.service.logs_dir, "logs/rhi"); - assert_eq!( - settings.config.logging.output_dir, - PathBuf::from("logs/rhi") - ); - assert_eq!(settings.config.logging.filter, "warn"); - assert!(!settings.config.logging.stdout); - assert_eq!( - settings.config.service.relays, - vec!["wss://relay.example.com"] - ); - assert_eq!( - settings.config.service.nip89_identifier.as_deref(), - Some("rhi") - ); - assert_eq!( - settings.config.service.nip89_extra_tags, - vec![vec!["t".to_owned(), "radroots".to_owned()]] - ); - assert_eq!(settings.config.subscriber.backoff.base_ms, 10); - assert_eq!(settings.config.subscriber.backoff.max_ms, 100); - assert_eq!(settings.config.subscriber.backoff.factor, 3); - assert_eq!(settings.config.subscriber.backoff.jitter_ms, 5); - assert_eq!( - settings.config.subscriber.state.path, - PathBuf::from("state/trade-agreement-attestation.json") - ); - assert_eq!( - settings.config.trade_agreement_attestation.backend, - TradeAgreementAttestationBackend::LocalStatementHash - ); - assert_eq!( - settings - .config - .trade_agreement_attestation - .expected_statement_contract_hash - .as_deref(), - Some("0x1111111111111111111111111111111111111111111111111111111111111111") - ); - assert_eq!( - settings - .config - .trade_agreement_attestation - .validator_set_addr - .as_deref(), - Some( - "30381:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:018f3d99-7d35-7c0c-8a0f-7f3b645abcde" - ) - ); - assert_eq!( - settings - .config - .trade_agreement_attestation - .validator_set_event_id - .as_deref(), - Some("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") - ); - } - - #[test] - fn load_settings_rejects_invalid_statement_contract_hash() { - let temp = tempfile::tempdir().expect("tempdir"); - let config_path = temp.path().join("config.toml"); - std::fs::write( - &config_path, - r#" -[metadata] -name = "rhi-test" - -[trade_agreement_attestation] -backend = "local_statement_hash" -expected_statement_contract_hash = "not-a-hash" -"#, - ) - .expect("write config"); - - let error = load_settings_from_path_with_resolver( - &config_path, - &linux_resolver(), - RadrootsPathProfile::InteractiveUser, - None, - ) - .expect_err("invalid statement contract hash must fail"); - let message = format!("{error:#}"); - assert!( - message.contains("invalid configured hash field"), - "{message}" - ); - } - - #[test] - fn load_settings_rejects_partial_validator_set_binding() { - let temp = tempfile::tempdir().expect("tempdir"); - let config_path = temp.path().join("config.toml"); - std::fs::write( - &config_path, - r#" -[metadata] -name = "rhi-test" - -[trade_agreement_attestation] -backend = "local_statement_hash" -validator_set_addr = "30381:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:018f3d99-7d35-7c0c-8a0f-7f3b645abcde" -"#, - ) - .expect("write config"); - - let error = load_settings_from_path_with_resolver( - &config_path, - &linux_resolver(), - RadrootsPathProfile::InteractiveUser, - None, - ) - .expect_err("partial validator-set binding must fail"); - let message = format!("{error:#}"); - assert!( - message.contains("attestation policy is missing validator_set_event_id"), - "{message}" - ); } #[test] - fn old_config_roots_are_rejected() { + fn path_leaf_overrides_are_rejected() { let temp = tempfile::tempdir().expect("tempdir"); - for (name, body, needle) in [ - ( - "config-root", - r#" -[metadata] -name = "rhi-test" - -[config] -relays = ["wss://relay.example.com"] -"#, - "unknown field `config`", - ), - ( - "config-subscriber-backoff", - r#" -[metadata] -name = "rhi-test" - -[config.subscriber.backoff] -base_ms = 10 -"#, - "unknown field `config`", - ), - ( - "config-subscriber-state", - r#" -[metadata] -name = "rhi-test" - -[config.subscriber.state] -replay_window_secs = 10 -"#, - "unknown field `config`", - ), - ( - "config-trade-agreement-attestation", - r#" -[metadata] -name = "rhi-test" - -[config.trade_agreement_attestation] -backend = "local_statement_hash" -"#, - "unknown field `config`", - ), + for (name, extra) in [ + ("logging", "[logging]\noutput_dir = \"/tmp/logs\"\n"), + ("state", "[subscriber.state]\npath = \"/tmp/state.json\"\n"), ] { let config_path = temp.path().join(format!("{name}.toml")); - std::fs::write(&config_path, body).expect("write config"); - - let error = load_settings_from_path_with_resolver( + std::fs::write( &config_path, - &linux_resolver(), - RadrootsPathProfile::InteractiveUser, - None, + format!("[metadata]\nname = \"rhi-test\"\n\n{extra}"), ) - .expect_err("old config root must fail"); - let message = format!("{error:#}"); - assert!(message.contains(needle), "{message}"); + .expect("config"); + assert!(load_settings_from_path(&config_path, &context()).is_err()); } } - - #[test] - fn default_subscriber_state_path_is_canonical_for_current_process() { - let path = - default_subscriber_state_path_for_process().expect("resolve current process defaults"); - assert!(path.ends_with("trade-agreement-attestation/state.json")); - } - - #[test] - fn runtime_contract_output_matches_interactive_user_contract() { - let contract = runtime_contract_with_resolver( - &linux_resolver(), - RadrootsPathProfile::InteractiveUser, - None, - ) - .expect("interactive-user contract"); - - assert_eq!(contract.active_profile, "interactive_user"); - assert_eq!(contract.path_overrides.profile_source, "caller"); - assert_eq!(contract.path_overrides.root_source, "host_defaults"); - assert_eq!(contract.path_overrides.repo_local_root, None); - assert_eq!(contract.path_overrides.repo_local_root_source, None); - assert_eq!( - contract.path_overrides.subordinate_path_override_source, - "config_artifact" - ); - assert_eq!( - contract.path_overrides.subordinate_path_override_keys, - vec![ - "logging.output_dir".to_owned(), - "subscriber.state.path".to_owned(), - ] - ); - assert_eq!( - contract.allowed_profiles, - vec![ - "interactive_user".to_owned(), - "service_host".to_owned(), - "repo_local".to_owned(), - ] - ); - assert_eq!(contract.default_shared_secret_backend, "encrypted_file"); - assert_eq!( - contract.allowed_shared_secret_backends, - vec!["encrypted_file".to_owned()] - ); - assert_eq!( - contract.canonical_config_path, - PathBuf::from("/home/treesap/.radroots/config/workers/rhi/config.toml") - ); - assert_eq!( - contract.canonical_logs_dir, - PathBuf::from("/home/treesap/.radroots/logs/workers/rhi") - ); - assert_eq!( - contract.canonical_identity_path, - PathBuf::from("/home/treesap/.radroots/secrets/workers/rhi/identity.secret.json") - ); - assert_eq!( - contract.canonical_subscriber_state_path, - PathBuf::from( - "/home/treesap/.radroots/data/workers/rhi/trade-agreement-attestation/state.json" - ) - ); - } - - #[test] - fn runtime_contract_output_matches_service_host_contract() { - let resolver = - RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); - let contract = - runtime_contract_with_resolver(&resolver, RadrootsPathProfile::ServiceHost, None) - .expect("service-host contract"); - - assert_eq!(contract.active_profile, "service_host"); - assert_eq!( - contract.canonical_config_path, - PathBuf::from("/etc/radroots/workers/rhi/config.toml") - ); - assert_eq!( - contract.canonical_logs_dir, - PathBuf::from("/var/log/radroots/workers/rhi") - ); - assert_eq!( - contract.canonical_identity_path, - PathBuf::from("/etc/radroots/secrets/workers/rhi/identity.secret.json") - ); - assert_eq!( - contract.canonical_subscriber_state_path, - PathBuf::from("/var/lib/radroots/workers/rhi/trade-agreement-attestation/state.json") - ); - } } diff --git a/src/features/trade_agreement_attestation.rs b/src/features/trade_agreement_attestation.rs @@ -245,8 +245,9 @@ pub enum TradeAgreementAttestationRuntimeError { impl Default for TradeAgreementAttestationRuntimeConfig { fn default() -> Self { Self { - state_path: crate::paths::default_subscriber_state_path_for_process() - .expect("resolve canonical rhi agreement-attestation state path"), + // In-memory runtimes never construct persistence from this value. + // Persistent runtimes must receive their context-derived path. + state_path: PathBuf::new(), replay_window_secs: 24 * 60 * 60, replay_overlap_secs: 5 * 60, } @@ -774,25 +775,6 @@ pub struct TradeAgreementAttestationSmokeResponse { pub error: Option<String>, } -pub async fn run_smoke_cli_command(command: crate::cli::Command) -> anyhow::Result<()> { - let crate::cli::Command::AttestationSmoke { input, output } = command; - let request_bytes = read_input(input.as_deref())?; - let response = handle_smoke_request_bytes(&request_bytes).await; - let response_bytes = serde_json::to_vec_pretty(&response)?; - write_output(output.as_deref(), &response_bytes)?; - if response.ok { - Ok(()) - } else { - Err(anyhow!( - "{}", - response - .error - .as_deref() - .unwrap_or("attestation smoke request failed") - )) - } -} - pub async fn handle_smoke_request_bytes(bytes: &[u8]) -> TradeAgreementAttestationSmokeResponse { match serde_json::from_slice::<TradeAgreementAttestationSmokeRequest>(bytes) { Ok(request) if request.protocol_id == RHI_AGREEMENT_ATTESTATION_PROTOCOL_ID => { @@ -831,25 +813,6 @@ pub async fn handle_smoke_request_bytes(bytes: &[u8]) -> TradeAgreementAttestati } } -fn read_input(path: Option<&Path>) -> anyhow::Result<Vec<u8>> { - match path { - Some(path) => std::fs::read(path).map_err(anyhow::Error::from), - None => std::io::read_to_string(std::io::stdin()) - .map(|value| value.into_bytes()) - .map_err(anyhow::Error::from), - } -} - -fn write_output(path: Option<&Path>, bytes: &[u8]) -> anyhow::Result<()> { - match path { - Some(path) => std::fs::write(path, bytes).map_err(anyhow::Error::from), - None => { - println!("{}", String::from_utf8_lossy(bytes)); - Ok(()) - } - } -} - #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod tests { diff --git a/src/host_paths/error.rs b/src/host_paths/error.rs @@ -1,35 +0,0 @@ -use thiserror::Error; - -use std::path::PathBuf; - -use super::{RadrootsPathProfile, RadrootsPlatform}; - -#[derive(Debug, Error, Clone, PartialEq, Eq)] -pub enum RadrootsRuntimePathsError { - #[error("interactive_user on {platform} requires a home directory")] - MissingHomeDir { platform: RadrootsPlatform }, - - #[error("interactive_user on windows requires APPDATA and LOCALAPPDATA roots")] - MissingWindowsUserDirs, - - #[error("service_host on windows requires a ProgramData root")] - MissingWindowsProgramDataDir, - - #[error("repo_local requires an explicit repo-local base root")] - MissingRepoLocalRoot, - - #[error("mobile_native requires explicit logical roots")] - MissingMobileRoots, - - #[error("{profile} is not supported on {platform}")] - UnsupportedProfilePlatform { - profile: RadrootsPathProfile, - platform: RadrootsPlatform, - }, - - #[error("runtime namespace `{value}` must be one non-empty path component")] - InvalidNamespaceComponent { value: String }, - - #[error("shared accounts data root `{path:?}` has no parent shared data root")] - SharedAccountsDataRootMissingParent { path: PathBuf }, -} diff --git a/src/host_paths/mod.rs b/src/host_paths/mod.rs @@ -1,11 +0,0 @@ -//! RHI-owned host path policy. - -mod error; -mod namespace; -mod platform; -mod roots; - -pub use error::RadrootsRuntimePathsError; -pub use namespace::{RadrootsRuntimeNamespace, RadrootsRuntimeNamespaceKind}; -pub use platform::{RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPlatform}; -pub use roots::{RadrootsPathOverrides, RadrootsPathResolver, RadrootsPaths}; diff --git a/src/host_paths/namespace.rs b/src/host_paths/namespace.rs @@ -1,148 +0,0 @@ -use std::path::PathBuf; - -use super::RadrootsRuntimePathsError; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RadrootsRuntimeNamespaceKind { - App, - Service, - Worker, - Shared, -} - -impl RadrootsRuntimeNamespaceKind { - #[must_use] - pub fn path_segment(self) -> &'static str { - match self { - Self::App => "apps", - Self::Service => "services", - Self::Worker => "workers", - Self::Shared => "shared", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RadrootsRuntimeNamespace { - kind: RadrootsRuntimeNamespaceKind, - value: String, -} - -impl RadrootsRuntimeNamespace { - pub fn app(value: impl Into<String>) -> Result<Self, RadrootsRuntimePathsError> { - Self::new(RadrootsRuntimeNamespaceKind::App, value) - } - - pub fn service(value: impl Into<String>) -> Result<Self, RadrootsRuntimePathsError> { - Self::new(RadrootsRuntimeNamespaceKind::Service, value) - } - - pub fn worker(value: impl Into<String>) -> Result<Self, RadrootsRuntimePathsError> { - Self::new(RadrootsRuntimeNamespaceKind::Worker, value) - } - - pub fn shared(value: impl Into<String>) -> Result<Self, RadrootsRuntimePathsError> { - Self::new(RadrootsRuntimeNamespaceKind::Shared, value) - } - - pub fn new( - kind: RadrootsRuntimeNamespaceKind, - value: impl Into<String>, - ) -> Result<Self, RadrootsRuntimePathsError> { - let value = value.into(); - validate_component(&value)?; - Ok(Self { kind, value }) - } - - #[must_use] - pub fn kind(&self) -> RadrootsRuntimeNamespaceKind { - self.kind - } - - #[must_use] - pub fn value(&self) -> &str { - self.value.as_str() - } - - #[must_use] - pub fn relative_path(&self) -> PathBuf { - PathBuf::from(self.kind.path_segment()).join(self.value.as_str()) - } -} - -fn validate_component(value: &str) -> Result<(), RadrootsRuntimePathsError> { - let trimmed = value.trim(); - if trimmed.is_empty() - || trimmed == "." - || trimmed == ".." - || trimmed.contains('/') - || trimmed.contains('\\') - { - return Err(RadrootsRuntimePathsError::InvalidNamespaceComponent { - value: value.to_owned(), - }); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use super::RadrootsRuntimePathsError; - use super::{RadrootsRuntimeNamespace, RadrootsRuntimeNamespaceKind}; - - #[test] - fn namespace_kind_path_segments_are_canonical() { - assert_eq!(RadrootsRuntimeNamespaceKind::App.path_segment(), "apps"); - assert_eq!( - RadrootsRuntimeNamespaceKind::Service.path_segment(), - "services" - ); - assert_eq!( - RadrootsRuntimeNamespaceKind::Worker.path_segment(), - "workers" - ); - assert_eq!( - RadrootsRuntimeNamespaceKind::Shared.path_segment(), - "shared" - ); - } - - #[test] - fn namespace_constructors_preserve_kind_and_value() { - let app = RadrootsRuntimeNamespace::app("cli").expect("app namespace"); - assert_eq!(app.kind(), RadrootsRuntimeNamespaceKind::App); - assert_eq!(app.value(), "cli"); - assert_eq!(app.relative_path(), PathBuf::from("apps/cli")); - - let service = RadrootsRuntimeNamespace::service("myc").expect("service namespace"); - assert_eq!(service.kind(), RadrootsRuntimeNamespaceKind::Service); - assert_eq!(service.value(), "myc"); - assert_eq!(service.relative_path(), PathBuf::from("services/myc")); - - let worker = RadrootsRuntimeNamespace::worker("rhi").expect("worker namespace"); - assert_eq!(worker.kind(), RadrootsRuntimeNamespaceKind::Worker); - assert_eq!(worker.value(), "rhi"); - assert_eq!(worker.relative_path(), PathBuf::from("workers/rhi")); - - let shared = RadrootsRuntimeNamespace::shared("runtime").expect("shared namespace"); - assert_eq!(shared.kind(), RadrootsRuntimeNamespaceKind::Shared); - assert_eq!(shared.value(), "runtime"); - assert_eq!(shared.relative_path(), PathBuf::from("shared/runtime")); - } - - #[test] - fn namespace_validation_rejects_invalid_components() { - for invalid in ["", " ", ".", "..", "a/b", r"a\b"] { - let err = RadrootsRuntimeNamespace::new(RadrootsRuntimeNamespaceKind::App, invalid) - .expect_err("invalid namespace component should fail"); - assert_eq!( - err, - RadrootsRuntimePathsError::InvalidNamespaceComponent { - value: invalid.to_owned(), - } - ); - } - } -} diff --git a/src/host_paths/platform.rs b/src/host_paths/platform.rs @@ -1,179 +0,0 @@ -use std::fmt; -use std::path::PathBuf; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RadrootsPlatform { - Linux, - Macos, - Windows, - Android, - Ios, -} - -impl RadrootsPlatform { - #[must_use] - #[cfg(target_os = "android")] - pub fn current() -> Self { - Self::Android - } - - #[must_use] - #[cfg(target_os = "ios")] - pub fn current() -> Self { - Self::Ios - } - - #[must_use] - #[cfg(target_os = "macos")] - pub fn current() -> Self { - Self::Macos - } - - #[must_use] - #[cfg(target_os = "windows")] - pub fn current() -> Self { - Self::Windows - } - - #[must_use] - #[cfg(all( - not(target_os = "android"), - not(target_os = "ios"), - not(target_os = "macos"), - not(target_os = "windows") - ))] - pub fn current() -> Self { - Self::Linux - } - - #[must_use] - pub fn is_unix_like(self) -> bool { - matches!(self, Self::Linux | Self::Macos) - } -} - -impl fmt::Display for RadrootsPlatform { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(match self { - Self::Linux => "linux", - Self::Macos => "macos", - Self::Windows => "windows", - Self::Android => "android", - Self::Ios => "ios", - }) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RadrootsPathProfile { - InteractiveUser, - ServiceHost, - RepoLocal, - MobileNative, -} - -impl fmt::Display for RadrootsPathProfile { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(match self { - Self::InteractiveUser => "interactive_user", - Self::ServiceHost => "service_host", - Self::RepoLocal => "repo_local", - Self::MobileNative => "mobile_native", - }) - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct RadrootsHostEnvironment { - pub home_dir: Option<PathBuf>, - pub appdata_dir: Option<PathBuf>, - pub localappdata_dir: Option<PathBuf>, - pub programdata_dir: Option<PathBuf>, -} - -impl RadrootsHostEnvironment { - #[must_use] - pub fn from_current_process() -> Self { - Self { - home_dir: std::env::var_os("HOME").map(PathBuf::from), - appdata_dir: std::env::var_os("APPDATA").map(PathBuf::from), - localappdata_dir: std::env::var_os("LOCALAPPDATA").map(PathBuf::from), - programdata_dir: std::env::var_os("ProgramData").map(PathBuf::from), - } - } -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use super::{RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPlatform}; - - #[test] - fn current_matches_compiled_target_platform() { - #[cfg(target_os = "android")] - let expected = RadrootsPlatform::Android; - #[cfg(target_os = "ios")] - let expected = RadrootsPlatform::Ios; - #[cfg(target_os = "macos")] - let expected = RadrootsPlatform::Macos; - #[cfg(target_os = "windows")] - let expected = RadrootsPlatform::Windows; - #[cfg(all( - not(target_os = "android"), - not(target_os = "ios"), - not(target_os = "macos"), - not(target_os = "windows") - ))] - let expected = RadrootsPlatform::Linux; - - assert_eq!(RadrootsPlatform::current(), expected); - } - - #[test] - fn unix_like_classification_is_explicit() { - assert!(RadrootsPlatform::Linux.is_unix_like()); - assert!(RadrootsPlatform::Macos.is_unix_like()); - assert!(!RadrootsPlatform::Windows.is_unix_like()); - assert!(!RadrootsPlatform::Android.is_unix_like()); - assert!(!RadrootsPlatform::Ios.is_unix_like()); - } - - #[test] - fn display_uses_canonical_labels() { - assert_eq!(RadrootsPlatform::Linux.to_string(), "linux"); - assert_eq!(RadrootsPlatform::Macos.to_string(), "macos"); - assert_eq!(RadrootsPlatform::Windows.to_string(), "windows"); - assert_eq!(RadrootsPlatform::Android.to_string(), "android"); - assert_eq!(RadrootsPlatform::Ios.to_string(), "ios"); - - assert_eq!( - RadrootsPathProfile::InteractiveUser.to_string(), - "interactive_user" - ); - assert_eq!(RadrootsPathProfile::ServiceHost.to_string(), "service_host"); - assert_eq!(RadrootsPathProfile::RepoLocal.to_string(), "repo_local"); - assert_eq!( - RadrootsPathProfile::MobileNative.to_string(), - "mobile_native" - ); - } - - #[test] - fn host_environment_reads_current_process_variables() { - let env = RadrootsHostEnvironment::from_current_process(); - assert_eq!(env.home_dir, std::env::var_os("HOME").map(PathBuf::from)); - assert_eq!( - env.appdata_dir, - std::env::var_os("APPDATA").map(PathBuf::from) - ); - assert_eq!( - env.localappdata_dir, - std::env::var_os("LOCALAPPDATA").map(PathBuf::from) - ); - assert_eq!( - env.programdata_dir, - std::env::var_os("ProgramData").map(PathBuf::from) - ); - } -} diff --git a/src/host_paths/roots.rs b/src/host_paths/roots.rs @@ -1,315 +0,0 @@ -use std::path::{Path, PathBuf}; - -use super::{ - RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPlatform, RadrootsRuntimeNamespace, - RadrootsRuntimePathsError, -}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RadrootsPaths { - pub config: PathBuf, - pub data: PathBuf, - pub cache: PathBuf, - pub logs: PathBuf, - pub run: PathBuf, - pub secrets: PathBuf, -} - -impl RadrootsPaths { - #[must_use] - pub fn from_base_root(base_root: impl AsRef<Path>) -> Self { - let base_root = base_root.as_ref(); - Self { - config: base_root.join("config"), - data: base_root.join("data"), - cache: base_root.join("cache"), - logs: base_root.join("logs"), - run: base_root.join("run"), - secrets: base_root.join("secrets"), - } - } - - #[must_use] - pub fn namespaced(&self, namespace: &RadrootsRuntimeNamespace) -> Self { - let relative = namespace.relative_path(); - Self { - config: self.config.join(&relative), - data: self.data.join(&relative), - cache: self.cache.join(&relative), - logs: self.logs.join(&relative), - run: self.run.join(&relative), - secrets: self.secrets.join(relative), - } - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct RadrootsPathOverrides { - pub repo_local_root: Option<PathBuf>, - pub mobile_roots: Option<RadrootsPaths>, -} - -impl RadrootsPathOverrides { - #[must_use] - pub fn repo_local(base_root: impl Into<PathBuf>) -> Self { - Self { - repo_local_root: Some(base_root.into()), - mobile_roots: None, - } - } - - #[must_use] - pub fn mobile(roots: RadrootsPaths) -> Self { - Self { - repo_local_root: None, - mobile_roots: Some(roots), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RadrootsPathResolver { - platform: RadrootsPlatform, - host_environment: RadrootsHostEnvironment, -} - -impl RadrootsPathResolver { - #[must_use] - pub fn new(platform: RadrootsPlatform, host_environment: RadrootsHostEnvironment) -> Self { - Self { - platform, - host_environment, - } - } - - #[must_use] - pub fn current() -> Self { - Self::new( - RadrootsPlatform::current(), - RadrootsHostEnvironment::from_current_process(), - ) - } - - #[must_use] - pub fn platform(&self) -> RadrootsPlatform { - self.platform - } - - pub fn resolve( - &self, - profile: RadrootsPathProfile, - overrides: &RadrootsPathOverrides, - ) -> Result<RadrootsPaths, RadrootsRuntimePathsError> { - match profile { - RadrootsPathProfile::InteractiveUser => self.resolve_interactive_user(), - RadrootsPathProfile::ServiceHost => self.resolve_service_host(), - RadrootsPathProfile::RepoLocal => overrides - .repo_local_root - .as_ref() - .map(RadrootsPaths::from_base_root) - .ok_or(RadrootsRuntimePathsError::MissingRepoLocalRoot), - RadrootsPathProfile::MobileNative => match self.platform { - RadrootsPlatform::Android | RadrootsPlatform::Ios => overrides - .mobile_roots - .clone() - .ok_or(RadrootsRuntimePathsError::MissingMobileRoots), - _ => Err(RadrootsRuntimePathsError::UnsupportedProfilePlatform { - profile, - platform: self.platform, - }), - }, - } - } - - fn resolve_interactive_user(&self) -> Result<RadrootsPaths, RadrootsRuntimePathsError> { - match self.platform { - RadrootsPlatform::Linux | RadrootsPlatform::Macos => self - .host_environment - .home_dir - .as_ref() - .map(|home| RadrootsPaths::from_base_root(home.join(".radroots"))) - .ok_or(RadrootsRuntimePathsError::MissingHomeDir { - platform: self.platform, - }), - RadrootsPlatform::Windows => { - let appdata = self - .host_environment - .appdata_dir - .as_ref() - .ok_or(RadrootsRuntimePathsError::MissingWindowsUserDirs)?; - let localappdata = self - .host_environment - .localappdata_dir - .as_ref() - .ok_or(RadrootsRuntimePathsError::MissingWindowsUserDirs)?; - let config_root = appdata.join("Radroots"); - let local_root = localappdata.join("Radroots"); - Ok(RadrootsPaths { - config: config_root.join("config"), - data: local_root.join("data"), - cache: local_root.join("cache"), - logs: local_root.join("logs"), - run: local_root.join("run"), - secrets: config_root.join("secrets"), - }) - } - RadrootsPlatform::Android | RadrootsPlatform::Ios => { - Err(RadrootsRuntimePathsError::UnsupportedProfilePlatform { - profile: RadrootsPathProfile::InteractiveUser, - platform: self.platform, - }) - } - } - } - - fn resolve_service_host(&self) -> Result<RadrootsPaths, RadrootsRuntimePathsError> { - match self.platform { - RadrootsPlatform::Windows => { - let programdata = self - .host_environment - .programdata_dir - .as_ref() - .ok_or(RadrootsRuntimePathsError::MissingWindowsProgramDataDir)?; - let base = programdata.join("Radroots"); - Ok(RadrootsPaths { - config: base.join("config"), - data: base.join("data"), - cache: base.join("cache"), - logs: base.join("logs"), - run: base.join("run"), - secrets: base.join("secrets"), - }) - } - RadrootsPlatform::Linux | RadrootsPlatform::Macos => Ok(RadrootsPaths { - config: PathBuf::from("/etc/radroots"), - data: PathBuf::from("/var/lib/radroots"), - cache: PathBuf::from("/var/cache/radroots"), - logs: PathBuf::from("/var/log/radroots"), - run: PathBuf::from("/run/radroots"), - secrets: PathBuf::from("/etc/radroots/secrets"), - }), - RadrootsPlatform::Android | RadrootsPlatform::Ios => { - Err(RadrootsRuntimePathsError::UnsupportedProfilePlatform { - profile: RadrootsPathProfile::ServiceHost, - platform: self.platform, - }) - } - } - } -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use super::{ - RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPlatform, RadrootsRuntimePathsError, - }; - use super::{RadrootsPathOverrides, RadrootsPathResolver, RadrootsPaths}; - - #[test] - fn path_override_helpers_only_populate_their_owned_slot() { - let repo_local = RadrootsPathOverrides::repo_local("/repo/.local/radroots"); - assert_eq!( - repo_local.repo_local_root, - Some(PathBuf::from("/repo/.local/radroots")) - ); - assert!(repo_local.mobile_roots.is_none()); - - let mobile_roots = RadrootsPaths::from_base_root("/sandbox"); - let mobile = RadrootsPathOverrides::mobile(mobile_roots.clone()); - assert!(mobile.repo_local_root.is_none()); - assert_eq!(mobile.mobile_roots, Some(mobile_roots)); - } - - #[test] - fn resolver_current_uses_process_platform_and_environment() { - let resolver = RadrootsPathResolver::current(); - assert_eq!(resolver.platform(), RadrootsPlatform::current()); - assert_eq!( - resolver, - RadrootsPathResolver::new( - RadrootsPlatform::current(), - RadrootsHostEnvironment::from_current_process() - ) - ); - } - - #[test] - fn mobile_profile_is_rejected_on_non_mobile_platforms() { - let resolver = - RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); - - let err = resolver - .resolve( - RadrootsPathProfile::MobileNative, - &RadrootsPathOverrides::default(), - ) - .expect_err("mobile profile should be rejected on linux"); - - assert_eq!( - err, - RadrootsRuntimePathsError::UnsupportedProfilePlatform { - profile: RadrootsPathProfile::MobileNative, - platform: RadrootsPlatform::Linux, - } - ); - } - - #[test] - fn interactive_user_is_rejected_on_mobile_platforms() { - for platform in [RadrootsPlatform::Android, RadrootsPlatform::Ios] { - let resolver = RadrootsPathResolver::new(platform, RadrootsHostEnvironment::default()); - let err = resolver - .resolve( - RadrootsPathProfile::InteractiveUser, - &RadrootsPathOverrides::default(), - ) - .expect_err("interactive_user should be unsupported on mobile"); - assert_eq!( - err, - RadrootsRuntimePathsError::UnsupportedProfilePlatform { - profile: RadrootsPathProfile::InteractiveUser, - platform, - } - ); - } - } - - #[test] - fn service_host_windows_requires_programdata() { - let resolver = RadrootsPathResolver::new( - RadrootsPlatform::Windows, - RadrootsHostEnvironment::default(), - ); - - let err = resolver - .resolve( - RadrootsPathProfile::ServiceHost, - &RadrootsPathOverrides::default(), - ) - .expect_err("service_host on windows should require programdata"); - - assert_eq!(err, RadrootsRuntimePathsError::MissingWindowsProgramDataDir); - } - - #[test] - fn service_host_is_rejected_on_mobile_platforms() { - for platform in [RadrootsPlatform::Android, RadrootsPlatform::Ios] { - let resolver = RadrootsPathResolver::new(platform, RadrootsHostEnvironment::default()); - let err = resolver - .resolve( - RadrootsPathProfile::ServiceHost, - &RadrootsPathOverrides::default(), - ) - .expect_err("service_host should be unsupported on mobile"); - assert_eq!( - err, - RadrootsRuntimePathsError::UnsupportedProfilePlatform { - profile: RadrootsPathProfile::ServiceHost, - platform, - } - ); - } - } -} diff --git a/src/host_runtime.rs b/src/host_runtime.rs @@ -2,22 +2,10 @@ use core::future::Future; use core::time::Duration; -use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; -use clap::{ArgAction, Args, ValueHint}; use serde::{Deserialize, Serialize}; -#[derive(Args, Debug, Clone)] -pub struct ServiceCliArgs { - #[arg(long, value_name = "PATH", value_hint = ValueHint::FilePath)] - pub config: Option<PathBuf>, - #[arg(long, value_name = "PATH", value_hint = ValueHint::FilePath)] - pub identity: Option<PathBuf>, - #[arg(long, action = ArgAction::SetTrue)] - pub allow_generate_identity: bool, -} - #[derive(Debug, Serialize, Deserialize, Clone)] pub struct NostrServiceConfig { pub logs_dir: String, diff --git a/src/identity_storage.rs b/src/identity_storage.rs @@ -35,23 +35,12 @@ pub fn encrypted_identity_key_path(path: impl AsRef<Path>) -> PathBuf { encrypted_identity_wrapping_key_path(path) } -pub fn load_service_identity( - path: Option<&Path>, - allow_generate: bool, -) -> Result<RadrootsIdentity, IdentityError> { - let path = path.map(Path::to_path_buf).unwrap_or_else(|| { - crate::paths::default_identity_path_for_process() - .expect("resolve canonical rhi identity path") - }); +pub fn load_service_identity(path: &Path) -> Result<RadrootsIdentity, IdentityError> { + let path = path.to_path_buf(); if path.exists() { return load_encrypted_identity(path); } - if !allow_generate { - return Err(IdentityError::GenerationNotAllowed(path)); - } - let identity = RadrootsIdentity::generate(); - store_encrypted_identity(path, &identity)?; - Ok(identity) + Err(IdentityError::GenerationNotAllowed(path)) } struct RhiFileKeyWrapping { diff --git a/src/lib.rs b/src/lib.rs @@ -1,25 +1,22 @@ #![cfg_attr(coverage_nightly, feature(coverage_attribute))] pub mod adapters; -pub mod cli; mod cli_v1; pub mod config; mod config_v1; pub mod features; pub mod host_identity; pub mod host_nostr; -pub mod host_paths; pub mod host_runtime; pub mod identity_storage; -pub mod paths; pub mod rhi; +mod runtime_context; -pub use cli::Args as cli_args; pub use cli_v1::{ - RHI_INSTANCE_ID_MAX_BYTES, RhiBootstrapProfileV1, RhiCliInvocationV1, RhiCliOutputModeV1, - RhiCliV1Error, RhiCliV1ErrorKind, RhiCommandV1, RhiConfigCommandV1, RhiIdentityCommandV1, - RhiMetricsCommandV1, RhiPresenceCommandV1, RhiPublicationCommandV1, RhiReconciliationCommandV1, - RhiSourcesCommandV1, RhiStateCommandV1, RhiTradeCommandV1, parse_rhi_cli_v1_from, + RhiBootstrapProfileV1, RhiCliInvocationV1, RhiCliOutputModeV1, RhiCliV1Error, + RhiCliV1ErrorKind, RhiCommandV1, RhiConfigCommandV1, RhiIdentityCommandV1, RhiMetricsCommandV1, + RhiPresenceCommandV1, RhiPublicationCommandV1, RhiReconciliationCommandV1, RhiSourcesCommandV1, + RhiStateCommandV1, RhiTradeCommandV1, parse_rhi_cli_v1_from, }; pub use config_v1::{ RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES, RHI_CONFIG_EFFECTIVE_MAX_UTF8_BYTES, RHI_CONFIG_SCHEMA, @@ -27,6 +24,15 @@ pub use config_v1::{ RhiConfigV1Error, RhiConfigV1ErrorKind, RhiConfigValueSource, RhiEffectiveConfigV1, RhiRuntimeThreadLimitsV1, parse_rhi_config_v1, }; +pub use radroots_runtime_paths::{ + INSTANCE_ID_MAX_BYTES, InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, + RadrootsPathResolver, RadrootsPlatform, RadrootsServiceInstanceArtifacts, RuntimeContext, + RuntimeContextSource, ServiceId, +}; +pub use runtime_context::{ + RhiRuntimeContext, RhiRuntimeContextError, RhiRuntimeContextErrorKind, + resolve_rhi_runtime_context, +}; use anyhow::{Context, Result, anyhow, bail}; use radroots_event::{ @@ -186,11 +192,8 @@ async fn wait_for_shutdown_or_stopped(handle: crate::rhi::RhiHandle) -> RunRhiWa } } -pub async fn run_rhi(settings: &config::Settings, args: &cli_args) -> Result<()> { - let identity = load_service_identity( - args.service.identity.as_deref(), - args.service.allow_generate_identity, - )?; +pub async fn run_rhi(settings: &config::Settings, context: &RhiRuntimeContext) -> Result<()> { + let identity = load_service_identity(context.identity_path())?; let keys = identity.keys().clone(); let agreement_attestation_runtime = TradeAgreementAttestationRuntime::load(TradeAgreementAttestationRuntimeConfig { @@ -279,9 +282,8 @@ mod tests { bootstrap_presence, build_authored_service_profile_event, release_product_handler_kinds, run_rhi, run_rhi_bootstrap_hook, run_rhi_wait_hook, }; - use crate::{cli_args, config}; + use crate::{config, parse_rhi_cli_v1_from, resolve_rhi_runtime_context}; use radroots_event::envelope::kind::TRADE_MUTATION_EVENT_KINDS; - use std::path::PathBuf; use std::sync::atomic::Ordering; use tokio::sync::{Mutex, MutexGuard}; @@ -300,7 +302,10 @@ mod tests { guard } - fn settings_with_relays(relays: Vec<String>) -> config::Settings { + fn settings_with_relays( + relays: Vec<String>, + context: &crate::RhiRuntimeContext, + ) -> config::Settings { config::Settings { metadata: serde_json::from_str(r#"{"name":"rhi-test"}"#).expect("metadata"), config: config::Configuration { @@ -326,8 +331,13 @@ mod tests { jitter_ms: 0, }, state: config::SubscriberStateConfig { - path: unique_state_path("settings"), - ..Default::default() + path: context + .context() + .paths() + .state() + .join("trade-agreement-attestation/state.json"), + replay_window_secs: 24 * 60 * 60, + replay_overlap_secs: 5 * 60, }, }, trade_agreement_attestation: @@ -336,43 +346,46 @@ mod tests { } } - fn args_for_identity(path: PathBuf) -> cli_args { - cli_args { - command: None, - service: crate::host_runtime::ServiceCliArgs { - config: Some(PathBuf::from("config.toml")), - identity: Some(path), - allow_generate_identity: true, - }, - } - } - - fn unique_identity_path(suffix: &str) -> PathBuf { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("time") - .as_nanos(); - std::env::temp_dir().join(format!("rhi-{suffix}-{nanos}.secret.json")) + fn context_for_root(root: &std::path::Path) -> crate::RhiRuntimeContext { + let root = root.to_str().expect("UTF-8 temp root"); + let invocation = parse_rhi_cli_v1_from([ + "rhi", + "--profile", + "repo-local", + "--instance", + "default", + "--repo-local-root", + root, + "run", + ]) + .expect("invocation"); + resolve_rhi_runtime_context( + &crate::RadrootsPathResolver::new( + crate::RadrootsPlatform::Linux, + crate::RadrootsHostEnvironment::default(), + ), + &invocation, + ) + .expect("context") } - fn unique_state_path(suffix: &str) -> PathBuf { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("time") - .as_nanos(); - std::env::temp_dir() - .join(format!("rhi-state-{suffix}-{nanos}")) - .join("state.json") + fn provision_identity(context: &crate::RhiRuntimeContext) { + crate::identity_storage::store_encrypted_identity( + context.identity_path(), + &crate::host_identity::RadrootsIdentity::generate(), + ) + .expect("identity"); } #[tokio::test] async fn run_rhi_starts_and_stops_without_relays() { let _guard = test_guard().await; RUN_RHI_AUTO_STOP.store(true, Ordering::Relaxed); - let identity_path = unique_identity_path("no-relays"); - let args = args_for_identity(identity_path); - let settings = settings_with_relays(Vec::new()); - run_rhi(&settings, &args).await.expect("run rhi"); + let temp = tempfile::tempdir().expect("tempdir"); + let context = context_for_root(temp.path()); + provision_identity(&context); + let settings = settings_with_relays(Vec::new(), &context); + run_rhi(&settings, &context).await.expect("run rhi"); } #[tokio::test] @@ -382,10 +395,11 @@ mod tests { *run_rhi_bootstrap_hook() .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(())); - let identity_path = unique_identity_path("relays"); - let args = args_for_identity(identity_path); - let settings = settings_with_relays(vec!["wss://relay.example.com".to_string()]); - run_rhi(&settings, &args).await.expect("run rhi"); + let temp = tempfile::tempdir().expect("tempdir"); + let context = context_for_root(temp.path()); + provision_identity(&context); + let settings = settings_with_relays(vec!["wss://relay.example.com".to_string()], &context); + run_rhi(&settings, &context).await.expect("run rhi"); assert_eq!(release_product_handler_kinds(), TRADE_MUTATION_EVENT_KINDS); } @@ -395,10 +409,11 @@ mod tests { *run_rhi_wait_hook() .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(RunRhiWaitOutcome::Stopped); - let identity_path = unique_identity_path("wait-hook"); - let args = args_for_identity(identity_path); - let settings = settings_with_relays(Vec::new()); - run_rhi(&settings, &args).await.expect("run rhi"); + let temp = tempfile::tempdir().expect("tempdir"); + let context = context_for_root(temp.path()); + provision_identity(&context); + let settings = settings_with_relays(Vec::new(), &context); + run_rhi(&settings, &context).await.expect("run rhi"); } #[tokio::test] diff --git a/src/main.rs b/src/main.rs @@ -1,104 +1,57 @@ #![cfg_attr(coverage_nightly, feature(coverage_attribute))] -#[cfg(not(test))] -use anyhow::Context; -use anyhow::Result; -#[cfg(not(test))] -use clap::Parser; -#[cfg(not(test))] -use rhi::cli::Command; -#[cfg(not(test))] -use rhi::features::trade_agreement_attestation::run_smoke_cli_command; -use rhi::{cli_args, config, paths, run_rhi}; use std::path::PathBuf; use std::process::ExitCode; -use tracing::info; -#[cfg(not(test))] -#[tokio::main] -async fn main() -> ExitCode { - exit_code_from_run(run().await) -} +use anyhow::{Context, Result, bail}; +use rhi::{ + RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, RhiCommandV1, + RhiRuntimeContext, parse_rhi_cli_v1_from, resolve_rhi_runtime_context, run_rhi, +}; -#[cfg(test)] fn main() -> ExitCode { - exit_code_from_run(Ok(())) + let invocation = match parse_rhi_cli_v1_from(std::env::args_os()) { + Ok(invocation) => invocation, + Err(_) => return ExitCode::FAILURE, + }; + let runtime = match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(_) => return ExitCode::FAILURE, + }; + exit_code_from_run(runtime.block_on(execute(invocation))) } fn exit_code_from_run(result: Result<()>) -> ExitCode { match result { Ok(()) => ExitCode::SUCCESS, - Err(err) => { - tracing::error!(error = ?err, "Fatal error"); - eprintln!("Fatal error: {err:#}"); + Err(_) => { + eprintln!("RHI command failed"); ExitCode::FAILURE } } } -#[cfg(test)] -type RunLoadHookValue = Option<Result<(cli_args, config::Settings)>>; -#[cfg(test)] -type RunLoadHook = std::sync::Mutex<RunLoadHookValue>; -#[cfg(test)] -static RUN_LOAD_HOOK: std::sync::OnceLock<RunLoadHook> = std::sync::OnceLock::new(); - -#[cfg(test)] -fn run_load_hook() -> &'static RunLoadHook { - RUN_LOAD_HOOK.get_or_init(|| std::sync::Mutex::new(None)) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct RhiRuntimeStartupReport { - active_profile: String, - config_path: PathBuf, - config_path_source: String, - canonical_config_path: PathBuf, - logs_dir: PathBuf, - logs_dir_source: String, - canonical_logs_dir: PathBuf, - identity_path: PathBuf, - identity_path_source: String, - canonical_identity_path: PathBuf, - subscriber_state_path: PathBuf, - subscriber_state_path_source: String, - canonical_subscriber_state_path: PathBuf, - path_overrides: paths::RhiRuntimePathOverrideContractOutput, - default_shared_secret_backend: String, - allowed_shared_secret_backends: Vec<String>, -} - -fn load_args_and_settings() -> Result<(cli_args, config::Settings)> { - #[cfg(test)] - { - if let Some(result) = run_load_hook() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take() - { - return result; - } - Err(anyhow::anyhow!("run loader hook not set")) +async fn execute(invocation: rhi::RhiCliInvocationV1) -> Result<()> { + let resolver = RadrootsPathResolver::new(RadrootsPlatform::current(), host_environment()); + let context = resolve_rhi_runtime_context(&resolver, &invocation) + .map_err(|_| anyhow::anyhow!("RHI runtime context is invalid"))?; + match invocation.command() { + RhiCommandV1::Run => execute_run(&context).await, + _ => bail!("RHI command execution is not available in this checkpoint"), } +} - #[cfg(not(test))] - { - let args = cli_args::try_parse()?; - let config_path = args - .service - .config - .clone() - .map(Ok) - .unwrap_or_else(paths::default_config_path_for_process)?; - let settings = - config::load_settings_from_path(&config_path).context("load configuration")?; - init_rhi_logging(&settings)?; - Ok((args, settings)) - } +async fn execute_run(context: &RhiRuntimeContext) -> Result<()> { + let settings = rhi::config::load_settings_from_path(context.selected_config_path(), context) + .context("load RHI configuration")?; + init_rhi_logging(&settings)?; + run_rhi(&settings, context).await } -#[cfg(not(test))] -fn init_rhi_logging(settings: &config::Settings) -> Result<()> { +fn init_rhi_logging(settings: &rhi::config::Settings) -> Result<()> { use tracing_subscriber::fmt::writer::MakeWriterExt as _; std::fs::create_dir_all(&settings.config.logging.output_dir) @@ -113,391 +66,48 @@ fn init_rhi_logging(settings: &config::Settings) -> Result<()> { .with_env_filter(filter) .with_writer(writer.and(std::io::stdout)) .try_init() - .map_err(|error| anyhow::anyhow!("initialize RHI logging: {error}"))?; + .map_err(|_| anyhow::anyhow!("initialize RHI logging"))?; } else { tracing_subscriber::fmt() .with_env_filter(filter) .with_writer(writer) .try_init() - .map_err(|error| anyhow::anyhow!("initialize RHI logging: {error}"))?; + .map_err(|_| anyhow::anyhow!("initialize RHI logging"))?; } LOG_GUARD .set(guard) .map_err(|_| anyhow::anyhow!("RHI logging is already initialized")) } -fn runtime_startup_report( - args: &cli_args, - settings: &config::Settings, - contract: &paths::RhiRuntimeContractOutput, -) -> RhiRuntimeStartupReport { - RhiRuntimeStartupReport { - active_profile: contract.active_profile.clone(), - config_path: args - .service - .config - .clone() - .unwrap_or_else(|| contract.canonical_config_path.clone()), - config_path_source: cli_or_profile_path_source( - args.service.config.is_some(), - &args - .service - .config - .clone() - .unwrap_or_else(|| contract.canonical_config_path.clone()), - &contract.canonical_config_path, - ), - canonical_config_path: contract.canonical_config_path.clone(), - logs_dir: settings.config.logging.output_dir.clone(), - logs_dir_source: config_or_profile_path_source( - &settings.config.logging.output_dir, - &contract.canonical_logs_dir, - ), - canonical_logs_dir: contract.canonical_logs_dir.clone(), - identity_path: args - .service - .identity - .clone() - .unwrap_or_else(|| contract.canonical_identity_path.clone()), - identity_path_source: cli_or_profile_path_source( - args.service.identity.is_some(), - &args - .service - .identity - .clone() - .unwrap_or_else(|| contract.canonical_identity_path.clone()), - &contract.canonical_identity_path, - ), - canonical_identity_path: contract.canonical_identity_path.clone(), - subscriber_state_path: settings.config.subscriber.state.path.clone(), - subscriber_state_path_source: config_or_profile_path_source( - &settings.config.subscriber.state.path, - &contract.canonical_subscriber_state_path, - ), - canonical_subscriber_state_path: contract.canonical_subscriber_state_path.clone(), - path_overrides: contract.path_overrides.clone(), - default_shared_secret_backend: contract.default_shared_secret_backend.clone(), - allowed_shared_secret_backends: contract.allowed_shared_secret_backends.clone(), - } -} - -fn cli_or_profile_path_source( - is_cli_arg: bool, - actual_path: &PathBuf, - canonical_path: &PathBuf, -) -> String { - if is_cli_arg { - "cli_arg".to_owned() - } else { - config_or_profile_path_source(actual_path, canonical_path) - } -} - -fn config_or_profile_path_source(actual_path: &PathBuf, canonical_path: &PathBuf) -> String { - if actual_path == canonical_path { - "profile_default".to_owned() - } else { - "config_artifact".to_owned() - } -} - -#[cfg(not(test))] -fn log_runtime_startup_report(report: &RhiRuntimeStartupReport) { - info!( - active_profile = report.active_profile.as_str(), - profile_source = report.path_overrides.profile_source.as_str(), - root_source = report.path_overrides.root_source.as_str(), - repo_local_root = ?report.path_overrides.repo_local_root, - repo_local_root_source = ?report.path_overrides.repo_local_root_source, - subordinate_path_override_source = report.path_overrides.subordinate_path_override_source.as_str(), - config_path = %report.config_path.display(), - config_path_source = report.config_path_source.as_str(), - canonical_config_path = %report.canonical_config_path.display(), - logs_dir = %report.logs_dir.display(), - logs_dir_source = report.logs_dir_source.as_str(), - canonical_logs_dir = %report.canonical_logs_dir.display(), - identity_path = %report.identity_path.display(), - identity_path_source = report.identity_path_source.as_str(), - canonical_identity_path = %report.canonical_identity_path.display(), - subscriber_state_path = %report.subscriber_state_path.display(), - subscriber_state_path_source = report.subscriber_state_path_source.as_str(), - canonical_subscriber_state_path = %report.canonical_subscriber_state_path.display(), - default_shared_secret_backend = report.default_shared_secret_backend.as_str(), - allowed_shared_secret_backends = ?report.allowed_shared_secret_backends, - "rhi runtime contract" - ); -} - -async fn run() -> Result<()> { - #[cfg(not(test))] - { - let args = cli_args::try_parse()?; - if let Some(command) = args.command { - return match command { - Command::AttestationSmoke { .. } => run_smoke_cli_command(command).await, - }; - } - } - - let (args, settings): (cli_args, config::Settings) = load_args_and_settings()?; - - #[cfg(not(test))] - { - let contract = paths::runtime_contract_for_process().context("resolve runtime contract")?; - let report = runtime_startup_report(&args, &settings, &contract); - log_runtime_startup_report(&report); +fn host_environment() -> RadrootsHostEnvironment { + let path = |name| { + std::env::var_os(name) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + }; + RadrootsHostEnvironment { + home_dir: path("HOME"), + xdg_config_home: path("XDG_CONFIG_HOME"), + xdg_data_home: path("XDG_DATA_HOME"), + xdg_state_home: path("XDG_STATE_HOME"), + xdg_cache_home: path("XDG_CACHE_HOME"), + xdg_runtime_dir: path("XDG_RUNTIME_DIR"), + appdata_dir: path("APPDATA"), + localappdata_dir: path("LOCALAPPDATA"), } - - info!("Starting"); - - run_rhi(&settings, &args).await } #[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] mod tests { - use super::{ - RhiRuntimeStartupReport, exit_code_from_run, main, run, run_load_hook, run_rhi, - runtime_startup_report, - }; - use rhi::features::trade_agreement_attestation::TradeAgreementAttestationRuntime; - use rhi::host_nostr::{Client, Keys}; - use rhi::{cli_args, config, paths}; - use std::path::PathBuf; + use super::exit_code_from_run; use std::process::ExitCode; - static RUN_HOOK_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - - async fn run_hook_test_guard() -> tokio::sync::MutexGuard<'static, ()> { - RUN_HOOK_TEST_LOCK.lock().await - } - - fn minimal_settings() -> config::Settings { - config::Settings { - metadata: serde_json::from_str(r#"{"name":"rhi-test"}"#).expect("metadata"), - config: config::Configuration { - service: rhi::host_runtime::NostrServiceConfig { - logs_dir: std::env::temp_dir() - .join("rhi-test-logs") - .display() - .to_string(), - relays: Vec::new(), - nip89_identifier: Some("rhi".to_string()), - nip89_extra_tags: Vec::new(), - }, - logging: config::LoggingConfig { - output_dir: std::env::temp_dir().join("rhi-test-logs"), - filter: "info".to_string(), - stdout: true, - }, - subscriber: config::SubscriberConfig::default(), - trade_agreement_attestation: - rhi::features::trade_agreement_attestation::TradeAgreementAttestationPolicy::default(), - }, - } - } - - fn sample_runtime_contract() -> paths::RhiRuntimeContractOutput { - paths::RhiRuntimeContractOutput { - active_profile: "interactive_user".to_string(), - allowed_profiles: vec![ - "interactive_user".to_string(), - "service_host".to_string(), - "repo_local".to_string(), - ], - path_overrides: paths::RhiRuntimePathOverrideContractOutput { - profile_source: "caller".to_string(), - root_source: "host_defaults".to_string(), - repo_local_root: None, - repo_local_root_source: None, - subordinate_path_override_source: "config_artifact".to_string(), - subordinate_path_override_keys: vec![ - "logging.output_dir".to_string(), - "subscriber.state.path".to_string(), - ], - }, - default_shared_secret_backend: "encrypted_file".to_string(), - allowed_shared_secret_backends: vec!["encrypted_file".to_string()], - canonical_config_path: PathBuf::from( - "/home/treesap/.radroots/config/workers/rhi/config.toml", - ), - canonical_logs_dir: PathBuf::from("/home/treesap/.radroots/logs/workers/rhi"), - canonical_identity_path: PathBuf::from( - "/home/treesap/.radroots/secrets/workers/rhi/identity.secret.json", - ), - canonical_subscriber_state_path: PathBuf::from( - "/home/treesap/.radroots/data/workers/rhi/trade-agreement-attestation/state.json", - ), - } - } - #[test] - fn exit_code_from_run_maps_success_and_error() { + fn process_result_is_stable() { assert_eq!(exit_code_from_run(Ok(())), ExitCode::SUCCESS); assert_eq!( - exit_code_from_run(Err(anyhow::anyhow!("boom"))), + exit_code_from_run(Err(anyhow::anyhow!("secret path"))), ExitCode::FAILURE ); } - - #[tokio::test] - async fn run_rhi_returns_error_when_identity_is_missing() { - let args = cli_args { - command: None, - service: rhi::host_runtime::ServiceCliArgs { - config: Some(PathBuf::from("config.toml")), - identity: Some(PathBuf::from("/tmp/rhi-missing-identity.secret.json")), - allow_generate_identity: false, - }, - }; - let settings = minimal_settings(); - let err = run_rhi(&settings, &args) - .await - .expect_err("identity should fail"); - let msg = format!("{err:#}"); - assert!(msg.contains("identity")); - } - - #[test] - fn main_returns_success_in_test_build() { - assert_eq!(main(), ExitCode::SUCCESS); - } - - #[tokio::test] - async fn run_uses_injected_config_loader_result() { - let _guard = run_hook_test_guard().await; - let args = cli_args { - command: None, - service: rhi::host_runtime::ServiceCliArgs { - config: Some(PathBuf::from("config.toml")), - identity: Some(PathBuf::from("/tmp/rhi-run-hook-missing.secret.json")), - allow_generate_identity: false, - }, - }; - *run_load_hook() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = - Some(Ok((args, minimal_settings()))); - let err = run().await.expect_err("missing identity should bubble"); - let msg = format!("{err:#}"); - assert!(msg.contains("identity")); - } - - #[tokio::test] - async fn run_returns_error_when_loader_hook_is_absent() { - let _guard = run_hook_test_guard().await; - *run_load_hook() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = None; - let err = run() - .await - .expect_err("loader hook should be required in test build"); - let msg = format!("{err:#}"); - assert!(msg.contains("run loader hook not set")); - } - - #[tokio::test] - async fn non_test_start_subscriber_path_can_start_and_stop() { - let keys = Keys::generate(); - let client = Client::new(keys.clone()); - let handle = rhi::rhi::start_subscriber( - client, - keys, - TradeAgreementAttestationRuntime::new(), - rhi::host_runtime::BackoffConfig { - base_ms: 1, - max_ms: 2, - factor: 1, - jitter_ms: 0, - }, - ) - .await; - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - handle.stop(); - handle.stopped().await; - } - - #[test] - fn runtime_startup_report_prefers_explicit_cli_paths() { - let args = cli_args { - service: rhi::host_runtime::ServiceCliArgs { - config: Some(PathBuf::from("/tmp/rhi/config.toml")), - identity: Some(PathBuf::from("/tmp/rhi/identity.secret.json")), - allow_generate_identity: false, - }, - command: None, - }; - let mut settings = minimal_settings(); - settings.config.service.logs_dir = "/tmp/rhi/logs".to_string(); - settings.config.logging.output_dir = PathBuf::from("/tmp/rhi/logs"); - settings.config.subscriber.state.path = PathBuf::from("/tmp/rhi/state.json"); - - let contract = sample_runtime_contract(); - let report = runtime_startup_report(&args, &settings, &contract); - - assert_eq!( - report, - RhiRuntimeStartupReport { - active_profile: "interactive_user".to_string(), - config_path: PathBuf::from("/tmp/rhi/config.toml"), - config_path_source: "cli_arg".to_string(), - canonical_config_path: PathBuf::from( - "/home/treesap/.radroots/config/workers/rhi/config.toml" - ), - logs_dir: PathBuf::from("/tmp/rhi/logs"), - logs_dir_source: "config_artifact".to_string(), - canonical_logs_dir: PathBuf::from("/home/treesap/.radroots/logs/workers/rhi"), - identity_path: PathBuf::from("/tmp/rhi/identity.secret.json"), - identity_path_source: "cli_arg".to_string(), - canonical_identity_path: PathBuf::from( - "/home/treesap/.radroots/secrets/workers/rhi/identity.secret.json" - ), - subscriber_state_path: PathBuf::from("/tmp/rhi/state.json"), - subscriber_state_path_source: "config_artifact".to_string(), - canonical_subscriber_state_path: PathBuf::from( - "/home/treesap/.radroots/data/workers/rhi/trade-agreement-attestation/state.json" - ), - path_overrides: sample_runtime_contract().path_overrides, - default_shared_secret_backend: "encrypted_file".to_string(), - allowed_shared_secret_backends: vec!["encrypted_file".to_string()], - } - ); - } - - #[test] - fn runtime_startup_report_falls_back_to_canonical_contract_paths() { - let args = cli_args { - command: None, - service: rhi::host_runtime::ServiceCliArgs { - config: None, - identity: None, - allow_generate_identity: false, - }, - }; - let contract = sample_runtime_contract(); - let mut settings = minimal_settings(); - settings.config.service.logs_dir = contract.canonical_logs_dir.display().to_string(); - settings.config.logging.output_dir = contract.canonical_logs_dir.clone(); - settings.config.subscriber.state.path = contract.canonical_subscriber_state_path.clone(); - - let report = runtime_startup_report(&args, &settings, &contract); - - assert_eq!(report.config_path, contract.canonical_config_path); - assert_eq!(report.config_path_source, "profile_default"); - assert_eq!(report.logs_dir, contract.canonical_logs_dir); - assert_eq!(report.logs_dir_source, "profile_default"); - assert_eq!(report.identity_path, contract.canonical_identity_path); - assert_eq!(report.identity_path_source, "profile_default"); - assert_eq!( - report.subscriber_state_path, - contract.canonical_subscriber_state_path - ); - assert_eq!(report.subscriber_state_path_source, "profile_default"); - assert_eq!(report.path_overrides, contract.path_overrides); - assert_eq!(report.default_shared_secret_backend, "encrypted_file"); - assert_eq!( - report.allowed_shared_secret_backends, - vec!["encrypted_file".to_string()] - ); - } } diff --git a/src/paths.rs b/src/paths.rs @@ -1,227 +0,0 @@ -use std::path::{Path, PathBuf}; - -use crate::host_paths::{ - RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, RadrootsRuntimeNamespace, -}; -use anyhow::{Context, Result, bail}; -use serde::Serialize; - -const RHI_RUNTIME_ID: &str = "rhi"; -const DEFAULT_CONFIG_FILE_NAME: &str = "config.toml"; -const DEFAULT_SERVICE_IDENTITY_FILE_NAME: &str = "identity.secret.json"; -const SUBSCRIBER_STATE_DIR_NAME: &str = "trade-agreement-attestation"; -const SUBSCRIBER_STATE_FILE_NAME: &str = "state.json"; -const RHI_PATHS_PROFILE_ENV: &str = "RHI_PATHS_PROFILE"; -const RHI_PATHS_REPO_LOCAL_ROOT_ENV: &str = "RHI_PATHS_REPO_LOCAL_ROOT"; -const RHI_DEFAULT_SHARED_SECRET_BACKEND: &str = "encrypted_file"; -const RHI_ALLOWED_PROFILES: [&str; 3] = ["interactive_user", "service_host", "repo_local"]; -const RHI_ALLOWED_SHARED_SECRET_BACKENDS: [&str; 1] = ["encrypted_file"]; -const SUBORDINATE_PATH_OVERRIDE_SOURCE: &str = "config_artifact"; -const SUBORDINATE_PATH_OVERRIDE_KEYS: [&str; 2] = ["logging.output_dir", "subscriber.state.path"]; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct RhiRuntimePaths { - pub(crate) config_path: PathBuf, - pub(crate) logs_dir: PathBuf, - pub(crate) identity_path: PathBuf, - pub(crate) subscriber_state_path: PathBuf, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct RhiRuntimeContractOutput { - pub active_profile: String, - pub allowed_profiles: Vec<String>, - pub path_overrides: RhiRuntimePathOverrideContractOutput, - pub default_shared_secret_backend: String, - pub allowed_shared_secret_backends: Vec<String>, - pub canonical_config_path: PathBuf, - pub canonical_logs_dir: PathBuf, - pub canonical_identity_path: PathBuf, - pub canonical_subscriber_state_path: PathBuf, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct RhiRuntimePathOverrideContractOutput { - pub profile_source: String, - pub root_source: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub repo_local_root: Option<PathBuf>, - #[serde(skip_serializing_if = "Option::is_none")] - pub repo_local_root_source: Option<String>, - pub subordinate_path_override_source: String, - pub subordinate_path_override_keys: Vec<String>, -} - -struct RhiRuntimePathSelection { - profile: RadrootsPathProfile, - profile_source: String, - repo_local_root: Option<PathBuf>, - repo_local_root_source: Option<String>, -} - -fn parse_path_profile(value: &str) -> Result<RadrootsPathProfile> { - match value { - "interactive_user" => Ok(RadrootsPathProfile::InteractiveUser), - "service_host" => Ok(RadrootsPathProfile::ServiceHost), - "repo_local" => Ok(RadrootsPathProfile::RepoLocal), - _ => bail!( - "{RHI_PATHS_PROFILE_ENV} must be `interactive_user`, `service_host`, or `repo_local`" - ), - } -} - -pub(crate) fn process_path_selection() -> Result<(RadrootsPathProfile, Option<PathBuf>)> { - let selection = process_path_selection_with_sources()?; - Ok((selection.profile, selection.repo_local_root)) -} - -fn process_path_selection_with_sources() -> Result<RhiRuntimePathSelection> { - let profile = match std::env::var(RHI_PATHS_PROFILE_ENV) { - Ok(value) => ( - parse_path_profile(&value)?, - format!("process_env:{RHI_PATHS_PROFILE_ENV}"), - ), - Err(std::env::VarError::NotPresent) => { - (RadrootsPathProfile::InteractiveUser, "default".to_owned()) - } - Err(std::env::VarError::NotUnicode(_)) => { - bail!("{RHI_PATHS_PROFILE_ENV} must be valid utf-8 when set") - } - }; - let repo_local_root_raw = std::env::var_os(RHI_PATHS_REPO_LOCAL_ROOT_ENV); - let repo_local_root = repo_local_root_raw.as_ref().map(PathBuf::from); - Ok(RhiRuntimePathSelection { - profile: profile.0, - profile_source: profile.1, - repo_local_root, - repo_local_root_source: repo_local_root_raw - .as_ref() - .map(|_| format!("process_env:{RHI_PATHS_REPO_LOCAL_ROOT_ENV}")), - }) -} - -fn path_overrides_for( - profile: RadrootsPathProfile, - repo_local_root: Option<&Path>, -) -> Result<RadrootsPathOverrides> { - match profile { - RadrootsPathProfile::RepoLocal => { - let repo_local_root = repo_local_root.context(format!( - "{RHI_PATHS_REPO_LOCAL_ROOT_ENV} must be set when {RHI_PATHS_PROFILE_ENV}=repo_local" - ))?; - Ok(RadrootsPathOverrides::repo_local(repo_local_root)) - } - _ => Ok(RadrootsPathOverrides::default()), - } -} - -pub(crate) fn resolve_runtime_paths_with_resolver( - resolver: &RadrootsPathResolver, - profile: RadrootsPathProfile, - repo_local_root: Option<&Path>, -) -> Result<RhiRuntimePaths> { - let namespace = RadrootsRuntimeNamespace::worker(RHI_RUNTIME_ID) - .map_err(|error| anyhow::anyhow!("resolve rhi namespace: {error}"))?; - let overrides = path_overrides_for(profile, repo_local_root)?; - let namespaced = resolver - .resolve(profile, &overrides) - .map_err(|error| anyhow::anyhow!("resolve rhi runtime paths: {error}"))? - .namespaced(&namespace); - Ok(RhiRuntimePaths { - config_path: namespaced.config.join(DEFAULT_CONFIG_FILE_NAME), - logs_dir: namespaced.logs, - identity_path: namespaced.secrets.join(DEFAULT_SERVICE_IDENTITY_FILE_NAME), - subscriber_state_path: namespaced - .data - .join(SUBSCRIBER_STATE_DIR_NAME) - .join(SUBSCRIBER_STATE_FILE_NAME), - }) -} - -pub(crate) fn default_runtime_paths_for_process() -> Result<RhiRuntimePaths> { - let (profile, repo_local_root) = process_path_selection()?; - resolve_runtime_paths_with_resolver( - &RadrootsPathResolver::current(), - profile, - repo_local_root.as_deref(), - ) -} - -pub fn default_config_path_for_process() -> Result<PathBuf> { - Ok(default_runtime_paths_for_process()?.config_path) -} - -pub fn default_identity_path_for_process() -> Result<PathBuf> { - Ok(default_runtime_paths_for_process()?.identity_path) -} - -pub fn default_subscriber_state_path_for_process() -> Result<PathBuf> { - Ok(default_runtime_paths_for_process()?.subscriber_state_path) -} - -pub fn runtime_contract_for_process() -> Result<RhiRuntimeContractOutput> { - let selection = process_path_selection_with_sources()?; - runtime_contract_with_selection(&RadrootsPathResolver::current(), &selection) -} - -#[cfg(test)] -pub(crate) fn runtime_contract_with_resolver( - resolver: &RadrootsPathResolver, - profile: RadrootsPathProfile, - repo_local_root: Option<&Path>, -) -> Result<RhiRuntimeContractOutput> { - runtime_contract_with_selection( - resolver, - &RhiRuntimePathSelection { - profile, - profile_source: "caller".to_owned(), - repo_local_root: repo_local_root.map(Path::to_path_buf), - repo_local_root_source: repo_local_root.map(|_| "caller".to_owned()), - }, - ) -} - -fn runtime_contract_with_selection( - resolver: &RadrootsPathResolver, - selection: &RhiRuntimePathSelection, -) -> Result<RhiRuntimeContractOutput> { - let profile = selection.profile; - let repo_local_root = selection.repo_local_root.as_deref(); - let paths = resolve_runtime_paths_with_resolver(resolver, profile, repo_local_root)?; - Ok(RhiRuntimeContractOutput { - active_profile: profile.to_string(), - allowed_profiles: RHI_ALLOWED_PROFILES - .into_iter() - .map(str::to_owned) - .collect(), - path_overrides: RhiRuntimePathOverrideContractOutput { - profile_source: selection.profile_source.clone(), - root_source: root_source_for_profile(profile).to_owned(), - repo_local_root: selection.repo_local_root.clone(), - repo_local_root_source: selection.repo_local_root_source.clone(), - subordinate_path_override_source: SUBORDINATE_PATH_OVERRIDE_SOURCE.to_owned(), - subordinate_path_override_keys: SUBORDINATE_PATH_OVERRIDE_KEYS - .into_iter() - .map(str::to_owned) - .collect(), - }, - default_shared_secret_backend: RHI_DEFAULT_SHARED_SECRET_BACKEND.to_owned(), - allowed_shared_secret_backends: RHI_ALLOWED_SHARED_SECRET_BACKENDS - .into_iter() - .map(str::to_owned) - .collect(), - canonical_config_path: paths.config_path, - canonical_logs_dir: paths.logs_dir, - canonical_identity_path: paths.identity_path, - canonical_subscriber_state_path: paths.subscriber_state_path, - }) -} - -fn root_source_for_profile(profile: RadrootsPathProfile) -> &'static str { - match profile { - RadrootsPathProfile::InteractiveUser => "host_defaults", - RadrootsPathProfile::ServiceHost => "service_host_defaults", - RadrootsPathProfile::RepoLocal => "repo_local_root", - RadrootsPathProfile::MobileNative => "mobile_native_defaults", - } -} diff --git a/src/runtime_context.rs b/src/runtime_context.rs @@ -0,0 +1,183 @@ +//! Sealed bootstrap binding for one canonical RHI service instance. + +use core::fmt; +use std::{error::Error, path::Path}; + +use radroots_runtime_paths::{ + RadrootsPathProfile, RadrootsPathResolver, RadrootsServiceInstanceArtifacts, RuntimeContext, + RuntimeContextBootstrap, RuntimeContextSource, ServiceCredentialArtifactName, ServiceId, + default_service_instance_artifacts, service_credential_artifact_path, +}; + +use crate::{RhiBootstrapProfileV1, RhiCliInvocationV1}; + +const RHI_SERVICE_ID: &str = "rhi"; +const RHI_IDENTITY_ARTIFACT_NAME: &str = "service.identity.ncrypt"; + +/// Stable source-free classification for RHI runtime-context failures. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiRuntimeContextErrorKind { + InvalidServiceIdentity, + InvalidBootstrapBinding, + PathSelection, +} + +impl RhiRuntimeContextErrorKind { + const fn message(self) -> &'static str { + match self { + Self::InvalidServiceIdentity => "RHI service identity is invalid", + Self::InvalidBootstrapBinding => "RHI bootstrap selectors are inconsistent", + Self::PathSelection => "RHI runtime path selection failed", + } + } +} + +/// One redacted RHI runtime-context failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RhiRuntimeContextError { + kind: RhiRuntimeContextErrorKind, +} + +impl RhiRuntimeContextError { + const fn new(kind: RhiRuntimeContextErrorKind) -> Self { + Self { kind } + } + + /// Returns the stable failure classification. + #[must_use] + pub const fn kind(self) -> RhiRuntimeContextErrorKind { + self.kind + } +} + +impl fmt::Debug for RhiRuntimeContextError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiRuntimeContextError") + .field("kind", &self.kind) + .finish() + } +} + +impl fmt::Display for RhiRuntimeContextError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.kind.message()) + } +} + +impl Error for RhiRuntimeContextError {} + +/// Immutable canonical paths and bootstrap selection for one RHI instance. +/// +/// Construction is sealed to the validated CLI invocation and the shared +/// runtime-path resolver. Callers cannot forge another service identity, path +/// set, artifact name, or selected configuration path: +/// +/// ```compile_fail +/// use rhi::RhiRuntimeContext; +/// +/// let _ = RhiRuntimeContext { +/// context: todo!(), +/// artifacts: todo!(), +/// selected_config_path: todo!(), +/// profile: todo!(), +/// }; +/// ``` +#[derive(Clone, PartialEq, Eq)] +pub struct RhiRuntimeContext { + context: RuntimeContext, + artifacts: RadrootsServiceInstanceArtifacts, + identity_path: std::path::PathBuf, + selected_config_path: std::path::PathBuf, + profile: RhiBootstrapProfileV1, +} + +impl RhiRuntimeContext { + /// Returns the shared immutable service-instance context. + #[must_use] + pub const fn context(&self) -> &RuntimeContext { + &self.context + } + + /// Returns the exact common service artifacts. + #[must_use] + pub const fn artifacts(&self) -> &RadrootsServiceInstanceArtifacts { + &self.artifacts + } + + /// Returns the exact validated encrypted service-identity artifact path. + #[must_use] + pub fn identity_path(&self) -> &Path { + &self.identity_path + } + + /// Returns the explicit or canonical configuration artifact selected once. + #[must_use] + pub fn selected_config_path(&self) -> &Path { + &self.selected_config_path + } + + /// Returns the validated bootstrap profile. + #[must_use] + pub const fn profile(&self) -> RhiBootstrapProfileV1 { + self.profile + } +} + +impl fmt::Debug for RhiRuntimeContext { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiRuntimeContext") + .field("profile", &self.profile) + .field("service", &RHI_SERVICE_ID) + .field("instance", &"[redacted]") + .field("paths", &"[redacted]") + .finish() + } +} + +/// Resolves one validated CLI selection into the sole RHI path authority. +pub fn resolve_rhi_runtime_context( + resolver: &RadrootsPathResolver, + invocation: &RhiCliInvocationV1, +) -> Result<RhiRuntimeContext, RhiRuntimeContextError> { + let profile = invocation.profile(); + let path_profile = match profile { + RhiBootstrapProfileV1::ServiceHost => RadrootsPathProfile::ServiceHost, + RhiBootstrapProfileV1::Interactive => RadrootsPathProfile::InteractiveUser, + RhiBootstrapProfileV1::RepoLocal => RadrootsPathProfile::RepoLocal, + }; + let bootstrap = RuntimeContextBootstrap::new( + path_profile, + invocation.repo_local_root().map(Path::to_path_buf), + RuntimeContextSource::BootstrapCli, + RuntimeContextSource::BootstrapCli, + ) + .map_err(|_| { + RhiRuntimeContextError::new(RhiRuntimeContextErrorKind::InvalidBootstrapBinding) + })?; + let service = ServiceId::new(RHI_SERVICE_ID).map_err(|_| { + RhiRuntimeContextError::new(RhiRuntimeContextErrorKind::InvalidServiceIdentity) + })?; + let context = + RuntimeContext::resolve(resolver, bootstrap, service, invocation.instance().clone()) + .map_err(|_| RhiRuntimeContextError::new(RhiRuntimeContextErrorKind::PathSelection))?; + let artifacts = default_service_instance_artifacts(context.paths()); + let identity_name = + ServiceCredentialArtifactName::new(RHI_IDENTITY_ARTIFACT_NAME).map_err(|_| { + RhiRuntimeContextError::new(RhiRuntimeContextErrorKind::InvalidServiceIdentity) + })?; + let identity_path = service_credential_artifact_path(context.paths(), &identity_name); + let selected_config_path = invocation + .config_path() + .map(Path::to_path_buf) + .unwrap_or_else(|| artifacts.config().to_path_buf()); + + Ok(RhiRuntimeContext { + context, + artifacts, + identity_path, + selected_config_path, + profile, + }) +} diff --git a/tests/services_hardening_cli.rs b/tests/services_hardening_cli.rs @@ -4,7 +4,7 @@ use std::error::Error; use std::path::Path; use rhi::{ - RHI_INSTANCE_ID_MAX_BYTES, RhiBootstrapProfileV1, RhiCliOutputModeV1, RhiCliV1ErrorKind, + INSTANCE_ID_MAX_BYTES, RhiBootstrapProfileV1, RhiCliOutputModeV1, RhiCliV1ErrorKind, RhiCommandV1, RhiConfigCommandV1, RhiIdentityCommandV1, RhiMetricsCommandV1, RhiPresenceCommandV1, RhiPublicationCommandV1, RhiReconciliationCommandV1, RhiSourcesCommandV1, RhiStateCommandV1, RhiTradeCommandV1, parse_rhi_cli_v1_from, @@ -158,12 +158,12 @@ fn bootstrap_values_are_explicit_bounded_and_cross_bound() { ]) .expect("repo-local invocation"); assert_eq!(repo.profile(), RhiBootstrapProfileV1::RepoLocal); - assert_eq!(repo.instance(), "review_01"); + assert_eq!(repo.instance().as_str(), "review_01"); assert_eq!(repo.repo_local_root(), Some(Path::new("/repo/radroots"))); assert_eq!(repo.config_path(), Some(Path::new("/repo/config/rhi.toml"))); assert_eq!(repo.output_mode(), RhiCliOutputModeV1::Json); - let exact = "a".repeat(RHI_INSTANCE_ID_MAX_BYTES); + let exact = "a".repeat(INSTANCE_ID_MAX_BYTES); assert!( parse_rhi_cli_v1_from([ "rhi", @@ -175,7 +175,7 @@ fn bootstrap_values_are_explicit_bounded_and_cross_bound() { ]) .is_ok() ); - let over = "a".repeat(RHI_INSTANCE_ID_MAX_BYTES + 1); + let over = "a".repeat(INSTANCE_ID_MAX_BYTES + 1); assert_eq!( parse_rhi_cli_v1_from([ "rhi", diff --git a/tests/services_hardening_runtime_context.rs b/tests/services_hardening_runtime_context.rs @@ -0,0 +1,290 @@ +#![forbid(unsafe_code)] + +use std::error::Error; +use std::path::{Path, PathBuf}; + +use rhi::{ + RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, RadrootsPlatform, + RhiBootstrapProfileV1, RuntimeContextSource, parse_rhi_cli_v1_from, + resolve_rhi_runtime_context, +}; + +fn resolve( + resolver: &RadrootsPathResolver, + profile: &str, + instance: &str, + repo_local_root: Option<&str>, + config_path: Option<&str>, +) -> rhi::RhiRuntimeContext { + let mut arguments = vec!["rhi", "--profile", profile, "--instance", instance]; + if let Some(root) = repo_local_root { + arguments.extend(["--repo-local-root", root]); + } + if let Some(path) = config_path { + arguments.extend(["--config", path]); + } + arguments.push("run"); + let invocation = parse_rhi_cli_v1_from(arguments).expect("validated invocation"); + resolve_rhi_runtime_context(resolver, &invocation).expect("runtime context") +} + +fn assert_roots(context: &rhi::RhiRuntimeContext, expected: [&str; 6]) { + let paths = context.context().paths(); + assert_eq!(paths.config(), Path::new(expected[0])); + assert_eq!(paths.state(), Path::new(expected[1])); + assert_eq!(paths.cache(), Path::new(expected[2])); + assert_eq!(paths.logs(), Path::new(expected[3])); + assert_eq!(paths.run(), Path::new(expected[4])); + assert_eq!(paths.secrets(), Path::new(expected[5])); +} + +#[test] +fn repo_local_context_binds_identity_provenance_and_exact_artifacts() { + let resolver = + RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); + let primary = resolve( + &resolver, + "repo-local", + "primary", + Some("/repo/.local/radroots"), + None, + ); + let secondary = resolve( + &resolver, + "repo-local", + "secondary", + Some("/repo/.local/radroots"), + None, + ); + + assert_eq!(primary.context().service().as_str(), "rhi"); + assert_eq!(primary.context().instance().as_str(), "primary"); + assert_eq!(primary.profile(), RhiBootstrapProfileV1::RepoLocal); + assert_eq!(primary.context().profile(), RadrootsPathProfile::RepoLocal); + assert_eq!( + primary.context().sources().service(), + RuntimeContextSource::SafeDefault + ); + assert_eq!( + primary.context().sources().instance(), + RuntimeContextSource::BootstrapCli + ); + assert_eq!( + primary.context().sources().profile(), + RuntimeContextSource::BootstrapCli + ); + assert_eq!( + primary.context().sources().repo_local_root(), + Some(RuntimeContextSource::BootstrapCli) + ); + assert_eq!( + primary.context().sources().paths(), + RuntimeContextSource::DerivedPath + ); + assert_roots( + &primary, + [ + "/repo/.local/radroots/config/services/rhi/primary", + "/repo/.local/radroots/data/services/rhi/primary", + "/repo/.local/radroots/cache/services/rhi/primary", + "/repo/.local/radroots/logs/services/rhi/primary", + "/repo/.local/radroots/run/services/rhi/primary", + "/repo/.local/radroots/secrets/services/rhi/primary", + ], + ); + assert_eq!( + primary.artifacts().config(), + Path::new("/repo/.local/radroots/config/services/rhi/primary/config.toml") + ); + assert_eq!( + primary.artifacts().state_database(), + Path::new("/repo/.local/radroots/data/services/rhi/primary/state.sqlite") + ); + assert_eq!( + primary.artifacts().state_lock(), + Path::new("/repo/.local/radroots/data/services/rhi/primary/state.lock") + ); + assert_eq!( + primary.artifacts().admin_socket(), + Path::new("/repo/.local/radroots/run/services/rhi/primary/admin.sock") + ); + assert_eq!( + primary.identity_path(), + Path::new("/repo/.local/radroots/secrets/services/rhi/primary/service.identity.ncrypt") + ); + assert_eq!(primary.selected_config_path(), primary.artifacts().config()); + assert_ne!(primary.context().paths(), secondary.context().paths()); +} + +#[test] +fn service_host_and_interactive_profiles_have_exact_roots() { + let service_host = resolve( + &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), + "service-host", + "default", + None, + None, + ); + assert_roots( + &service_host, + [ + "/etc/radroots/services/rhi/default", + "/var/lib/radroots/services/rhi/default", + "/var/cache/radroots/services/rhi/default", + "/var/log/radroots/services/rhi/default", + "/run/radroots/services/rhi/default", + "/etc/radroots/secrets/services/rhi/default", + ], + ); + + let interactive = resolve( + &RadrootsPathResolver::new( + RadrootsPlatform::Linux, + RadrootsHostEnvironment { + home_dir: Some(PathBuf::from("/home/operator")), + xdg_config_home: Some(PathBuf::from("/xdg/config")), + xdg_data_home: Some(PathBuf::from("/xdg/data")), + xdg_state_home: Some(PathBuf::from("/xdg/state")), + xdg_cache_home: Some(PathBuf::from("/xdg/cache")), + xdg_runtime_dir: Some(PathBuf::from("/xdg/run")), + ..RadrootsHostEnvironment::default() + }, + ), + "interactive", + "default", + None, + Some("/operator/rhi.toml"), + ); + assert_roots( + &interactive, + [ + "/xdg/config/radroots/services/rhi/default", + "/xdg/data/radroots/services/rhi/default", + "/xdg/cache/radroots/services/rhi/default", + "/xdg/state/radroots/logs/services/rhi/default", + "/xdg/run/radroots/services/rhi/default", + "/xdg/config/radroots/secrets/services/rhi/default", + ], + ); + assert_eq!( + interactive.selected_config_path(), + Path::new("/operator/rhi.toml") + ); +} + +#[test] +fn macos_and_windows_interactive_roots_remain_exact_and_injected() { + let macos = resolve( + &RadrootsPathResolver::new( + RadrootsPlatform::Macos, + RadrootsHostEnvironment { + home_dir: Some(PathBuf::from("/Users/operator")), + ..RadrootsHostEnvironment::default() + }, + ), + "interactive", + "default", + None, + None, + ); + assert_roots( + &macos, + [ + "/Users/operator/Library/Application Support/Radroots/config/services/rhi/default", + "/Users/operator/Library/Application Support/Radroots/data/services/rhi/default", + "/Users/operator/Library/Caches/Radroots/services/rhi/default", + "/Users/operator/Library/Logs/Radroots/services/rhi/default", + "/Users/operator/Library/Application Support/Radroots/run/services/rhi/default", + "/Users/operator/Library/Application Support/Radroots/secrets/services/rhi/default", + ], + ); + + let windows = resolve( + &RadrootsPathResolver::new( + RadrootsPlatform::Windows, + RadrootsHostEnvironment { + appdata_dir: Some(PathBuf::from(r"C:\Users\operator\AppData\Roaming")), + localappdata_dir: Some(PathBuf::from(r"C:\Users\operator\AppData\Local")), + ..RadrootsHostEnvironment::default() + }, + ), + "interactive", + "default", + None, + None, + ); + assert_roots( + &windows, + [ + r"C:\Users\operator\AppData\Roaming/Radroots/config/services/rhi/default", + r"C:\Users\operator\AppData\Local/Radroots/data/services/rhi/default", + r"C:\Users\operator\AppData\Local/Radroots/cache/services/rhi/default", + r"C:\Users\operator\AppData\Local/Radroots/logs/services/rhi/default", + r"C:\Users\operator\AppData\Local/Radroots/run/services/rhi/default", + r"C:\Users\operator\AppData\Roaming/Radroots/secrets/services/rhi/default", + ], + ); +} + +#[test] +fn debug_and_errors_do_not_disclose_paths_or_instances() { + let context = resolve( + &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), + "repo-local", + "secret-instance", + Some("/secret/root"), + Some("/secret/config.toml"), + ); + let rendered = format!("{context:?}"); + for forbidden in ["secret-instance", "/secret/root", "/secret/config.toml"] { + assert!(!rendered.contains(forbidden), "{rendered}"); + } + + let invocation = parse_rhi_cli_v1_from([ + "rhi", + "--profile", + "interactive", + "--instance", + "default", + "run", + ]) + .expect("invocation"); + let error = resolve_rhi_runtime_context( + &RadrootsPathResolver::new(RadrootsPlatform::Macos, RadrootsHostEnvironment::default()), + &invocation, + ) + .expect_err("missing HOME"); + assert!(error.source().is_none()); + assert!(!format!("{error:?} {error}").contains("HOME")); +} + +#[test] +fn source_contains_no_legacy_path_authority() { + let lib = include_str!("../src/lib.rs"); + let context = include_str!("../src/runtime_context.rs"); + let main = include_str!("../src/main.rs"); + let config = include_str!("../src/config.rs"); + for forbidden in [ + "pub mod host_paths", + "pub mod paths", + "RHI_PATHS_PROFILE", + "RHI_PATHS_REPO_LOCAL_ROOT", + "RadrootsRuntimeNamespace::worker", + "RhiRuntimeStartupReport", + "RhiRuntimeContractOutput", + "default_config_path_for_process", + "default_identity_path_for_process", + "default_subscriber_state_path_for_process", + ] { + assert!(!lib.contains(forbidden)); + assert!(!context.contains(forbidden)); + assert!(!main.contains(forbidden)); + assert!(!config.contains(forbidden)); + } + for removed in ["src/paths.rs", "src/host_paths/mod.rs", "src/cli.rs"] { + assert!( + !Path::new(env!("CARGO_MANIFEST_DIR")).join(removed).exists(), + "legacy path authority remains at {removed}" + ); + } +} diff --git a/tests/source_guards.rs b/tests/source_guards.rs @@ -94,7 +94,7 @@ fn rhi_release_product_surface_has_no_order_or_receipt_modules() { fn rhi_agreement_attestation_is_release_product_optional_infrastructure() { let worker = read_repo_file("src/features/trade_agreement_attestation.rs"); let lib = read_repo_file("src/lib.rs"); - let cli = read_repo_file("src/cli.rs"); + let cli = read_repo_file("src/cli_v1.rs"); let config = read_repo_file("src/config.rs"); for required in [ @@ -132,10 +132,10 @@ fn rhi_agreement_attestation_is_release_product_optional_infrastructure() { "RHI service presence must advertise canonical release-product trade mutation kinds" ); assert!( - cli.contains("attestation-smoke") - && !cli.contains("proof-smoke") + !cli.contains("AttestationSmoke") + && !cli.contains("ProofSmoke") && !cli.contains("remote-prove"), - "RHI CLI must expose only release-product agreement attestation smoke command" + "RHI CLI must not retain prototype smoke commands" ); assert!( config.contains("settings.config.trade_agreement_attestation.validate()?"), @@ -145,20 +145,24 @@ fn rhi_agreement_attestation_is_release_product_optional_infrastructure() { #[test] fn rhi_state_paths_are_named_for_agreement_attestation() { - let paths = read_repo_file("src/paths.rs"); let config = read_repo_file("src/config.rs"); - let main = read_repo_file("src/main.rs"); + let context = read_repo_file("src/runtime_context.rs"); - for source in [paths.as_str(), config.as_str(), main.as_str()] { - assert!( - source.contains("trade-agreement-attestation"), - "RHI runtime state paths must use agreement-attestation naming" - ); + assert!( + config.contains("trade-agreement-attestation"), + "transitional RHI state paths must use agreement-attestation naming" + ); + for source in [config.as_str(), context.as_str()] { assert!( !source.contains("trade-listing"), "RHI runtime state paths must not retain trade-listing naming" ); } + assert!( + context.contains("default_service_instance_artifacts") + && context.contains("service.identity.ncrypt"), + "RHI path authority must derive exact common and credential artifacts" + ); } fn read_repo_file(relative_path: &str) -> String {