Skip to main content

khive_runtime/
file_policy.rs

1//! Confined server file destinations shared by result sinks and blob verbs.
2
3use std::path::{Path, PathBuf};
4
5/// Environment override for the allowed `save_to` export root.
6pub const EXPORT_ROOT_ENV: &str = "KHIVE_SAVE_TO_ROOT";
7
8/// Resolve (and create) the allowed export root for `save_to` destinations.
9/// Defaults to `~/.khive/exports`; overridable via `KHIVE_SAVE_TO_ROOT`.
10pub fn export_root() -> anyhow::Result<PathBuf> {
11    let root = match std::env::var(EXPORT_ROOT_ENV) {
12        Ok(v) if !v.trim().is_empty() => PathBuf::from(v),
13        _ => {
14            let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
15            PathBuf::from(home).join(".khive").join("exports")
16        }
17    };
18    std::fs::create_dir_all(&root)
19        .map_err(|e| anyhow::anyhow!("create export root {}: {e}", root.display()))?;
20    root.canonicalize()
21        .map_err(|e| anyhow::anyhow!("canonicalize export root {}: {e}", root.display()))
22}
23
24/// Validate a client-supplied `save_to` path against the allowed export `root`
25/// and return the canonicalized destination. Rejects `..` traversal, a
26/// resolved parent outside `root`, and an existing symlink at the
27/// destination. See `crates/khive-mcp/docs/save-sink.md`.
28pub fn validate_destination(root: &Path, requested: &Path) -> anyhow::Result<PathBuf> {
29    if requested.as_os_str().is_empty() {
30        anyhow::bail!("save_to path must not be empty");
31    }
32    if requested
33        .components()
34        .any(|c| matches!(c, std::path::Component::ParentDir))
35    {
36        anyhow::bail!(
37            "save_to path must not contain '..' traversal components: {}",
38            requested.display()
39        );
40    }
41
42    let joined = if requested.is_absolute() {
43        requested.to_path_buf()
44    } else {
45        root.join(requested)
46    };
47
48    let parent = joined.parent().filter(|p| !p.as_os_str().is_empty());
49    let parent = match parent {
50        Some(p) => p,
51        None => anyhow::bail!("save_to path has no parent directory: {}", joined.display()),
52    };
53
54    // Containment must be proven BEFORE any directory creation: walk up to the
55    // deepest existing ancestor and canonicalize that. `..` components were
56    // already rejected above, so the not-yet-existing suffix can only descend
57    // beneath the ancestor — if the ancestor is inside the root, the parent is.
58    let mut existing = parent;
59    while !existing.exists() {
60        existing = match existing.parent().filter(|p| !p.as_os_str().is_empty()) {
61            Some(p) => p,
62            None => anyhow::bail!(
63                "save_to path has no existing ancestor: {}",
64                joined.display()
65            ),
66        };
67    }
68    let canonical_existing = existing.canonicalize().map_err(|e| {
69        anyhow::anyhow!("canonicalize save_to ancestor {}: {e}", existing.display())
70    })?;
71    if !canonical_existing.starts_with(root) {
72        anyhow::bail!(
73            "save_to path escapes the allowed export root ({}): {}",
74            root.display(),
75            joined.display()
76        );
77    }
78
79    std::fs::create_dir_all(parent)
80        .map_err(|e| anyhow::anyhow!("create save_to parent dir {}: {e}", parent.display()))?;
81
82    let canonical_parent = parent
83        .canonicalize()
84        .map_err(|e| anyhow::anyhow!("canonicalize save_to parent {}: {e}", parent.display()))?;
85
86    if !canonical_parent.starts_with(root) {
87        anyhow::bail!(
88            "save_to path escapes the allowed export root ({}): {}",
89            root.display(),
90            joined.display()
91        );
92    }
93
94    let file_name = joined
95        .file_name()
96        .ok_or_else(|| anyhow::anyhow!("save_to path has no file name: {}", joined.display()))?;
97    let dest = canonical_parent.join(file_name);
98
99    if let Ok(meta) = std::fs::symlink_metadata(&dest) {
100        if meta.file_type().is_symlink() {
101            anyhow::bail!(
102                "save_to destination must not be a symlink: {}",
103                dest.display()
104            );
105        }
106    }
107
108    Ok(dest)
109}
110
111pub fn resolve_destination(path: &Path, restrict_to_export_root: bool) -> anyhow::Result<PathBuf> {
112    if path.as_os_str().is_empty() {
113        anyhow::bail!("save_to path must not be empty");
114    }
115
116    let destination = if restrict_to_export_root {
117        let root = export_root()?;
118        validate_destination(&root, path)
119    } else {
120        if let Some(parent) = path.parent() {
121            if !parent.as_os_str().is_empty() {
122                std::fs::create_dir_all(parent)
123                    .map_err(|e| anyhow::anyhow!("create parent dir {}: {e}", parent.display()))?;
124            }
125        }
126        Ok(path.to_path_buf())
127    }?;
128
129    match std::fs::symlink_metadata(&destination) {
130        Ok(metadata) if !metadata.file_type().is_file() => anyhow::bail!(
131            "save_to destination must be absent or an existing regular file: {}",
132            destination.display()
133        ),
134        Ok(_) => {}
135        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
136        Err(error) => anyhow::bail!(
137            "inspect save_to destination {}: {error}",
138            destination.display()
139        ),
140    }
141
142    Ok(destination)
143}
144
145/// Environment override for the confined server import directory.
146pub const IMPORT_ROOT_ENV: &str = "KHIVE_IMPORT_FROM_ROOT";
147
148/// Resolve (and create) the import root, defaulting to `~/.khive/imports`.
149pub fn import_root() -> anyhow::Result<PathBuf> {
150    let root = match std::env::var(IMPORT_ROOT_ENV) {
151        Ok(value) if !value.trim().is_empty() => PathBuf::from(value),
152        _ => {
153            let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
154            PathBuf::from(home).join(".khive").join("imports")
155        }
156    };
157    std::fs::create_dir_all(&root)
158        .map_err(|error| anyhow::anyhow!("create import root {}: {error}", root.display()))?;
159    root.canonicalize()
160        .map_err(|error| anyhow::anyhow!("canonicalize import root {}: {error}", root.display()))
161}
162
163/// Both file verbs require disjoint canonical roots at the time of the call.
164pub fn confined_file_roots() -> anyhow::Result<(PathBuf, PathBuf)> {
165    let imports = import_root()?;
166    let exports = export_root()?;
167    if imports.starts_with(&exports) || exports.starts_with(&imports) {
168        anyhow::bail!("import and export roots must not be equal or nested");
169    }
170    Ok((imports, exports))
171}
172
173/// Validate an existing import file without accepting symlinks within the root.
174pub fn validate_import(root: &Path, requested: &Path) -> anyhow::Result<PathBuf> {
175    if requested.as_os_str().is_empty() {
176        anyhow::bail!("import path must not be empty");
177    }
178    if requested
179        .components()
180        .any(|component| matches!(component, std::path::Component::ParentDir))
181    {
182        anyhow::bail!("import path must not contain '..' traversal components");
183    }
184    let joined = if requested.is_absolute() {
185        requested.to_path_buf()
186    } else {
187        root.join(requested)
188    };
189    if !joined.starts_with(root) {
190        anyhow::bail!(
191            "import path escapes the allowed import root ({})",
192            root.display()
193        );
194    }
195    let mut current = root.to_path_buf();
196    for component in joined.strip_prefix(root)?.components() {
197        current.push(component);
198        let metadata = std::fs::symlink_metadata(&current).map_err(|error| {
199            anyhow::anyhow!("inspect import path {}: {error}", current.display())
200        })?;
201        if metadata.file_type().is_symlink() {
202            anyhow::bail!(
203                "import path must not contain a symlink: {}",
204                current.display()
205            );
206        }
207    }
208    let canonical = joined.canonicalize().map_err(|error| {
209        anyhow::anyhow!("canonicalize import file {}: {error}", joined.display())
210    })?;
211    if !canonical.starts_with(root) {
212        anyhow::bail!(
213            "import path escapes the allowed import root ({})",
214            root.display()
215        );
216    }
217    if !std::fs::metadata(&canonical)?.is_file() {
218        anyhow::bail!("import source must be a regular file: {}", joined.display());
219    }
220    Ok(canonical)
221}
222
223/// Open the validated source and recheck the opened object's type.
224pub fn open_import(root: &Path, requested: &Path) -> anyhow::Result<std::fs::File> {
225    let path = validate_import(root, requested)?;
226    let mut options = std::fs::OpenOptions::new();
227    options.read(true);
228    #[cfg(unix)]
229    {
230        use std::os::unix::fs::OpenOptionsExt as _;
231        options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
232    }
233    let file = options
234        .open(&path)
235        .map_err(|error| anyhow::anyhow!("open import source {}: {error}", path.display()))?;
236    if !file.metadata()?.is_file() {
237        anyhow::bail!("import source must be a regular file: {}", path.display());
238    }
239    Ok(file)
240}