1use 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
41pub 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}