Mehdi Akiki
Rust Failure Atlas / FFI and targets

RFA-346 · Case file with fixtures · Case 318 of 694 · Runtime evidence

read_link Returns the Stored Relative Symlink Target

On Unix, read_link exposes the path stored in the symlink. A relative target is interpreted from the symlink's parent when followed, so resolution and canonical identity require separate explicit operations.

Reviewed
Rust
Rust 1.98.1, edition 2024
Targets
Unix
Profiles
dev, release, test

Direct answer

What this Rust failure means

Why it happens
read_link exposes the path stored in the symlink, and a relative target gains its resolution context from the symlink's parent only when followed.
First discriminating check
Keep the link path and stored target separate, join a relative target to the link parent, and canonicalize only when current resolved identity is required.

I once called read_link on an absolute path and expected an absolute target back. Instead I received target.txt. The answer was not incomplete: that relative path was exactly what the symlink stored.

The failing Unix program creates a link using the relative target target.txt. It then compares the read_link result with the absolute path of the real file and fails.

A Unix symbolic link contains a path used during later name resolution. It does not have to store the canonical identity of an existing object. Its target may be relative, absolute, missing, or eventually point somewhere different after filesystem changes.

fs::read_link reads that link. On Unix the standard library documentation says the operation currently corresponds to readlink. The returned PathBuf can therefore be the relative text that was supplied at creation.

Calling the function with an absolute link path does not transform the stored target into an absolute one. The input and output describe different path roles.

When the operating system follows a relative symlink target, it interprets that target relative to the directory containing the symlink, not relative to the process current working directory.

This detail is the source of many repairs that work in a local shell and fail in a service. current_dir().join(stored_target) is wrong whenever the link lives elsewhere.

The repaired program gets the link's parent and joins the stored target there. That reconstructs the path used for following this one-level relative link.

Resolution and canonicalization answer different questions

Joining the parent and target creates a usable path, but it may still contain .., further symlinks, or non-normal components. fs::canonicalize resolves a path to an absolute canonical form using the filesystem.

I canonicalize when I need to compare the object reached now. I keep the raw read_link value when I need to inspect, copy, rewrite, or preserve the symlink itself.

Canonicalization performs filesystem work and normally requires the path to resolve. A dangling link can be valid configuration even though canonicalization fails. Replacing every read_link with canonicalize would lose that use case.

Lexical paths are not stable object identities

Two different path strings can reach the same file through symlinks, hard links, mount points, or case rules. Conversely, the same path string can reach another file later after replacement.

If authorization depends on whether a target stays inside a directory, a simple string prefix or lexical join is not a complete security boundary. Resolution races and platform rules need a design based on handles and operating-system guarantees.

The fixture teaches path semantics, not a complete sandbox recipe. I avoid presenting canonicalization alone as protection against adversarial filesystem mutation.

Platform behavior deserves a visible boundary

Rust exposes read_link on multiple platforms, but link and reparse-point semantics differ. The documentation names platform-specific underlying operations and warns they may change.

This case is marked Unix and uses std::os::unix::fs::symlink. Cross-platform application code needs tests on each supported target and a decision about junctions, reparse points, privileges, and path prefixes.

I do not infer Windows behavior from a Unix fixture simply because both return PathBuf.

Tests should preserve the three path forms

My filesystem test keeps the link path, stored target, and resolved target in separate variables. It asserts read_link returns target.txt, then joins through the link parent and compares canonical paths.

I also test an absolute stored target, ../ components, a nested symlink, a dangling target, and a link in another directory from the process working directory. Cleanup happens after all values needed for the assertion are collected.

These cases prevent a helper named “resolve link” from quietly mixing raw inspection with current object resolution.

The wider rule is to name the level of interpretation

Paths appear simple because they print as strings, but each operation interprets them in a context. read_link reveals stored path data. Joining applies a directory context. Following asks the filesystem to resolve components. Canonicalizing returns one current absolute representation.

I state which level a function returns in its name and type documentation. A raw target is not a resolved target, and a resolved path is not permanent file identity. Once these stages are separate, the surprising target.txt answer becomes the precise one.