khive_runtime/
file_policy.rs1use std::path::{Path, PathBuf};
4
5pub const EXPORT_ROOT_ENV: &str = "KHIVE_SAVE_TO_ROOT";
7
8pub 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
24pub 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 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
145pub const IMPORT_ROOT_ENV: &str = "KHIVE_IMPORT_FROM_ROOT";
147
148pub 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
163pub 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
173pub 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(¤t).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
223pub 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}