| 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 |