Skip to content

feat(assert): symlink assertions — a link is currently indistinguishable from its target #981

Description

@Chemaclass

Summary

There is no way to assert anything about a symlink. Every existing filesystem assertion follows the link, so a link and its target are indistinguishable, and a broken link is indistinguishable from a path that was never there.

Reproduced on main:

function test_symlink_passes_every_file_assertion() {
  local d; d=$(bashunit::temp_dir)
  printf 'x' > "$d/target"; ln -s "$d/target" "$d/link"

  assert_is_file "$d/link"           # passes — -f follows the link
  assert_file_exists "$d/link"       # passes

  ln -s "$d/missing" "$d/broken"
  assert_file_not_exists "$d/broken" # passes — a broken link "does not exist"
}

All three pass. Nothing in the 76-assertion catalogue can tell you that $d/link is a link, where it points, or that $d/broken is a dangling link rather than an absent file.

Why this matters

Symlinks are the payload of a large share of what people write bash for: install scripts, dotfile managers, update-alternatives-style switching, release layouts with a current -> releases/N pointer. For those, "the link exists and points at the right place" is the assertion, and today it has to be written as:

assert_true "[ -L \"$path\" ]"                    # …which does not work, see #<this repo's assert_true issue>
assert_same "$target" "$(readlink "$path")"       # works, but loses the failure message

The second form works and is what people will fall back to. It just reports Expected '/a/b' but got '/a/c' with no indication that a symlink was involved.

Proposal

Three assertions, matching the existing assert_is_* naming:

Assertion Passes when
assert_is_symlink "$path" $path is a symbolic link (-L), regardless of whether the target resolves
assert_is_not_symlink "$path" $path exists and is not a symbolic link
assert_symlink_to "$expected_target" "$path" $path is a link and its target matches

Argument order for the third follows assert_same "expected" "actual" — expected first — which is the majority convention in this catalogue.

Open question worth deciding rather than guessing: should assert_symlink_to compare the literal target (readlink) or the resolved one (readlink -f)? They differ for relative links, and readlink -f is not portable to macOS's BSD readlink. My suggestion is literal readlink, documented as such, because that is what the test author wrote in the first place.

A dangling link is deliberately still a link for assert_is_symlink — separating "is a link" from "the target resolves" is the distinction that is missing today.

Constraints

  • Bash 3.0+. [ -L ] is fine. readlink is one fork; per-assertion paths are meant to stay fork-free, but there is no pure-bash way to read a link target, so one fork is inherent here — the same category as stat in assert_file_permissions. Note readlink -f is GNU-only; BSD/macOS needs readlink without -f or stat -f %Y.
  • Windows/Git Bash: symlink creation often needs privileges and silently produces a copy. Guard the new tests with the same is_windows skip the permission tests already use — see tests/unit/assert/files_test.sh for the pattern.
  • Public API addition: needs docs/assertions.md entries, and the bashunit doc snapshot regenerated.
  • New assertions must appear in both completion scripts or tests/unit/main/completions_test.sh fails.
  • CHANGELOG.md entry under ## Unreleased### Added.

Acceptance criteria

  • assert_is_symlink passes on a link, fails on a regular file and on a missing path
  • assert_is_symlink passes on a dangling link (this is the case nothing covers today)
  • assert_is_not_symlink passes on a regular file, fails on a link
  • assert_symlink_to compares the target and fails with both paths in the message
  • The literal-vs-resolved decision is made explicitly and documented
  • Tests skip on Windows rather than failing
  • docs/assertions.md + regenerated doc snapshot + both completion scripts
  • make sa · make lint · ./bashunit --parallel --simple --strict tests/ · bash build.sh bin -v

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

Status
No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions