Skip to content

Fix Path::with_extension dropping the file name for ..X - #160240

Open
lazureykis wants to merge 3 commits into
rust-lang:mainfrom
lazureykis:fix/path-with-extension-dotdot
Open

Fix Path::with_extension dropping the file name for ..X#160240
lazureykis wants to merge 3 commits into
rust-lang:mainfrom
lazureykis:fix/path-with-extension-dotdot

Conversation

@lazureykis

@lazureykis lazureykis commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Fixes #160239.

Two changes. The first is the reported bug; the second is the spec change approved at the 2026-08-04 libs-api meeting.

1. with_extension dropped the file name after ..

Path::new("a/..foo").with_extension("txt") returned a/.. — a parent directory — where set_extension returned a/..txt. with_extension is documented purely as "See [PathBuf::set_extension] for more details", so the two should agree.

_with_extension chopped only the previous extension off the byte slice, leaving the trailing dot in place, then called set_extension on that intermediate. For ..foo the intermediate ends in .., which has no file_name(), so set_extension returned false and did nothing — and its return value was discarded, so the file name was silently dropped. Regression from #113106, first released in 1.73.0.

The fix copies up to the end of the file stem — the boundary set_extension truncates to — and appends the dot and extension directly, keeping the single exact-capacity allocation.

2. Up to three leading dots are part of the file stem

file_stem and extension held back only the first . of a file name, so a name of nothing but dots could become the stem: ...foo had stem .., and set_extension("") on it produced a parent directory. file_prefix had the matching defect — ..config.toml had prefix ..

Both now hold back up to three leading dots (MAX_LEADING_DOTS, shared by rsplit_file_at_dot and split_file_at_dot so the two cannot drift). The cap is what keeps the stem a file name in its own right — never . or .. — while still letting ..., itself a legal file name, carry an extension: ....foo splits as stem ... and extension foo.

Three is the least cap that achieves this: over the 9841 file names of length ≤ 8 over {. a b}, a cap of 1 leaves 189 names with a stem of . or .., a cap of 2 leaves 63, and a cap of 3 leaves none. Caps above 3 are behaviourally identical to 3.

Behaviour changes are limited to file names whose stem was previously made up only of dots. Full before/after matrix in @clarfonthey's comment: #160240 (comment)

Tests

Tests were added to test_with_extension and test_set_extension, which already mirror each other case for case, and to test_stem_ext / test_prefix_ext. They cover the cap boundary (....x, ...., .....x) and, per @nia-e's request, pin that dots after the start of a file name still split as they always have (x..y, x...y, .x..y, ..x..y, foo..bar, bar/foo..baz).

./x test library/std is green and ./x fmt --check is clean. Reverting the source change while keeping the tests fails all four of test_stem_ext, test_prefix_ext, test_set_extension and test_with_extension.

@rustbot rustbot added S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. T-libs Relevant to the library team, which will review and decide on the PR/issue. labels Jul 30, 2026
@rustbot

rustbot commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the pull request, and welcome! The Rust Project is excited to review your changes, and you should hear from @clarfonthey (or someone else) some time within the next two weeks.

Please see the contribution instructions for more information. Namely, in order to ensure the minimum review times lag, PR authors and assigned reviewers should ensure that the review label (S-waiting-on-review and S-waiting-on-author) stays updated, invoking these commands when appropriate:

  • @rustbot author: the review is finished, PR author should check the comments and take action accordingly
  • @rustbot review: the author is ready for a review, this PR will be queued again in the reviewer's queue
Why was this reviewer chosen?

The reviewer was selected based on:

  • libs expanded to 12 candidates
  • Random selection from 8 candidates

@rustbot

This comment has been minimized.

`_with_extension` chopped only the previous extension off the byte slice,
leaving the trailing dot in place, and then called `set_extension` on that
intermediate. For a file name like `..foo`, `extension()` is `Some("foo")`,
so the intermediate ends in `..` -- which has no `file_name()` -- and
`set_extension` returns `false` without doing anything. The whole file name
is dropped and the result is a parent directory:

    Path::new("a/..foo").with_extension("txt")  // "a/.."
    PathBuf::from("a/..foo").set_extension("txt")  // "a/..txt"

`with_extension` is documented purely as "See `PathBuf::set_extension` for
more details", so the two must agree. They did until 1a44b45 replaced
`to_path_buf()` + `set_extension` with the hand-rolled byte path to save an
allocation; the `..X` case was not covered by the tests added there.

Copy up to the end of the file stem instead -- the same boundary
`set_extension` truncates to -- and append the dot and extension directly,
which keeps the single exact-capacity allocation. An exhaustive differential
check over all 1365 paths of length <= 5 from `{. a b /}` crossed with six
extensions finds no remaining disagreement between the two methods.
@lazureykis
lazureykis force-pushed the fix/path-with-extension-dotdot branch from cc4de3d to 2fa9d8b Compare July 30, 2026 23:26
Comment thread library/std/src/path.rs
let file_stem = file_stem.as_encoded_bytes();
let end_file_stem = file_stem[file_stem.len()..].as_ptr().addr();
let start = self_bytes.as_ptr().addr();
(&self_bytes[..end_file_stem.wrapping_sub(start)], true)

@asder8215 asder8215 Jul 31, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This whole logic seems to be more complicated than it needs to be. Your intent in this block seems to be parsing away the file_stem portion of the Path as a u8 slice. You can just return (file_stem.as_encoded_bytes(), true) here, no?

View changes since the review

Comment thread library/std/src/path.rs
};

let add_dot_and_extension = set_extension && !extension.is_empty();
let new_capacity =

@asder8215 asder8215 Jul 31, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I liked the previous implementation of destructuring what the match block returns to (new_capacity, slice_to_copy). You can check if the extension argument is empty in the Some(_) block and then have set_extension be a part of the destructuring match pattern alongside new_capacity and slice_to_copy so that down below you use the set_extension instead of add_dot_and_extension as the condition to check to append the extension to the new path.

View changes since the review

@clarfonthey

clarfonthey commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

I actually want to strengthen the guarantees here, and also nominate to libs-api since this is an API change that feels reasonable.

Essentially, the docs/spec for PathBuf::set_extension are:

Updates self.extension to Some(extension) or to None if extension is empty.

Returns false and does nothing if self.file_name is None, returns true and updates the extension otherwise.

If self.extension is None, the extension is added; otherwise it is replaced.

If extension is the empty string, self.extension will be None afterwards, not Some("").

And the docs for Path::extension are:

The extension is:

  • None, if there is no file name;
  • None, if there is no embedded .;
  • None, if the file name begins with . and has no other .s within;
  • Otherwise, the portion of the file name after the final .

I'm proposing to update the docs (and spec):

  • Up to three leading dots are treated as part of the filename (previously one) to ensure that set_extension("") never creates "", ".", or "..".
  • with_extension and set_extension should always match, with the extra boolean return value discarded.

Status quo:

path .file_name() .extension() .set_extension("") .set_extension("txt") .set_extension("md") .with_extension("") .with_extension("txt") .with_extension("md")
"README" Some("README") None ("README", true) ("README.txt", true) ("README.md", true) "README" "README.txt" "README.md"
".README" Some(".README") None (".README", true) (".README.txt", true) (".README.md", true) ".README" ".README.txt" ".README.md"
"..README" Some("..README") Some("README") (".", true) ("..txt", true) ("..md", true) ".." ".." ".."
"...README" Some("...README") Some("README") ("..", true) ("...txt", true) ("...md", true) ".." "...txt" "...md"
"....README" Some("....README") Some("README") ("...", true) ("....txt", true) ("....md", true) "..." "....txt" "....md"
"" None None ("", false) ("", false) ("", false) "" "" ""
"." None None (".", false) (".", false) (".", false) "." "." "."
".." None None ("..", false) ("..", false) ("..", false) ".." ".." ".."
"..." Some("...") Some("") ("..", true) ("...txt", true) ("...md", true) ".." "...txt" "...md"
"...." Some("....") Some("") ("...", true) ("....txt", true) ("....md", true) "..." "....txt" "....md"
"README.md" Some("README.md") Some("md") ("README", true) ("README.txt", true) ("README.md", true) "README" "README.txt" "README.md"
".README.md" Some(".README.md") Some("md") (".README", true) (".README.txt", true) (".README.md", true) ".README" ".README.txt" ".README.md"
"..README.md" Some("..README.md") Some("md") ("..README", true) ("..README.txt", true) ("..README.md", true) "..README" "..README.txt" "..README.md"
"...README.md" Some("...README.md") Some("md") ("...README", true) ("...README.txt", true) ("...README.md", true) "...README" "...README.txt" "...README.md"
"....README.md" Some("....README.md") Some("md") ("....README", true) ("....README.txt", true) ("....README.md", true) "....README" "....README.txt" "....README.md"
"README.txt" Some("README.txt") Some("txt") ("README", true) ("README.txt", true) ("README.md", true) "README" "README.txt" "README.md"
".README.txt" Some(".README.txt") Some("txt") (".README", true) (".README.txt", true) (".README.md", true) ".README" ".README.txt" ".README.md"
"..README.txt" Some("..README.txt") Some("txt") ("..README", true) ("..README.txt", true) ("..README.md", true) "..README" "..README.txt" "..README.md"
"...README.txt" Some("...README.txt") Some("txt") ("...README", true) ("...README.txt", true) ("...README.md", true) "...README" "...README.txt" "...README.md"
"....README.txt" Some("....README.txt") Some("txt") ("....README", true) ("....README.txt", true) ("....README.md", true) "....README" "....README.txt" "....README.md"

Proposal (with_extension column removed because it's redundant if correct):

path .file_name() .extension() .set_extension("") .set_extension("txt") .set_extension("md")
"README" Some("README") None ("README", true) ("README.txt", true) ("README.md", true)
".README" Some(".README") None (".README", true) (".README.txt", true) (".README.md", true)
"..README" Some("..README") None ("..README", true) ("..README.txt", true) ("..README.md", true)
"...README" Some("...README") None ("...README", true) ("...README.txt", true) ("...README.md", true)
"....README" Some("....README") Some("README") ("...", true) ("....txt", true) ("....md", true)
"" None None ("", false) ("", false) ("", false)
"." None None (".", false) (".", false) (".", false)
".." None None ("..", false) ("..", false) ("..", false)
"..." Some("...") None ("...", true) ("....txt", true) ("....md", true)
"...." Some("....") Some("") ("...", true) ("....txt", true) ("....md", true)
"README.md" Some("README.md") Some("md") ("README", true) ("README.txt", true) ("README.md", true)
".README.md" Some(".README.md") Some("md") (".README", true) (".README.txt", true) (".README.md", true)
"..README.md" Some("..README.md") Some("md") ("..README", true) ("..README.txt", true) ("..README.md", true)
"...README.md" Some("...README.md") Some("md") ("...README", true) ("...README.txt", true) ("...README.md", true)
"....README.md" Some("....README.md") Some("md") ("....README", true) ("....README.txt", true) ("....README.md", true)
"README.txt" Some("README.txt") Some("txt") ("README", true) ("README.txt", true) ("README.md", true)
".README.txt" Some(".README.txt") Some("txt") (".README", true) (".README.txt", true) (".README.md", true)
"..README.txt" Some("..README.txt") Some("txt") ("..README", true) ("..README.txt", true) ("..README.md", true)
"...README.txt" Some("...README.txt") Some("txt") ("...README", true) ("...README.txt", true) ("...README.md", true)
"....README.txt" Some("....README.txt") Some("txt") ("....README", true) ("....README.txt", true) ("....README.md", true)

Difference from original:

path old .extension() new .extension() old .set_extension("") new .set_extension("") old .set_extension("txt") new .set_extension("txt") old .set_extension("md") new .set_extension("md")
"..README" Some("README") None (".", true) ("..README", true) ("..txt", true) ("..README.txt", true) ("..md", true) ("..README.md", true)
"...README" Some("README") None ("..", true) ("...README", true) ("...txt", true) ("...README.txt", true) ("...md", true) ("...README.md", true)
"..." Some("") None ("..", true) ("...", true) ("...txt", true) ("....txt", true) ("...md", true) ("....md", true)

@clarfonthey clarfonthey added the I-libs-api-nominated Nominated for discussion during a libs-api team meeting. label Aug 1, 2026
@lazureykis

Copy link
Copy Markdown
Contributor Author

Right now, set_extension also returns false in the case where self.file_name starts with . and has no other .s within

It doesn't — PathBuf::from(".cargo").set_extension("txt") returns true and gives .cargo.txt. file_stem is Some whenever file_name is, so false does mean file_name == None.

Agreed on the behaviour change, but I'd widen it to the leading run of dots, which also covers ...foo — stem .. today, so set_extension("") turns it into a parent directory. That gives one line to spec against: the stem is always itself a file name, never . or ... file_prefix needs it too (..config.toml → prefix . today). It changes 56 of the 1092 file names of length ≤ 6 over {. a b}, exactly those whose stem is all dots today.

Implemented, full ./x test clean: two commits on my fork, stacked on this PR. Fold it in, or keep it separate pending the meeting?

@clarfonthey

clarfonthey commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Yeah, to be fair, I should have actually tested my assumptions instead of just guessing based upon a read of the code. The libs-api meeting is on Tuesday so I'll try to prepare a dedicated example matrix of what is expected to change under the new proposal.

The goal is to try and rectify what we have and what seems reasonable.

@clarfonthey

clarfonthey commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Okay, took some time and generated a table. Terrible code to do so below:

Terrible code
use std::path::{Path, PathBuf};
static NAMES: &[&str] = &[
    "README",
    ".README",
    "..README",
    "...README",
    "....README",
    "",
    ".",
    "..",
    "...",
    "....",
    "README.md",
    ".README.md",
    "..README.md",
    "...README.md",
    "....README.md",
    "README.txt",
    ".README.txt",
    "..README.txt",
    "...README.txt",
    "....README.txt",
];

fn setext(path: &Path, ext: &str) -> (PathBuf, bool) {
    let mut path = path.to_path_buf();
    let ret = path.set_extension(ext);
    (path, ret)
}

fn main() {
    println!(
        r#"|`path`|`.file_name()`|`.extension()`|`.set_extension("")`|`.set_extension("txt")`|`.set_extension("md")`|`.with_extension("")`|`.with_extension("txt")`|`.with_extension("md")`|"#
    );
    println!(r#"|-|-|-|-|-|-|-|-|-|"#);
    for name in NAMES {
        let path = Path::new(name);
        let fname = path.file_name();
        let ext = path.extension();
        println!(
            r#"|`{path:?}`|`{fname:?}`|`{ext:?}`|`{:?}`|`{:?}`|`{:?}`|`{:?}`|`{:?}`|`{:?}`|"#,
            setext(path, ""),
            setext(path, "txt"),
            setext(path, "md"),
            path.with_extension(""),
            path.with_extension("txt"),
            path.with_extension("md"),
        );
    }
}

Proposed changes:

  1. Obviously, set_extension and with_extension should always match.
  2. Allow up to three leading periods to be considered part of a filename, whereas previously only one was considered. (This avoids set_extension("") creating "", ".", or "..", but since "..." is a valid filename, we stop after three.)

Note: full writeup at #160240 (comment)

@nia-e nia-e removed the I-libs-api-nominated Nominated for discussion during a libs-api team meeting. label Aug 4, 2026
@nia-e

nia-e commented Aug 4, 2026

Copy link
Copy Markdown
Member

Per today's libs-api meeting, this looks fine to us - maybe some tests could be added to make sure behaviour isn't changed for dots in the middle of a path segment, but otherwise looks good. ty ^^

@lazureykis

Copy link
Copy Markdown
Contributor Author

Did the meeting land on the three-dot cap or the full leading run? @nia-e @clarfonthey

@nia-e

nia-e commented Aug 4, 2026

Copy link
Copy Markdown
Member

We didn't explicitly discuss this, but the usecases that we considered favour treating arbitrarily many dots the same as two or three

@clarfonthey

Copy link
Copy Markdown
Contributor

The main reason I left it at three is that "..." is a valid file name by itself and it would be peculiar to not allow "....ext" for that reason.

@nia-e

nia-e commented Aug 4, 2026

Copy link
Copy Markdown
Member

hmm, i see. since this is a bugfix and we're not committing to any behaviour I'd say it's up to you as the reviewer, but maybe post on zulip about this ^^

`Path::file_stem` and `Path::extension` held back only the first `.` of a
file name, so a name of nothing but dots could become the stem: `...foo`
had stem `..`, and `set_extension("")` on it produced a parent directory.

Hold back up to three leading dots instead. The cap is what keeps the stem
a file name in its own right — never `.` or `..` — while still letting
`...`, itself a legal file name, carry an extension: `....foo` splits as
stem `...` and extension `foo`.
`file_prefix` split before the second dot of a name that began with one,
so `..config.toml` had prefix `.` — the same defect the previous commit
fixed for `file_stem`, and the same fix: hold back up to three leading
dots, leaving the prefix a file name in its own right.
@lazureykis

Copy link
Copy Markdown
Contributor Author

Thanks for the reviews. I updated the PR with the three-dot cap and the extra tests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. T-libs Relevant to the library team, which will review and decide on the PR/issue.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Path::with_extension drops the file name for names starting with .., returning a parent directory

5 participants