Skip to main content

rch_common/
isolated_staging.rs

1//! Invocation-owned Linux source layout and receiver admission (GH #104).
2//!
3//! This is a staging backend, NOT an enabled ordinary-exec route. Orchestration
4//! must supply the complete canonical dependency closure, hold its existing
5//! durable source grant around receivers/build/retrieval, and persist this
6//! layout before writing remote state. Recovery verifies that same invocation;
7//! it must not generate a new identity or replay an uncertain build.
8//!
9//! Mapping retains the full POSIX hierarchy under one private source container,
10//! preserving relative relationships between roots, nested working directories,
11//! and project-relative artifact locations. It does not rewrite absolute paths
12//! embedded in manifests, commands, environment variables, or source symlinks.
13//! Such references need a separate proven remapping/namespace policy before an
14//! ordinary build can use this backend. Caches containing compiler-visible paths
15//! must include `source_container()` in their identity; do not blindly attach a
16//! canonical-path target pool to an invocation-isolated source tree.
17
18use 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/// Journal only canonical inputs; never deserialize unchecked remote paths.
41#[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/// Immutable, validated layout. The invocation is a fresh UUID's 32-character
53/// lowercase hexadecimal representation, supplied by the existing recovery ID.
54#[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    /// Map only paths inside a declared root, with component-boundary checks.
129    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    /// Reverse mapping for diagnostics. Do not accept a neighboring invocation
146    /// or an undeclared sibling simply because it shares a textual prefix.
147    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    /// Atomically claim a fresh invocation. Never call this to resume recovery.
167    pub fn prepare_command(&self) -> String {
168        shell_call("rch_stage_prepare", &[&self.remote_base, &self.invocation])
169    }
170
171    /// Read-only owner/path verification; NOT proof that a previous process or
172    /// source writer has stopped. The durable lease protocol remains authority.
173    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    /// Pass to rsync's --rsync-path for SOURCE UPLOAD only. rsync appends its
182    /// server argv; the receiver validates and pins the exact planned target.
183    /// The existing checkout fence/source lease must still wrap this command.
184    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    // function is an internal constant, never caller-supplied shell grammar.
260    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(&quote(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(&quote(&layout(ID).remote_path("/data/app").unwrap())));
431    }
432}