//! Execution target descriptors: configured identity vs verified identity. //! //! A target descriptor names a configured machine/account and a CHECKED //! workspace, never just a friendly hostname. The SSH alias stays //! configuration; the identity is what a connect boundary actually returned //! and verified. A friendly hostname that resolves to a different account or //! platform than configured is a typed mismatch, never a fallback: there is //! no home-directory or local substitution anywhere in this module. //! //! Ownership: m1-protocol (klbr-m7x). Live callers: m1-backend's backend //! trait (session placement), m1-relay's launcher, m1-bootstrap's //! readiness/bundle identity, and the protocol envelope binding below. use anyhow::{ensure, Result}; use serde::{Deserialize, Serialize}; use std::fmt; use std::str::FromStr; /// A config-level target name, e.g. "linux-build". This is the selector the /// operator writes, NOT a verified machine identity. #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(try_from = "String", into = "String")] pub struct TargetId(String); impl TargetId { pub fn parse(value: impl Into) -> Result { let value = value.into(); ensure!( !value.is_empty() && value.len() <= 128 && value .bytes() .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)), "invalid target id" ); Ok(Self(value)) } #[must_use] pub fn as_str(&self) -> &str { &self.0 } } impl TryFrom for TargetId { type Error = String; fn try_from(value: String) -> std::result::Result { Self::parse(value).map_err(|e| e.to_string()) } } impl From for String { fn from(id: TargetId) -> Self { id.0 } } impl fmt::Display for TargetId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } /// Auxiliary namespace identity. M2 opens additional kernels; the identity /// exists now so every envelope binds namespaces uniformly. #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(try_from = "String", into = "String")] pub struct NamespaceId(String); impl NamespaceId { pub fn parse(value: impl Into) -> Result { let value = value.into(); ensure!( !value.is_empty() && value.len() <= 128 && value .bytes() .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)), "invalid namespace id" ); Ok(Self(value)) } #[must_use] pub fn as_str(&self) -> &str { &self.0 } } impl TryFrom for NamespaceId { type Error = String; fn try_from(value: String) -> std::result::Result { Self::parse(value).map_err(|e| e.to_string()) } } impl From for String { fn from(id: NamespaceId) -> Self { id.0 } } impl fmt::Display for NamespaceId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } /// Namespace generation: a NEW generation is produced on every placement /// change or transport loss. Reattachment (M2) is a distinct explicit /// contract and never reuses a generation. #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(try_from = "String", into = "String")] pub struct NamespaceGeneration(String); impl NamespaceGeneration { #[must_use] pub fn fresh() -> Self { Self(uuid::Uuid::new_v4().to_string()) } pub fn parse(value: impl Into) -> Result { let value = value.into(); ensure!( !value.is_empty() && value.len() <= 128 && value .bytes() .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)), "invalid namespace generation" ); Ok(Self(value)) } #[must_use] pub fn as_str(&self) -> &str { &self.0 } } impl TryFrom for NamespaceGeneration { type Error = String; fn try_from(value: String) -> std::result::Result { Self::parse(value).map_err(|e| e.to_string()) } } impl From for String { fn from(id: NamespaceGeneration) -> Self { id.0 } } impl fmt::Display for NamespaceGeneration { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } /// Opaque content identity of a provisioned environment (interpreter/ABI, /// skill source revision, dependency lock, bootstrap recipe). Bootstrap (M1 /// bootstrap workstream) produces it; this vocabulary carries it verbatim /// and compares it for equality only. #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(try_from = "String", into = "String")] pub struct EnvironmentFingerprint(String); impl EnvironmentFingerprint { pub fn parse(value: impl Into) -> Result { let value = value.into(); ensure!( !value.is_empty() && value.len() <= 128 && value .bytes() .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)), "invalid environment fingerprint" ); Ok(Self(value)) } #[must_use] pub fn as_str(&self) -> &str { &self.0 } } impl TryFrom for EnvironmentFingerprint { type Error = String; fn try_from(value: String) -> std::result::Result { Self::parse(value).map_err(|e| e.to_string()) } } impl From for String { fn from(id: EnvironmentFingerprint) -> Self { id.0 } } impl fmt::Display for EnvironmentFingerprint { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } /// Where a session's workbench runs. Local execution needs no descriptor; /// remote execution is always a full configured descriptor. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "placement", rename_all = "snake_case", deny_unknown_fields)] pub enum KernelTarget { Local, Remote(RemoteTarget), } impl FromStr for TargetId { type Err = anyhow::Error; fn from_str(value: &str) -> std::result::Result { Self::parse(value.to_owned()) } } impl FromStr for NamespaceId { type Err = anyhow::Error; fn from_str(value: &str) -> std::result::Result { Self::parse(value.to_owned()) } } impl FromStr for NamespaceGeneration { type Err = anyhow::Error; fn from_str(value: &str) -> std::result::Result { Self::parse(value.to_owned()) } } impl FromStr for EnvironmentFingerprint { type Err = anyhow::Error; fn from_str(value: &str) -> std::result::Result { Self::parse(value.to_owned()) } } /// A durable placement descriptor for a session's workbench, persisted by /// the host (store.rs, m1-backend writes it) and re-checked at read time. /// A restarted daemon refuses remote work without a backend instead of /// silently falling back to local: Some(descriptor) means the placement is /// STILL the contract. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct SessionPlacement { pub kernel_target: KernelTarget, pub namespace_id: NamespaceId, pub namespace_generation: NamespaceGeneration, } impl SessionPlacement { /// Set-time validation: the serialized shape must be the typed /// descriptor, and a remote placement must carry an explicitly /// configured workspace. A missing workspace is a typed refusal, never /// a home fallback; machine identity is verified separately by /// RemoteTarget::verify_identity once a connect boundary resolves. pub fn from_json(value: &serde_json::Value) -> Result { let placement: Self = serde_json::from_value(value.clone()) .map_err(|e| anyhow::anyhow!("invalid session placement descriptor: {e}"))?; if let KernelTarget::Remote(target) = &placement.kernel_target { ensure!( !target.workspace.path.trim().is_empty(), TargetMismatch::WorkspaceUnusable { target: target.id.clone(), workspace: target.workspace.path.clone(), } ); } Ok(placement) } #[must_use] pub fn to_json(&self) -> serde_json::Value { serde_json::to_value(self).unwrap_or(serde_json::Value::Null) } } /// An explicitly configured workspace for a remote target. A missing or /// unusable configured workspace is a typed failure at the selection /// boundary; it never falls back to a home directory. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RemoteWorkspace { pub path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub revision: Option, } /// Configured remote target descriptor. Everything here is DECLARED /// configuration; nothing here was verified by a connection. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RemoteTarget { pub id: TargetId, /// SSH alias or host as configured. Configuration, not identity. pub ssh_alias: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub account: Option, pub workspace: RemoteWorkspace, /// Explicitly configured interpreter override. A configured interpreter /// must still pass the same capability/protocol checks as a default one. #[serde(default, skip_serializing_if = "Option::is_none")] pub interpreter: Option, /// Platform the descriptor requires, when the configuration declares /// one (e.g. a linux-build target). None accepts any platform. #[serde(default, skip_serializing_if = "Option::is_none")] pub required_platform: Option, } /// The identity a connect/launch boundary actually verified, once per /// launch. The agent sees exactly this. Values are non-optional: an /// identity with unchecked values cannot be constructed. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct TargetIdentity { /// Canonical account as the machine reports it, e.g. "mayer". pub account: String, /// Canonical machine identity as returned (uname nodename), not the /// configured alias. pub machine: String, /// Platform as returned, e.g. "Darwin", "Linux", "NixOS". pub platform: String, pub arch: String, /// Absolute interpreter path as resolved on the target. pub interpreter_path: String, /// Interpreter version string as reported. pub interpreter_version: String, /// Working directory the boundary actually chdir'd into and verified. pub cwd: String, /// Verified environment fingerprint, when the deployment records one. #[serde(default, skip_serializing_if = "Option::is_none")] pub environment_fingerprint: Option, /// Highest capability/protocol revision the target serves. pub capability_revision: u16, } impl TargetIdentity { /// Fallible constructor: an identity cannot be constructed from /// unchecked values. #[allow(clippy::too_many_arguments)] pub fn new( account: impl Into, machine: impl Into, platform: impl Into, arch: impl Into, interpreter_path: impl Into, interpreter_version: impl Into, cwd: impl Into, environment_fingerprint: Option, capability_revision: u16, ) -> Result { let identity = Self { account: account.into(), machine: machine.into(), platform: platform.into(), arch: arch.into(), interpreter_path: interpreter_path.into(), interpreter_version: interpreter_version.into(), cwd: cwd.into(), environment_fingerprint, capability_revision, }; ensure!( !identity.account.trim().is_empty(), "target identity account is empty" ); ensure!( !identity.machine.trim().is_empty(), "target identity machine is empty" ); ensure!( !identity.platform.trim().is_empty(), "target identity platform is empty" ); ensure!( !identity.arch.trim().is_empty(), "target identity arch is empty" ); ensure!( !identity.interpreter_path.trim().is_empty(), "target identity interpreter path is empty" ); ensure!( !identity.interpreter_version.trim().is_empty(), "target identity interpreter version is empty" ); ensure!( !identity.cwd.trim().is_empty(), "target identity cwd is empty" ); Ok(identity) } } /// One typed mismatch kind per check that can fail when a resolved identity /// contradicts the configured target. No fallback variant exists: a /// mismatch is fatal at the selection boundary. #[derive(Clone, Debug, PartialEq, Eq)] pub enum TargetMismatch { AccountMismatch { target: TargetId, configured: String, resolved: String, }, PlatformMismatch { target: TargetId, configured: String, resolved: String, }, WorkspaceUnusable { target: TargetId, workspace: String, }, } impl fmt::Display for TargetMismatch { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::AccountMismatch { target, configured, resolved, } => write!( f, "target {target}: account mismatch: configured {configured:?}, resolved {resolved:?}" ), Self::PlatformMismatch { target, configured, resolved, } => write!( f, "target {target}: platform mismatch: configured {configured:?}, resolved {resolved:?}" ), Self::WorkspaceUnusable { target, workspace } => write!( f, "target {target}: workspace {workspace:?} does not exist or is unusable on the target" ), } } } impl std::error::Error for TargetMismatch {} /// A target whose configured and verified identities agree, plus the /// identity that was actually checked. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ResolvedTarget { pub target: RemoteTarget, pub identity: TargetIdentity, } impl RemoteTarget { /// Which platform identity this descriptor requires, if it declares one /// (e.g. a linux-build target may require Linux). None means any. #[must_use] pub fn required_platform(&self) -> Option { self.required_platform.clone() } /// Check a resolved identity against this descriptor. This is the /// "no wrong-machine, no wrong-account" contract: mismatch is typed, /// fatal, and reported - never silently retried elsewhere. pub fn verify_identity(&self, resolved: &TargetIdentity) -> Result { if let Some(expected_account) = &self.account { ensure!( resolved.account == *expected_account, TargetMismatch::AccountMismatch { target: self.id.clone(), configured: expected_account.clone(), resolved: resolved.account.clone(), } ); } if let Some(expected_platform) = &self.required_platform { ensure!( resolved.platform == *expected_platform, TargetMismatch::PlatformMismatch { target: self.id.clone(), configured: expected_platform.clone(), resolved: resolved.platform.clone(), } ); } ensure!( resolved.cwd == self.workspace.path, TargetMismatch::WorkspaceUnusable { target: self.id.clone(), workspace: self.workspace.path.clone(), } ); Ok(ResolvedTarget { target: self.clone(), identity: resolved.clone(), }) } } #[cfg(test)] mod tests { use super::*; fn identity(account: &str, platform: &str, cwd: &str) -> TargetIdentity { TargetIdentity::new( account, "chernobog.internal", platform, "arm64", "/usr/bin/python3", "3.11.9", cwd, None, 1, ) .expect("identity") } fn remote_target() -> RemoteTarget { serde_json::from_value(serde_json::json!({ "id": "linux-build", "ssh_alias": "linux-build", "account": "mayer", "workspace": {"path": "/srv/work/tangled"}, "required_platform": "Linux" })) .expect("target") } #[test] fn target_id_rejects_bad_names() { assert!(TargetId::parse("").is_err()); assert!(TargetId::parse("a b").is_err()); assert!(TargetId::parse("linux-build").is_ok()); } #[test] fn mismatching_account_is_a_typed_failure_not_a_fallback() { let target = remote_target(); let resolved = identity("root", "Linux", "/srv/work/tangled"); let err = target.verify_identity(&resolved).expect_err("mismatch"); assert!(matches!( err.downcast_ref::(), Some(TargetMismatch::AccountMismatch { .. }) )); } #[test] fn mismatching_platform_is_a_typed_failure_not_a_fallback() { let target = remote_target(); let resolved = identity("mayer", "Darwin", "/srv/work/tangled"); let err = target.verify_identity(&resolved).expect_err("mismatch"); assert!(matches!( err.downcast_ref::(), Some(TargetMismatch::PlatformMismatch { .. }) )); } #[test] fn wrong_directory_is_never_a_home_fallback() { let target = remote_target(); let resolved = identity("mayer", "Linux", "/home/mayer"); let err = target.verify_identity(&resolved).expect_err("mismatch"); assert!(matches!( err.downcast_ref::(), Some(TargetMismatch::WorkspaceUnusable { .. }) )); } #[test] fn verified_identity_binds() { let target = remote_target(); let resolved = identity("mayer", "Linux", "/srv/work/tangled"); let bound = target.verify_identity(&resolved).expect("bound"); assert_eq!(bound.target.id.as_str(), "linux-build"); assert_eq!(bound.identity.account, "mayer"); } }