diff --git a/src/uu/shuf/src/shuf.rs b/src/uu/shuf/src/shuf.rs index ca2451698f2..b523001ce51 100644 --- a/src/uu/shuf/src/shuf.rs +++ b/src/uu/shuf/src/shuf.rs @@ -123,24 +123,21 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { }, }; - let mut output = BufWriter::with_capacity( - BUF_SIZE, - match options.output { - None => Box::new(stdout()) as Box, - Some(ref s) => { - let file = File::create(s).map_err_context( - || translate!("shuf-error-failed-to-open-for-writing", "file" => s.quote()), - )?; - Box::new(file) as Box - } - }, - ); - if options.head_count == 0 { - // In this case we do want to touch the output file but we can quit immediately. + // GNU still truncates the -o file in this case, but quits immediately + // without reading the input or the random source. + if let Some(ref s) = options.output { + File::create(s).map_err_context( + || translate!("shuf-error-failed-to-open-for-writing", "file" => s.quote()), + )?; + } return Ok(()); } + // Open the random source and read the input *before* creating the output + // file. Truncating the -o file only once the data is in hand means a failure + // here (missing input, unreadable random source) leaves an existing output + // file untouched, matching GNU and avoiding silent data loss. let mut rng = match options.random_source { RandomSource::None => WrappedRng::Default(rand::rng()), RandomSource::Seed(ref seed) => WrappedRng::Seed(SeededRng::new(seed)), @@ -153,6 +150,13 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } }; + let fdata = match mode { + Mode::Default(ref filename) => Some(read_input_file(filename)?), + _ => None, + }; + + let mut output = open_output(options.output.as_deref())?; + match mode { Mode::Echo(args) => { let mut evec: Vec<&OsStr> = args.iter().map(AsRef::as_ref).collect(); @@ -161,8 +165,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { Mode::InputRange(mut range) => { shuf_exec(&mut range, &options, &mut rng, &mut output)?; } - Mode::Default(filename) => { - let fdata = read_input_file(&filename)?; + Mode::Default(_) => { + let fdata = fdata.unwrap_or_default(); let mut items = split_seps(&fdata, options.sep); shuf_exec(&mut items, &options, &mut rng, &mut output)?; } @@ -255,6 +259,19 @@ pub fn uu_app() -> Command { ) } +fn open_output(output: Option<&Path>) -> UResult>> { + let writer: Box = match output { + None => Box::new(stdout()), + Some(s) => { + let file = File::create(s).map_err_context( + || translate!("shuf-error-failed-to-open-for-writing", "file" => s.quote()), + )?; + Box::new(file) + } + }; + Ok(BufWriter::with_capacity(BUF_SIZE, writer)) +} + fn read_input_file(filename: &Path) -> UResult> { if filename.as_os_str() == "-" { let mut data = Vec::new(); diff --git a/tests/by-util/test_shuf.rs b/tests/by-util/test_shuf.rs index 2e9ce9c5d94..1246297b596 100644 --- a/tests/by-util/test_shuf.rs +++ b/tests/by-util/test_shuf.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (ToDO) unwritable +// spell-checker:ignore (ToDO) unwritable GHSA use std::fmt::Write; use uutests::at_and_ucmd; @@ -525,6 +525,29 @@ fn test_zero_head_count_file_touch_output_positive_existing() { ); } +#[test] +fn test_output_not_truncated_when_input_missing() { + // A failure to read the input must leave an existing -o file untouched + // instead of truncating it first (data-loss regression, GHSA-5g6r-45q4-3p5r). + let (at, mut ucmd) = at_and_ucmd!(); + at.write("out", "keep me\n"); + ucmd.args(&["-o", "out", "does-not-exist"]) + .fails_with_code(1) + .stderr_contains("does-not-exist"); + assert_eq!(at.read("out"), "keep me\n"); +} + +#[test] +fn test_output_not_truncated_when_random_source_missing() { + // Same guarantee when the random source can't be opened. + let (at, mut ucmd) = at_and_ucmd!(); + at.write("out", "keep me\n"); + at.write("in", "a\nb\nc\n"); + ucmd.args(&["-o", "out", "--random-source=does-not-exist", "in"]) + .fails_with_code(1); + assert_eq!(at.read("out"), "keep me\n"); +} + #[test] fn test_zero_head_count_echo() { new_ucmd!()