Skip to main content

gate4agent_node_core/
materialization.rs

1//! Session materialization ownership records and path classes (transport-side shared types).
2
3use crate::protocol::{
4    ManagedWorktreeLeaseId, NodeIncarnationId, ResolvedBundleReceipt, ResolvedContextPackReceipt,
5    ResolvedEnvironmentProfileReceipt, SessionRecordId,
6};
7use gate4agent_types::{AgentInstanceId, SessionGeneration};
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use std::collections::BTreeSet;
10use std::ffi::OsString;
11use std::io;
12use std::path::{Component, Path, PathBuf};
13use thiserror::Error;
14
15pub const MAX_SESSION_MATERIALIZATION_FILES: usize = 128;
16pub const MAX_SESSION_MATERIALIZATION_RELATIVE_PATH_BYTES: usize = 512;
17pub const MAX_SESSION_MATERIALIZATIONS: usize = 4_096;
18
19pub fn path_to_opaque(path: &Path) -> io::Result<crate::protocol::OpaqueHostPath> {
20    #[cfg(unix)]
21    {
22        use std::os::unix::ffi::OsStrExt;
23        crate::protocol::OpaqueHostPath::unix_bytes(path.as_os_str().as_bytes().to_vec()).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
24    }
25    #[cfg(windows)]
26    {
27        crate::protocol::OpaqueHostPath::utf8(path.to_string_lossy().into_owned()).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
28    }
29}
30
31pub fn opaque_to_path(path: &crate::protocol::OpaqueHostPath) -> io::Result<PathBuf> {
32    if let Some(value) = path.as_utf8() { return Ok(PathBuf::from(value)); }
33    #[cfg(unix)]
34    if let Some(value) = path.as_unix_bytes() {
35        use std::os::unix::ffi::OsStringExt;
36        return Ok(PathBuf::from(OsString::from_vec(value.to_vec())));
37    }
38    Err(io::Error::new(io::ErrorKind::InvalidData, "materialization path encoding is unsupported"))
39}
40
41/// True when `path` is a non-empty, bounded, normal-components-only relative path.
42pub fn relative_path_is_valid(path: &Path) -> bool {
43    let Some(encoded) = path.to_str() else { return false };
44    !(path.as_os_str().is_empty()
45        || encoded.len() > MAX_SESSION_MATERIALIZATION_RELATIVE_PATH_BYTES
46        || path.components().any(|component| !matches!(component, Component::Normal(_))))
47}
48
49pub fn valid_materialization_id(value: &str) -> bool {
50    let mut bytes = value.bytes();
51    let Some(first) = bytes.next() else {
52        return false;
53    };
54    value.len() <= 128
55        && (first.is_ascii_lowercase() || first.is_ascii_digit())
56        && bytes.all(|byte| {
57            byte.is_ascii_lowercase()
58                || byte.is_ascii_digit()
59                || matches!(byte, b'-' | b'_' | b'.')
60        })
61}
62
63fn checked_timestamp(created: u64, now: u64) -> Result<u64, MaterializationRecordError> {
64    if now < created { Err(MaterializationRecordError::InvalidRecord) } else { Ok(now) }
65}
66
67#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
68#[serde(rename_all = "kebab-case")]
69pub enum NodeSessionPathClass {
70    ProviderHome,
71    Config,
72    Cache,
73    Data,
74    State,
75    Tmp,
76    #[doc(hidden)]
77    BundleRoot,
78    #[doc(hidden)]
79    PluginData,
80    #[doc(hidden)]
81    Context,
82}
83
84impl NodeSessionPathClass {
85    pub fn directory_name(self) -> &'static str {
86        match self {
87            Self::ProviderHome => "home",
88            Self::Config => "config",
89            Self::Cache => "cache",
90            Self::Data => "data",
91            Self::State => "state",
92            Self::Tmp => "tmp",
93            Self::BundleRoot => "bundle",
94            Self::PluginData => "plugin-data",
95            Self::Context => "context",
96        }
97    }
98}
99
100#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
101pub struct MaterializationId(String);
102
103impl MaterializationId {
104    pub fn new(value: impl Into<String>) -> Result<Self, MaterializationRecordError> {
105        let value = value.into();
106        if !valid_materialization_id(&value) {
107            return Err(MaterializationRecordError::InvalidId);
108        }
109        Ok(Self(value))
110    }
111
112    pub fn as_str(&self) -> &str {
113        &self.0
114    }
115}
116
117impl Serialize for MaterializationId {
118    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
119    where
120        S: Serializer,
121    {
122        serializer.serialize_str(&self.0)
123    }
124}
125
126impl<'de> Deserialize<'de> for MaterializationId {
127    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
128    where
129        D: Deserializer<'de>,
130    {
131        let value = String::deserialize(deserializer)?;
132        Self::new(value).map_err(serde::de::Error::custom)
133    }
134}
135
136#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
137#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
138pub enum MaterializationOwner {
139    Session {
140        incarnation_id: NodeIncarnationId,
141        instance_id: AgentInstanceId,
142        generation: SessionGeneration,
143    },
144    Record {
145        record_id: SessionRecordId,
146    },
147}
148
149#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
150#[serde(rename_all = "kebab-case")]
151pub enum MaterializationState {
152    Preparing,
153    Ready,
154    CleanupRequired,
155    RecoveryRequired,
156}
157
158#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
159#[serde(rename_all = "kebab-case")]
160pub enum MaterializedPathKind {
161    Generated,
162    Secret,
163}
164
165#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
166#[serde(deny_unknown_fields)]
167pub struct MaterializedPathDeclaration {
168    pub class: NodeSessionPathClass,
169    pub relative_path: PathBuf,
170    pub kind: MaterializedPathKind,
171}
172
173#[derive(Clone, Eq, PartialEq)]
174pub struct MaterializationOwnershipRecord {
175    pub id: MaterializationId,
176    pub environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
177    pub bundle: Option<ResolvedBundleReceipt>,
178    pub context: Option<ResolvedContextPackReceipt>,
179    pub owner: MaterializationOwner,
180    pub managed_lease_id: Option<ManagedWorktreeLeaseId>,
181    pub state: MaterializationState,
182    pub root: PathBuf,
183    pub provider_home: PathBuf,
184    pub bundle_root: PathBuf,
185    pub plugin_data: PathBuf,
186    pub declared_paths: Vec<MaterializedPathDeclaration>,
187    pub created_at_unix_ms: u64,
188    pub updated_at_unix_ms: u64,
189}
190
191impl MaterializationOwnershipRecord {
192    #[allow(clippy::too_many_arguments)]
193    pub fn from_persisted(
194        id: MaterializationId,
195        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
196        bundle: Option<ResolvedBundleReceipt>,
197        context: Option<ResolvedContextPackReceipt>,
198        owner: MaterializationOwner,
199        managed_lease_id: Option<ManagedWorktreeLeaseId>,
200        state: MaterializationState,
201        root: PathBuf,
202        provider_home: PathBuf,
203        bundle_root: PathBuf,
204        plugin_data: PathBuf,
205        declared_paths: Vec<MaterializedPathDeclaration>,
206        created_at_unix_ms: u64,
207        updated_at_unix_ms: u64,
208    ) -> Result<Self, MaterializationRecordError> {
209        let record = Self {
210            id,
211            environment_profile,
212            bundle,
213            context,
214            owner,
215            managed_lease_id,
216            state,
217            root,
218            provider_home,
219            bundle_root,
220            plugin_data,
221            declared_paths,
222            created_at_unix_ms,
223            updated_at_unix_ms,
224        };
225        record.validate()?;
226        Ok(record)
227    }
228
229    pub fn id(&self) -> &MaterializationId { &self.id }
230    pub fn environment_profile(&self) -> Option<&ResolvedEnvironmentProfileReceipt> { self.environment_profile.as_ref() }
231    pub fn bundle(&self) -> Option<&ResolvedBundleReceipt> { self.bundle.as_ref() }
232    pub fn context(&self) -> Option<&ResolvedContextPackReceipt> { self.context.as_ref() }
233    pub fn owner(&self) -> &MaterializationOwner { &self.owner }
234    pub fn managed_lease_id(&self) -> Option<&ManagedWorktreeLeaseId> { self.managed_lease_id.as_ref() }
235    pub fn state(&self) -> MaterializationState { self.state }
236    pub fn root(&self) -> &Path { &self.root }
237    pub fn provider_home(&self) -> &Path { &self.provider_home }
238    pub fn bundle_root(&self) -> &Path { &self.bundle_root }
239    pub fn plugin_data(&self) -> &Path { &self.plugin_data }
240    pub fn declared_paths(&self) -> &[MaterializedPathDeclaration] { &self.declared_paths }
241    pub fn created_at_unix_ms(&self) -> u64 { self.created_at_unix_ms }
242    pub fn updated_at_unix_ms(&self) -> u64 { self.updated_at_unix_ms }
243
244    pub fn mark_ready(&mut self, now: u64) -> Result<(), MaterializationRecordError> {
245        self.transition(MaterializationState::Preparing, MaterializationState::Ready, now)
246    }
247
248    pub fn transfer_to_record(
249        &mut self,
250        record_id: SessionRecordId,
251        now: u64,
252    ) -> Result<(), MaterializationRecordError> {
253        if self.state != MaterializationState::Ready || !matches!(self.owner, MaterializationOwner::Session { .. }) {
254            return Err(MaterializationRecordError::InvalidTransition);
255        }
256        self.owner = MaterializationOwner::Record { record_id };
257        self.updated_at_unix_ms = checked_timestamp(self.created_at_unix_ms, now)?;
258        Ok(())
259    }
260
261    pub fn transfer_record_owner(
262        &mut self,
263        expected_record_id: &SessionRecordId,
264        replacement_record_id: SessionRecordId,
265        now: u64,
266    ) -> Result<(), MaterializationRecordError> {
267        if self.state != MaterializationState::Ready
268            || !matches!(
269                &self.owner,
270                MaterializationOwner::Record { record_id }
271                    if record_id == expected_record_id
272            )
273        {
274            return Err(MaterializationRecordError::InvalidTransition);
275        }
276        self.owner = MaterializationOwner::Record {
277            record_id: replacement_record_id,
278        };
279        self.updated_at_unix_ms = checked_timestamp(self.created_at_unix_ms, now)?;
280        Ok(())
281    }
282
283    pub fn mark_cleanup_required(&mut self, now: u64) -> Result<(), MaterializationRecordError> {
284        if !matches!(self.state, MaterializationState::Preparing | MaterializationState::Ready | MaterializationState::RecoveryRequired) {
285            return Err(MaterializationRecordError::InvalidTransition);
286        }
287        self.state = MaterializationState::CleanupRequired;
288        self.updated_at_unix_ms = checked_timestamp(self.created_at_unix_ms, now)?;
289        Ok(())
290    }
291
292    pub fn mark_recovery_required(&mut self, now: u64) -> Result<(), MaterializationRecordError> {
293        if self.state == MaterializationState::RecoveryRequired {
294            return Ok(());
295        }
296        self.state = MaterializationState::RecoveryRequired;
297        self.updated_at_unix_ms = checked_timestamp(self.created_at_unix_ms, now)?;
298        Ok(())
299    }
300
301    fn transition(&mut self, from: MaterializationState, to: MaterializationState, now: u64) -> Result<(), MaterializationRecordError> {
302        if self.state != from { return Err(MaterializationRecordError::InvalidTransition); }
303        self.state = to;
304        self.updated_at_unix_ms = checked_timestamp(self.created_at_unix_ms, now)?;
305        Ok(())
306    }
307
308    pub fn validate(&self) -> Result<(), MaterializationRecordError> {
309        if (self.environment_profile.is_none() && self.bundle.is_none() && self.context.is_none())
310            || !self.root.is_absolute()
311            || !self.provider_home.is_absolute()
312            || !self.bundle_root.is_absolute()
313            || !self.plugin_data.is_absolute()
314            || self.provider_home != self.root.join(NodeSessionPathClass::ProviderHome.directory_name())
315            || self.bundle_root != self.root.join(NodeSessionPathClass::BundleRoot.directory_name())
316            || self.plugin_data != self.root.join(NodeSessionPathClass::PluginData.directory_name())
317            || self.created_at_unix_ms > self.updated_at_unix_ms
318            || self.declared_paths.len() > MAX_SESSION_MATERIALIZATION_FILES
319        {
320            return Err(MaterializationRecordError::InvalidRecord);
321        }
322        let mut seen = BTreeSet::new();
323        for (index, declaration) in self.declared_paths.iter().enumerate() {
324            if !relative_path_is_valid(&declaration.relative_path) { return Err(MaterializationRecordError::InvalidRecord); }
325            if !seen.insert((declaration.class, declaration.relative_path.clone())) {
326                return Err(MaterializationRecordError::InvalidRecord);
327            }
328            for other in self.declared_paths.iter().skip(index + 1) {
329                if declaration.class == other.class
330                    && (declaration.relative_path.starts_with(&other.relative_path)
331                        || other.relative_path.starts_with(&declaration.relative_path))
332                {
333                    return Err(MaterializationRecordError::InvalidRecord);
334                }
335            }
336        }
337        Ok(())
338    }
339}
340
341#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
342pub enum MaterializationRecordError {
343    #[error("materialization ID is invalid")]
344    InvalidId,
345    #[error("materialization ownership record is invalid")]
346    InvalidRecord,
347    #[error("materialization state transition is invalid")]
348    InvalidTransition,
349}