Skip to main content

scope_cli/cli/tui/
clip.rs

1//! # Copy and Save
2//!
3//! The last command's plain output, as an OSC 52 clipboard sequence (Ctrl-Y)
4//! or as a file (`save`).
5
6use base64::Engine as _;
7use base64::engine::general_purpose::STANDARD;
8use scope::error::ScopeError;
9use std::io::Write;
10use std::path::{Component, Path, PathBuf};
11
12/// What `save` says when a relative path leaves the current directory.
13pub const SAVE_ESCAPE: &str = "save writes inside the current directory or to an absolute path";
14
15/// The usage line for `save`.
16pub const SAVE_USAGE: &str = "Usage: save <file> [--force]";
17
18/// The most base64 bytes Ctrl-Y sends. Terminals drop larger OSC 52 writes.
19pub const OSC52_MAX_PAYLOAD: usize = 100_000;
20
21/// Why `save` wrote nothing.
22#[derive(Debug)]
23pub enum SaveError {
24    /// The request was refused: usage, path rules, nothing to save, or an
25    /// existing file.
26    Refused(String),
27    /// The OS failed the write. The error keeps its kind and its source
28    /// ([`ScopeError::io_context`]).
29    Failed(ScopeError),
30}
31
32impl SaveError {
33    /// An OS failure while `doing` (for example `Could not write x.txt`).
34    pub fn failed(e: std::io::Error, doing: impl std::fmt::Display) -> Self {
35        Self::Failed(ScopeError::io_context(e, doing))
36    }
37
38    /// The text to show and to journal.
39    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
48/// The OSC 52 sequence that puts `text` on the system clipboard.
49pub fn osc52(text: &str) -> String {
50    format!("\x1b]52;c;{}\x07", STANDARD.encode(text))
51}
52
53/// True when the OSC 52 payload for `text` stays within the cap.
54pub fn fits_osc52(text: &str) -> bool {
55    text.len().div_ceil(3) * 4 <= OSC52_MAX_PAYLOAD
56}
57
58/// Where `save` writes `arg`: the checked canonical parent joined with the
59/// file name, so the path that was checked is the path that is written. A
60/// `~` or a leading `~/` means `home`. A relative path with `..`, or one whose
61/// parent resolves outside `cwd` (a symlink), is refused.
62fn 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
95/// Writes `text` to `target`. Without `force` it creates the file and fails
96/// when it exists; a failed write removes the partial file. With `force` it
97/// writes a temp file beside the target and renames it over the target, so
98/// a symlink at the target is replaced and never followed.
99fn 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            // This call created the file, so it is not anyone's data.
108            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
129/// Runs `save <file> [--force]` over the last output. Returns the note to
130/// show on success, or the reason nothing was written.
131pub 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        // Without --force the link counts as an existing file.
255        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}