filesystem: follow POSIX kind rules and self-rename in rename

Merged
VA
4d ago
+367-24

Summary

The Cloud fs reference model (cl-proof-fs) found that rename_with_replace diverges from POSIX in four ways:

  • Directory over a non-directory. The kernel never compared endpoint kinds, so mkdir /a; symlink /d; rename(/a, /d) committed. It is now refused with NotDirectory (ENOTDIR), which Workspace maps to WorkspaceError::NotDirectory.
  • Non-directory over a directory. Also unchecked. It is now refused with IsDirectory (EISDIR).
  • Name over its own ancestor. rename(/d/e/a, /d/e) committed, because the emptiness check ran after the source was detached. A destination that is a proper ancestor of the source is now refused first with DirectoryNotEmpty, whatever the kinds, as on Linux. A directory over an unrelated non-empty directory was already refused.
  • rename(x, x). Plan validation refused it with SameSourceAndDestination, but POSIX makes it a successful no-op. Rename onto itself is now admitted, and the kernel's existing same-identity no-op handles it. link(x, x) is still refused.

Without replacement (RENAME_NOREPLACE), an existing destination is now refused with AlreadyExists before any other check. That includes the source's own name and other hard-link aliases of the same inode, as on Linux. Before this change, an alias with replace=false returned success.

Tests

  • rename_follows_the_posix_kind_rules checks every combination against a POSIX reference: each pair of endpoint kinds (absent, regular, symlink, empty directory, non-empty directory), both replacement policies, and each kind renamed onto itself. For refusals it also asserts the expected error, and that kind refusals happen before any backend write.
  • rename_over_an_ancestor_is_refused covers a directory over its parent, a directory over its grandparent, and a file over its parent.
  • transaction_rename_follows_posix_kind_rules is the reported Workspace repro.

Not changed

A transaction that stages nothing still fails at commit with NoPendingMutations. The facade contract and its tests are deliberate: Committed means "this call published a generation". Treating a no-op transaction as success is a separate API decision.

Gates

  • cargo fmt --all --check: clean
  • cargo clippy -p acyclic-fs --all-targets --all-features --locked -- -D warnings: clean
  • cargo test -p acyclic-fs --all-features --locked: lib 1186 passed, 25 ignored; all integration tests passed

🤖 Generated with Claude Code


Summary by cubic

Aligns rename with the POSIX kind and self-rename rules. Previously the kernel never compared endpoint kinds, so a directory could replace a regular file or symlink and vice versa, and rename(x, x) was refused even though POSIX makes it a successful no-op.

  • A directory over a non-directory is now refused with NotDirectory and a non-directory over a directory with IsDirectory.
  • A destination that is a proper ancestor of the source is refused with DirectoryNotEmpty before the source is detached, matching Linux.
  • rename(x, x) succeeds and changes nothing; link(x, x) is still refused. Without replacement, any existing destination (including aliases of the source inode) is refused with AlreadyExists before other checks.
  • WorkspaceError::NotDirectory now maps the new kernel error.

Tests

  • A matrix test checks every pair of endpoint kinds (absent, regular, symlink, empty and non-empty directory) and both replacement policies against a POSIX reference, asserting the expected error and that refusals happen before any backend write.
  • Adds a workspace repro and a test for renaming over an ancestor.