#[cfg(feature = "alloc")]
use alloc::{
format,
string::{String, ToString},
vec,
vec::Vec,
};
use crate::Harness;
use crate::vocabulary::{RunStatus, Vocabulary};
use super::{
DispatchError, DispatchResult, PathAuthority, ProjectFilesystemId, ProjectId, Role,
RootSessionBinding, RunId, SessionId, constant_time_digest_eq,
};
pub const PROFILE_LEASE_SCHEMA: &str = "shepherd.profile-lease/1";
pub const PROFILE_ATTACHMENT_SCHEMA: &str = "shepherd.profile-attachment/1";
#[derive(
Clone,
Copy,
Debug,
Eq,
Hash,
Ord,
PartialEq,
PartialOrd,
serde::Deserialize,
serde::Serialize,
strum::AsRefStr,
strum::Display,
strum::EnumCount,
strum::EnumIs,
strum::EnumString,
strum::IntoStaticStr,
strum::VariantArray,
strum::VariantNames,
)]
#[serde(rename_all = "kebab-case")]
#[strum(ascii_case_insensitive, serialize_all = "kebab-case")]
pub enum Profile {
Planter,
Shepherd,
}
impl Profile {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Planter => "planter",
Self::Shepherd => "shepherd",
}
}
#[must_use]
pub const fn startup_skill(self) -> &'static str {
match self {
Self::Planter => "planting",
Self::Shepherd => "shepherd",
}
}
}
#[derive(
Clone,
Copy,
Debug,
Eq,
Hash,
Ord,
PartialEq,
PartialOrd,
serde::Deserialize,
serde::Serialize,
strum::AsRefStr,
strum::Display,
strum::EnumCount,
strum::EnumIs,
strum::EnumString,
strum::IntoStaticStr,
strum::VariantNames,
)]
#[serde(rename_all = "kebab-case")]
#[strum(ascii_case_insensitive, serialize_all = "snake_case")]
pub enum ProfileLeaseState {
Entered,
Active,
Exited,
Revoked,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct ProfileAttachmentExpectation {
pub schema: String,
pub target: Harness,
pub profile: Profile,
pub startup_skill: String,
#[serde(with = "super::pending::digest_serde")]
pub candidate_sha256: [u8; 32],
#[serde(with = "super::pending::digest_serde")]
pub carrier_attachment_sha256: [u8; 32],
#[serde(with = "super::pending::digest_serde")]
pub attachment_nonce_sha256: [u8; 32],
}
impl ProfileAttachmentExpectation {
pub fn new(
target: Harness,
profile: Profile,
startup_skill: impl Into<String>,
candidate_sha256: [u8; 32],
carrier_attachment_sha256: [u8; 32],
attachment_nonce_sha256: [u8; 32],
) -> DispatchResult<Self> {
let value = Self {
schema: PROFILE_ATTACHMENT_SCHEMA.into(),
target,
profile,
startup_skill: startup_skill.into(),
candidate_sha256,
carrier_attachment_sha256,
attachment_nonce_sha256,
};
value.validate()?;
Ok(value)
}
pub fn validate(&self) -> DispatchResult<()> {
if self.schema != PROFILE_ATTACHMENT_SCHEMA
|| self.startup_skill != self.profile.startup_skill()
|| self.candidate_sha256 == [0; 32]
|| self.carrier_attachment_sha256 == [0; 32]
|| self.attachment_nonce_sha256 == [0; 32]
{
return Err(DispatchError::InvalidProfile(
"profile attachment is not the exact typed startup bundle".into(),
));
}
Ok(())
}
#[must_use]
pub fn attestation(&self) -> LoadedProfileAttestation {
LoadedProfileAttestation {
schema: PROFILE_ATTACHMENT_SCHEMA.into(),
target: self.target,
profile: self.profile,
startup_skill: self.startup_skill.clone(),
candidate_sha256: self.candidate_sha256,
carrier_attachment_sha256: self.carrier_attachment_sha256,
attachment_nonce_sha256: self.attachment_nonce_sha256,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct LoadedProfileAttestation {
pub schema: String,
pub target: Harness,
pub profile: Profile,
pub startup_skill: String,
#[serde(with = "super::pending::digest_serde")]
pub candidate_sha256: [u8; 32],
#[serde(with = "super::pending::digest_serde")]
pub carrier_attachment_sha256: [u8; 32],
#[serde(with = "super::pending::digest_serde")]
pub attachment_nonce_sha256: [u8; 32],
}
impl LoadedProfileAttestation {
pub fn validate_against(&self, expected: &ProfileAttachmentExpectation) -> DispatchResult<()> {
expected.validate()?;
if self.schema != PROFILE_ATTACHMENT_SCHEMA
|| self.target != expected.target
|| self.profile != expected.profile
|| self.startup_skill != expected.startup_skill
|| !constant_time_digest_eq(&self.candidate_sha256, &expected.candidate_sha256)
|| !constant_time_digest_eq(
&self.carrier_attachment_sha256,
&expected.carrier_attachment_sha256,
)
|| !constant_time_digest_eq(
&self.attachment_nonce_sha256,
&expected.attachment_nonce_sha256,
)
{
return Err(DispatchError::AttachmentMismatch(
"loaded profile attachment differs from native expectation".into(),
));
}
Ok(())
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct ProfileLease {
pub schema: String,
pub project_id: ProjectId,
pub project_filesystem_id: ProjectFilesystemId,
pub root_session_id: SessionId,
pub run: RunId,
pub harness: Harness,
pub profile: Profile,
pub expected_attachment: ProfileAttachmentExpectation,
pub write_scope: Vec<PathAuthority>,
pub entered_at: i64,
pub expires_at: i64,
pub state: ProfileLeaseState,
}
impl ProfileLease {
pub fn enter(
root: &RootSessionBinding,
expected_attachment: ProfileAttachmentExpectation,
now: i64,
expires_at: i64,
run_status: &Vocabulary<RunStatus>,
verified_seed_persisted: bool,
) -> DispatchResult<Self> {
root.validate()
.map_err(|error| DispatchError::InvalidProfile(error.to_string()))?;
expected_attachment.validate()?;
if root.role != Role::Shepherd
|| !root.mode.is_planting()
|| !run_status.is(RunStatus::Planted)
|| verified_seed_persisted
|| expected_attachment.profile != Profile::Planter
|| expected_attachment.target != root.harness
|| now < root.bound_at
|| now >= root.expires_at
|| expires_at <= now
|| expires_at > root.expires_at
{
return Err(DispatchError::InvalidProfile(
"profile enter requires one live planning root on a planted run with no verified seed"
.into(),
));
}
let project_filesystem_id = root
.project_filesystem_id
.as_deref()
.ok_or_else(|| {
DispatchError::InvalidProfile(
"profile enter requires the bound workspace identity".into(),
)
})
.and_then(ProjectFilesystemId::new)?;
Ok(Self {
schema: PROFILE_LEASE_SCHEMA.into(),
project_id: root.project_id.clone(),
project_filesystem_id,
root_session_id: root.session_id.clone(),
run: root.run.clone(),
harness: root.harness,
profile: Profile::Planter,
expected_attachment,
write_scope: Vec::new(),
entered_at: now,
expires_at,
state: ProfileLeaseState::Entered,
})
}
pub fn validate(&self) -> DispatchResult<()> {
self.expected_attachment.validate()?;
if self.schema != PROFILE_LEASE_SCHEMA
|| self.profile != Profile::Planter
|| self.expected_attachment.profile != self.profile
|| self.expected_attachment.target != self.harness
|| self.entered_at < 0
|| self.expires_at <= self.entered_at
{
return Err(DispatchError::InvalidProfile(
"persisted profile lease has invalid identity or time".into(),
));
}
let expected_scope = active_scope(&self.run)?;
let valid_scope = match self.state {
ProfileLeaseState::Active => self.write_scope == expected_scope,
ProfileLeaseState::Entered | ProfileLeaseState::Exited | ProfileLeaseState::Revoked => {
self.write_scope.is_empty()
}
};
if !valid_scope {
return Err(DispatchError::InvalidProfile(
"profile write scope does not match lease state".into(),
));
}
Ok(())
}
pub fn activate(
&mut self,
root: &RootSessionBinding,
attestation: &LoadedProfileAttestation,
now: i64,
) -> DispatchResult<()> {
self.validate_root(root, now)?;
if self.state != ProfileLeaseState::Entered {
return Err(DispatchError::ProfileReplay);
}
attestation.validate_against(&self.expected_attachment)?;
self.write_scope = active_scope(&self.run)?;
self.state = ProfileLeaseState::Active;
self.validate()
}
pub fn authorize_write(
&self,
root: &RootSessionBinding,
path: &str,
now: i64,
) -> DispatchResult<()> {
self.validate_root(root, now)?;
if self.state != ProfileLeaseState::Active {
return Err(DispatchError::ProfileInactive);
}
if self
.write_scope
.iter()
.any(|scope| scope.contains(path).unwrap_or(false))
{
Ok(())
} else {
Err(DispatchError::InvalidProfile(format!(
"path `{path}` is outside the active profile lease"
)))
}
}
pub fn validate_for_root(&self, root: &RootSessionBinding, now: i64) -> DispatchResult<()> {
self.validate()?;
self.validate_root(root, now)
}
pub fn exit(
&mut self,
root: &RootSessionBinding,
now: i64,
verified_seed_persisted: bool,
) -> DispatchResult<()> {
self.validate_root(root, now)?;
if self.state != ProfileLeaseState::Active {
return Err(DispatchError::ProfileReplay);
}
if !verified_seed_persisted {
return Err(DispatchError::InvalidProfile(
"profile exit requires the shared verifier and persisted seed pointer".into(),
));
}
self.write_scope.clear();
self.state = ProfileLeaseState::Exited;
self.validate()
}
pub fn revoke(&mut self, root: &RootSessionBinding, now: i64) -> DispatchResult<()> {
self.validate_root_identity(root)?;
if now < self.entered_at {
return Err(DispatchError::InvalidProfile(
"profile revocation predates lease entry".into(),
));
}
if matches!(
self.state,
ProfileLeaseState::Exited | ProfileLeaseState::Revoked
) {
return Err(DispatchError::ProfileReplay);
}
self.write_scope.clear();
self.state = ProfileLeaseState::Revoked;
self.validate()
}
fn validate_root(&self, root: &RootSessionBinding, now: i64) -> DispatchResult<()> {
self.validate_root_identity(root)?;
if now >= self.expires_at || now >= root.expires_at {
return Err(DispatchError::ProfileExpired {
expires_at: self.expires_at.min(root.expires_at),
});
}
Ok(())
}
fn validate_root_identity(&self, root: &RootSessionBinding) -> DispatchResult<()> {
root.validate()
.map_err(|error| DispatchError::InvalidProfile(error.to_string()))?;
let filesystem_id = root
.project_filesystem_id
.as_deref()
.map(ProjectFilesystemId::new)
.transpose()?;
if root.role != Role::Shepherd
|| !root.mode.is_planting()
|| root.project_id != self.project_id
|| filesystem_id.as_ref() != Some(&self.project_filesystem_id)
|| root.session_id != self.root_session_id
|| root.run != self.run
|| root.harness != self.harness
{
return Err(DispatchError::InvalidProfile(
"profile lease does not belong to this bound root".into(),
));
}
Ok(())
}
}
fn active_scope(run: &RunId) -> DispatchResult<Vec<PathAuthority>> {
Ok(vec![
PathAuthority::exact(format!(".shepherd/runs/{run}/mesh.md"))?,
PathAuthority::exact(format!(".shepherd/runs/{run}/seed.md"))?,
])
}