1use base64::Engine as _;
7use base64::engine::general_purpose::STANDARD;
8use scope::error::ScopeError;
9use std::io::Write;
10use std::path::{Component, Path, PathBuf};
11
12pub const SAVE_ESCAPE: &str = "save writes inside the current directory or to an absolute path";
14
15pub const SAVE_USAGE: &str = "Usage: save <file> [--force]";
17
18pub const OSC52_MAX_PAYLOAD: usize = 100_000;
20
21#[derive(Debug)]
23pub enum SaveError {
24 Refused(String),
27 Failed(ScopeError),
30}
31
32impl SaveError {
33 pub fn failed(e: std::io::Error, doing: impl std::fmt::Display) -> Self {
35 Self::Failed(ScopeError::io_context(e, doing))
36 }
37
38 pub fn message(&self) -> String {
40 match self {
41 SaveError::Refused(m) => m.clone(),
42 SaveError::Failed(ScopeError::IoError(e)) => e.to_string(),
43 SaveError::Failed(other) => other.to_string(),
44 }
45 }
46}
47
48pub fn osc52(text: &str) -> String {
50 format!("\x1b]52;c;{}\x07", STANDARD.encode(text))
51}
52
53pub fn fits_osc52(text: &str) -> bool {
55 text.len().div_ceil(3) * 4 <= OSC52_MAX_PAYLOAD
56}
57
58fn resolve(arg: &str, cwd: &Path, home: Option<&Path>) -> Result<PathBuf, SaveError> {
63 let expanded = match (arg, arg.strip_prefix("~/"), home) {
64 ("~", _, Some(home)) => home.to_path_buf(),
65 (_, Some(rest), Some(home)) => home.join(rest),
66 _ => PathBuf::from(arg),
67 };
68 let absolute = expanded.is_absolute();
69 if !absolute && expanded.components().any(|c| c == Component::ParentDir) {
70 return Err(SaveError::Refused(SAVE_ESCAPE.to_string()));
71 }
72 let Some(name) = expanded.file_name() else {
73 return Err(SaveError::Refused(SAVE_USAGE.to_string()));
74 };
75 let joined = if absolute {
76 expanded.clone()
77 } else {
78 cwd.join(&expanded)
79 };
80 let parent = joined.parent().unwrap_or(&joined);
81 let real = parent
82 .canonicalize()
83 .map_err(|e| SaveError::failed(e, format!("Could not write {}", joined.display())))?;
84 if !absolute {
85 let root = cwd
86 .canonicalize()
87 .map_err(|e| SaveError::failed(e, format!("Could not read {}", cwd.display())))?;
88 if !real.starts_with(&root) {
89 return Err(SaveError::Refused(SAVE_ESCAPE.to_string()));
90 }
91 }
92 Ok(real.join(name))
93}
94
95fn write_file(target: &Path, text: &str, force: bool) -> std::io::Result<()> {
100 if !force {
101 let mut f = std::fs::OpenOptions::new()
102 .write(true)
103 .create_new(true)
104 .open(target)?;
105 let result = f.write_all(text.as_bytes()).and_then(|()| f.sync_all());
106 if result.is_err() {
107 let _ = std::fs::remove_file(target);
109 }
110 return result;
111 }
112 let name = target.file_name().unwrap_or_default().to_string_lossy();
113 let tmp = target.with_file_name(format!(".{}.scope-save-{}", name, std::process::id()));
114 let result = (|| {
115 let mut f = std::fs::OpenOptions::new()
116 .write(true)
117 .create_new(true)
118 .open(&tmp)?;
119 f.write_all(text.as_bytes())?;
120 f.sync_all()?;
121 std::fs::rename(&tmp, target)
122 })();
123 if result.is_err() {
124 let _ = std::fs::remove_file(&tmp);
125 }
126 result
127}
128
129pub fn save(
132 args: &[&str],
133 last: Option<&str>,
134 cwd: &Path,
135 home: Option<&Path>,
136) -> Result<String, SaveError> {
137 let force = args.contains(&"--force");
138 let files: Vec<&str> = args.iter().copied().filter(|a| *a != "--force").collect();
139 let [file] = files.as_slice() else {
140 return Err(SaveError::Refused(SAVE_USAGE.to_string()));
141 };
142 let target = resolve(file, cwd, home)?;
143 let Some(text) = last else {
144 return Err(SaveError::Refused("nothing to save yet".to_string()));
145 };
146 match std::fs::symlink_metadata(&target) {
147 Ok(m) if m.is_dir() => {
148 return Err(SaveError::Refused(format!(
149 "{} is a directory",
150 target.display()
151 )));
152 }
153 Ok(_) if !force => {
154 return Err(SaveError::Refused(format!(
155 "{} exists; add --force to replace it",
156 target.display()
157 )));
158 }
159 _ => {}
160 }
161 write_file(&target, text, force)
162 .map_err(|e| SaveError::failed(e, format!("Could not write {}", target.display())))?;
163 let n = text.lines().count();
164 Ok(format!(
165 "saved {} line{} to {}",
166 n,
167 if n == 1 { "" } else { "s" },
168 target.display()
169 ))
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 fn saved(args: &[&str], text: Option<&str>, cwd: &Path) -> Result<String, SaveError> {
177 save(args, text, cwd, None)
178 }
179
180 fn refused(r: Result<String, SaveError>) -> String {
181 match r {
182 Err(SaveError::Refused(m)) => m,
183 other => panic!("expected a refusal, got {other:?}"),
184 }
185 }
186
187 #[test]
188 fn test_osc52_encodes_base64_payload() {
189 assert_eq!(osc52("hi"), "\x1b]52;c;aGk=\x07");
190 }
191
192 #[test]
193 fn test_osc52_cap_is_on_the_base64_payload() {
194 let at_cap = "a".repeat(OSC52_MAX_PAYLOAD / 4 * 3);
195 assert!(fits_osc52(&at_cap));
196 assert!(!fits_osc52(&format!("{at_cap}a")));
197 }
198
199 #[test]
200 fn test_save_writes_inside_cwd_and_refuses_overwrite_without_force() {
201 let dir = tempfile::tempdir().unwrap();
202 let note = saved(&["out.json"], Some("{\"a\":1}\n"), dir.path()).unwrap();
203 assert!(note.starts_with("saved 1 line to "), "{note}");
204 let path = dir.path().join("out.json");
205 assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"a\":1}\n");
206
207 let again = refused(saved(&["out.json"], Some("new"), dir.path()));
208 assert!(again.contains("exists; add --force"), "{again}");
209 assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"a\":1}\n");
210
211 let note = saved(&["--force", "out.json"], Some("a\nb\n"), dir.path()).unwrap();
212 assert!(note.starts_with("saved 2 lines to "), "{note}");
213 assert_eq!(std::fs::read_to_string(&path).unwrap(), "a\nb\n");
214 }
215
216 #[test]
217 fn test_save_refuses_a_path_that_climbs_out() {
218 let dir = tempfile::tempdir().unwrap();
219 let cwd = dir.path().join("work");
220 std::fs::create_dir(&cwd).unwrap();
221 for arg in ["../x.json", "sub/../../x.json", ".."] {
222 assert_eq!(
223 refused(saved(&[arg], Some("t"), &cwd)),
224 SAVE_ESCAPE,
225 "{arg}"
226 );
227 }
228 assert!(!dir.path().join("x.json").exists());
229 }
230
231 #[cfg(unix)]
232 #[test]
233 fn test_save_refuses_a_symlinked_parent_outside_cwd() {
234 let dir = tempfile::tempdir().unwrap();
235 let cwd = dir.path().join("work");
236 let outside = dir.path().join("outside");
237 std::fs::create_dir(&cwd).unwrap();
238 std::fs::create_dir(&outside).unwrap();
239 std::os::unix::fs::symlink(&outside, cwd.join("link")).unwrap();
240 assert_eq!(
241 refused(saved(&["link/x.json"], Some("t"), &cwd)),
242 SAVE_ESCAPE
243 );
244 assert!(!outside.join("x.json").exists());
245 }
246
247 #[cfg(unix)]
248 #[test]
249 fn test_save_force_replaces_a_symlink_and_never_writes_through_it() {
250 let dir = tempfile::tempdir().unwrap();
251 let victim = dir.path().join("victim.txt");
252 std::fs::write(&victim, "keep").unwrap();
253 std::os::unix::fs::symlink(&victim, dir.path().join("out.txt")).unwrap();
254 assert!(refused(saved(&["out.txt"], Some("new"), dir.path())).contains("exists"));
256 saved(&["--force", "out.txt"], Some("new"), dir.path()).unwrap();
257 assert_eq!(std::fs::read_to_string(&victim).unwrap(), "keep");
258 let meta = std::fs::symlink_metadata(dir.path().join("out.txt")).unwrap();
259 assert!(meta.is_file(), "the link is replaced by a plain file");
260 }
261
262 #[test]
263 fn test_save_allows_an_absolute_path_and_reports_os_errors() {
264 let dir = tempfile::tempdir().unwrap();
265 let elsewhere = tempfile::tempdir().unwrap();
266 let abs = elsewhere.path().join("a.txt");
267 saved(&[abs.to_str().unwrap()], Some("t"), dir.path()).unwrap();
268
269 let missing = elsewhere.path().join("no-dir").join("a.txt");
270 let Err(failed @ SaveError::Failed(_)) =
271 saved(&[missing.to_str().unwrap()], Some("t"), dir.path())
272 else {
273 panic!("expected an OS failure");
274 };
275 let SaveError::Failed(ScopeError::IoError(e)) = &failed else {
276 panic!("an I/O error");
277 };
278 assert_eq!(e.kind(), std::io::ErrorKind::NotFound, "the kind stays");
279 assert!(std::error::Error::source(e).is_some(), "the source stays");
280 let note = failed.message();
281 assert!(note.starts_with("Could not write "), "{note}");
282 assert!(note.contains("no-dir"), "{note}");
283 assert!(matches!(
284 saved(&["no-dir/a.txt"], Some("t"), dir.path()),
285 Err(SaveError::Failed(_))
286 ));
287 }
288
289 #[test]
290 fn test_save_needs_one_file_and_some_output() {
291 let dir = tempfile::tempdir().unwrap();
292 for args in [&[][..], &["a", "b"], &[""], &["."]] {
293 assert_eq!(
294 refused(saved(args, Some("t"), dir.path())),
295 SAVE_USAGE,
296 "{args:?}"
297 );
298 }
299 assert_eq!(
300 refused(saved(&["a"], None, dir.path())),
301 "nothing to save yet"
302 );
303 }
304
305 #[test]
306 fn test_save_to_a_directory_says_so() {
307 let dir = tempfile::tempdir().unwrap();
308 std::fs::create_dir(dir.path().join("d")).unwrap();
309 for force in [false, true] {
310 let args: Vec<&str> = if force {
311 vec!["--force", "d"]
312 } else {
313 vec!["d"]
314 };
315 let m = refused(saved(&args, Some("t"), dir.path()));
316 assert!(m.ends_with("d is a directory"), "{m}");
317 }
318 }
319
320 #[test]
321 fn test_save_expands_a_leading_tilde_to_home() {
322 let cwd = tempfile::tempdir().unwrap();
323 let home = tempfile::tempdir().unwrap();
324 save(&["~/h.txt"], Some("t"), cwd.path(), Some(home.path())).unwrap();
325 assert!(home.path().join("h.txt").exists());
326 assert!(!cwd.path().join("~").exists());
327 }
328
329 #[test]
330 fn test_save_to_bare_tilde_means_the_home_directory() {
331 let cwd = tempfile::tempdir().unwrap();
332 let home = tempfile::tempdir().unwrap();
333 let err = save(&["~"], Some("t"), cwd.path(), Some(home.path())).unwrap_err();
334 assert!(format!("{err:?}").contains("is a directory"), "{err:?}");
335 assert!(!cwd.path().join("~").exists());
336 }
337
338 #[cfg(unix)]
339 #[test]
340 fn test_a_refused_create_never_removes_an_existing_file() {
341 let dir = tempfile::tempdir().unwrap();
342 let big = dir.path().join("q.txt");
343 std::fs::write(&big, "").unwrap();
344 assert!(
345 write_file(&big, "x", false).is_err(),
346 "exists: nothing is touched"
347 );
348 assert!(big.exists(), "an existing file is never removed");
349 }
350}