Skip to main content

shannon_nu_command/filesystem/
save.rs

1use crate::progress_bar;
2use nu_engine::{command_prelude::*, get_eval_block};
3use nu_path::{expand_path_with, is_windows_device_path};
4use nu_protocol::{
5    ByteStreamSource, DataSource, OutDest, PipelineMetadata, Signals, ast,
6    byte_stream::copy_with_signals, process::ChildPipe, shell_error::generic::GenericError,
7    shell_error::io::IoError,
8};
9use std::{
10    borrow::Cow,
11    fs::File,
12    io::{self, BufRead, BufReader, Read, Write},
13    path::{Path, PathBuf},
14    thread,
15    time::{Duration, Instant},
16};
17
18#[derive(Clone)]
19pub struct Save;
20
21impl Command for Save {
22    fn name(&self) -> &str {
23        "save"
24    }
25
26    fn description(&self) -> &str {
27        "Save a file."
28    }
29
30    fn search_terms(&self) -> Vec<&str> {
31        vec![
32            "write",
33            "write_file",
34            "append",
35            "redirection",
36            "file",
37            "io",
38            ">",
39            ">>",
40        ]
41    }
42
43    fn signature(&self) -> nu_protocol::Signature {
44        Signature::build("save")
45            .input_output_types(vec![(Type::Any, Type::Nothing)])
46            .required("filename", SyntaxShape::Filepath, "The filename to use.")
47            .named(
48                "stderr",
49                SyntaxShape::Filepath,
50                "The filename used to save stderr, only works with `-r` flag.",
51                Some('e'),
52            )
53            .switch("raw", "Save file as raw binary.", Some('r'))
54            .switch("append", "Append input to the end of the file.", Some('a'))
55            .switch("force", "Overwrite the destination.", Some('f'))
56            .switch("progress", "Enable progress bar.", Some('p'))
57            .category(Category::FileSystem)
58    }
59
60    fn run(
61        &self,
62        engine_state: &EngineState,
63        stack: &mut Stack,
64        call: &Call,
65        input: PipelineData,
66    ) -> Result<PipelineData, ShellError> {
67        let raw = call.has_flag(engine_state, stack, "raw")?;
68        let append = call.has_flag(engine_state, stack, "append")?;
69        let force = call.has_flag(engine_state, stack, "force")?;
70        let progress = call.has_flag(engine_state, stack, "progress")?;
71
72        let span = call.head;
73        let cwd = engine_state.cwd(Some(stack))?.into_std_path_buf();
74
75        let path_arg = call.req::<Spanned<PathBuf>>(engine_state, stack, 0)?;
76        let path = Spanned {
77            item: expand_path_with(path_arg.item, &cwd, true),
78            span: path_arg.span,
79        };
80
81        let stderr_path = call
82            .get_flag::<Spanned<PathBuf>>(engine_state, stack, "stderr")?
83            .map(|arg| Spanned {
84                item: expand_path_with(arg.item, cwd, true),
85                span: arg.span,
86            });
87
88        let from_io_error = IoError::factory(span, path.item.as_path());
89        match input {
90            PipelineData::ByteStream(stream, metadata) => {
91                check_saving_to_source_file(metadata.as_ref(), &path, stderr_path.as_ref())?;
92
93                let (file, stderr_file) =
94                    get_files(engine_state, &path, stderr_path.as_ref(), append, force)?;
95
96                let size = stream.known_size();
97                let signals = engine_state.signals();
98
99                match stream.into_source() {
100                    ByteStreamSource::Read(read) => {
101                        stream_to_file(read, size, signals, file, span, progress)?;
102                    }
103                    ByteStreamSource::File(source) => {
104                        stream_to_file(source, size, signals, file, span, progress)?;
105                    }
106                    #[cfg(feature = "os")]
107                    ByteStreamSource::Child(mut child) => {
108                        fn write_or_consume_stderr(
109                            stderr: ChildPipe,
110                            file: Option<File>,
111                            span: Span,
112                            signals: &Signals,
113                            progress: bool,
114                        ) -> Result<(), ShellError> {
115                            if let Some(file) = file {
116                                match stderr {
117                                    ChildPipe::Pipe(pipe) => {
118                                        stream_to_file(pipe, None, signals, file, span, progress)
119                                    }
120                                    ChildPipe::Tee(tee) => {
121                                        stream_to_file(tee, None, signals, file, span, progress)
122                                    }
123                                }?
124                            } else {
125                                match stderr {
126                                    ChildPipe::Pipe(mut pipe) => {
127                                        io::copy(&mut pipe, &mut io::stderr())
128                                    }
129                                    ChildPipe::Tee(mut tee) => {
130                                        io::copy(&mut tee, &mut io::stderr())
131                                    }
132                                }
133                                .map_err(|err| IoError::new(err, span, None))?;
134                            }
135                            Ok(())
136                        }
137
138                        match (child.stdout.take(), child.stderr.take()) {
139                            (Some(stdout), stderr) => {
140                                // delegate a thread to redirect stderr to result.
141                                let handler = stderr
142                                    .map(|stderr| {
143                                        let signals = signals.clone();
144                                        thread::Builder::new().name("stderr saver".into()).spawn(
145                                            move || {
146                                                write_or_consume_stderr(
147                                                    stderr,
148                                                    stderr_file,
149                                                    span,
150                                                    &signals,
151                                                    progress,
152                                                )
153                                            },
154                                        )
155                                    })
156                                    .transpose()
157                                    .map_err(&from_io_error)?;
158
159                                let res = match stdout {
160                                    ChildPipe::Pipe(pipe) => {
161                                        stream_to_file(pipe, None, signals, file, span, progress)
162                                    }
163                                    ChildPipe::Tee(tee) => {
164                                        stream_to_file(tee, None, signals, file, span, progress)
165                                    }
166                                };
167                                if let Some(h) = handler {
168                                    h.join().map_err(|err| ShellError::ExternalCommand {
169                                        label: "Fail to receive external commands stderr message"
170                                            .to_string(),
171                                        help: format!("{err:?}"),
172                                        span,
173                                    })??;
174                                }
175                                res?;
176                            }
177                            (None, Some(stderr)) => {
178                                write_or_consume_stderr(
179                                    stderr,
180                                    stderr_file,
181                                    span,
182                                    signals,
183                                    progress,
184                                )?;
185                            }
186                            (None, None) => {}
187                        };
188
189                        child.wait()?;
190                    }
191                }
192
193                Ok(PipelineData::empty())
194            }
195            PipelineData::ListStream(ls, pipeline_metadata)
196                if raw || prepare_path(&path, append, force)?.0.extension().is_none() =>
197            {
198                check_saving_to_source_file(
199                    pipeline_metadata.as_ref(),
200                    &path,
201                    stderr_path.as_ref(),
202                )?;
203
204                let (mut file, _) =
205                    get_files(engine_state, &path, stderr_path.as_ref(), append, force)?;
206                for val in ls {
207                    file.write_all(&value_to_bytes(val)?)
208                        .map_err(&from_io_error)?;
209                    file.write_all("\n".as_bytes()).map_err(&from_io_error)?;
210                }
211                file.flush().map_err(&from_io_error)?;
212
213                Ok(PipelineData::empty())
214            }
215            input => {
216                // It's not necessary to check if we are saving to the same file if this is a
217                // collected value, and not a stream
218                if !matches!(input, PipelineData::Value(..) | PipelineData::Empty) {
219                    check_saving_to_source_file(
220                        input.metadata().as_ref(),
221                        &path,
222                        stderr_path.as_ref(),
223                    )?;
224                }
225
226                // Try to convert the input pipeline into another type if we know the extension
227                let ext = extract_extension(&input, &path.item, raw);
228                let converted = match ext {
229                    None => input,
230                    Some(ext) => convert_to_extension(engine_state, &ext, stack, input, span)?,
231                };
232
233                // Save custom value however they implement saving
234                if let PipelineData::Value(v @ Value::Custom { .. }, ..) = converted {
235                    let val_span = v.span();
236                    let val = v.into_custom_value()?;
237                    return val
238                        .save(
239                            Spanned {
240                                item: &path.item,
241                                span: path.span,
242                            },
243                            val_span,
244                            span,
245                        )
246                        .map(|()| PipelineData::empty());
247                }
248
249                let bytes = value_to_bytes(converted.into_value(span)?)?;
250
251                // Only open file after successful conversion
252                let (mut file, _) =
253                    get_files(engine_state, &path, stderr_path.as_ref(), append, force)?;
254
255                file.write_all(&bytes).map_err(&from_io_error)?;
256                file.flush().map_err(&from_io_error)?;
257
258                Ok(PipelineData::empty())
259            }
260        }
261    }
262
263    fn examples(&self) -> Vec<Example<'_>> {
264        vec![
265            Example {
266                description: "Save a string to foo.txt in the current directory.",
267                example: "'save me' | save foo.txt",
268                result: None,
269            },
270            Example {
271                description: "Append a string to the end of foo.txt.",
272                example: "'append me' | save --append foo.txt",
273                result: None,
274            },
275            Example {
276                description: "Save a record to foo.json in the current directory.",
277                example: "{ a: 1, b: 2 } | save foo.json",
278                result: None,
279            },
280            Example {
281                description: "Save a running program's stderr to foo.txt.",
282                example: "do -i {} | save foo.txt --stderr foo.txt",
283                result: None,
284            },
285            Example {
286                description: "Save a running program's stderr to separate file.",
287                example: "do -i {} | save foo.txt --stderr bar.txt",
288                result: None,
289            },
290            Example {
291                description: "Show the extensions for which the `save` command will automatically serialize.",
292                example: r#"scope commands
293    | where name starts-with "to "
294    | insert extension { get name | str replace -r "^to " "" | $"*.($in)" }
295    | select extension name
296    | rename extension command
297"#,
298                result: None,
299            },
300        ]
301    }
302
303    fn pipe_redirection(&self) -> (Option<OutDest>, Option<OutDest>) {
304        (Some(OutDest::PipeSeparate), Some(OutDest::PipeSeparate))
305    }
306}
307
308fn saving_to_source_file_error(dest: &Spanned<PathBuf>) -> ShellError {
309    ShellError::Generic(
310        GenericError::new(
311            "pipeline input and output are the same file",
312            format!(
313                "can't save output to '{}' while it's being read",
314                dest.item.display()
315            ),
316            dest.span,
317        )
318        .with_help(
319            "insert a `collect` command in the pipeline before `save` (see `help collect`).",
320        ),
321    )
322}
323
324fn check_saving_to_source_file(
325    metadata: Option<&PipelineMetadata>,
326    dest: &Spanned<PathBuf>,
327    stderr_dest: Option<&Spanned<PathBuf>>,
328) -> Result<(), ShellError> {
329    let Some(DataSource::FilePath(source)) = metadata.map(|meta| &meta.data_source) else {
330        return Ok(());
331    };
332
333    if &dest.item == source {
334        return Err(saving_to_source_file_error(dest));
335    }
336
337    if let Some(dest) = stderr_dest
338        && &dest.item == source
339    {
340        return Err(saving_to_source_file_error(dest));
341    }
342
343    Ok(())
344}
345
346/// Extract extension for conversion.
347fn extract_extension<'e>(input: &PipelineData, path: &'e Path, raw: bool) -> Option<Cow<'e, str>> {
348    match (raw, input) {
349        (true, _)
350        | (_, PipelineData::ByteStream(..))
351        | (_, PipelineData::Value(Value::String { .. }, ..)) => None,
352        _ => path.extension().map(|name| name.to_string_lossy()),
353    }
354}
355
356/// Convert given data into content of file of specified extension if
357/// corresponding `to` command exists. Otherwise attempt to convert
358/// data to bytes as is
359fn convert_to_extension(
360    engine_state: &EngineState,
361    extension: &str,
362    stack: &mut Stack,
363    input: PipelineData,
364    span: Span,
365) -> Result<PipelineData, ShellError> {
366    if let Some(decl_id) = engine_state.find_decl(format!("to {extension}").as_bytes(), &[]) {
367        let decl = engine_state.get_decl(decl_id);
368        if let Some(block_id) = decl.block_id() {
369            let block = engine_state.get_block(block_id);
370            let eval_block = get_eval_block(engine_state);
371            eval_block(engine_state, stack, block, input).map(|p| p.body)
372        } else {
373            let call = ast::Call::new(span);
374            decl.run(engine_state, stack, &(&call).into(), input)
375        }
376    } else {
377        Ok(input)
378    }
379}
380
381/// Convert [`Value::String`] [`Value::Binary`] or [`Value::List`] into [`Vec`] of bytes
382///
383/// Propagates [`Value::Error`] and creates error otherwise
384fn value_to_bytes(value: Value) -> Result<Vec<u8>, ShellError> {
385    match value {
386        Value::String { val, .. } => Ok(val.into_bytes()),
387        Value::Binary { val, .. } => Ok(val),
388        Value::List { vals, .. } => {
389            let val = vals
390                .into_iter()
391                .map(Value::coerce_into_string)
392                .collect::<Result<Vec<String>, ShellError>>()?
393                .join("\n")
394                + "\n";
395
396            Ok(val.into_bytes())
397        }
398        // Propagate errors by explicitly matching them before the final case.
399        Value::Error { error, .. } => Err(*error),
400        other => Ok(other.coerce_into_string()?.into_bytes()),
401    }
402}
403
404/// Convert string path to [`Path`] and [`Span`] and check if this path
405/// can be used with given flags
406fn prepare_path(
407    path: &Spanned<PathBuf>,
408    append: bool,
409    force: bool,
410) -> Result<(&Path, Span), ShellError> {
411    let span = path.span;
412    let path = &path.item;
413
414    if !(force || append) && path.exists() {
415        Err(ShellError::Generic(
416            GenericError::new(
417                "Destination file already exists",
418                format!(
419                    "Destination file '{}' already exists",
420                    path.to_string_lossy()
421                ),
422                span,
423            )
424            .with_help("you can use -f, --force to force overwriting the destination"),
425        ))
426    } else {
427        Ok((path, span))
428    }
429}
430
431fn open_file(
432    engine_state: &EngineState,
433    path: &Path,
434    span: Span,
435    append: bool,
436) -> Result<File, ShellError> {
437    let file: std::io::Result<File> = match (append, path.exists() || is_windows_device_path(path))
438    {
439        (true, true) => std::fs::OpenOptions::new().append(true).open(path),
440        _ => {
441            // This is a temporary solution until `std::fs::File::create` is fixed on Windows (rust-lang/rust#134893)
442            // A TOCTOU problem exists here, which may cause wrong error message to be shown
443            #[cfg(target_os = "windows")]
444            if path.is_dir() {
445                #[allow(
446                    deprecated,
447                    reason = "we don't get a IsADirectory error, so we need to provide it"
448                )]
449                Err(std::io::ErrorKind::IsADirectory.into())
450            } else {
451                std::fs::File::create(path)
452            }
453            #[cfg(not(target_os = "windows"))]
454            std::fs::File::create(path)
455        }
456    };
457
458    match file {
459        Ok(file) => Ok(file),
460        Err(err) => {
461            // In caase of NotFound, search for the missing parent directory.
462            // This also presents a TOCTOU (or TOUTOC, technically?)
463            if err.kind() == std::io::ErrorKind::NotFound
464                && let Some(missing_component) =
465                    path.ancestors().skip(1).filter(|dir| !dir.exists()).last()
466            {
467                // By looking at the postfix to remove, rather than the prefix
468                // to keep, we are able to handle relative paths too.
469                let components_to_remove = path
470                    .strip_prefix(missing_component)
471                    .expect("Stripping ancestor from a path should never fail")
472                    .as_os_str()
473                    .as_encoded_bytes();
474
475                return Err(ShellError::Io(IoError::new(
476                    ErrorKind::DirectoryNotFound,
477                    engine_state
478                        .span_match_postfix(span, components_to_remove)
479                        .map(|(pre, _post)| pre)
480                        .unwrap_or(span),
481                    PathBuf::from(missing_component),
482                )));
483            }
484
485            Err(ShellError::Io(IoError::new(err, span, PathBuf::from(path))))
486        }
487    }
488}
489
490/// Get output file and optional stderr file
491fn get_files(
492    engine_state: &EngineState,
493    path: &Spanned<PathBuf>,
494    stderr_path: Option<&Spanned<PathBuf>>,
495    append: bool,
496    force: bool,
497) -> Result<(File, Option<File>), ShellError> {
498    // First check both paths
499    let (path, path_span) = prepare_path(path, append, force)?;
500    let stderr_path_and_span = stderr_path
501        .as_ref()
502        .map(|stderr_path| prepare_path(stderr_path, append, force))
503        .transpose()?;
504
505    // Only if both files can be used open and possibly truncate them
506    let file = open_file(engine_state, path, path_span, append)?;
507
508    let stderr_file = stderr_path_and_span
509        .map(|(stderr_path, stderr_path_span)| {
510            if path == stderr_path {
511                Err(ShellError::Generic(
512                    GenericError::new(
513                        "input and stderr input to same file",
514                        "can't save both input and stderr input to the same file",
515                        stderr_path_span,
516                    )
517                    .with_help("you should use `o+e> file` instead"),
518                ))
519            } else {
520                open_file(engine_state, stderr_path, stderr_path_span, append)
521            }
522        })
523        .transpose()?;
524
525    Ok((file, stderr_file))
526}
527
528fn stream_to_file(
529    source: impl Read,
530    known_size: Option<u64>,
531    signals: &Signals,
532    mut file: File,
533    span: Span,
534    progress: bool,
535) -> Result<(), ShellError> {
536    // TODO: maybe we can get a path in here
537    let from_io_error = IoError::factory(span, None);
538
539    // https://github.com/nushell/nushell/pull/9377 contains the reason for not using `BufWriter`
540    if progress {
541        let mut bytes_processed = 0;
542
543        let mut bar = progress_bar::NuProgressBar::new(known_size);
544
545        let mut last_update = Instant::now();
546
547        let mut reader = BufReader::new(source);
548
549        let res = loop {
550            if let Err(err) = signals.check(&span) {
551                bar.abandoned_msg("# Cancelled #".to_owned());
552                return Err(err);
553            }
554
555            match reader.fill_buf() {
556                Ok(&[]) => break Ok(()),
557                Ok(buf) => {
558                    file.write_all(buf).map_err(&from_io_error)?;
559                    let len = buf.len();
560                    reader.consume(len);
561                    bytes_processed += len as u64;
562                    if last_update.elapsed() >= Duration::from_millis(75) {
563                        bar.update_bar(bytes_processed);
564                        last_update = Instant::now();
565                    }
566                }
567                Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
568                Err(e) => break Err(e),
569            }
570        };
571
572        // If the process failed, stop the progress bar with an error message.
573        if let Err(err) = res {
574            let _ = file.flush();
575            bar.abandoned_msg("# Error while saving #".to_owned());
576            Err(from_io_error(err).into())
577        } else {
578            file.flush().map_err(&from_io_error)?;
579            Ok(())
580        }
581    } else {
582        copy_with_signals(source, file, span, signals)?;
583        Ok(())
584    }
585}