Mehdi Akiki
Rust Failure Atlas / FFI and targets

RFA-217 · Case file with fixtures · Case 189 of 694 · Runtime evidence

Path::extension Returns None for .env and gz for tar.gz

Rust path extensions are lexical final suffixes. A lone leading dot makes a dotfile, not an extension, while multi-dot names expose only the portion after the last dot.

Reviewed
Rust
Rust 1.98.1, edition 2024
Targets
all targets; path separators and prefixes remain platform-specific
Profiles
dev, release, test

Direct answer

What this Rust failure means

Why it happens
A single leading dot does not create an extension, and ordinary multi-dot names expose only the portion after the final dot.
First discriminating check
Inspect extension, file_stem, and file_prefix for a dotfile, a multi-suffix archive, a trailing dot, and a name with no dot.

I needed to classify filenames and treated everything after the first dot as an extension. That made .env look like extension env and archive.tar.gz look like extension tar.gz. Rust's path API uses another convention.

The failing program checks both names. The actual pair is None and Some("gz").

An extension is the final lexical suffix, with a special rule for a lone leading dot.

A dotfile is not an empty stem plus extension

Path::extension returns None when a filename begins with a dot and has no other dots. Therefore .env, .gitignore, and .profile have no extension under this API.

Their file_stem is the complete filename, including the leading dot.

This convention matches the common meaning of dotfiles on Unix-like systems, while the lexical method behaves the same on other targets for this simple filename.

The repaired program asserts extension and stem together, so the missing extension cannot be confused with a missing filename.

Multi-dot names expose only the final part

For archive.tar.gz, the extension is gz and the stem is archive.tar. Calling file_stem again on that stem is not directly available because it is an OsStr, but I can apply a deliberate compound-suffix policy if the application needs one.

Rust does not know that tar.gz is a meaningful archive format while report.final.pdf normally has extension pdf. Compound extensions belong to a registry or domain rule, not to generic path syntax.

I check known compound suffixes from longest to shortest and keep case sensitivity policy explicit for the target application.

file_prefix answers another question

Rust 1.91 added Path::file_prefix, which returns the portion before the first non-leading dot. For archive.tar.gz, the prefix is archive, while the stem is archive.tar.

These names describe three different views:

file_prefix: archive
file_stem:   archive.tar
extension:   gz

None of them is a MIME type or proof of file contents.

Extensions are not security validation

A file named photo.jpg.exe has final extension exe. A file named photo.jpg can still contain arbitrary bytes. Unicode lookalikes, trailing spaces accepted by some systems, and platform-specific filename rules make visual checks weaker.

For uploads I inspect content with a bounded parser, apply an allowlist, generate server-side storage names, and keep the original display name as untrusted metadata. An extension can help select a parser, but the parser must still reject invalid contents.

I avoid lowercasing a native OsStr through lossy UTF-8 conversion merely to compare an extension. If the product accepts only Unicode filenames, I validate that boundary explicitly.

Trailing dots and empty extensions need tests

A filename ending in a dot can have an empty extension representation rather than no extension, depending on the exact API contract. Empty and absent are different states, just as an empty path parent differs from no parent.

Names such as file..gz, .config.toml, .., directories ending in dots, and paths without a filename reveal boundary assumptions. Platform normalization may also change what the filesystem ultimately accepts.

I test the lexical path functions separately from filesystem existence. Path::extension does not access disk and cannot tell whether the path is a regular file.

Replacing an extension follows final-suffix semantics

Operations such as with_extension and PathBuf::set_extension work from the same final extension model. Replacing archive.tar.gz with zip produces archive.tar.zip, not archive.zip.

If I want to replace the compound tar.gz, I first identify that compound suffix through application policy and construct the new filename deliberately. Blind repeated extension removal can damage names with meaningful dots.

My regression keeps native path values

The fixture compares OsStr values and avoids string conversion. I add dotfiles, one extension, multiple dots, no dot, leading dots with another dot, and trailing dot to the real test table.

For archive handling I also assert the selected decoder, not only the extracted text suffix. This prevents a correct gz result from being fed into a policy that expected tar.gz.

The core principle is that generic path syntax cannot know domain-specific filename formats. Path::extension gives the final suffix and treats a lone leading dot as part of a dotfile. Compound formats and security validation remain explicit application responsibilities.