From e0430cd1e922a056a211c3287eb6cd08df4effc9 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 9 Jul 2026 21:02:39 +0200 Subject: [PATCH 1/3] feat(assert): add assert_file_permissions Assert a file's octal permission mode (e.g. 644, 0755). A leading zero is optional and modes are compared numerically, so 0755 and 755 are equal. Reads the mode portably via GNU stat with a BSD stat fallback (macOS). Closes #742 --- CHANGELOG.md | 3 +++ docs/assertions.md | 27 ++++++++++++++++++++++ src/assert_files.sh | 51 +++++++++++++++++++++++++++++++++++++++++ tests/unit/file_test.sh | 46 +++++++++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f62c1e4..b1962715 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- `assert_file_permissions ` asserts a file's octal permission mode (`644`, `0755`); portable across GNU/BSD `stat` (#742) + ### Fixed - `watch` subcommand failed with `bashunit::watch::run: command not found` in the released binary because `src/watch.sh` was missing from the build; it is now bundled (#735) diff --git a/docs/assertions.md b/docs/assertions.md index 73194ff8..39a42817 100644 --- a/docs/assertions.md +++ b/docs/assertions.md @@ -830,6 +830,33 @@ function test_failure() { ``` ::: +## assert_file_permissions +> `assert_file_permissions "mode" "file"` + +Reports an error if `file` does not have the expected octal permission `mode` +(e.g. `644`, `0755`). A leading zero is optional (`0755` and `755` are equal). +Works on both Linux (GNU `stat`) and macOS (BSD `stat`). + +::: code-group +```bash [Example] +function test_success() { + local file="/tmp/file-path.txt" + touch "$file" + chmod 600 "$file" + + assert_file_permissions "600" "$file" +} + +function test_failure() { + local file="/tmp/file-path.txt" + touch "$file" + chmod 644 "$file" + + assert_file_permissions "600" "$file" +} +``` +::: + ## assert_is_file > `assert_is_file "file"` diff --git a/src/assert_files.sh b/src/assert_files.sh index 27fab431..aeca0048 100644 --- a/src/assert_files.sh +++ b/src/assert_files.sh @@ -149,3 +149,54 @@ function assert_file_not_contains() { bashunit::state::add_assertions_passed } + +## +# Normalizes an octal file mode to its decimal value, dropping leading zeros +# (so "0755" and "755" compare equal). Echoes nothing on invalid octal input. +# Arguments: $1 - octal mode string +## +function bashunit::assert::_octal_to_decimal() { + local mode="$1" + case "$mode" in + '' | *[!0-7]*) return 1 ;; + esac + printf '%d' "$((8#$mode))" +} + +## +# Asserts a file has the expected octal permission mode (e.g. "644", "0755"). +# Arguments: $1 - expected octal mode, $2 - file path +## +function assert_file_permissions() { + bashunit::assert::should_skip && return 0 + + local expected="$1" + local file="$2" + local test_fn + test_fn="$(bashunit::helper::find_test_function_name)" + local label + label="$(bashunit::helper::normalize_test_function_name "$test_fn")" + + if [ ! -e "$file" ]; then + bashunit::assert::mark_failed + bashunit::console_results::print_failed_test \ + "${label}" "${file}" "to have permissions ${expected}" "but the file does not exist" + return + fi + + local actual + actual="$(stat -c '%a' "$file" 2>/dev/null || stat -f '%Lp' "$file" 2>/dev/null)" + + local expected_dec actual_dec + expected_dec="$(bashunit::assert::_octal_to_decimal "$expected")" + actual_dec="$(bashunit::assert::_octal_to_decimal "$actual")" + + if [ "$expected_dec" != "$actual_dec" ]; then + bashunit::assert::mark_failed + bashunit::console_results::print_failed_test \ + "${label}" "${file}" "to have permissions ${expected}" "but got ${actual}" + return + fi + + bashunit::state::add_assertions_passed +} diff --git a/tests/unit/file_test.sh b/tests/unit/file_test.sh index 848b5686..24044160 100644 --- a/tests/unit/file_test.sh +++ b/tests/unit/file_test.sh @@ -190,3 +190,49 @@ function test_fails_assert_file_not_contains() { rm "$file" } + +# shellcheck disable=SC2155 +function test_successful_assert_file_permissions() { + local file="/tmp/test_successful_assert_file_permissions_$$" + touch "$file" + chmod 600 "$file" + + assert_empty "$(assert_file_permissions "600" "$file")" + + rm "$file" +} + +# shellcheck disable=SC2155 +function test_successful_assert_file_permissions_accepts_leading_zero() { + local file="/tmp/test_assert_file_permissions_leading_zero_$$" + touch "$file" + chmod 755 "$file" + + assert_empty "$(assert_file_permissions "0755" "$file")" + + rm "$file" +} + +# shellcheck disable=SC2155 +function test_unsuccessful_assert_file_permissions() { + local file="/tmp/test_unsuccessful_assert_file_permissions_$$" + touch "$file" + chmod 600 "$file" + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert file permissions" "$file" "to have permissions 644" "but got 600")" \ + "$(assert_file_permissions "644" "$file")" + + rm "$file" +} + +function test_unsuccessful_assert_file_permissions_when_file_does_not_exist() { + local file="a_random_file_that_will_not_exist" + + assert_same \ + "$(bashunit::console_results::print_failed_test \ + "Unsuccessful assert file permissions when file does not exist" \ + "$file" "to have permissions 644" "but the file does not exist")" \ + "$(assert_file_permissions "644" "$file")" +} From 07f293127ad5257dc7e1cea4352ff71df2869115 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 9 Jul 2026 21:05:56 +0200 Subject: [PATCH 2/3] test(assert): update doc snapshot for assert_file_permissions --- ...t_bashunit_should_display_all_assert_docs.snapshot | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot index e8232e44..3657a9d4 100644 --- a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot +++ b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_all_assert_docs.snapshot @@ -340,6 +340,15 @@ Reports an error if `file` does not contains the search string. - assert_file_not_contains is the inverse of this assertion and takes the same arguments. +## assert_file_permissions +-------------- +> `assert_file_permissions "mode" "file"` + +Reports an error if `file` does not have the expected octal permission `mode` +(e.g. `644`, `0755`). A leading zero is optional (`0755` and `755` are equal). +Works on both Linux (GNU `stat`) and macOS (BSD `stat`). + + ## assert_is_file -------------- > `assert_is_file "file"` @@ -589,3 +598,5 @@ Reports an error if `command` completes in `threshold_ms` milliseconds or less. Unambiguously reports an error message. Useful for reporting specific message when testing situations not covered by any `assert_*` functions. + + From efd1d18f04122aabf0cfcbdc93fcfa0684241d28 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 9 Jul 2026 21:09:24 +0200 Subject: [PATCH 3/3] test(assert): skip assert_file_permissions tests on Windows Git Bash fakes POSIX permissions, so chmod/stat cannot reproduce exact modes. --- tests/unit/file_test.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/unit/file_test.sh b/tests/unit/file_test.sh index 24044160..9fc424af 100644 --- a/tests/unit/file_test.sh +++ b/tests/unit/file_test.sh @@ -193,6 +193,11 @@ function test_fails_assert_file_not_contains() { # shellcheck disable=SC2155 function test_successful_assert_file_permissions() { + # Windows (Git Bash) fakes POSIX permissions, so chmod/stat are unreliable there. + if bashunit::check_os::is_windows; then + bashunit::skip && return + fi + local file="/tmp/test_successful_assert_file_permissions_$$" touch "$file" chmod 600 "$file" @@ -204,6 +209,10 @@ function test_successful_assert_file_permissions() { # shellcheck disable=SC2155 function test_successful_assert_file_permissions_accepts_leading_zero() { + if bashunit::check_os::is_windows; then + bashunit::skip && return + fi + local file="/tmp/test_assert_file_permissions_leading_zero_$$" touch "$file" chmod 755 "$file" @@ -215,6 +224,10 @@ function test_successful_assert_file_permissions_accepts_leading_zero() { # shellcheck disable=SC2155 function test_unsuccessful_assert_file_permissions() { + if bashunit::check_os::is_windows; then + bashunit::skip && return + fi + local file="/tmp/test_unsuccessful_assert_file_permissions_$$" touch "$file" chmod 600 "$file"