diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bb6eb00..a159af7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,6 +32,10 @@ jobs: target: x86_64-pc-windows-msvc use-cross: false + - os: windows-latest + target: aarch64-pc-windows-msvc + use-cross: false + - os: macos-latest target: x86_64-apple-darwin use-cross: false @@ -44,6 +48,10 @@ jobs: target: aarch64-unknown-linux-musl use-cross: true + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + use-cross: true + - os: ubuntu-latest target: armv7-unknown-linux-gnueabihf use-cross: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0a432c3..e3097a9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,6 +31,10 @@ jobs: target: x86_64-pc-windows-msvc use-cross: false + - os: windows-latest + target: aarch64-pc-windows-msvc + use-cross: false + - os: macos-latest target: x86_64-apple-darwin use-cross: false @@ -43,6 +47,10 @@ jobs: target: aarch64-unknown-linux-musl use-cross: true + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + use-cross: true + - os: ubuntu-latest target: armv7-unknown-linux-gnueabihf use-cross: true @@ -85,7 +93,7 @@ jobs: # For legal reasons, cross doesn't support Apple Silicon. See this: # https://github.com/cross-rs/cross-toolchains#darwin-targets # It builds and runs fine, but there's no easy way to test it in CI - if [ "${{ matrix.target }}" = "aarch64-apple-darwin" ]; then + if [ "${{ matrix.use-cross }}" = "true" ] || [ "${{ matrix.target }}" = "aarch64-apple-darwin" ] || [ "${{ matrix.target }}" = "aarch64-pc-windows-msvc" ]; then $CARGO build --target ${{ matrix.target }} else $CARGO test --target ${{ matrix.target }} diff --git a/CHANGELOG.md b/CHANGELOG.md index d0a52e3..82ec222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,45 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Breaking + +- #328 Make line-by-line processing the default and add `--across` / `-A` (@Orion Gonzalez) + - `sd` now processes input line-by-line by default, reducing memory usage and + enabling streaming output for stdin + - The previous whole-file behavior is still available via `--across` / `-A` + +### Improvements + +- #313 Replace the unescape implementation with a more lenient one (@Orion Gonzalez) + - Avoids the previous all-or-nothing behavior when escape parsing partially fails +- #326 Retain file ownership on atomic writes (@Gregory) + - Preserves original file uid/gid when replacing files through the atomic write path + +### Docs + +- #279 Update man page examples for the renamed string literal flag (@Philipp Gillé) +- #281 Update README for string literal argument changes (@Evan Platzer) +- #292 Fix capture group example in the man page (@John Careaga) +- #299 Add `README_zh-CN.md` and follow-up note adjustments (@zhangyanming) +- #320 Fix missing single quotes in a man page example (@Freimut Diener) + +### Pre-built Releases + +- (90bc67d) Add Windows ARM64 (`aarch64-pc-windows-msvc`) release targets (@Orion Gonzalez) +- (c864c58) Add `aarch64-unknown-linux-gnu` target to CI and releases (@Orion Gonzalez) +- #293 Bump `svenstaro/upload-release-action` from `2.7.0` to `2.9.0` (@dependabot[bot]) + +### Internal + +- #265 Overall codebase reorganization (@Blair Noctis) + - Refactors application structure, error handling, and tests +- `xtask` cleanup and test setup refactors (@Gregory, @Orion Gonzalez) +- Dependency and tooling updates + - Bump `libc` from `0.2.149` to `0.2.155` (@Jingyun Hua) + - Upgrade `assert_cmd` and fix clippy warnings (@Gregory) + ## [1.0.0] - 2023-11-07 A quick note to any packages. The generated shell completions and man page are @@ -187,4 +226,3 @@ To reflect this change, `--input` is also renamed to `--in-place`. This is the f ### Improvements - Files are now written to [atomically](https://github.com/chmln/sd/issues/3) - diff --git a/sd-cli/tests/cli.rs b/sd-cli/tests/cli.rs index 590d4cd..07e3403 100644 --- a/sd-cli/tests/cli.rs +++ b/sd-cli/tests/cli.rs @@ -19,11 +19,11 @@ mod cli { ignore = "Windows symlinks are privileged" )] fn create_soft_link>( - src: &P, - dst: &P, + _src: &P, + _dst: &P, ) -> Result<()> { #[cfg(target_family = "unix")] - std::os::unix::fs::symlink(src, dst)?; + std::os::unix::fs::symlink(_src, _dst)?; Ok(()) } @@ -282,38 +282,6 @@ mod cli { Ok(()) } - #[cfg_attr(not(target_family = "unix"), ignore = "only runs on unix")] - #[test] - fn correctly_fails_on_unreadable_file() -> Result<()> { - use std::os::unix::fs::OpenOptionsExt; - - let test_dir = tempfile::Builder::new().prefix("sd-test-").tempdir()?; - let test_home = test_dir.path(); - - let valid = test_home.join("valid"); - fs::write(&valid, UNTOUCHED_CONTENTS)?; - let write_only = { - let path = test_home.join("write_only"); - let mut write_only_file = std::fs::OpenOptions::new() - .mode(0o333) - .create(true) - .truncate(true) - .write(true) - .open(&path)?; - write!(write_only_file, "unreadable")?; - path - }; - - assert_fails_correctly( - sd().args([".*", ""]).arg(&valid).arg(&write_only), - &valid, - test_home, - "correctly_fails_on_unreadable_file", - ); - - Ok(()) - } - #[test] fn line_by_line_stdin() -> Result<()> { sd().args(["foo", "bar"]) @@ -372,69 +340,219 @@ mod cli { Ok(()) } - // Failing to create a temporary file in the same directory as the input is - // one of the failure cases that is past the "point of no return" (after we - // already start making replacements). This means that any files that could - // be modified are, and we report any failure cases - #[cfg_attr(not(target_family = "unix"), ignore = "only runs on unix")] - #[test] - fn reports_errors_on_atomic_file_swap_creation_failure() -> Result<()> { - use std::os::unix::fs::PermissionsExt; + #[cfg(unix)] + mod unix_only { + use super::*; + + #[test] + fn correctly_fails_on_unreadable_file() -> Result<()> { + use std::os::unix::fs::OpenOptionsExt; + + let test_dir = + tempfile::Builder::new().prefix("sd-test-").tempdir()?; + let test_home = test_dir.path(); + + let valid = test_home.join("valid"); + fs::write(&valid, UNTOUCHED_CONTENTS)?; + let write_only = { + let path = test_home.join("write_only"); + let mut write_only_file = std::fs::OpenOptions::new() + .mode(0o333) + .create(true) + .truncate(true) + .write(true) + .open(&path)?; + write!(write_only_file, "unreadable")?; + path + }; + + assert_fails_correctly( + sd().args([".*", ""]).arg(&valid).arg(&write_only), + &valid, + test_home, + "correctly_fails_on_unreadable_file", + ); + + Ok(()) + } + + // Failing to create a temporary file in the same directory as the + // input is one of the failure cases that is past the "point of no + // return" (after we already start making replacements). This means + // that any files that could be modified are, and we report any failure + // cases + #[test] + fn reports_errors_on_atomic_file_swap_creation_failure() -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + const FIND_REPLACE: [&str; 2] = ["able", "ed"]; + const ORIG_TEXT: &str = "modifiable"; + const MODIFIED_TEXT: &str = "modified"; + + let test_dir = + tempfile::Builder::new().prefix("sd-test-").tempdir()?; + let test_home = test_dir.path().canonicalize()?; + + let writable_dir = test_home.join("writable"); + fs::create_dir(&writable_dir)?; + let writable_dir_file = writable_dir.join("foo"); + fs::write(&writable_dir_file, ORIG_TEXT)?; + + let unwritable_dir = test_home.join("unwritable"); + fs::create_dir(&unwritable_dir)?; + let unwritable_dir_file1 = unwritable_dir.join("bar"); + fs::write(&unwritable_dir_file1, ORIG_TEXT)?; + let unwritable_dir_file2 = unwritable_dir.join("baz"); + fs::write(&unwritable_dir_file2, ORIG_TEXT)?; + let mut perms = fs::metadata(&unwritable_dir)?.permissions(); + perms.set_mode(0o555); + fs::set_permissions(&unwritable_dir, perms)?; + + let failed_command = sd() + // Force whole-file processing so this test exercises the + // atomic temp-file swap path (and not line-by-line preflight). + .arg("--across") + .args(FIND_REPLACE) + .arg(&writable_dir_file) + .arg(&unwritable_dir_file1) + .arg(&unwritable_dir_file2) + .assert() + .failure() + .code(1); + + // Confirm that we modified the one file that we were able to + assert_eq!(fs::read_to_string(&writable_dir_file)?, MODIFIED_TEXT); + assert_eq!(fs::read_to_string(&unwritable_dir_file1)?, ORIG_TEXT); + assert_eq!(fs::read_to_string(&unwritable_dir_file2)?, ORIG_TEXT); + + let stderr_orig = std::str::from_utf8( + &failed_command.get_output().stderr, + ) + .unwrap(); + // Normalize unstable path bits + let stderr_partial_norm = stderr_orig + .replace(test_home.to_str().unwrap(), "") + .replace('\\', "/"); + let tmp_file_rep = regex::Regex::new(r"\.tmp\w+")?; + let stderr_norm = + tmp_file_rep.replace_all(&stderr_partial_norm, ""); + insta::assert_snapshot!(stderr_norm); + + // Make the unwritable dir writable again, so it can be cleaned up + // when dropping the temp dir + let mut perms = fs::metadata(&unwritable_dir)?.permissions(); + perms.set_mode(0o777); + fs::set_permissions(&unwritable_dir, perms)?; + test_dir.close()?; + + Ok(()) + } + } + + #[cfg(windows)] + mod windows_only { + use super::*; + use std::os::windows::fs::OpenOptionsExt; const FIND_REPLACE: [&str; 2] = ["able", "ed"]; const ORIG_TEXT: &str = "modifiable"; const MODIFIED_TEXT: &str = "modified"; - - let test_dir = tempfile::Builder::new().prefix("sd-test-").tempdir()?; - let test_home = test_dir.path().canonicalize()?; - - let writable_dir = test_home.join("writable"); - fs::create_dir(&writable_dir)?; - let writable_dir_file = writable_dir.join("foo"); - fs::write(&writable_dir_file, ORIG_TEXT)?; - - let unwritable_dir = test_home.join("unwritable"); - fs::create_dir(&unwritable_dir)?; - let unwritable_dir_file1 = unwritable_dir.join("bar"); - fs::write(&unwritable_dir_file1, ORIG_TEXT)?; - let unwritable_dir_file2 = unwritable_dir.join("baz"); - fs::write(&unwritable_dir_file2, ORIG_TEXT)?; - let mut perms = fs::metadata(&unwritable_dir)?.permissions(); - perms.set_mode(0o555); - fs::set_permissions(&unwritable_dir, perms)?; - - let failed_command = sd() - .args(FIND_REPLACE) - .arg(&writable_dir_file) - .arg(&unwritable_dir_file1) - .arg(&unwritable_dir_file2) - .assert() - .failure() - .code(1); - - // Confirm that we modified the one file that we were able to - assert_eq!(fs::read_to_string(&writable_dir_file)?, MODIFIED_TEXT); - assert_eq!(fs::read_to_string(&unwritable_dir_file1)?, ORIG_TEXT); - assert_eq!(fs::read_to_string(&unwritable_dir_file2)?, ORIG_TEXT); - - let stderr_orig = - std::str::from_utf8(&failed_command.get_output().stderr).unwrap(); - // Normalize unstable path bits - let stderr_partial_norm = stderr_orig - .replace(test_home.to_str().unwrap(), "") - .replace('\\', "/"); - let tmp_file_rep = regex::Regex::new(r"\.tmp\w+")?; - let stderr_norm = - tmp_file_rep.replace_all(&stderr_partial_norm, ""); - insta::assert_snapshot!(stderr_norm); - - // Make the unwritable dir writable again, so it can be cleaned up - // when dropping the temp dir - let mut perms = fs::metadata(&unwritable_dir)?.permissions(); - perms.set_mode(0o777); - fs::set_permissions(&unwritable_dir, perms)?; - test_dir.close()?; - - Ok(()) + const FILE_SHARE_NONE: u32 = 0; + const FILE_SHARE_READ: u32 = 0x00000001; + const FILE_SHARE_WRITE: u32 = 0x00000002; + + #[test] + fn correctly_fails_on_unreadable_file() -> Result<()> { + let test_dir = + tempfile::Builder::new().prefix("sd-test-").tempdir()?; + let test_home = test_dir.path(); + + let valid = test_home.join("valid"); + fs::write(&valid, UNTOUCHED_CONTENTS)?; + + let locked = test_home.join("locked"); + fs::write(&locked, "unreadable")?; + let _lock = std::fs::OpenOptions::new() + .read(true) + .write(true) + .share_mode(FILE_SHARE_NONE) + .open(&locked)?; + + let failed_command = sd() + .args([".*", ""]) + .arg(&valid) + .arg(&locked) + .assert() + .failure() + .code(1); + + assert_eq!(fs::read_to_string(&valid)?, UNTOUCHED_CONTENTS); + let stderr = + std::str::from_utf8(&failed_command.get_output().stderr)?; + assert!( + !stderr.is_empty(), + "expected an error message for locked file failure" + ); + + Ok(()) + } + + #[test] + fn reports_errors_on_atomic_file_swap_creation_failure() -> Result<()> { + let test_dir = + tempfile::Builder::new().prefix("sd-test-").tempdir()?; + let test_home = test_dir.path(); + + let writable_dir = test_home.join("writable"); + fs::create_dir(&writable_dir)?; + let writable_dir_file = writable_dir.join("foo"); + fs::write(&writable_dir_file, ORIG_TEXT)?; + + let locked_dir = test_home.join("locked"); + fs::create_dir(&locked_dir)?; + let locked_file1 = locked_dir.join("bar"); + fs::write(&locked_file1, ORIG_TEXT)?; + let locked_file2 = locked_dir.join("baz"); + fs::write(&locked_file2, ORIG_TEXT)?; + + // Allow reads/writes so the file can be processed, but deny + // delete-sharing so the final atomic replace (rename/persist) + // fails. + let _lock1 = std::fs::OpenOptions::new() + .read(true) + .write(true) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .open(&locked_file1)?; + let _lock2 = std::fs::OpenOptions::new() + .read(true) + .write(true) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .open(&locked_file2)?; + + let failed_command = sd() + // Force whole-file processing so this test exercises the + // atomic temp-file swap path (and not line-by-line preflight). + .arg("--across") + .args(FIND_REPLACE) + .arg(&writable_dir_file) + .arg(&locked_file1) + .arg(&locked_file2) + .assert() + .failure() + .code(1); + + assert_eq!(fs::read_to_string(&writable_dir_file)?, MODIFIED_TEXT); + assert_eq!(fs::read_to_string(&locked_file1)?, ORIG_TEXT); + assert_eq!(fs::read_to_string(&locked_file2)?, ORIG_TEXT); + + let stderr = + std::str::from_utf8(&failed_command.get_output().stderr)?; + assert!( + !stderr.is_empty(), + "expected an error message for atomic swap failure" + ); + + Ok(()) + } } } diff --git a/sd-cli/tests/snapshots/cli__cli__reports_errors_on_atomic_file_swap_creation_failure.snap b/sd-cli/tests/snapshots/cli__cli__unix_only__reports_errors_on_atomic_file_swap_creation_failure.snap similarity index 100% rename from sd-cli/tests/snapshots/cli__cli__reports_errors_on_atomic_file_swap_creation_failure.snap rename to sd-cli/tests/snapshots/cli__cli__unix_only__reports_errors_on_atomic_file_swap_creation_failure.snap