commit 5704286ef326288a0ef8f4aaca53f8dcc3682c56
parent 9e79449c2a4d3559c38c90e172b9ab8e58c8e91b
Author: triesap <tyson@radroots.org>
Date: Fri, 21 Aug 2026 13:14:41 +0000
runtime-paths: adopt sealed Myc instance context
- replace the local resolver and environment model with the source-locked runtime_paths authority
- bind typed service and instance identity to exact common artifact paths
- add cross-profile, redaction, source-lock, and removal qualification
Diffstat:
24 files changed, 922 insertions(+), 678 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1531,6 +1531,7 @@ dependencies = [
"radroots_identity",
"radroots_nostr",
"radroots_nostr_connect",
+ "radroots_runtime_paths",
"radroots_secrets",
"radroots_signing",
"rand 0.9.2",
@@ -2050,6 +2051,15 @@ dependencies = [
]
[[package]]
+name = "radroots_runtime_paths"
+version = "0.1.0-alpha"
+source = "git+https://github.com/radrootslabs/lib?rev=b44119fbac5985be8127ad1bf56d2950e6399427#b44119fbac5985be8127ad1bf56d2950e6399427"
+dependencies = [
+ "serde",
+ "thiserror 1.0.69",
+]
+
+[[package]]
name = "radroots_secrets"
version = "0.1.0-alpha"
source = "git+https://github.com/radrootslabs/lib?rev=b44119fbac5985be8127ad1bf56d2950e6399427#b44119fbac5985be8127ad1bf56d2950e6399427"
diff --git a/Cargo.toml b/Cargo.toml
@@ -43,6 +43,7 @@ radroots_identity = { git = "https://github.com/radrootslabs/lib", rev = "b44119
radroots_event = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["serde"] }
radroots_nostr = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["events"] }
radroots_nostr_connect = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha" }
+radroots_runtime_paths = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha" }
radroots_secrets = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["std", "keyring"] }
radroots_signing = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["std"] }
serde = { version = "1.0", features = ["derive"] }
diff --git a/README b/README
@@ -34,8 +34,14 @@ text, paths, credentials, relay URLs, or identity values.
The prototype environment loader, `.env` example, environment selectors,
implicit command/profile selection, compatibility aliases, and arbitrary leaf
-flags have been removed. The remaining internal runtime model is not a process
-configuration surface and is replaced only by its later owning checkpoints.
+flags have been removed. Bootstrap now resolves a sealed runtime context from
+the validated CLI profile and typed instance ID through the source-locked
+`radroots_runtime_paths` authority. Config, state, cache, logs, run, secrets,
+`config.toml`, `state.sqlite`, `state.lock`, and `admin.sock` are derived under
+the exact `services/myc/<instance>` namespace. The service contains no local
+host-environment resolver, worker namespace, ambient selector, or implicit
+path default. An explicit absolute `--config` may select the document to read
+without changing the canonical common artifact inventory.
## NIP-46 runtime contract
diff --git a/radroots.lib.source-lock.v1.toml b/radroots.lib.source-lock.v1.toml
@@ -6,4 +6,4 @@ workspace_catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e
version = "0.1.0-alpha"
source_archive_sha256 = "975474804e6358b9228981add0a23181dbdd1afddf5ae12579c82220876bc379"
lockfile = "Cargo.lock"
-lockfile_sha256 = "06df285eb4b6ae0e88a70e4bf55710744f8bbf320fa2d1873c241138208823b5"
+lockfile_sha256 = "25d0049df23e822284b368707f08d08ddcfac85c30703d7154baf80b9baadf3e"
diff --git a/src/app/backend.rs b/src/app/backend.rs
@@ -351,8 +351,6 @@ mod tests {
use nostr::Keys;
use crate::app::MycRuntime;
- use crate::config::MycConfig;
-
fn write_identity(path: &std::path::Path, secret_key: &str) {
let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity");
crate::identity_files::store_encrypted_identity(path, &identity).expect("save identity");
@@ -360,8 +358,7 @@ mod tests {
fn test_runtime() -> MycRuntime {
let temp = tempfile::tempdir().expect("tempdir").keep();
- let mut config = MycConfig::default();
- config.paths.state_dir = PathBuf::from(&temp).join("state");
+ let mut config = crate::config::test_config(PathBuf::from(&temp).as_path());
config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json");
config.paths.user_identity_path = PathBuf::from(&temp).join("user.json");
write_identity(
diff --git a/src/app/mod.rs b/src/app/mod.rs
@@ -41,11 +41,9 @@ impl MycApp {
#[cfg(test)]
mod tests {
- use std::path::PathBuf;
-
use crate::host_identity::RadrootsIdentity;
- use crate::config::{MycConfig, MycSignerStateBackend};
+ use crate::config::MycSignerStateBackend;
use super::MycApp;
@@ -58,8 +56,7 @@ mod tests {
#[test]
fn app_bootstrap_preserves_runtime_snapshot() {
let temp = tempfile::tempdir().expect("tempdir");
- let mut config = MycConfig::default();
- config.paths.state_dir = PathBuf::from(temp.path()).join("state");
+ let mut config = crate::config::test_config(temp.path());
config.paths.signer_identity_path = temp.path().join("identity.json");
config.paths.user_identity_path = temp.path().join("user.json");
write_test_identity(
@@ -74,7 +71,7 @@ mod tests {
let app = MycApp::bootstrap(config).expect("bootstrap");
let snapshot = app.snapshot();
- assert!(snapshot.state_dir.ends_with("state"));
+ assert!(snapshot.state_dir.ends_with("services/myc/test"));
assert!(snapshot.audit_dir.ends_with("audit"));
assert!(
snapshot
@@ -112,8 +109,7 @@ mod tests {
#[test]
fn app_bootstrap_uses_backend_aware_signer_state_path() {
let temp = tempfile::tempdir().expect("tempdir");
- let mut config = MycConfig::default();
- config.paths.state_dir = PathBuf::from(temp.path()).join("state");
+ let mut config = crate::config::test_config(temp.path());
config.paths.signer_identity_path = temp.path().join("identity.json");
config.paths.user_identity_path = temp.path().join("user.json");
config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite;
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
@@ -202,7 +202,13 @@ impl MycRuntime {
let signer_public = self.signer.signer_identity.to_public();
let user_public = self.signer.user_identity.to_public();
MycStartupSnapshot {
- instance_name: self.config.service.instance_name.clone(),
+ instance_name: self
+ .config
+ .runtime_context()
+ .context()
+ .instance()
+ .as_str()
+ .to_owned(),
log_filter: self.config.logging.filter.clone(),
observability_enabled: self.config.observability.enabled,
observability_bind_addr: self.config.observability.bind_addr,
@@ -1038,7 +1044,7 @@ impl MycRuntimePaths {
}
fn from_config(config: &MycConfig) -> Self {
- let state_dir = config.paths.state_dir.clone();
+ let state_dir = config.paths.state_dir().to_path_buf();
let audit_dir = Self::audit_dir_for_state_dir(&state_dir);
Self {
signer_identity_path: config.paths.signer_identity_path.clone(),
@@ -1306,7 +1312,7 @@ mod tests {
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
- use std::path::PathBuf;
+ use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::host_identity::RadrootsIdentity;
@@ -1320,9 +1326,7 @@ mod tests {
use super::{MycRuntime, startup_identity_path};
use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord};
- use crate::config::{
- MycConfig, MycIdentityBackend, MycRuntimeAuditBackend, MycSignerStateBackend,
- };
+ use crate::config::{MycIdentityBackend, MycRuntimeAuditBackend, MycSignerStateBackend};
use crate::discovery::MycDiscoveryContext;
use crate::error::MycError;
use crate::outbox::{MycDeliveryOutboxKind, MycDeliveryOutboxRecord, MycDeliveryOutboxStatus};
@@ -1353,8 +1357,7 @@ mod tests {
#[test]
fn bootstrap_creates_runtime_directories() {
let temp = tempfile::tempdir().expect("tempdir");
- let mut config = MycConfig::default();
- config.paths.state_dir = PathBuf::from(temp.path()).join("state");
+ let mut config = crate::config::test_config(temp.path());
config.paths.signer_identity_path = temp.path().join("identity.json");
config.paths.user_identity_path = temp.path().join("user.json");
write_test_identity(
@@ -1418,19 +1421,20 @@ mod tests {
#[test]
fn bootstrap_rejects_invalid_config() {
- let mut config = MycConfig::default();
- config.service.instance_name.clear();
+ let mut config = crate::config::test_config(Path::new("/tmp/radroots-myc-invalid"));
+ config.logging.filter.clear();
let err = match MycRuntime::bootstrap(config) {
Ok(_) => panic!("expected invalid config error"),
Err(err) => err,
};
- assert!(err.to_string().contains("service.instance_name"));
+ assert!(err.to_string().contains("logging.filter"));
}
#[test]
fn bootstrap_rejects_mismatched_persisted_signer_identity() {
let temp = tempfile::tempdir().expect("tempdir");
+ let mut config = crate::config::test_config(temp.path());
let identity_path = temp.path().join("identity.json");
let user_path = temp.path().join("user.json");
write_test_identity(
@@ -1447,15 +1451,13 @@ mod tests {
)
.expect("second identity");
let store = Arc::new(RadrootsNostrFileSignerStore::new(
- temp.path().join("state").join("signer-state.json"),
+ config.paths.state_dir().join("signer-state.json"),
));
let manager = RadrootsNostrSignerManager::new(store).expect("manager");
manager
.set_signer_identity(store_identity.to_public())
.expect("persist signer");
- let mut config = MycConfig::default();
- config.paths.state_dir = temp.path().join("state");
config.paths.signer_identity_path = identity_path;
config.paths.user_identity_path = user_path;
@@ -1469,8 +1471,7 @@ mod tests {
#[test]
fn bootstrap_keeps_signer_and_user_identities_distinct() {
let temp = tempfile::tempdir().expect("tempdir");
- let mut config = MycConfig::default();
- config.paths.state_dir = temp.path().join("state");
+ let mut config = crate::config::test_config(temp.path());
config.paths.signer_identity_path = temp.path().join("signer.json");
config.paths.user_identity_path = temp.path().join("user.json");
write_test_identity(
@@ -1497,8 +1498,7 @@ mod tests {
#[test]
fn bootstrap_cleans_stale_trusted_authorized_sessions() {
let temp = tempfile::tempdir().expect("tempdir");
- let mut config = MycConfig::default();
- config.paths.state_dir = temp.path().join("state");
+ let mut config = crate::config::test_config(temp.path());
config.paths.signer_identity_path = temp.path().join("signer.json");
config.paths.user_identity_path = temp.path().join("user.json");
config.policy.auth_url = Some("https://auth.example/challenge".to_owned());
@@ -1555,8 +1555,7 @@ mod tests {
#[test]
fn bootstrap_prepares_transport_when_enabled() {
let temp = tempfile::tempdir().expect("tempdir");
- let mut config = MycConfig::default();
- config.paths.state_dir = temp.path().join("state");
+ let mut config = crate::config::test_config(temp.path());
config.paths.signer_identity_path = temp.path().join("signer.json");
config.paths.user_identity_path = temp.path().join("user.json");
config.transport.enabled = true;
@@ -1587,8 +1586,7 @@ mod tests {
&helper_path,
"1111111111111111111111111111111111111111111111111111111111111111",
);
- let mut config = MycConfig::default();
- config.paths.state_dir = temp.path().join("state");
+ let mut config = crate::config::test_config(temp.path());
config.paths.signer_identity_backend = MycIdentityBackend::ExternalCommand;
config.paths.signer_identity_path = helper_path;
config.paths.user_identity_path = temp.path().join("user.json");
@@ -1625,8 +1623,7 @@ mod tests {
&helper_path,
"6666666666666666666666666666666666666666666666666666666666666666",
);
- let mut config = MycConfig::default();
- config.paths.state_dir = temp.path().join("state");
+ let mut config = crate::config::test_config(temp.path());
config.paths.signer_identity_path = temp.path().join("signer.json");
config.paths.user_identity_path = temp.path().join("user.json");
config.discovery.enabled = true;
@@ -1659,8 +1656,7 @@ mod tests {
#[test]
fn bootstrap_supports_sqlite_signer_state_backend() {
let temp = tempfile::tempdir().expect("tempdir");
- let mut config = MycConfig::default();
- config.paths.state_dir = temp.path().join("state");
+ let mut config = crate::config::test_config(temp.path());
config.paths.signer_identity_path = temp.path().join("signer.json");
config.paths.user_identity_path = temp.path().join("user.json");
config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite;
@@ -1688,6 +1684,7 @@ mod tests {
#[test]
fn bootstrap_rejects_mismatched_persisted_sqlite_signer_identity() {
let temp = tempfile::tempdir().expect("tempdir");
+ let mut config = crate::config::test_config(temp.path());
let identity_path = temp.path().join("identity.json");
let user_path = temp.path().join("user.json");
write_test_identity(
@@ -1705,7 +1702,7 @@ mod tests {
.expect("second identity");
let store = Arc::new(
RadrootsNostrSqliteSignerStore::open(
- temp.path().join("state").join("signer-state.sqlite"),
+ config.paths.state_dir().join("signer-state.sqlite"),
)
.expect("open sqlite store"),
);
@@ -1714,8 +1711,6 @@ mod tests {
.set_signer_identity(store_identity.to_public())
.expect("persist signer");
- let mut config = MycConfig::default();
- config.paths.state_dir = temp.path().join("state");
config.paths.signer_identity_path = identity_path;
config.paths.user_identity_path = user_path;
config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite;
@@ -1730,8 +1725,7 @@ mod tests {
#[test]
fn bootstrap_supports_sqlite_operation_audit_backend() {
let temp = tempfile::tempdir().expect("tempdir");
- let mut config = MycConfig::default();
- config.paths.state_dir = temp.path().join("state");
+ let mut config = crate::config::test_config(temp.path());
config.paths.signer_identity_path = temp.path().join("signer.json");
config.paths.user_identity_path = temp.path().join("user.json");
config.persistence.runtime_audit_backend = MycRuntimeAuditBackend::Sqlite;
@@ -1772,7 +1766,7 @@ mod tests {
#[test]
fn startup_identity_path_reporting_matches_backend_sources() {
- let mut config = MycConfig::default();
+ let mut config = crate::config::test_config(Path::new("/tmp/radroots-myc-reporting"));
config.paths.signer_identity_backend = MycIdentityBackend::HostVault;
config.paths.signer_identity_keyring_account_id =
Some("1111111111111111111111111111111111111111111111111111111111111111".to_owned());
@@ -1800,8 +1794,7 @@ mod tests {
#[tokio::test]
async fn startup_recovery_rejects_orphaned_signer_publish_workflow() {
let temp = tempfile::tempdir().expect("tempdir");
- let mut config = MycConfig::default();
- config.paths.state_dir = temp.path().join("state");
+ let mut config = crate::config::test_config(temp.path());
config.paths.signer_identity_path = temp.path().join("signer.json");
config.paths.user_identity_path = temp.path().join("user.json");
write_test_identity(
@@ -1846,8 +1839,7 @@ mod tests {
#[tokio::test]
async fn startup_recovery_finalizes_published_connect_secret_workflow() {
let temp = tempfile::tempdir().expect("tempdir");
- let mut config = MycConfig::default();
- config.paths.state_dir = temp.path().join("state");
+ let mut config = crate::config::test_config(temp.path());
config.paths.signer_identity_path = temp.path().join("signer.json");
config.paths.user_identity_path = temp.path().join("user.json");
write_test_identity(
@@ -1962,8 +1954,7 @@ mod tests {
#[tokio::test]
async fn startup_recovery_rejects_queued_job_with_missing_signer_workflow() {
let temp = tempfile::tempdir().expect("tempdir");
- let mut config = MycConfig::default();
- config.paths.state_dir = temp.path().join("state");
+ let mut config = crate::config::test_config(temp.path());
config.paths.signer_identity_path = temp.path().join("signer.json");
config.paths.user_identity_path = temp.path().join("user.json");
write_test_identity(
diff --git a/src/cli_v1.rs b/src/cli_v1.rs
@@ -6,12 +6,11 @@ use std::fmt;
use std::path::{Component, Path, PathBuf};
use clap::{Parser, Subcommand, ValueEnum};
-
-/// Maximum encoded length of a Myc instance identifier.
-pub const MYC_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)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "snake_case")]
pub enum MycBootstrapProfileV1 {
ServiceHost,
Interactive,
@@ -121,7 +120,7 @@ impl Error for MycCliV1Error {}
/// A validated one-pass Myc bootstrap and command selection.
pub struct MycCliInvocationV1 {
profile: MycBootstrapProfileV1,
- instance: Box<str>,
+ instance: InstanceId,
repo_local_root: Option<PathBuf>,
config_path: Option<PathBuf>,
command: MycCommandV1,
@@ -136,7 +135,7 @@ impl MycCliInvocationV1 {
/// Returns the validated instance identifier.
#[must_use]
- pub fn instance(&self) -> &str {
+ pub fn instance(&self) -> &InstanceId {
&self.instance
}
@@ -197,7 +196,8 @@ where
let instance = parsed
.instance
.ok_or_else(|| MycCliV1Error::new(MycCliV1ErrorKind::InvalidArguments))?;
- validate_instance(&instance)?;
+ let instance = InstanceId::new(instance)
+ .map_err(|_| MycCliV1Error::new(MycCliV1ErrorKind::InvalidInstance))?;
validate_bootstrap_paths(
profile,
parsed.repo_local_root.as_deref(),
@@ -206,29 +206,13 @@ where
Ok(MycCliInvocationV1 {
profile,
- instance: instance.into_boxed_str(),
+ instance,
repo_local_root: parsed.repo_local_root,
config_path: parsed.config,
command: parsed.command.into(),
})
}
-fn validate_instance(value: &str) -> Result<(), MycCliV1Error> {
- let bytes = value.as_bytes();
- let is_boundary = |byte: u8| byte.is_ascii_lowercase() || byte.is_ascii_digit();
- if bytes.is_empty()
- || bytes.len() > MYC_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(MycCliV1Error::new(MycCliV1ErrorKind::InvalidInstance));
- }
- Ok(())
-}
-
fn validate_bootstrap_paths(
profile: MycBootstrapProfileV1,
repo_local_root: Option<&Path>,
@@ -484,7 +468,7 @@ mod tests {
"run",
])
.expect("production 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/myc.toml"))
@@ -543,7 +527,7 @@ mod tests {
.kind(),
MycCliV1ErrorKind::InvalidInstance
);
- let exact = "a".repeat(MYC_INSTANCE_ID_MAX_BYTES);
+ let exact = "a".repeat(radroots_runtime_paths::INSTANCE_ID_MAX_BYTES);
assert!(
parse_myc_cli_v1_from([
"myc",
@@ -555,7 +539,7 @@ mod tests {
])
.is_ok()
);
- let overlong = "a".repeat(MYC_INSTANCE_ID_MAX_BYTES + 1);
+ let overlong = "a".repeat(radroots_runtime_paths::INSTANCE_ID_MAX_BYTES + 1);
assert_eq!(
parse_myc_cli_v1_from([
"myc",
diff --git a/src/config.rs b/src/config.rs
@@ -1,26 +1,24 @@
use std::collections::BTreeSet;
use std::net::SocketAddr;
-use std::path::{Path, PathBuf};
+use std::path::PathBuf;
use crate::nostr_contract::RadrootsNostrRelayUrl;
-use crate::paths::{RadrootsPathResolver, RadrootsRuntimePathPolicyContract};
use crate::signer::prelude::RadrootsNostrSignerApprovalRequirement;
use nostr::PublicKey;
use radroots_nostr_connect::permission::Permissions;
use serde::{Deserialize, Serialize};
use tracing_subscriber::EnvFilter;
+use crate::MycBootstrapProfileV1;
use crate::error::MycError;
-use crate::paths::MycPathOverrideFlags;
-pub use crate::paths::{MycPathProfile, MycPathsConfig};
+pub use crate::paths::MycPathsConfig;
+use crate::runtime_context::MycRuntimeContext;
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(default, deny_unknown_fields)]
+#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MycConfig {
- pub service: MycServiceConfig,
pub logging: MycLoggingConfig,
pub custody: MycCustodyConfig,
- pub paths: MycPathsConfig,
+ pub(crate) paths: MycPathsConfig,
pub persistence: MycPersistenceConfig,
pub audit: MycAuditConfig,
pub observability: MycObservabilityConfig,
@@ -31,12 +29,6 @@ pub struct MycConfig {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
-pub struct MycServiceConfig {
- pub instance_name: String,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(default, deny_unknown_fields)]
pub struct MycLoggingConfig {
pub filter: String,
pub output_dir: Option<PathBuf>,
@@ -160,8 +152,8 @@ pub struct MycIdentitySourceSpec {
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MycRuntimeContractOutput {
- pub active_profile: MycPathProfile,
- pub allowed_profiles: Vec<MycPathProfile>,
+ pub active_profile: MycBootstrapProfileV1,
+ pub allowed_profiles: Vec<MycBootstrapProfileV1>,
pub default_shared_secret_backend: MycIdentityBackend,
pub allowed_shared_secret_backends: Vec<MycIdentityBackend>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -171,7 +163,12 @@ pub struct MycRuntimeContractOutput {
pub path_overrides: MycRuntimePathOverrideContractOutput,
}
-pub type MycRuntimePathOverrideContractOutput = RadrootsRuntimePathPolicyContract;
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct MycRuntimePathOverrideContractOutput {
+ pub canonical_root_selection: String,
+ pub canonical_subordinate_path_override: String,
+ pub leaf_path_env_posture: String,
+}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -199,25 +196,6 @@ pub struct MycPolicyConfig {
pub auth_challenge_rate_limit_max_attempts: Option<usize>,
}
-impl Default for MycConfig {
- fn default() -> Self {
- Self::default_with_path_selection(
- &RadrootsPathResolver::current(),
- MycPathProfile::InteractiveUser,
- None,
- )
- .expect("current process should resolve myc runtime paths")
- }
-}
-
-impl Default for MycServiceConfig {
- fn default() -> Self {
- Self {
- instance_name: "myc".to_owned(),
- }
- }
-}
-
impl Default for MycLoggingConfig {
fn default() -> Self {
Self {
@@ -352,10 +330,10 @@ impl MycIdentityBackend {
}
}
-const MYC_ALLOWED_PROFILES: [MycPathProfile; 3] = [
- MycPathProfile::InteractiveUser,
- MycPathProfile::ServiceHost,
- MycPathProfile::RepoLocal,
+const MYC_ALLOWED_PROFILES: [MycBootstrapProfileV1; 3] = [
+ MycBootstrapProfileV1::Interactive,
+ MycBootstrapProfileV1::ServiceHost,
+ MycBootstrapProfileV1::RepoLocal,
];
const MYC_ALLOWED_SHARED_SECRET_BACKENDS: [MycIdentityBackend; 4] = [
MycIdentityBackend::EncryptedFile,
@@ -371,7 +349,7 @@ const MYC_CANONICAL_SUBORDINATE_PATH_OVERRIDE: &str = "config_document_cli_only"
const MYC_LEAF_PATH_ENV_POSTURE: &str = "forbidden";
impl MycRuntimeContractOutput {
- pub fn for_active_profile(active_profile: MycPathProfile) -> Self {
+ pub fn for_active_profile(active_profile: MycBootstrapProfileV1) -> Self {
Self {
active_profile,
allowed_profiles: MYC_ALLOWED_PROFILES.to_vec(),
@@ -382,11 +360,12 @@ impl MycRuntimeContractOutput {
.map(str::to_owned)
.collect(),
host_vault_policy: Some(MYC_HOST_VAULT_POLICY.to_owned()),
- path_overrides: RadrootsRuntimePathPolicyContract::new(
- MYC_CANONICAL_ROOT_SELECTION,
- MYC_CANONICAL_SUBORDINATE_PATH_OVERRIDE,
- MYC_LEAF_PATH_ENV_POSTURE,
- ),
+ path_overrides: MycRuntimePathOverrideContractOutput {
+ canonical_root_selection: MYC_CANONICAL_ROOT_SELECTION.to_owned(),
+ canonical_subordinate_path_override: MYC_CANONICAL_SUBORDINATE_PATH_OVERRIDE
+ .to_owned(),
+ leaf_path_env_posture: MYC_LEAF_PATH_ENV_POSTURE.to_owned(),
+ },
}
}
}
@@ -410,7 +389,7 @@ impl MycRuntimeAuditBackend {
}
impl MycConfig {
- pub fn allowed_profiles() -> Vec<MycPathProfile> {
+ pub fn allowed_profiles() -> Vec<MycBootstrapProfileV1> {
MYC_ALLOWED_PROFILES.to_vec()
}
@@ -434,37 +413,49 @@ impl MycConfig {
}
pub fn runtime_contract_output(&self) -> MycRuntimeContractOutput {
- MycRuntimeContractOutput::for_active_profile(self.paths.profile)
+ MycRuntimeContractOutput::for_active_profile(self.paths.runtime_context().profile())
}
- fn default_with_path_selection(
- resolver: &RadrootsPathResolver,
- profile: MycPathProfile,
- repo_local_root: Option<&Path>,
- ) -> Result<Self, MycError> {
- let mut config = Self {
- service: MycServiceConfig::default(),
- logging: MycLoggingConfig::default(),
+ #[must_use]
+ pub fn from_runtime_context(runtime_context: MycRuntimeContext) -> Self {
+ let logs_dir = runtime_context.context().paths().logs().to_path_buf();
+ let nip05_output_path = runtime_context
+ .context()
+ .paths()
+ .state()
+ .join("public/.well-known/nostr.json");
+ let logging = MycLoggingConfig {
+ output_dir: Some(logs_dir),
+ ..MycLoggingConfig::default()
+ };
+ let discovery = MycDiscoveryConfig {
+ nip05_output_path: Some(nip05_output_path),
+ ..MycDiscoveryConfig::default()
+ };
+ Self {
+ logging,
custody: MycCustodyConfig::default(),
- paths: MycPathsConfig::default_with_path_selection(resolver, profile, repo_local_root)?,
+ paths: MycPathsConfig::from_runtime_context(runtime_context),
persistence: MycPersistenceConfig::default(),
audit: MycAuditConfig::default(),
observability: MycObservabilityConfig::default(),
- discovery: MycDiscoveryConfig::default(),
+ discovery,
policy: MycPolicyConfig::default(),
transport: MycTransportConfig::default(),
- };
- crate::paths::apply_path_defaults(&mut config, resolver, &MycPathOverrideFlags::default())?;
- Ok(config)
+ }
}
- pub fn validate(&self) -> Result<(), MycError> {
- if self.service.instance_name.trim().is_empty() {
- return Err(MycError::InvalidConfig(
- "service.instance_name must not be empty".to_owned(),
- ));
- }
+ #[must_use]
+ pub fn runtime_context(&self) -> &MycRuntimeContext {
+ self.paths.runtime_context()
+ }
+
+ #[must_use]
+ pub fn paths(&self) -> &MycPathsConfig {
+ &self.paths
+ }
+ pub fn validate(&self) -> Result<(), MycError> {
if self.logging.filter.trim().is_empty() {
return Err(MycError::InvalidConfig(
"logging.filter must not be empty".to_owned(),
@@ -486,12 +477,6 @@ impl MycConfig {
));
}
- if self.paths.state_dir.as_os_str().is_empty() {
- return Err(MycError::InvalidConfig(
- "paths.state_dir must not be empty".to_owned(),
- ));
- }
-
if self.custody.external_command_timeout_secs == 0 {
return Err(MycError::InvalidConfig(
"custody.external_command_timeout_secs must be greater than zero".to_owned(),
@@ -649,6 +634,33 @@ impl MycConfig {
}
}
+#[cfg(test)]
+pub(crate) fn test_config(repo_local_root: &std::path::Path) -> MycConfig {
+ use std::ffi::OsString;
+
+ use crate::{
+ RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, parse_myc_cli_v1_from,
+ resolve_myc_runtime_context,
+ };
+
+ let invocation = parse_myc_cli_v1_from(vec![
+ OsString::from("myc"),
+ OsString::from("--profile"),
+ OsString::from("repo-local"),
+ OsString::from("--instance"),
+ OsString::from("test"),
+ OsString::from("--repo-local-root"),
+ repo_local_root.as_os_str().to_owned(),
+ OsString::from("run"),
+ ])
+ .expect("test CLI selection");
+ let resolver =
+ RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default());
+ let context =
+ resolve_myc_runtime_context(&resolver, &invocation).expect("test runtime context");
+ MycConfig::from_runtime_context(context)
+}
+
fn validate_optional_rate_limit(
label: &str,
window_secs: Option<u64>,
@@ -1031,45 +1043,66 @@ fn discovery_host_is_local(host: Option<&str>) -> bool {
#[cfg(test)]
mod tests {
- use crate::paths::{RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform};
-
use super::*;
+ use crate::{
+ RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, parse_myc_cli_v1_from,
+ resolve_myc_runtime_context,
+ };
- fn linux_resolver(home: &str) -> RadrootsPathResolver {
- RadrootsPathResolver::new(
+ fn config_for(
+ resolver: &RadrootsPathResolver,
+ profile: &str,
+ instance: &str,
+ repo_local_root: Option<&str>,
+ ) -> MycConfig {
+ let mut arguments = vec!["myc", "--profile", profile, "--instance", instance];
+ if let Some(root) = repo_local_root {
+ arguments.extend(["--repo-local-root", root]);
+ }
+ arguments.push("run");
+ let invocation = parse_myc_cli_v1_from(arguments).expect("CLI selection");
+ let context = resolve_myc_runtime_context(resolver, &invocation).expect("runtime context");
+ MycConfig::from_runtime_context(context)
+ }
+
+ fn default_config() -> MycConfig {
+ super::test_config(std::path::Path::new("/repo/.local/radroots"))
+ }
+
+ #[test]
+ fn interactive_config_is_context_derived() {
+ let resolver = RadrootsPathResolver::new(
RadrootsPlatform::Linux,
RadrootsHostEnvironment {
- home_dir: Some(PathBuf::from(home)),
+ home_dir: Some(PathBuf::from("/home/treesap")),
+ xdg_runtime_dir: Some(PathBuf::from("/run/user/1000")),
..RadrootsHostEnvironment::default()
},
- )
- }
-
- #[test]
- fn default_config_is_stable() {
- let resolver = linux_resolver("/home/treesap");
- let config = MycConfig::default_with_path_selection(
- &resolver,
- MycPathProfile::InteractiveUser,
- None,
- )
- .expect("default config");
- assert_eq!(config.service.instance_name, "myc");
+ );
+ let config = config_for(&resolver, "interactive", "primary", None);
assert_eq!(config.logging.filter, "info,myc=info");
- assert_eq!(config.paths.profile, MycPathProfile::InteractiveUser);
- assert_eq!(config.paths.repo_local_root, None);
assert_eq!(
- config.paths.run_dir,
- PathBuf::from("/home/treesap/.radroots/run/services/myc")
+ config.runtime_context().profile(),
+ MycBootstrapProfileV1::Interactive
+ );
+ assert_eq!(
+ config.runtime_context().context().instance().as_str(),
+ "primary"
+ );
+ assert_eq!(
+ config.paths.run_dir(),
+ PathBuf::from("/run/user/1000/radroots/services/myc/primary")
);
assert_eq!(
config.logging.output_dir,
- Some(PathBuf::from("/home/treesap/.radroots/logs/services/myc"))
+ Some(PathBuf::from(
+ "/home/treesap/.local/state/radroots/logs/services/myc/primary"
+ ))
);
assert!(config.logging.stdout);
assert_eq!(
- config.paths.state_dir,
- PathBuf::from("/home/treesap/.radroots/data/services/myc/state")
+ config.paths.state_dir(),
+ PathBuf::from("/home/treesap/.local/share/radroots/services/myc/primary")
);
assert_eq!(
config.paths.signer_identity_backend,
@@ -1077,7 +1110,9 @@ mod tests {
);
assert_eq!(
config.paths.signer_identity_path,
- PathBuf::from("/home/treesap/.radroots/secrets/services/myc/signer-identity.json")
+ PathBuf::from(
+ "/home/treesap/.config/radroots/secrets/services/myc/primary/signer-identity.json"
+ )
);
assert_eq!(config.paths.signer_identity_keyring_account_id, None);
assert_eq!(
@@ -1091,7 +1126,9 @@ mod tests {
);
assert_eq!(
config.paths.user_identity_path,
- PathBuf::from("/home/treesap/.radroots/secrets/services/myc/user-identity.json")
+ PathBuf::from(
+ "/home/treesap/.config/radroots/secrets/services/myc/primary/user-identity.json"
+ )
);
assert_eq!(config.paths.user_identity_keyring_account_id, None);
assert_eq!(
@@ -1144,7 +1181,7 @@ mod tests {
assert_eq!(
config.discovery.nip05_output_path,
Some(PathBuf::from(
- "/home/treesap/.radroots/data/services/myc/public/.well-known/nostr.json"
+ "/home/treesap/.local/share/radroots/services/myc/primary/public/.well-known/nostr.json"
))
);
assert!(!config.transport.enabled);
@@ -1162,82 +1199,87 @@ mod tests {
#[test]
fn service_host_profile_uses_canonical_defaults() {
- let resolver = linux_resolver("/home/treesap");
- let config =
- MycConfig::default_with_path_selection(&resolver, MycPathProfile::ServiceHost, None)
- .expect("service-host config");
+ let resolver =
+ RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default());
+ let config = config_for(&resolver, "service-host", "primary", None);
- assert_eq!(config.paths.profile, MycPathProfile::ServiceHost);
+ assert_eq!(
+ config.runtime_context().profile(),
+ MycBootstrapProfileV1::ServiceHost
+ );
assert_eq!(
config.logging.output_dir,
- Some(PathBuf::from("/var/log/radroots/services/myc"))
+ Some(PathBuf::from("/var/log/radroots/services/myc/primary"))
);
assert_eq!(
- config.paths.run_dir,
- PathBuf::from("/run/radroots/services/myc")
+ config.paths.run_dir(),
+ PathBuf::from("/run/radroots/services/myc/primary")
);
assert_eq!(
- config.paths.state_dir,
- PathBuf::from("/var/lib/radroots/services/myc/state")
+ config.paths.state_dir(),
+ PathBuf::from("/var/lib/radroots/services/myc/primary")
);
assert_eq!(
config.paths.signer_identity_path,
- PathBuf::from("/etc/radroots/secrets/services/myc/signer-identity.json")
+ PathBuf::from("/etc/radroots/secrets/services/myc/primary/signer-identity.json")
);
assert_eq!(
config.paths.user_identity_path,
- PathBuf::from("/etc/radroots/secrets/services/myc/user-identity.json")
+ PathBuf::from("/etc/radroots/secrets/services/myc/primary/user-identity.json")
);
assert_eq!(
config.discovery.nip05_output_path,
Some(PathBuf::from(
- "/var/lib/radroots/services/myc/public/.well-known/nostr.json"
+ "/var/lib/radroots/services/myc/primary/public/.well-known/nostr.json"
))
);
}
#[test]
fn repo_local_profile_uses_explicit_repo_local_root() {
- let resolver = linux_resolver("/home/treesap");
+ let resolver =
+ RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default());
let repo_local_root = PathBuf::from("/repo/.local/radroots/dev/myc");
- let config = MycConfig::default_with_path_selection(
+ let config = config_for(
&resolver,
- MycPathProfile::RepoLocal,
- Some(repo_local_root.as_path()),
- )
- .expect("repo-local config");
+ "repo-local",
+ "primary",
+ Some("/repo/.local/radroots/dev/myc"),
+ );
- assert_eq!(config.paths.profile, MycPathProfile::RepoLocal);
- assert_eq!(config.paths.repo_local_root, Some(repo_local_root.clone()));
+ assert_eq!(
+ config.runtime_context().profile(),
+ MycBootstrapProfileV1::RepoLocal
+ );
assert_eq!(
config.logging.output_dir,
- Some(repo_local_root.join("logs/services/myc"))
+ Some(repo_local_root.join("logs/services/myc/primary"))
);
assert_eq!(
- config.paths.run_dir,
- repo_local_root.join("run/services/myc")
+ config.paths.run_dir(),
+ repo_local_root.join("run/services/myc/primary")
);
assert_eq!(
- config.paths.state_dir,
- repo_local_root.join("data/services/myc/state")
+ config.paths.state_dir(),
+ repo_local_root.join("data/services/myc/primary")
);
assert_eq!(
config.paths.signer_identity_path,
- repo_local_root.join("secrets/services/myc/signer-identity.json")
+ repo_local_root.join("secrets/services/myc/primary/signer-identity.json")
);
assert_eq!(
config.paths.user_identity_path,
- repo_local_root.join("secrets/services/myc/user-identity.json")
+ repo_local_root.join("secrets/services/myc/primary/user-identity.json")
);
assert_eq!(
config.discovery.nip05_output_path,
- Some(repo_local_root.join("data/services/myc/public/.well-known/nostr.json"))
+ Some(repo_local_root.join("data/services/myc/primary/public/.well-known/nostr.json"))
);
}
#[test]
fn validate_rejects_enabled_transport_without_relays() {
- let mut config = MycConfig::default();
+ let mut config = default_config();
config.transport.enabled = true;
let err = config.validate().expect_err("missing relays");
@@ -1246,7 +1288,7 @@ mod tests {
#[test]
fn validate_rejects_zero_audit_read_limit() {
- let mut config = MycConfig::default();
+ let mut config = default_config();
config.audit.default_read_limit = 0;
let err = config.validate().expect_err("invalid audit read limit");
@@ -1255,7 +1297,7 @@ mod tests {
#[test]
fn validate_rejects_zero_external_command_timeout() {
- let mut config = MycConfig::default();
+ let mut config = default_config();
config.custody.external_command_timeout_secs = 0;
let err = config.validate().expect_err("invalid custody timeout");
@@ -1267,7 +1309,7 @@ mod tests {
#[test]
fn validate_rejects_non_loopback_observability_bind_addr() {
- let mut config = MycConfig::default();
+ let mut config = default_config();
config.observability.enabled = true;
config.observability.bind_addr = "0.0.0.0:9460"
.parse()
@@ -1284,7 +1326,7 @@ mod tests {
#[test]
fn discovery_validation_requires_domain_and_relays_when_enabled() {
- let mut config = MycConfig::default();
+ let mut config = default_config();
config.discovery.enabled = true;
config.transport.enabled = true;
config.transport.relays = vec!["wss://relay.example.com".to_owned()];
@@ -1300,7 +1342,7 @@ mod tests {
#[test]
fn discovery_validation_allows_localhost_http_nostrconnect_template() {
- let mut config = MycConfig::default();
+ let mut config = default_config();
config.discovery.enabled = true;
config.discovery.domain = Some("localhost".to_owned());
config.discovery.public_relays = vec!["ws://localhost:8080".to_owned()];
@@ -1312,7 +1354,7 @@ mod tests {
#[test]
fn discovery_validation_rejects_invalid_nostrconnect_template() {
- let mut config = MycConfig::default();
+ let mut config = default_config();
config.discovery.enabled = true;
config.discovery.domain = Some("myc.example.com".to_owned());
config.discovery.public_relays = vec!["wss://relay.example.com".to_owned()];
@@ -1327,7 +1369,7 @@ mod tests {
#[test]
fn validate_rejects_invalid_delivery_policy_settings() {
- let mut config = MycConfig::default();
+ let mut config = default_config();
config.transport.enabled = true;
config.transport.relays = vec!["wss://relay.example.com".to_owned()];
config.transport.delivery_policy = MycTransportDeliveryPolicy::Quorum;
@@ -1353,7 +1395,7 @@ mod tests {
#[test]
fn validate_rejects_invalid_publish_retry_settings() {
- let mut config = MycConfig::default();
+ let mut config = default_config();
config.transport.publish_max_attempts = 0;
let err = config.validate().expect_err("zero attempts");
assert!(err.to_string().contains("publish_max_attempts"));
@@ -1377,7 +1419,7 @@ mod tests {
#[test]
fn validate_rejects_overlapping_policy_client_lists() {
- let mut config = MycConfig::default();
+ let mut config = default_config();
config.policy.trusted_client_pubkeys =
vec!["1111111111111111111111111111111111111111111111111111111111111111".to_owned()];
config.policy.denied_client_pubkeys =
@@ -1391,7 +1433,7 @@ mod tests {
#[test]
fn validate_requires_auth_url_for_auth_ttl_policy() {
- let mut config = MycConfig::default();
+ let mut config = default_config();
config.policy.auth_authorized_ttl_secs = Some(60);
let err = config.validate().expect_err("missing auth url");
@@ -1400,7 +1442,7 @@ mod tests {
#[test]
fn validate_requires_complete_rate_limit_pairs() {
- let mut config = MycConfig::default();
+ let mut config = default_config();
config.policy.connect_rate_limit_window_secs = Some(60);
let err = config
@@ -1408,7 +1450,7 @@ mod tests {
.expect_err("incomplete connect rate limit");
assert!(err.to_string().contains("policy.connect_rate_limit"));
- let mut config = MycConfig::default();
+ let mut config = default_config();
config.policy.auth_challenge_rate_limit_max_attempts = Some(2);
let err = config
@@ -1419,10 +1461,10 @@ mod tests {
#[test]
fn runtime_contract_output_matches_shared_runtime_contract() {
- let config = MycConfig::default();
+ let config = default_config();
let contract = config.runtime_contract_output();
- assert_eq!(contract.active_profile, MycPathProfile::InteractiveUser);
+ assert_eq!(contract.active_profile, MycBootstrapProfileV1::RepoLocal);
assert_eq!(contract.allowed_profiles, MycConfig::allowed_profiles());
assert_eq!(
contract.default_shared_secret_backend,
diff --git a/src/control.rs b/src/control.rs
@@ -814,8 +814,7 @@ mod tests {
F: FnOnce(&mut MycConfig),
{
let temp = tempfile::tempdir().expect("tempdir").keep();
- let mut config = MycConfig::default();
- config.paths.state_dir = PathBuf::from(&temp).join("state");
+ let mut config = crate::config::test_config(PathBuf::from(&temp).as_path());
config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json");
config.paths.user_identity_path = PathBuf::from(&temp).join("user.json");
config.policy.connection_approval = approval;
diff --git a/src/discovery.rs b/src/discovery.rs
@@ -2122,8 +2122,6 @@ mod tests {
use crate::host_identity::RadrootsIdentity;
use nostr::JsonUtil;
- use crate::config::MycConfig;
-
use super::{MycDiscoveryContext, build_metadata, verify_bundle, write_pretty_json};
use crate::MycError;
use crate::app::MycRuntime;
@@ -2135,8 +2133,7 @@ mod tests {
fn runtime() -> MycRuntime {
let temp = tempfile::tempdir().expect("tempdir").keep();
- let mut config = MycConfig::default();
- config.paths.state_dir = PathBuf::from(&temp).join("state");
+ let mut config = crate::config::test_config(PathBuf::from(&temp).as_path());
config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json");
config.paths.user_identity_path = PathBuf::from(&temp).join("user.json");
config.discovery.enabled = true;
diff --git a/src/lib.rs b/src/lib.rs
@@ -21,6 +21,7 @@ mod outbox_sqlite;
mod paths;
pub mod persistence;
pub mod policy;
+mod runtime_context;
pub mod signer;
mod signing_adapter;
pub mod sql;
@@ -35,16 +36,15 @@ pub use audit::{
};
pub use audit_sqlite::MycSqliteOperationAuditStore;
pub use cli_v1::{
- MYC_INSTANCE_ID_MAX_BYTES, MycBootstrapProfileV1, MycCliInvocationV1, MycCliV1Error,
- MycCliV1ErrorKind, MycCommandV1, MycConfigCommandV1, MycIdentityCommandV1, MycStateCommandV1,
- parse_myc_cli_v1_from,
+ MycBootstrapProfileV1, MycCliInvocationV1, MycCliV1Error, MycCliV1ErrorKind, MycCommandV1,
+ MycConfigCommandV1, MycIdentityCommandV1, MycStateCommandV1, parse_myc_cli_v1_from,
};
pub use config::{
MycAuditConfig, MycConfig, MycConnectionApproval, MycCustodyConfig, MycDiscoveryConfig,
MycDiscoveryMetadataConfig, MycIdentityBackend, MycIdentitySourceSpec, MycLoggingConfig,
MycObservabilityConfig, MycPathsConfig, MycPersistenceConfig, MycPolicyConfig,
- MycRuntimeAuditBackend, MycRuntimeContractOutput, MycServiceConfig, MycSignerStateBackend,
- MycTransportConfig, MycTransportDeliveryPolicy,
+ MycRuntimeAuditBackend, MycRuntimeContractOutput, MycSignerStateBackend, MycTransportConfig,
+ MycTransportDeliveryPolicy,
};
pub use config_v1::{
MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES, MYC_CONFIG_SCHEMA, MYC_CONFIG_SCHEMA_VERSION,
@@ -95,4 +95,13 @@ pub use persistence::{
verify_restored_state,
};
pub use policy::{MycConnectDecision, MycPolicyContext};
+pub use radroots_runtime_paths::{
+ INSTANCE_ID_MAX_BYTES, InstanceId, RadrootsHostEnvironment, RadrootsPathProfile,
+ RadrootsPathResolver, RadrootsPlatform, RadrootsServiceInstanceArtifacts, RuntimeContext,
+ RuntimeContextSource, ServiceId,
+};
+pub use runtime_context::{
+ MycRuntimeContext, MycRuntimeContextError, MycRuntimeContextErrorKind,
+ resolve_myc_runtime_context,
+};
pub use transport::{MycNostrTransport, MycRelayPublishResult, MycTransportSnapshot};
diff --git a/src/operability/mod.rs b/src/operability/mod.rs
@@ -1662,7 +1662,7 @@ mod tests {
};
use crate::app::{MycRuntime, MycRuntimePaths};
use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord};
- use crate::config::{MycConfig, MycRuntimeAuditBackend};
+ use crate::config::MycRuntimeAuditBackend;
fn write_test_identity(path: &Path, secret_key: &str) {
let identity =
@@ -1757,8 +1757,7 @@ mod tests {
#[test]
fn collect_metrics_uses_live_state_after_bootstrap() {
let temp = tempfile::tempdir().expect("tempdir");
- let mut config = MycConfig::default();
- config.paths.state_dir = temp.path().join("state");
+ let mut config = crate::config::test_config(temp.path());
config.paths.signer_identity_path = temp.path().join("signer.json");
config.paths.user_identity_path = temp.path().join("user.json");
write_test_identity(
@@ -1824,8 +1823,7 @@ mod tests {
};
let temp = tempfile::tempdir().expect("tempdir");
- let mut config = MycConfig::default();
- config.paths.state_dir = temp.path().join("state");
+ let mut config = crate::config::test_config(temp.path());
config.paths.signer_identity_path = temp.path().join("signer.json");
config.paths.user_identity_path = temp.path().join("user.json");
write_test_identity(
@@ -1867,8 +1865,7 @@ mod tests {
use crate::signer::prelude::RadrootsNostrSignerBackend;
let temp = tempfile::tempdir().expect("tempdir");
- let mut config = MycConfig::default();
- config.paths.state_dir = temp.path().join("state");
+ let mut config = crate::config::test_config(temp.path());
config.paths.signer_identity_path = temp.path().join("signer.json");
config.paths.user_identity_path = temp.path().join("user.json");
config.transport.enabled = true;
diff --git a/src/paths.rs b/src/paths.rs
@@ -1,362 +1,107 @@
+use core::fmt;
use std::path::{Path, PathBuf};
-use serde::{Deserialize, Serialize};
-
use crate::{
- config::{MycConfig, MycIdentityBackend, MycIdentitySourceSpec},
- error::MycError,
+ config::{MycIdentityBackend, MycIdentitySourceSpec},
+ runtime_context::MycRuntimeContext,
};
-const DEFAULT_STATE_DIR_NAME: &str = "state";
-const DEFAULT_CUSTODY_DIR_NAME: &str = "custody";
const DEFAULT_SIGNER_IDENTITY_FILE_NAME: &str = "signer-identity.json";
const DEFAULT_USER_IDENTITY_FILE_NAME: &str = "user-identity.json";
-const DEFAULT_DISCOVERY_APP_IDENTITY_FILE_NAME: &str = "discovery-app-identity.json";
-const DEFAULT_SIGNER_MANAGED_ACCOUNT_FILE_NAME: &str = "signer-accounts.json";
-const DEFAULT_USER_MANAGED_ACCOUNT_FILE_NAME: &str = "user-accounts.json";
-const DEFAULT_DISCOVERY_MANAGED_ACCOUNT_FILE_NAME: &str = "discovery-accounts.json";
-const DEFAULT_DISCOVERY_PUBLIC_DIR_NAME: &str = "public";
-const DEFAULT_DISCOVERY_NIP05_RELATIVE_PATH: &str = ".well-known/nostr.json";
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-#[allow(dead_code)]
-pub enum RadrootsPlatform {
- Linux,
- Macos,
- Windows,
-}
-
-#[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 {
- fn current() -> 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),
- }
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-#[allow(dead_code)]
-pub enum RadrootsPathProfile {
- InteractiveUser,
- ServiceHost,
- RepoLocal,
- MobileNative,
-}
-
-#[derive(Debug, Clone)]
-pub struct RadrootsPathResolver {
- platform: RadrootsPlatform,
- environment: RadrootsHostEnvironment,
-}
-
-impl RadrootsPathResolver {
- pub const fn new(platform: RadrootsPlatform, environment: RadrootsHostEnvironment) -> Self {
- Self {
- platform,
- environment,
- }
- }
-
- pub fn current() -> Self {
- #[cfg(target_os = "windows")]
- let platform = RadrootsPlatform::Windows;
- #[cfg(target_os = "macos")]
- let platform = RadrootsPlatform::Macos;
- #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
- let platform = RadrootsPlatform::Linux;
- Self::new(platform, RadrootsHostEnvironment::current())
- }
-
- fn roots(
- &self,
- profile: RadrootsPathProfile,
- repo_local_root: Option<&Path>,
- ) -> Result<RuntimeRoots, String> {
- match profile {
- RadrootsPathProfile::RepoLocal => repo_local_root
- .map(RuntimeRoots::from_base)
- .ok_or_else(|| "repo_local requires an explicit root".to_owned()),
- RadrootsPathProfile::ServiceHost => match self.platform {
- RadrootsPlatform::Linux | RadrootsPlatform::Macos => Ok(RuntimeRoots {
- config: PathBuf::from("/etc/radroots"),
- data: PathBuf::from("/var/lib/radroots"),
- logs: PathBuf::from("/var/log/radroots"),
- run: PathBuf::from("/run/radroots"),
- secrets: PathBuf::from("/etc/radroots/secrets"),
- }),
- RadrootsPlatform::Windows => {
- let base = self
- .environment
- .programdata_dir
- .as_deref()
- .ok_or_else(|| "PROGRAMDATA is required".to_owned())?
- .join("Radroots");
- Ok(RuntimeRoots::from_base(&base))
- }
- },
- RadrootsPathProfile::InteractiveUser | RadrootsPathProfile::MobileNative => {
- match self.platform {
- RadrootsPlatform::Linux | RadrootsPlatform::Macos => {
- let base = self
- .environment
- .home_dir
- .as_deref()
- .ok_or_else(|| "HOME is required".to_owned())?
- .join(".radroots");
- Ok(RuntimeRoots::from_base(&base))
- }
- RadrootsPlatform::Windows => {
- let roaming = self
- .environment
- .appdata_dir
- .as_deref()
- .ok_or_else(|| "APPDATA is required".to_owned())?
- .join("Radroots");
- let local = self
- .environment
- .localappdata_dir
- .as_deref()
- .ok_or_else(|| "LOCALAPPDATA is required".to_owned())?
- .join("Radroots");
- Ok(RuntimeRoots {
- config: roaming.join("config"),
- data: local.join("data"),
- logs: local.join("logs"),
- run: local.join("run"),
- secrets: roaming.join("secrets"),
- })
- }
- }
- }
- }
- }
-}
-#[derive(Debug, Clone)]
-struct RuntimeRoots {
- config: PathBuf,
- data: PathBuf,
- logs: PathBuf,
- run: PathBuf,
- secrets: PathBuf,
+/// Transitional prototype runtime inputs derived from a sealed Myc context.
+///
+/// The fields are crate-private so external callers cannot replace canonical
+/// service-instance paths independently of the typed runtime context. Later
+/// ordered state/provider checkpoints remove the remaining prototype provider
+/// fields rather than promoting them into the hardened configuration contract.
+///
+/// ```compile_fail
+/// use myc::MycPathsConfig;
+///
+/// let _ = MycPathsConfig {
+/// runtime_context: todo!(),
+/// signer_identity_backend: todo!(),
+/// signer_identity_path: todo!(),
+/// signer_identity_keyring_account_id: None,
+/// signer_identity_keyring_service_name: String::new(),
+/// signer_identity_profile_path: None,
+/// user_identity_backend: todo!(),
+/// user_identity_path: todo!(),
+/// user_identity_keyring_account_id: None,
+/// user_identity_keyring_service_name: String::new(),
+/// user_identity_profile_path: None,
+/// };
+/// ```
+#[derive(Clone, PartialEq, Eq)]
+pub struct MycPathsConfig {
+ pub(crate) runtime_context: MycRuntimeContext,
+ pub(crate) signer_identity_backend: MycIdentityBackend,
+ pub(crate) signer_identity_path: PathBuf,
+ pub(crate) signer_identity_keyring_account_id: Option<String>,
+ pub(crate) signer_identity_keyring_service_name: String,
+ pub(crate) signer_identity_profile_path: Option<PathBuf>,
+ pub(crate) user_identity_backend: MycIdentityBackend,
+ pub(crate) user_identity_path: PathBuf,
+ pub(crate) user_identity_keyring_account_id: Option<String>,
+ pub(crate) user_identity_keyring_service_name: String,
+ pub(crate) user_identity_profile_path: Option<PathBuf>,
}
-impl RuntimeRoots {
- fn from_base(base: &Path) -> Self {
- Self {
- config: base.join("config"),
- data: base.join("data"),
- logs: base.join("logs"),
- run: base.join("run"),
- secrets: base.join("secrets"),
- }
- }
-
- fn service(self, service: &str) -> Self {
- Self {
- config: self.config.join("services").join(service),
- data: self.data.join("services").join(service),
- logs: self.logs.join("services").join(service),
- run: self.run.join("services").join(service),
- secrets: self.secrets.join("services").join(service),
- }
+impl fmt::Debug for MycPathsConfig {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("MycPathsConfig([redacted])")
}
}
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct RadrootsRuntimePathSelection {
- pub profile: RadrootsPathProfile,
- pub repo_local_root: Option<PathBuf>,
-}
-
-impl RadrootsRuntimePathSelection {
- pub fn caller(profile: RadrootsPathProfile, repo_local_root: Option<PathBuf>) -> Self {
+impl MycPathsConfig {
+ pub(crate) fn from_runtime_context(runtime_context: MycRuntimeContext) -> Self {
+ let secrets = runtime_context.context().paths().secrets();
+ let signer_identity_path = secrets.join(DEFAULT_SIGNER_IDENTITY_FILE_NAME);
+ let user_identity_path = secrets.join(DEFAULT_USER_IDENTITY_FILE_NAME);
Self {
- profile,
- repo_local_root,
+ runtime_context,
+ signer_identity_backend: MycIdentityBackend::EncryptedFile,
+ signer_identity_path,
+ signer_identity_keyring_account_id: None,
+ signer_identity_keyring_service_name: "org.radroots.myc.signer".to_owned(),
+ signer_identity_profile_path: None,
+ user_identity_backend: MycIdentityBackend::EncryptedFile,
+ user_identity_path,
+ user_identity_keyring_account_id: None,
+ user_identity_keyring_service_name: "org.radroots.myc.user".to_owned(),
+ user_identity_profile_path: None,
}
}
- fn resolve_service_roots(
- &self,
- resolver: &RadrootsPathResolver,
- service: &str,
- ) -> Result<RuntimeRoots, String> {
- Ok(resolver
- .roots(self.profile, self.repo_local_root.as_deref())?
- .service(service))
+ #[must_use]
+ pub fn runtime_context(&self) -> &MycRuntimeContext {
+ &self.runtime_context
}
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct RadrootsRuntimePathPolicyContract {
- pub canonical_root_selection: String,
- pub canonical_subordinate_path_override: String,
- pub leaf_path_env_posture: String,
-}
-impl RadrootsRuntimePathPolicyContract {
- pub fn new(root: &str, subordinate: &str, leaf: &str) -> Self {
- Self {
- canonical_root_selection: root.to_owned(),
- canonical_subordinate_path_override: subordinate.to_owned(),
- leaf_path_env_posture: leaf.to_owned(),
- }
+ #[must_use]
+ pub fn state_dir(&self) -> &Path {
+ self.runtime_context.context().paths().state()
}
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(default, deny_unknown_fields)]
-pub struct MycPathsConfig {
- pub profile: MycPathProfile,
- pub repo_local_root: Option<PathBuf>,
- pub run_dir: PathBuf,
- pub state_dir: PathBuf,
- pub signer_identity_backend: MycIdentityBackend,
- pub signer_identity_path: PathBuf,
- pub signer_identity_keyring_account_id: Option<String>,
- pub signer_identity_keyring_service_name: String,
- pub signer_identity_profile_path: Option<PathBuf>,
- pub user_identity_backend: MycIdentityBackend,
- pub user_identity_path: PathBuf,
- pub user_identity_keyring_account_id: Option<String>,
- pub user_identity_keyring_service_name: String,
- pub user_identity_profile_path: Option<PathBuf>,
-}
-
-#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum MycPathProfile {
- #[default]
- InteractiveUser,
- ServiceHost,
- RepoLocal,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-struct MycResolvedRuntimePaths {
- logs_dir: PathBuf,
- run_dir: PathBuf,
- state_dir: PathBuf,
- signer_identity_path: PathBuf,
- user_identity_path: PathBuf,
- signer_managed_account_path: PathBuf,
- user_managed_account_path: PathBuf,
- discovery_app_identity_path: PathBuf,
- discovery_managed_account_path: PathBuf,
- discovery_nip05_output_path: PathBuf,
-}
-
-#[derive(Debug, Clone, Default, PartialEq, Eq)]
-pub(crate) struct MycPathOverrideFlags {
- pub(crate) logging_output_dir: bool,
- pub(crate) state_dir: bool,
- pub(crate) signer_identity_path: bool,
- pub(crate) user_identity_path: bool,
- pub(crate) discovery_app_identity_path: bool,
- pub(crate) discovery_nip05_output_path: bool,
-}
-
-impl Default for MycPathsConfig {
- fn default() -> Self {
- Self::default_with_path_selection(
- &RadrootsPathResolver::current(),
- MycPathProfile::InteractiveUser,
- None,
- )
- .expect("current process should resolve myc runtime paths")
- }
-}
-impl MycPathProfile {
- pub fn as_str(self) -> &'static str {
- match self {
- Self::InteractiveUser => "interactive_user",
- Self::ServiceHost => "service_host",
- Self::RepoLocal => "repo_local",
- }
+ #[must_use]
+ pub fn run_dir(&self) -> &Path {
+ self.runtime_context.context().paths().run()
}
- fn into_radroots_profile(self) -> RadrootsPathProfile {
- match self {
- Self::InteractiveUser => RadrootsPathProfile::InteractiveUser,
- Self::ServiceHost => RadrootsPathProfile::ServiceHost,
- Self::RepoLocal => RadrootsPathProfile::RepoLocal,
- }
+ #[must_use]
+ pub fn logs_dir(&self) -> &Path {
+ self.runtime_context.context().paths().logs()
}
-}
-impl MycResolvedRuntimePaths {
- fn resolve(
- resolver: &RadrootsPathResolver,
- profile: MycPathProfile,
- repo_local_root: Option<&Path>,
- ) -> Result<Self, MycError> {
- let selection = RadrootsRuntimePathSelection::caller(
- profile.into_radroots_profile(),
- repo_local_root.map(Path::to_path_buf),
- );
- let namespaced = selection
- .resolve_service_roots(resolver, "myc")
- .map_err(|error| {
- MycError::InvalidConfig(format!("resolve myc runtime paths: {error}"))
- })?;
- let custody_dir = namespaced.data.join(DEFAULT_CUSTODY_DIR_NAME);
- Ok(Self {
- logs_dir: namespaced.logs,
- run_dir: namespaced.run,
- state_dir: namespaced.data.join(DEFAULT_STATE_DIR_NAME),
- signer_identity_path: namespaced.secrets.join(DEFAULT_SIGNER_IDENTITY_FILE_NAME),
- user_identity_path: namespaced.secrets.join(DEFAULT_USER_IDENTITY_FILE_NAME),
- signer_managed_account_path: custody_dir.join(DEFAULT_SIGNER_MANAGED_ACCOUNT_FILE_NAME),
- user_managed_account_path: custody_dir.join(DEFAULT_USER_MANAGED_ACCOUNT_FILE_NAME),
- discovery_app_identity_path: namespaced
- .secrets
- .join(DEFAULT_DISCOVERY_APP_IDENTITY_FILE_NAME),
- discovery_managed_account_path: custody_dir
- .join(DEFAULT_DISCOVERY_MANAGED_ACCOUNT_FILE_NAME),
- discovery_nip05_output_path: namespaced
- .data
- .join(DEFAULT_DISCOVERY_PUBLIC_DIR_NAME)
- .join(DEFAULT_DISCOVERY_NIP05_RELATIVE_PATH),
- })
+ #[must_use]
+ pub fn signer_identity_path(&self) -> &Path {
+ &self.signer_identity_path
}
-}
-impl MycPathsConfig {
- pub(crate) fn default_with_path_selection(
- resolver: &RadrootsPathResolver,
- profile: MycPathProfile,
- repo_local_root: Option<&Path>,
- ) -> Result<Self, MycError> {
- let resolved = MycResolvedRuntimePaths::resolve(resolver, profile, repo_local_root)?;
- Ok(Self {
- profile,
- repo_local_root: repo_local_root.map(Path::to_path_buf),
- run_dir: resolved.run_dir,
- state_dir: resolved.state_dir,
- signer_identity_backend: MycIdentityBackend::EncryptedFile,
- signer_identity_path: resolved.signer_identity_path,
- signer_identity_keyring_account_id: None,
- signer_identity_keyring_service_name: "org.radroots.myc.signer".to_owned(),
- signer_identity_profile_path: None,
- user_identity_backend: MycIdentityBackend::EncryptedFile,
- user_identity_path: resolved.user_identity_path,
- user_identity_keyring_account_id: None,
- user_identity_keyring_service_name: "org.radroots.myc.user".to_owned(),
- user_identity_profile_path: None,
- })
+ #[must_use]
+ pub fn user_identity_path(&self) -> &Path {
+ &self.user_identity_path
}
pub fn signer_identity_source(&self) -> MycIdentitySourceSpec {
@@ -429,58 +174,3 @@ impl MycPathsConfig {
}
}
}
-
-pub(crate) fn apply_path_defaults(
- config: &mut MycConfig,
- resolver: &RadrootsPathResolver,
- overrides: &MycPathOverrideFlags,
-) -> Result<(), MycError> {
- let resolved = MycResolvedRuntimePaths::resolve(
- resolver,
- config.paths.profile,
- config.paths.repo_local_root.as_deref(),
- )?;
- config.paths.run_dir = resolved.run_dir;
- if !overrides.logging_output_dir {
- config.logging.output_dir = Some(resolved.logs_dir);
- }
- if !overrides.state_dir {
- config.paths.state_dir = resolved.state_dir;
- }
- if !overrides.signer_identity_path {
- config.paths.signer_identity_path = match config.paths.signer_identity_backend {
- MycIdentityBackend::EncryptedFile | MycIdentityBackend::PlaintextFile => {
- resolved.signer_identity_path
- }
- MycIdentityBackend::ManagedAccount => resolved.signer_managed_account_path,
- MycIdentityBackend::HostVault => PathBuf::new(),
- MycIdentityBackend::ExternalCommand => PathBuf::new(),
- };
- }
- if !overrides.user_identity_path {
- config.paths.user_identity_path = match config.paths.user_identity_backend {
- MycIdentityBackend::EncryptedFile | MycIdentityBackend::PlaintextFile => {
- resolved.user_identity_path
- }
- MycIdentityBackend::ManagedAccount => resolved.user_managed_account_path,
- MycIdentityBackend::HostVault => PathBuf::new(),
- MycIdentityBackend::ExternalCommand => PathBuf::new(),
- };
- }
- if !overrides.discovery_app_identity_path {
- config.discovery.app_identity_path = match config.discovery.app_identity_backend {
- Some(MycIdentityBackend::EncryptedFile) | Some(MycIdentityBackend::PlaintextFile) => {
- Some(resolved.discovery_app_identity_path)
- }
- Some(MycIdentityBackend::ManagedAccount) => {
- Some(resolved.discovery_managed_account_path)
- }
- Some(MycIdentityBackend::HostVault) | None => None,
- Some(MycIdentityBackend::ExternalCommand) => None,
- };
- }
- if !overrides.discovery_nip05_output_path {
- config.discovery.nip05_output_path = Some(resolved.discovery_nip05_output_path);
- }
- Ok(())
-}
diff --git a/src/persistence.rs b/src/persistence.rs
@@ -252,10 +252,10 @@ pub fn import_json_to_sqlite(
) -> Result<MycPersistenceImportJsonToSqliteOutput, MycError> {
config.validate()?;
let selection = selection.resolve(config)?;
- let state_dir = &config.paths.state_dir;
+ let state_dir = config.paths.state_dir();
let audit_dir = MycRuntimePaths::audit_dir_for_state_dir(state_dir);
fs::create_dir_all(state_dir).map_err(|source| MycError::CreateDir {
- path: state_dir.clone(),
+ path: state_dir.to_path_buf(),
source,
})?;
fs::create_dir_all(&audit_dir).map_err(|source| MycError::CreateDir {
@@ -285,7 +285,7 @@ pub fn backup_persistence(
let output_dir = output_dir.as_ref().to_path_buf();
let backup_manifest_path = output_dir.join(MYC_PERSISTENCE_BACKUP_MANIFEST_FILE_NAME);
- let state_dir = &config.paths.state_dir;
+ let state_dir = config.paths.state_dir();
let audit_dir = MycRuntimePaths::audit_dir_for_state_dir(state_dir);
let signer_state_path = MycRuntimePaths::signer_state_path_for_backend(
state_dir,
@@ -350,7 +350,7 @@ pub fn backup_persistence(
backup_dir: output_dir.clone(),
manifest_path: backup_manifest_path,
state_dir: MycPersistenceBackupStateOutput {
- source_path: state_dir.clone(),
+ source_path: state_dir.to_path_buf(),
destination_path: backup_state_dir,
file_count: state_files.len(),
},
@@ -380,7 +380,7 @@ pub fn restore_backup(
)));
}
- ensure_restore_state_destination_clear(&config.paths.state_dir)?;
+ ensure_restore_state_destination_clear(config.paths.state_dir())?;
let signer_identity_reference = restore_identity_reference(
&backup_dir,
&manifest.signer_identity_reference,
@@ -404,14 +404,14 @@ pub fn restore_backup(
};
let restored_state_files =
- copy_dir_recursive_collect(&state_source_dir, &config.paths.state_dir)?;
+ copy_dir_recursive_collect(&state_source_dir, config.paths.state_dir())?;
Ok(MycPersistenceRestoreOutput {
backup_dir: backup_dir.clone(),
manifest_path: backup_manifest_path,
state_dir: MycPersistenceRestoreStateOutput {
source_path: state_source_dir,
- destination_path: config.paths.state_dir.clone(),
+ destination_path: config.paths.state_dir().to_path_buf(),
file_count: restored_state_files.len(),
},
signer_identity_reference,
@@ -425,7 +425,7 @@ pub fn verify_restored_state(
) -> Result<MycPersistenceVerifyRestoreOutput, MycError> {
config.validate()?;
- let state_dir = &config.paths.state_dir;
+ let state_dir = config.paths.state_dir();
let audit_dir = MycRuntimePaths::audit_dir_for_state_dir(state_dir);
let signer_state_path = MycRuntimePaths::signer_state_path_for_backend(
state_dir,
@@ -550,11 +550,11 @@ fn import_signer_state_json_to_sqlite(
config: &MycConfig,
) -> Result<MycSignerStateImportOutput, MycError> {
let source_path = MycRuntimePaths::signer_state_path_for_backend(
- &config.paths.state_dir,
+ config.paths.state_dir(),
MycSignerStateBackend::JsonFile,
);
let destination_path = MycRuntimePaths::signer_state_path_for_backend(
- &config.paths.state_dir,
+ config.paths.state_dir(),
MycSignerStateBackend::Sqlite,
);
let source_store = RadrootsNostrFileSignerStore::new(&source_path);
@@ -1519,7 +1519,8 @@ mod tests {
}
fn load_json_signer_state(temp: &Path) -> RadrootsNostrSignerStoreState {
- RadrootsNostrFileSignerStore::new(temp.join("state").join("signer-state.json"))
+ let config = crate::config::test_config(temp);
+ RadrootsNostrFileSignerStore::new(config.paths.state_dir().join("signer-state.json"))
.load()
.expect("load signer state")
}
@@ -1535,8 +1536,7 @@ mod tests {
}
fn base_config(temp: &Path) -> MycConfig {
- let mut config = MycConfig::default();
- config.paths.state_dir = temp.join("state");
+ let mut config = crate::config::test_config(temp);
config.paths.signer_identity_path = temp.join("signer.json");
config.paths.user_identity_path = temp.join("user.json");
write_identity(&config.paths.signer_identity_path, SIGNER_SECRET_KEY);
@@ -1915,13 +1915,14 @@ mod tests {
sqlite_config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite;
let sqlite_store = RadrootsNostrSqliteSignerStore::open(
- temp.path().join("state").join("signer-state.sqlite"),
+ sqlite_config.paths.state_dir().join("signer-state.sqlite"),
)
.expect("sqlite store");
- let existing_state =
- RadrootsNostrFileSignerStore::new(temp.path().join("state").join("signer-state.json"))
- .load()
- .expect("load source state");
+ let existing_state = RadrootsNostrFileSignerStore::new(
+ sqlite_config.paths.state_dir().join("signer-state.json"),
+ )
+ .load()
+ .expect("load source state");
sqlite_store
.save(&existing_state)
.expect("save sqlite state");
@@ -1953,7 +1954,7 @@ mod tests {
sqlite_config.persistence.runtime_audit_backend = MycRuntimeAuditBackend::Sqlite;
let sqlite_audit_store = MycSqliteOperationAuditStore::open(
- temp.path().join("state").join("audit"),
+ sqlite_config.paths.state_dir().join("audit"),
sqlite_config.audit.clone(),
)
.expect("sqlite audit store");
diff --git a/src/runtime_context.rs b/src/runtime_context.rs
@@ -0,0 +1,163 @@
+//! Sealed bootstrap binding for one canonical Myc service instance.
+
+use core::fmt;
+use std::{error::Error, path::Path};
+
+use radroots_runtime_paths::{
+ RadrootsPathProfile, RadrootsPathResolver, RadrootsServiceInstanceArtifacts, RuntimeContext,
+ RuntimeContextBootstrap, RuntimeContextSource, ServiceId, default_service_instance_artifacts,
+};
+
+use crate::{MycBootstrapProfileV1, MycCliInvocationV1};
+
+const MYC_SERVICE_ID: &str = "myc";
+
+/// Stable source-free classification for Myc runtime-context failures.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum MycRuntimeContextErrorKind {
+ InvalidServiceIdentity,
+ InvalidBootstrapBinding,
+ PathSelection,
+}
+
+impl MycRuntimeContextErrorKind {
+ const fn message(self) -> &'static str {
+ match self {
+ Self::InvalidServiceIdentity => "Myc service identity is invalid",
+ Self::InvalidBootstrapBinding => "Myc bootstrap selectors are inconsistent",
+ Self::PathSelection => "Myc runtime path selection failed",
+ }
+ }
+}
+
+/// One redacted Myc runtime-context failure.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub struct MycRuntimeContextError {
+ kind: MycRuntimeContextErrorKind,
+}
+
+impl MycRuntimeContextError {
+ const fn new(kind: MycRuntimeContextErrorKind) -> Self {
+ Self { kind }
+ }
+
+ #[must_use]
+ pub const fn kind(self) -> MycRuntimeContextErrorKind {
+ self.kind
+ }
+}
+
+impl fmt::Debug for MycRuntimeContextError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("MycRuntimeContextError")
+ .field("kind", &self.kind)
+ .finish()
+ }
+}
+
+impl fmt::Display for MycRuntimeContextError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(self.kind.message())
+ }
+}
+
+impl Error for MycRuntimeContextError {}
+
+/// Immutable canonical paths and bootstrap selection for one Myc instance.
+///
+/// Construction is sealed to the validated CLI invocation and the shared
+/// runtime-path resolver. Callers cannot forge an alternate service identity,
+/// path set, artifact name, or selected configuration path:
+///
+/// ```compile_fail
+/// use myc::MycRuntimeContext;
+///
+/// let _ = MycRuntimeContext {
+/// context: todo!(),
+/// artifacts: todo!(),
+/// selected_config_path: todo!(),
+/// profile: todo!(),
+/// };
+/// ```
+#[derive(Clone, PartialEq, Eq)]
+pub struct MycRuntimeContext {
+ context: RuntimeContext,
+ artifacts: RadrootsServiceInstanceArtifacts,
+ selected_config_path: std::path::PathBuf,
+ profile: MycBootstrapProfileV1,
+}
+
+impl MycRuntimeContext {
+ #[must_use]
+ pub fn context(&self) -> &RuntimeContext {
+ &self.context
+ }
+
+ #[must_use]
+ pub fn artifacts(&self) -> &RadrootsServiceInstanceArtifacts {
+ &self.artifacts
+ }
+
+ #[must_use]
+ pub fn selected_config_path(&self) -> &Path {
+ &self.selected_config_path
+ }
+
+ #[must_use]
+ pub const fn profile(&self) -> MycBootstrapProfileV1 {
+ self.profile
+ }
+}
+
+impl fmt::Debug for MycRuntimeContext {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("MycRuntimeContext")
+ .field("profile", &self.profile)
+ .field("service", &MYC_SERVICE_ID)
+ .field("instance", &"[redacted]")
+ .field("paths", &"[redacted]")
+ .finish()
+ }
+}
+
+/// Resolves one validated CLI selection into the sole Myc path authority.
+pub fn resolve_myc_runtime_context(
+ resolver: &RadrootsPathResolver,
+ invocation: &MycCliInvocationV1,
+) -> Result<MycRuntimeContext, MycRuntimeContextError> {
+ let profile = invocation.profile();
+ let path_profile = match profile {
+ MycBootstrapProfileV1::ServiceHost => RadrootsPathProfile::ServiceHost,
+ MycBootstrapProfileV1::Interactive => RadrootsPathProfile::InteractiveUser,
+ MycBootstrapProfileV1::RepoLocal => RadrootsPathProfile::RepoLocal,
+ };
+ let bootstrap = RuntimeContextBootstrap::new(
+ path_profile,
+ invocation.repo_local_root().map(Path::to_path_buf),
+ RuntimeContextSource::BootstrapCli,
+ RuntimeContextSource::BootstrapCli,
+ )
+ .map_err(|_| {
+ MycRuntimeContextError::new(MycRuntimeContextErrorKind::InvalidBootstrapBinding)
+ })?;
+ let service = ServiceId::new(MYC_SERVICE_ID).map_err(|_| {
+ MycRuntimeContextError::new(MycRuntimeContextErrorKind::InvalidServiceIdentity)
+ })?;
+ let context =
+ RuntimeContext::resolve(resolver, bootstrap, service, invocation.instance().clone())
+ .map_err(|_| MycRuntimeContextError::new(MycRuntimeContextErrorKind::PathSelection))?;
+ let artifacts = default_service_instance_artifacts(context.paths());
+ let selected_config_path = invocation
+ .config_path()
+ .map(Path::to_path_buf)
+ .unwrap_or_else(|| artifacts.config().to_path_buf());
+
+ Ok(MycRuntimeContext {
+ context,
+ artifacts,
+ selected_config_path,
+ profile,
+ })
+}
diff --git a/src/transport/nip46.rs b/src/transport/nip46.rs
@@ -861,8 +861,7 @@ mod tests {
F: FnOnce(&mut MycConfig),
{
let temp = tempfile::tempdir().expect("tempdir").keep();
- let mut config = MycConfig::default();
- config.paths.state_dir = temp.join("state");
+ let mut config = crate::config::test_config(&temp);
config.paths.signer_identity_path = temp.join("signer.json");
config.paths.user_identity_path = temp.join("user.json");
config.policy.connection_approval = approval;
diff --git a/tests/build_policy.rs b/tests/build_policy.rs
@@ -27,6 +27,13 @@ fn service_host_is_the_exact_default_feature_profile() {
}
#[test]
+fn shared_runtime_paths_is_exactly_pinned_to_the_source_locked_lib() {
+ assert!(MANIFEST.contains(
+ "radroots_runtime_paths = { git = \"https://github.com/radrootslabs/lib\", rev = \"b44119fbac5985be8127ad1bf56d2950e6399427\", version = \"=0.1.0-alpha\" }"
+ ));
+}
+
+#[test]
fn release_acceptance_checks_both_feature_profiles() {
assert!(
RELEASE_ACCEPTANCE.contains("cargo check --locked --all-targets --no-default-features\n")
diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs
@@ -49,6 +49,8 @@ use tokio::sync::{Mutex, Notify, mpsc, oneshot};
use tokio::time::{Instant, sleep, timeout};
use tokio_tungstenite::tungstenite::Message;
+mod support;
+
type TestResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
fn connect_public_key(public_key: PublicKey) -> radroots_identity::PublicKey {
@@ -469,21 +471,20 @@ impl MycTestRuntime {
F: FnOnce(&mut MycConfig),
{
let temp = tempfile::tempdir().expect("tempdir");
- let mut config = MycConfig::default();
- config.paths.state_dir = temp.path().join("state");
- config.paths.signer_identity_path = temp.path().join("signer.json");
- config.paths.user_identity_path = temp.path().join("user.json");
+ let mut config = support::repo_local_config(temp.path());
config.policy.connection_approval = approval;
config.transport.enabled = true;
config.transport.connect_timeout_secs = 1;
config.transport.relays = relay_urls.iter().map(|relay| (*relay).to_owned()).collect();
+ let signer_identity_path = config.paths().signer_identity_path().to_path_buf();
+ let user_identity_path = config.paths().user_identity_path().to_path_buf();
configure(&mut config);
write_identity(
- &config.paths.signer_identity_path,
+ &signer_identity_path,
"1111111111111111111111111111111111111111111111111111111111111111",
);
write_identity(
- &config.paths.user_identity_path,
+ &user_identity_path,
"2222222222222222222222222222222222222222222222222222222222222222",
);
@@ -507,10 +508,7 @@ impl MycTestRuntime {
connect_timeout_secs: u64,
) -> Self {
let temp = tempfile::tempdir().expect("tempdir");
- let mut config = MycConfig::default();
- config.paths.state_dir = temp.path().join("state");
- config.paths.signer_identity_path = temp.path().join("signer.json");
- config.paths.user_identity_path = temp.path().join("user.json");
+ let mut config = support::repo_local_config(temp.path());
config.policy.connection_approval = approval;
config.transport.connect_timeout_secs = connect_timeout_secs;
config.discovery.enabled = true;
@@ -522,12 +520,14 @@ impl MycTestRuntime {
config.discovery.nostrconnect_url_template =
Some("https://signer.example.com/connect?uri=<nostrconnect>".to_owned());
config.discovery.app_identity_path = Some(temp.path().join("app.json"));
+ let signer_identity_path = config.paths().signer_identity_path().to_path_buf();
+ let user_identity_path = config.paths().user_identity_path().to_path_buf();
write_identity(
- &config.paths.signer_identity_path,
+ &signer_identity_path,
"1111111111111111111111111111111111111111111111111111111111111111",
);
write_identity(
- &config.paths.user_identity_path,
+ &user_identity_path,
"2222222222222222222222222222222222222222222222222222222222222222",
);
write_identity(
diff --git a/tests/operability_e2e.rs b/tests/operability_e2e.rs
@@ -19,6 +19,8 @@ use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio::time::sleep;
+mod support;
+
type TestResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
struct TestRelay {
@@ -142,17 +144,16 @@ where
F: FnOnce(&mut MycConfig),
{
let temp = tempfile::tempdir().expect("tempdir").keep();
- let mut config = MycConfig::default();
- config.paths.state_dir = PathBuf::from(&temp).join("state");
- config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json");
- config.paths.user_identity_path = PathBuf::from(&temp).join("user.json");
+ let mut config = support::repo_local_config(PathBuf::from(&temp).as_path());
config.transport.connect_timeout_secs = 1;
+ let signer_identity_path = config.paths().signer_identity_path().to_path_buf();
+ let user_identity_path = config.paths().user_identity_path().to_path_buf();
write_test_identity(
- &config.paths.signer_identity_path,
+ &signer_identity_path,
"1111111111111111111111111111111111111111111111111111111111111111",
);
write_test_identity(
- &config.paths.user_identity_path,
+ &user_identity_path,
"2222222222222222222222222222222222222222222222222222222222222222",
);
configure(&mut config);
diff --git a/tests/operability_server.rs b/tests/operability_server.rs
@@ -10,6 +10,8 @@ use tokio::net::{TcpListener, TcpStream};
use tokio::sync::oneshot;
use tokio::time::{sleep, timeout};
+mod support;
+
type TestResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
const HTTP_READY_TIMEOUT: Duration = Duration::from_secs(15);
@@ -127,19 +129,18 @@ where
{
let temp = tempfile::tempdir().expect("tempdir").keep();
let bind_addr = free_loopback_addr();
- let mut config = MycConfig::default();
- config.paths.state_dir = PathBuf::from(&temp).join("state");
- config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json");
- config.paths.user_identity_path = PathBuf::from(&temp).join("user.json");
+ let mut config = support::repo_local_config(PathBuf::from(&temp).as_path());
config.transport.connect_timeout_secs = 1;
config.observability.enabled = true;
config.observability.bind_addr = bind_addr;
+ let signer_identity_path = config.paths().signer_identity_path().to_path_buf();
+ let user_identity_path = config.paths().user_identity_path().to_path_buf();
write_test_identity(
- &config.paths.signer_identity_path,
+ &signer_identity_path,
"1111111111111111111111111111111111111111111111111111111111111111",
);
write_test_identity(
- &config.paths.user_identity_path,
+ &user_identity_path,
"2222222222222222222222222222222222222222222222222222222222222222",
);
configure(&mut config);
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 myc::{
- MYC_INSTANCE_ID_MAX_BYTES, MycBootstrapProfileV1, MycCliV1ErrorKind, MycCommandV1,
+ INSTANCE_ID_MAX_BYTES, MycBootstrapProfileV1, MycCliV1ErrorKind, MycCommandV1,
MycConfigCommandV1, MycIdentityCommandV1, MycStateCommandV1, parse_myc_cli_v1_from,
};
@@ -117,7 +117,7 @@ fn root_api_exposes_validated_cross_bound_bootstrap_values() {
])
.expect("repo-local invocation");
assert_eq!(invocation.profile(), MycBootstrapProfileV1::RepoLocal);
- assert_eq!(invocation.instance(), "dev-01");
+ assert_eq!(invocation.instance().as_str(), "dev-01");
assert_eq!(
invocation.repo_local_root(),
Some(Path::new("/repo/radroots"))
@@ -126,7 +126,7 @@ fn root_api_exposes_validated_cross_bound_bootstrap_values() {
invocation.config_path(),
Some(Path::new("/repo/radroots/config/services/myc/config.toml"))
);
- assert_eq!(MYC_INSTANCE_ID_MAX_BYTES, 128);
+ assert_eq!(INSTANCE_ID_MAX_BYTES, 128);
}
#[test]
diff --git a/tests/services_hardening_runtime_context.rs b/tests/services_hardening_runtime_context.rs
@@ -0,0 +1,328 @@
+#![forbid(unsafe_code)]
+
+use std::error::Error;
+use std::path::{Path, PathBuf};
+
+use myc::{
+ MycBootstrapProfileV1, MycConfig, MycRuntimeContextErrorKind, RadrootsHostEnvironment,
+ RadrootsPathProfile, RadrootsPathResolver, RadrootsPlatform, RuntimeContextSource,
+ parse_myc_cli_v1_from, resolve_myc_runtime_context,
+};
+
+const MANIFEST: &str = include_str!("../Cargo.toml");
+const LIB_SOURCE: &str = include_str!("../src/lib.rs");
+const PATHS_SOURCE: &str = include_str!("../src/paths.rs");
+const CONFIG_SOURCE: &str = include_str!("../src/config.rs");
+const CONTEXT_SOURCE: &str = include_str!("../src/runtime_context.rs");
+
+fn resolve(
+ resolver: &RadrootsPathResolver,
+ profile: &str,
+ instance: &str,
+ repo_local_root: Option<&str>,
+ config_path: Option<&str>,
+) -> myc::MycRuntimeContext {
+ let mut arguments = vec!["myc", "--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_myc_cli_v1_from(arguments).expect("validated CLI selection");
+ resolve_myc_runtime_context(resolver, &invocation).expect("Myc runtime context")
+}
+
+fn assert_exact_roots(context: &myc::MycRuntimeContext, 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_typed_identity_sources_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(), "myc");
+ assert_eq!(primary.context().instance().as_str(), "primary");
+ assert_eq!(primary.profile(), MycBootstrapProfileV1::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_exact_roots(
+ &primary,
+ [
+ "/repo/.local/radroots/config/services/myc/primary",
+ "/repo/.local/radroots/data/services/myc/primary",
+ "/repo/.local/radroots/cache/services/myc/primary",
+ "/repo/.local/radroots/logs/services/myc/primary",
+ "/repo/.local/radroots/run/services/myc/primary",
+ "/repo/.local/radroots/secrets/services/myc/primary",
+ ],
+ );
+ assert_eq!(
+ primary.artifacts().config(),
+ Path::new("/repo/.local/radroots/config/services/myc/primary/config.toml")
+ );
+ assert_eq!(
+ primary.artifacts().state_database(),
+ Path::new("/repo/.local/radroots/data/services/myc/primary/state.sqlite")
+ );
+ assert_eq!(
+ primary.artifacts().state_lock(),
+ Path::new("/repo/.local/radroots/data/services/myc/primary/state.lock")
+ );
+ assert_eq!(
+ primary.artifacts().admin_socket(),
+ Path::new("/repo/.local/radroots/run/services/myc/primary/admin.sock")
+ );
+ assert_eq!(primary.selected_config_path(), primary.artifacts().config());
+ assert_ne!(primary.context().paths(), secondary.context().paths());
+ assert_eq!(secondary.context().instance().as_str(), "secondary");
+}
+
+#[test]
+fn service_host_and_interactive_profiles_use_the_shared_exact_roots() {
+ let service_host = resolve(
+ &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()),
+ "service-host",
+ "primary",
+ None,
+ None,
+ );
+ assert_exact_roots(
+ &service_host,
+ [
+ "/etc/radroots/services/myc/primary",
+ "/var/lib/radroots/services/myc/primary",
+ "/var/cache/radroots/services/myc/primary",
+ "/var/log/radroots/services/myc/primary",
+ "/run/radroots/services/myc/primary",
+ "/etc/radroots/secrets/services/myc/primary",
+ ],
+ );
+ assert_eq!(
+ service_host.artifacts().admin_socket(),
+ Path::new("/run/radroots/services/myc/primary/admin.sock")
+ );
+
+ let linux_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",
+ "primary",
+ None,
+ None,
+ );
+ assert_exact_roots(
+ &linux_interactive,
+ [
+ "/xdg/config/radroots/services/myc/primary",
+ "/xdg/data/radroots/services/myc/primary",
+ "/xdg/cache/radroots/services/myc/primary",
+ "/xdg/state/radroots/logs/services/myc/primary",
+ "/xdg/run/radroots/services/myc/primary",
+ "/xdg/config/radroots/secrets/services/myc/primary",
+ ],
+ );
+}
+
+#[test]
+fn interactive_macos_and_windows_roots_remain_exact_and_injected() {
+ let macos = resolve(
+ &RadrootsPathResolver::new(
+ RadrootsPlatform::Macos,
+ RadrootsHostEnvironment {
+ home_dir: Some(PathBuf::from("/Users/operator")),
+ ..RadrootsHostEnvironment::default()
+ },
+ ),
+ "interactive",
+ "primary",
+ None,
+ None,
+ );
+ assert_exact_roots(
+ &macos,
+ [
+ "/Users/operator/Library/Application Support/Radroots/config/services/myc/primary",
+ "/Users/operator/Library/Application Support/Radroots/data/services/myc/primary",
+ "/Users/operator/Library/Caches/Radroots/services/myc/primary",
+ "/Users/operator/Library/Logs/Radroots/services/myc/primary",
+ "/Users/operator/Library/Application Support/Radroots/run/services/myc/primary",
+ "/Users/operator/Library/Application Support/Radroots/secrets/services/myc/primary",
+ ],
+ );
+
+ 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",
+ "primary",
+ None,
+ None,
+ );
+ assert_exact_roots(
+ &windows,
+ [
+ r"C:\Users\operator\AppData\Roaming/Radroots/config/services/myc/primary",
+ r"C:\Users\operator\AppData\Local/Radroots/data/services/myc/primary",
+ r"C:\Users\operator\AppData\Local/Radroots/cache/services/myc/primary",
+ r"C:\Users\operator\AppData\Local/Radroots/logs/services/myc/primary",
+ r"C:\Users\operator\AppData\Local/Radroots/run/services/myc/primary",
+ r"C:\Users\operator\AppData\Roaming/Radroots/secrets/services/myc/primary",
+ ],
+ );
+}
+
+#[test]
+fn explicit_config_path_overrides_only_the_common_config_artifact_selection() {
+ let resolver =
+ RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default());
+ let context = resolve(
+ &resolver,
+ "repo-local",
+ "primary",
+ Some("/repo/.local/radroots"),
+ Some("/operator/config/myc.toml"),
+ );
+ assert_eq!(
+ context.selected_config_path(),
+ Path::new("/operator/config/myc.toml")
+ );
+ assert_eq!(
+ context.artifacts().config(),
+ Path::new("/repo/.local/radroots/config/services/myc/primary/config.toml")
+ );
+}
+
+#[test]
+fn unsupported_profile_platform_and_diagnostics_fail_safely() {
+ let invocation = parse_myc_cli_v1_from([
+ "myc",
+ "--profile",
+ "service-host",
+ "--instance",
+ "secret-instance",
+ "--config",
+ "/private/secret-config.toml",
+ "run",
+ ])
+ .expect("valid CLI");
+ let error = resolve_myc_runtime_context(
+ &RadrootsPathResolver::new(RadrootsPlatform::Macos, RadrootsHostEnvironment::default()),
+ &invocation,
+ )
+ .expect_err("unsupported service-host platform");
+ assert_eq!(error.kind(), MycRuntimeContextErrorKind::PathSelection);
+ assert!(Error::source(&error).is_none());
+ let rendered = format!("{error} {error:?}");
+ assert!(!rendered.contains("secret-instance"));
+ assert!(!rendered.contains("secret-config"));
+
+ let context = resolve(
+ &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()),
+ "repo-local",
+ "secret-instance",
+ Some("/private/secret-root"),
+ Some("/private/secret-config.toml"),
+ );
+ let config = MycConfig::from_runtime_context(context.clone());
+ for debug in [format!("{context:?}"), format!("{:?}", config.paths())] {
+ assert!(!debug.contains("secret-instance"));
+ assert!(!debug.contains("secret-root"));
+ assert!(!debug.contains("secret-config"));
+ }
+}
+
+#[test]
+fn shared_runtime_paths_are_the_only_path_policy_and_identity_authority() {
+ assert!(MANIFEST.contains(
+ "radroots_runtime_paths = { git = \"https://github.com/radrootslabs/lib\", rev = \"b44119fbac5985be8127ad1bf56d2950e6399427\", version = \"=0.1.0-alpha\" }"
+ ));
+ assert!(LIB_SOURCE.contains("mod runtime_context;"));
+ assert!(LIB_SOURCE.contains("mod paths;"));
+ assert!(!LIB_SOURCE.contains("pub mod runtime_context;"));
+ assert!(!LIB_SOURCE.contains("pub mod paths;"));
+ assert!(CONTEXT_SOURCE.contains("RuntimeContext::resolve("));
+ assert!(CONTEXT_SOURCE.contains("default_service_instance_artifacts("));
+
+ for forbidden in [
+ "struct RadrootsHostEnvironment",
+ "enum RadrootsPlatform",
+ "enum RadrootsPathProfile",
+ "struct RadrootsPathResolver",
+ "struct RadrootsRuntimePathSelection",
+ "struct RuntimeRoots",
+ "std::env::",
+ "var_os(",
+ "worker_path",
+ "worker_namespace",
+ "apply_path_defaults",
+ "default_with_path_selection",
+ ] {
+ assert!(!PATHS_SOURCE.contains(forbidden), "found `{forbidden}`");
+ }
+ for forbidden in [
+ "impl Default for MycConfig",
+ "struct MycServiceConfig",
+ "service.instance_name",
+ "MYC_INSTANCE_ID_MAX_BYTES",
+ ] {
+ assert!(!CONFIG_SOURCE.contains(forbidden), "found `{forbidden}`");
+ }
+}
diff --git a/tests/support/mod.rs b/tests/support/mod.rs
@@ -0,0 +1,25 @@
+use std::{ffi::OsString, path::Path};
+
+use myc::{
+ MycConfig, RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform,
+ parse_myc_cli_v1_from, resolve_myc_runtime_context,
+};
+
+pub fn repo_local_config(root: &Path) -> MycConfig {
+ let invocation = parse_myc_cli_v1_from(vec![
+ OsString::from("myc"),
+ OsString::from("--profile"),
+ OsString::from("repo-local"),
+ OsString::from("--instance"),
+ OsString::from("test"),
+ OsString::from("--repo-local-root"),
+ root.as_os_str().to_owned(),
+ OsString::from("run"),
+ ])
+ .expect("test CLI selection");
+ let resolver =
+ RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default());
+ let context =
+ resolve_myc_runtime_context(&resolver, &invocation).expect("test runtime context");
+ MycConfig::from_runtime_context(context)
+}