返回 CodeWhale
session_export_host.rs
根目录 / crates / tui / src / commands / session_export_host.rs
1 //! TUI-owned host services for the session-export slice (FEAT-025 D5/D7).
2 //!
3 //! This module deliberately lives outside `commands/groups/session`, which
4 //! FEAT-043 moves into `codewhale-commands`. It owns the two filesystem
5 //! services the `/export` slice needs from the host:
6 //!
7 //! * the shared `last-copy.md` recovery writer reused by `/export` and `/copy`
8 //! (D5), and
9 //! * the protected export-destination resolver/writer used only by `/export`
10 //! (D7).
11 //!
12 //! The algorithm, check order, error text, and platform behavior are the exact
13 //! baseline implementations relocated unchanged. The portable `/export`
14 //! handler reaches these services only through
15 //! `CommandSessionExportContext` — never by importing this module — so the
16 //! future portable group keeps no host dependency.
17
18 use std::fs::{self, OpenOptions};
19 use std::io::Write as _;
20 use std::path::{Component, Path, PathBuf};
21
22 /// Write the export to a predictable last-copy file under the Codewhale home
23 /// (#5555): a clipboard-only export on SSH/headless must never dead-end the
24 /// user, so the same content lands at `<home>/exports/last-copy.md` and every
25 /// failure message names it. Returns the path when the write succeeded.
26 pub(crate) fn write_last_copy(markdown: &str) -> Option<PathBuf> {
27 let home = codewhale_paths::codewhale_home().ok().flatten()?;
28 let exports_dir = home.join("exports");
29 std::fs::create_dir_all(&exports_dir).ok()?;
30 let physical_home = std::fs::canonicalize(&home).ok()?;
31 let physical_exports = std::fs::canonicalize(&exports_dir).ok()?;
32 if !physical_exports.starts_with(&physical_home) {
33 return None;
34 }
35 write_last_copy_to(&exports_dir, markdown).ok()
36 }
37
38 fn write_last_copy_to(exports_dir: &Path, markdown: &str) -> std::io::Result<PathBuf> {
39 std::fs::create_dir_all(exports_dir)?;
40 let path = exports_dir.join("last-copy.md");
41 // Reuse the private atomic writer: random same-directory temp names,
42 // restrictive creation mode, symlink-safe replacement, and Windows
43 // replace retries are all part of the existing persistence contract.
44 crate::utils::write_atomic(&path, markdown.as_bytes())?;
45 Ok(path)
46 }
47
48 /// Resolve a requested export destination against the trusted workspace root.
49 ///
50 /// Verbatim baseline algorithm (D7): trim; empty → `export path is empty`;
51 /// reject `..` components; canonicalize the workspace with its current
52 /// fallback; rebase workspace-absolute paths (raw or canonicalized); otherwise
53 /// join workspace-relative; require a file name. Errors are returned unwrapped
54 /// so the portable handler keeps the exact baseline text.
55 pub(crate) fn resolve_export_path(workspace: &Path, raw: &str) -> Result<PathBuf, String> {
56 let raw = raw.trim();
57 if raw.is_empty() {
58 return Err("export path is empty".to_string());
59 }
60 let requested = PathBuf::from(raw);
61 if requested
62 .components()
63 .any(|component| component == Component::ParentDir)
64 {
65 return Err(
66 "export paths may not contain `..`; use an explicit normalized absolute path instead"
67 .to_string(),
68 );
69 }
70 // Resolve the trusted workspace root once so platform aliases such as
71 // macOS `/var -> /private/var` do not make every workspace-relative
72 // export look like it traverses a user-controlled symlink. Requested
73 // components beneath that root remain lexical and are checked below.
74 let resolved_workspace =
75 fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
76 let path = if requested.is_absolute() {
77 if let Ok(relative) = requested.strip_prefix(workspace) {
78 resolved_workspace.join(relative)
79 } else if let Ok(relative) = requested.strip_prefix(&resolved_workspace) {
80 resolved_workspace.join(relative)
81 } else {
82 requested
83 }
84 } else {
85 resolved_workspace.join(requested)
86 };
87 if path.file_name().is_none() {
88 return Err(format!("export path must name a file: {}", path.display()));
89 }
90 Ok(path)
91 }
92
93 /// Write rendered export bytes to a resolved destination with the exact
94 /// baseline protections (D7): parent presence/directory checks, symlink
95 /// rejection (leaf and ancestors), no overwrite by default, non-regular-file
96 /// rejection, exclusive creation with `0o600` on Unix, `fsync`, cleanup after a
97 /// failed write, forced atomic replacement, and owner-only permissions.
98 pub(crate) fn write_export_file(path: &Path, contents: &[u8], force: bool) -> Result<(), String> {
99 let parent = path
100 .parent()
101 .filter(|parent| !parent.as_os_str().is_empty())
102 .ok_or_else(|| format!("path has no parent directory: {}", path.display()))?;
103 let parent_metadata = fs::metadata(parent).map_err(|err| {
104 format!(
105 "parent directory {} is unavailable: {err}",
106 parent.display()
107 )
108 })?;
109 if !parent_metadata.is_dir() {
110 return Err(format!("parent is not a directory: {}", parent.display()));
111 }
112 reject_symlink_components(path)?;
113
114 match fs::symlink_metadata(path) {
115 Ok(_) if !force => {
116 return Err(format!(
117 "destination already exists: {}. Re-run with `/export file --force <path>` to replace it",
118 path.display()
119 ));
120 }
121 Ok(metadata) if !metadata.file_type().is_file() => {
122 return Err(format!(
123 "refusing to replace a non-regular file: {}",
124 path.display()
125 ));
126 }
127 Ok(_) => {}
128 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
129 Err(err) => return Err(format!("could not inspect {}: {err}", path.display())),
130 }
131
132 if force {
133 crate::utils::write_atomic(path, contents).map_err(|err| err.to_string())?;
134 set_owner_only(path).map_err(|err| format!("could not secure file permissions: {err}"))?;
135 return Ok(());
136 }
137
138 let mut options = OpenOptions::new();
139 options.write(true).create_new(true);
140 #[cfg(unix)]
141 {
142 use std::os::unix::fs::OpenOptionsExt;
143 options.mode(0o600);
144 }
145 let mut file = options.open(path).map_err(|err| {
146 if err.kind() == std::io::ErrorKind::AlreadyExists {
147 format!(
148 "destination already exists: {}. Re-run with `/export file --force <path>` to replace it",
149 path.display()
150 )
151 } else {
152 err.to_string()
153 }
154 })?;
155 if let Err(err) = file.write_all(contents).and_then(|()| file.sync_all()) {
156 drop(file);
157 let _ = fs::remove_file(path);
158 return Err(err.to_string());
159 }
160 set_owner_only(path).map_err(|err| format!("could not secure file permissions: {err}"))
161 }
162
163 fn reject_symlink_components(path: &Path) -> Result<(), String> {
164 for component_path in path.ancestors() {
165 match fs::symlink_metadata(component_path) {
166 Ok(metadata) if metadata.file_type().is_symlink() => {
167 return Err(format!(
168 "refusing export through symlink component: {}",
169 component_path.display()
170 ));
171 }
172 Ok(_) => {}
173 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
174 Err(err) => {
175 return Err(format!(
176 "could not inspect path component {}: {err}",
177 component_path.display()
178 ));
179 }
180 }
181 }
182 Ok(())
183 }
184
185 #[cfg(unix)]
186 fn set_owner_only(path: &Path) -> std::io::Result<()> {
187 use std::os::unix::fs::PermissionsExt;
188 fs::set_permissions(path, fs::Permissions::from_mode(0o600))
189 }
190
191 #[cfg(not(unix))]
192 fn set_owner_only(_path: &Path) -> std::io::Result<()> {
193 Ok(())
194 }
195
196 #[cfg(test)]
197 mod tests {
198 use super::*;
199 use tempfile::TempDir;
200
201 #[test]
202 fn last_copy_writes_the_export_without_leaving_a_temp_artifact() {
203 let tmp = TempDir::new().expect("tempdir");
204 let dir = tmp.path().join("exports");
205 let path = write_last_copy_to(&dir, "# export\n\nhello\n").expect("write");
206 assert_eq!(path, dir.join("last-copy.md"));
207 assert_eq!(
208 std::fs::read_to_string(&path).expect("read"),
209 "# export\n\nhello\n"
210 );
211 // The next export overwrites the same predictable path.
212 write_last_copy_to(&dir, "# second\n").expect("rewrite");
213 assert_eq!(std::fs::read_to_string(&path).expect("read"), "# second\n");
214 assert_eq!(
215 std::fs::read_dir(&dir).expect("read exports").count(),
216 1,
217 "the atomic writer must not leave a temp artifact"
218 );
219
220 #[cfg(unix)]
221 {
222 use std::os::unix::fs::PermissionsExt;
223 let mode = std::fs::metadata(&path)
224 .expect("metadata")
225 .permissions()
226 .mode();
227 assert_eq!(mode & 0o077, 0, "recovery copy must remain private");
228 }
229 }
230
231 #[test]
232 fn last_copy_stays_inside_an_explicit_codewhale_home() {
233 let ambient = TempDir::new().expect("ambient home");
234 let isolated = TempDir::new().expect("isolated Codewhale home");
235 let _env_lock = crate::test_support::lock_test_env();
236 let _home = crate::test_support::EnvVarGuard::set("HOME", ambient.path());
237 let _codewhale_home =
238 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", isolated.path());
239
240 let path = write_last_copy("isolated response").expect("recovery copy");
241
242 assert_eq!(path, isolated.path().join("exports/last-copy.md"));
243 assert_eq!(
244 std::fs::read_to_string(&path).expect("read recovery copy"),
245 "isolated response"
246 );
247 assert!(
248 !ambient.path().join("exports/last-copy.md").exists(),
249 "explicit CODEWHALE_HOME must prevent ambient-home writes"
250 );
251 }
252
253 #[cfg(unix)]
254 #[test]
255 fn last_copy_refuses_an_exports_symlink_outside_codewhale_home() {
256 use std::os::unix::fs::symlink;
257
258 let ambient = TempDir::new().expect("ambient home");
259 let isolated = TempDir::new().expect("isolated Codewhale home");
260 let external = TempDir::new().expect("external dir");
261 symlink(external.path(), isolated.path().join("exports")).expect("exports symlink");
262 let _env_lock = crate::test_support::lock_test_env();
263 let _home = crate::test_support::EnvVarGuard::set("HOME", ambient.path());
264 let _codewhale_home =
265 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", isolated.path());
266
267 assert_eq!(write_last_copy("must stay isolated"), None);
268 assert!(
269 !external.path().join("last-copy.md").exists(),
270 "recovery content must not escape through a nested symlink"
271 );
272 }
273 }
274
274 lines RUST