Skip to main content

lean_ctx/core/context_bundle/
export.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Where a bundle may be written as a file.
3//!
4//! A bundle file leaves the delivery path of a tool result, so its
5//! destination follows the rule the package export already applies
6//! (`cli::pack_cmd::checkpoint`): bound to the project the bundle was built
7//! for, also when the general tool jail is compiled out or switched off.
8//! The caller writes the RELEASED text there, never the text as built.
9
10use std::path::{Path, PathBuf};
11
12/// The file a bundle of the project `root` may be written to, or the reason
13/// it may not. `candidate` is the absolute destination the caller named.
14pub fn destination(tool: &str, root: &Path, candidate: &Path) -> Result<PathBuf, String> {
15    let (output, _) = crate::core::io_boundary::jail_and_check_export_path(tool, candidate, root)?;
16    // The jail also admits lean-ctx's own data and state directories and
17    // configured allow paths; a bundle of project sources belongs inside the
18    // project only. Both sides go through the same canonicalizer.
19    let canonical_root = crate::core::pathutil::canonicalize_secure(root)
20        .map_err(|_| "project root is unavailable".to_string())?;
21    let inside = output
22        .parent()
23        .and_then(|parent| crate::core::pathutil::canonicalize_secure(parent).ok())
24        .is_some_and(|parent| parent.starts_with(&canonical_root));
25    if !inside {
26        return Err("a bundle file must stay inside the project root".to_string());
27    }
28    crate::core::pathjail::enforce_writable(&output)?;
29    Ok(output)
30}
31
32/// Write `bytes` to a bundle destination below `root`, refusing a symlinked
33/// final component. `destination` leaves a dangling symlink unresolved (the
34/// jail resolves only the existing ancestor), so a cloned repository can ship
35/// `bundle.xml -> ../../outside`; the confined write rebuilds the path below
36/// the project and fails closed on any symlinked component.
37pub fn write(tool: &str, root: &Path, candidate: &Path, bytes: &[u8]) -> Result<PathBuf, String> {
38    let output = destination(tool, root, candidate)?;
39    crate::core::pathjail_write::write_requested_below(root, candidate, &output, bytes)
40}
41
42#[cfg(all(test, unix))]
43mod tests {
44    use super::*;
45    use std::os::unix::fs::symlink;
46
47    /// A cloned repository can ship the bundle file as a dangling symlink; the
48    /// write must be refused, not carried out of the project.
49    #[test]
50    fn write_refuses_a_symlinked_bundle_file() {
51        let _iso = crate::core::data_dir::isolated_data_dir();
52        let tmp = tempfile::tempdir().unwrap();
53        let root = tmp.path().join("proj");
54        std::fs::create_dir_all(&root).unwrap();
55        std::fs::write(root.join("Cargo.toml"), "[package]\nname=\"p\"\n").unwrap();
56        let outside = tmp.path().join("outside.xml");
57
58        // dangling link (target absent): destination's parent check passes.
59        symlink(&outside, root.join("bundle.xml")).unwrap();
60        let err = write("ctx_pack", &root, &root.join("bundle.xml"), b"<x/>").unwrap_err();
61        assert!(err.contains("symlink"), "{err}");
62        assert!(!outside.exists());
63
64        // existing-file link is refused too.
65        let victim = tmp.path().join("victim.xml");
66        std::fs::write(&victim, "keep").unwrap();
67        symlink(&victim, root.join("bundle2.xml")).unwrap();
68        assert!(write("ctx_pack", &root, &root.join("bundle2.xml"), b"<x/>").is_err());
69        assert_eq!(std::fs::read_to_string(&victim).unwrap(), "keep");
70
71        // a plain destination inside the project still writes.
72        let path = write("ctx_pack", &root, &root.join("ok.xml"), b"<x/>").unwrap();
73        assert_eq!(std::fs::read(&path).unwrap(), b"<x/>");
74    }
75}