1use serde::{Deserialize, Serialize};
19use std::collections::BTreeSet;
20use std::fmt;
21
22pub const ISOLATED_STAGING_SCRIPT: &str = include_str!("isolated_staging.sh");
23const LAYOUT_VERSION: u32 = 1;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct StagingError(String);
27
28impl fmt::Display for StagingError {
29 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
30 formatter.write_str(&self.0)
31 }
32}
33
34impl std::error::Error for StagingError {}
35
36fn refused(message: impl Into<String>) -> StagingError {
37 StagingError(message.into())
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43struct LayoutWire {
44 version: u32,
45 remote_base: String,
46 invocation: String,
47 primary_root: String,
48 working_directory: String,
49 roots: Vec<String>,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(try_from = "LayoutWire", into = "LayoutWire")]
56pub struct IsolatedStagingLayout {
57 remote_base: String,
58 invocation: String,
59 primary_root: String,
60 working_directory: String,
61 roots: Vec<String>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct ArtifactMapping {
66 pub remote_path: String,
67 pub local_path: String,
68}
69
70impl IsolatedStagingLayout {
71 pub fn new(
72 remote_base: &str,
73 invocation: &str,
74 primary_root: &str,
75 working_directory: &str,
76 roots: impl IntoIterator<Item = String>,
77 ) -> Result<Self, StagingError> {
78 validate_absolute(remote_base)?;
79 validate_absolute(primary_root)?;
80 validate_absolute(working_directory)?;
81 if invocation.len() != 32
82 || !invocation
83 .bytes()
84 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
85 {
86 return Err(refused(
87 "invocation must be 32 lowercase hexadecimal characters",
88 ));
89 }
90 let mut unique = BTreeSet::new();
91 for root in roots {
92 validate_absolute(&root)?;
93 if contains_path(&root, remote_base) || contains_path(remote_base, &root) {
94 return Err(refused("staging base overlaps the source closure"));
95 }
96 unique.insert(root);
97 }
98 if unique.is_empty() {
99 return Err(refused("source closure is empty"));
100 }
101 if !unique.contains(primary_root) {
102 return Err(refused("primary root is absent from the source closure"));
103 }
104 if !contains_path(primary_root, working_directory) {
105 return Err(refused("working directory is outside the primary root"));
106 }
107 Ok(Self {
108 remote_base: remote_base.to_owned(),
109 invocation: invocation.to_owned(),
110 primary_root: primary_root.to_owned(),
111 working_directory: working_directory.to_owned(),
112 roots: unique.into_iter().collect(),
113 })
114 }
115
116 pub fn invocation_root(&self) -> String {
117 format!("{}/invocations/{}", self.remote_base, self.invocation)
118 }
119
120 pub fn source_container(&self) -> String {
121 format!("{}/source", self.invocation_root())
122 }
123
124 pub fn roots(&self) -> &[String] {
125 &self.roots
126 }
127
128 pub fn remote_path(&self, local_path: &str) -> Result<String, StagingError> {
130 validate_absolute(local_path)?;
131 if !self
132 .roots
133 .iter()
134 .any(|root| contains_path(root, local_path))
135 {
136 return Err(refused("path is outside the declared source closure"));
137 }
138 Ok(format!("{}{local_path}", self.source_container()))
139 }
140
141 pub fn remote_working_directory(&self) -> String {
142 format!("{}{}", self.source_container(), self.working_directory)
143 }
144
145 pub fn local_path(&self, remote_path: &str) -> Result<String, StagingError> {
148 validate_absolute(remote_path)?;
149 let container = self.source_container();
150 let local = remote_path
151 .strip_prefix(&container)
152 .ok_or_else(|| refused("remote path belongs to another source container"))?;
153 self.remote_path(local)?;
154 Ok(local.to_owned())
155 }
156
157 pub fn artifact_mapping(&self, relative: &str) -> Result<ArtifactMapping, StagingError> {
158 validate_relative(relative)?;
159 let local_path = format!("{}/{relative}", self.primary_root);
160 Ok(ArtifactMapping {
161 remote_path: self.remote_path(&local_path)?,
162 local_path,
163 })
164 }
165
166 pub fn prepare_command(&self) -> String {
168 shell_call("rch_stage_prepare", &[&self.remote_base, &self.invocation])
169 }
170
171 pub fn verify_command(&self, root: &str) -> Result<String, StagingError> {
174 let remote = self.declared_remote_root(root)?;
175 Ok(shell_call(
176 "rch_stage_verify",
177 &[&self.remote_base, &self.invocation, &remote],
178 ))
179 }
180
181 pub fn receiver_command(&self, root: &str) -> Result<String, StagingError> {
185 let remote = self.declared_remote_root(root)?;
186 Ok(shell_call(
187 "rch_stage_receive",
188 &[&self.remote_base, &self.invocation, &remote],
189 ))
190 }
191
192 fn declared_remote_root(&self, root: &str) -> Result<String, StagingError> {
193 if !self.roots.iter().any(|declared| declared == root) {
194 return Err(refused("receiver root is not a declared closure root"));
195 }
196 self.remote_path(root)
197 }
198}
199
200impl TryFrom<LayoutWire> for IsolatedStagingLayout {
201 type Error = StagingError;
202
203 fn try_from(value: LayoutWire) -> Result<Self, Self::Error> {
204 if value.version != LAYOUT_VERSION {
205 return Err(refused("unsupported isolated staging layout version"));
206 }
207 Self::new(
208 &value.remote_base,
209 &value.invocation,
210 &value.primary_root,
211 &value.working_directory,
212 value.roots,
213 )
214 }
215}
216
217impl From<IsolatedStagingLayout> for LayoutWire {
218 fn from(value: IsolatedStagingLayout) -> Self {
219 Self {
220 version: LAYOUT_VERSION,
221 remote_base: value.remote_base,
222 invocation: value.invocation,
223 primary_root: value.primary_root,
224 working_directory: value.working_directory,
225 roots: value.roots,
226 }
227 }
228}
229
230fn contains_path(root: &str, path: &str) -> bool {
231 path.strip_prefix(root)
232 .is_some_and(|suffix| suffix.is_empty() || suffix.starts_with('/'))
233}
234
235fn validate_absolute(path: &str) -> Result<(), StagingError> {
236 let relative = path
237 .strip_prefix('/')
238 .ok_or_else(|| refused("expected an absolute POSIX path"))?;
239 validate_relative(relative)
240}
241
242fn validate_relative(path: &str) -> Result<(), StagingError> {
243 if path.is_empty()
244 || path.chars().any(char::is_control)
245 || path.split('/').any(|part| matches!(part, "" | "." | ".."))
246 {
247 return Err(refused(
248 "path is empty, non-canonical, or contains control characters",
249 ));
250 }
251 Ok(())
252}
253
254fn quote(value: &str) -> String {
255 format!("'{}'", value.replace('\'', "'\\''"))
256}
257
258fn shell_call(function: &str, args: &[&str]) -> String {
259 let script = format!("{ISOLATED_STAGING_SCRIPT}\n{function} \"$@\"\n");
261 let mut command = format!("sh -c {} rch-isolated-stage", quote(&script));
262 for arg in args {
263 command.push(' ');
264 command.push_str("e(arg));
265 }
266 command
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 const ID: &str = "0123456789abcdef0123456789abcdef";
274
275 fn layout(id: &str) -> IsolatedStagingLayout {
276 IsolatedStagingLayout::new(
277 "/var/tmp/rch-isolated",
278 id,
279 "/data/app",
280 "/data/app/crates/client",
281 [
282 "/data/shared".into(),
283 "/data/app".into(),
284 "/data/app/vendor/nested".into(),
285 ],
286 )
287 .unwrap()
288 }
289
290 #[test]
291 fn sibling_and_nested_roots_share_one_hierarchy() {
292 let layout = layout(ID);
293 let base = layout.source_container();
294 for root in layout.roots() {
295 assert_eq!(layout.remote_path(root).unwrap(), format!("{base}{root}"));
296 }
297 assert_eq!(
298 layout.remote_working_directory(),
299 format!("{base}/data/app/crates/client")
300 );
301 let artifact = layout.artifact_mapping("target/debug/app").unwrap();
302 assert_eq!(artifact.local_path, "/data/app/target/debug/app");
303 assert_eq!(
304 artifact.remote_path,
305 format!("{base}/data/app/target/debug/app")
306 );
307 }
308
309 #[test]
310 fn concurrent_invocations_do_not_share_sources() {
311 let first = layout(ID);
312 let second = layout("fedcba9876543210fedcba9876543210");
313 assert_ne!(first.source_container(), second.source_container());
314 assert!(
315 first
316 .local_path(&second.remote_working_directory())
317 .is_err()
318 );
319 }
320
321 #[test]
322 fn journal_roundtrip_keeps_recovery_identity() {
323 let before = layout(ID);
324 let json = serde_json::to_string(&before).unwrap();
325 let after: IsolatedStagingLayout = serde_json::from_str(&json).unwrap();
326 assert_eq!(before, after);
327 assert_eq!(
328 before.verify_command("/data/app"),
329 after.verify_command("/data/app")
330 );
331 let bad_version = json.replace("\"version\":1", "\"version\":2");
332 assert!(serde_json::from_str::<IsolatedStagingLayout>(&bad_version).is_err());
333 let unsafe_base = json.replace("/var/tmp/rch-isolated", "/var/tmp/../data");
334 assert!(serde_json::from_str::<IsolatedStagingLayout>(&unsafe_base).is_err());
335 }
336
337 #[test]
338 fn boundaries_and_artifact_traversal_are_refused() {
339 let layout = layout(ID);
340 for path in [
341 "/data/application",
342 "/data/app/../other",
343 "/data/app//x",
344 "/",
345 ] {
346 assert!(layout.remote_path(path).is_err(), "{path}");
347 }
348 for relative in [
349 "",
350 "../src/main.rs",
351 "/etc/passwd",
352 "target/../../src",
353 "target//x",
354 ] {
355 assert!(layout.artifact_mapping(relative).is_err(), "{relative}");
356 }
357 assert!(
358 layout
359 .receiver_command("/data/app/undeclared-child")
360 .is_err()
361 );
362 assert_eq!(
363 layout
364 .local_path(&layout.remote_path("/data/shared/src/lib.rs").unwrap())
365 .unwrap(),
366 "/data/shared/src/lib.rs"
367 );
368 }
369
370 #[test]
371 fn invalid_layouts_are_refused_before_remote_commands() {
372 for base in [
373 "/",
374 "relative",
375 "/var//tmp",
376 "/var/../tmp",
377 "/data",
378 "/data/app/cache",
379 "/tmp\n",
380 ] {
381 assert!(
382 IsolatedStagingLayout::new(
383 base,
384 ID,
385 "/data/app",
386 "/data/app",
387 ["/data/app".into()],
388 )
389 .is_err()
390 );
391 }
392 for id in [
393 "",
394 "abc",
395 "0123456789ABCDEF0123456789ABCDEF",
396 "0123456789abcdef0123456789abcdeg",
397 ] {
398 assert!(
399 IsolatedStagingLayout::new(
400 "/tmp/stage",
401 id,
402 "/data/app",
403 "/data/app",
404 ["/data/app".into()],
405 )
406 .is_err()
407 );
408 }
409 assert!(
410 IsolatedStagingLayout::new("/tmp/stage", ID, "/data/app", "/data/app", Vec::new())
411 .is_err()
412 );
413 assert!(
414 IsolatedStagingLayout::new(
415 "/tmp/stage",
416 ID,
417 "/data/app",
418 "/data/application",
419 ["/data/app".into()],
420 )
421 .is_err()
422 );
423 }
424
425 #[test]
426 fn shell_values_are_not_executable_fragments() {
427 assert_eq!(quote("x'y;$(id)"), "'x'\\''y;$(id)'");
428 let command = layout(ID).receiver_command("/data/app").unwrap();
429 assert!(command.contains("rch_stage_receive"));
430 assert!(command.ends_with("e(&layout(ID).remote_path("/data/app").unwrap())));
431 }
432}