From fa9b3381bcf7d5cb91ba32aa83512e19b4965c3f Mon Sep 17 00:00:00 2001 From: tao <2471314@gmail.com> Date: Wed, 1 Jul 2026 20:25:21 +0800 Subject: [PATCH] feat(path): fnamemodify(':h') preserves logical root #40447 Problem: On Windows, `fnamemodify('//foo/C$', ':h')` incorrectly removes `C$` as a regular file name and returns `//foo`. However, this is a valid UNC path, `foo` is a server name and `C$` is a share name. The correct result should be `//foo/C$`. Solution: Extend `os_fileinfo2` and `FileInfo` with `prefix_off`, `rest_off` to identify path types and logical root boundaries. ':h' can use this info to prevent traversing past the logical root. Examples: /foo => / //foo => // (POSIX) //foo/bar => //foo (POSIX) //server/share/foo => //server/share/ (Windows) C:/foo => C:/ //?/C:/foo => //?/C:/ Co-authored-by: Barrett Ruth --- runtime/doc/news.txt | 1 + src/nvim/eval/fs.c | 21 ++---- src/nvim/os/fs.c | 68 +++++++++++++----- src/nvim/os/fs_defs.h | 40 +++++++++-- .../functional/vimscript/fnamemodify_spec.lua | 72 +++++++++++-------- 5 files changed, 139 insertions(+), 63 deletions(-) diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 5dee6ad646..a4c23d9e76 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -369,6 +369,7 @@ These existing features changed their behavior. • Markdown inline highlighting now conceals the backslash in backslash escapes. • Markdown inline backslash escapes and hard line breaks no longer use the `@string.escape` capture. +• `fnamemodify(':h')` preserves logical roots for more path formats. ============================================================================== REMOVED FEATURES *news-removed* diff --git a/src/nvim/eval/fs.c b/src/nvim/eval/fs.c index 6093626c49..b076235619 100644 --- a/src/nvim/eval/fs.c +++ b/src/nvim/eval/fs.c @@ -208,34 +208,25 @@ repeat: } } -#ifndef MSWIN + FileInfo file_info; + os_fileinfo2(*fnamep, &file_info); if (src[*usedlen] == ':' && src[*usedlen + 1] == 'h') { - char *fname = *fnamep; - // POSIX reserves exactly "//"; longer slash runs are ordinary absolute paths. - // "///foo/bar" => "/foo" - // "//foo/bar" => "//foo" - if (fname[0] == '/' && fname[1] == '/' && fname[2] == '/') { - while (fname[1] == '/') { - fname++; - } - *fnamep = fname; - } + s = *fnamep + file_info.rest_off; + *fnamep = *fnamep + file_info.prefix_off; } -#endif char *tail = path_tail(*fnamep); *fnamelen = strlen(*fnamep); // ":h" - head, remove "/file_name", can be repeated - // Don't remove the first "/" or "c:\" + // Don't remove the logical root, see `FileInfo`. while (src[*usedlen] == ':' && src[*usedlen + 1] == 'h') { valid |= VALID_HEAD; *usedlen += 2; - s = get_past_head(*fnamep); while (tail > s && after_pathsep(s, tail)) { MB_PTR_BACK(*fnamep, tail); } - *fnamelen = (size_t)(tail - *fnamep); + *fnamelen = tail <= s ? (size_t)(s - *fnamep) : (size_t)(tail - *fnamep); if (*fnamelen == 0) { // Result is empty. Turn it into "." to make ":cd %:h" work. xfree(*bufp); diff --git a/src/nvim/os/fs.c b/src/nvim/os/fs.c index 39feb93786..14cb6cb611 100644 --- a/src/nvim/os/fs.c +++ b/src/nvim/os/fs.c @@ -1202,9 +1202,15 @@ bool os_fileinfo(const char *path, FileInfo *file_info) return os_stat(path, &(file_info->stat)) == kLibuvSuccess; } -/// Identify Windows path type and provide root offset for now, examples: -/// - "//?/C:/foo" => type is `kPathDevice` root offset is 4 -/// - "//?/UNC/localhost/C$/foo" => type is `kPathDeviceUNC`, root offset is 8 +static const char *path_skip_sep(const char *path) +{ + while (*path != NUL && vim_ispathsep_nocolon(*path)) { + path++; + } + return path; +} + +/// Parses `path` into a `FileInfo` structure. /// /// TODO(ntdiary): Could be extended for path.c cleanup and path normalization /// logic. Eventually merge this with `os_fileinfo` @@ -1214,25 +1220,55 @@ bool os_fileinfo2(const char *path, FileInfo *info) FUNC_ATTR_NONNULL_ALL { CLEAR_POINTER(info); -#ifdef MSWIN - size_t len = strlen(path); - if (len < 4) { + if (path_with_url(path)) { return true; } - if (vim_ispathsep_nocolon(path[0]) - && vim_ispathsep_nocolon(path[1]) - && (path[2] == '?' || path[2] == '.') - && vim_ispathsep_nocolon(path[3])) { - if (len >= 8 && vim_strnicmp_asc(path + 4, "unc/", 4) == 0) { - info->root_off = 8; - info->type = kPathDeviceUNC; - } else { - info->root_off = 4; - info->type = kPathDevice; + // Preserves the leading two "/"; runs of 3+ "/" collapse to a single "/" (IEEE 1003.1). + // The same rule applies to kPathUNC and kPathGeneric on Windows, but not to kPathDevice + // or kPathDeviceUNC, where the leading "//" is significant. + const char *p = path_skip_sep(path); + size_t leading_slashes = (size_t)(p - path); +#ifdef MSWIN + if (leading_slashes == 0 && ASCII_ISALPHA(p[0]) && p[1] == ':') { + info->type = kPathDrive; + p = path_skip_sep(p + 2); + info->rest_off = (size_t)(p - path); + return true; + } + if (p[0] == '?' || p[0] == '.') { + if (!vim_ispathsep_nocolon(p[1])) { + return true; } + info->type = kPathDevice; + info->prefix_off = leading_slashes - 2; + p = path_skip_sep(p + 2); + if (vim_strnicmp_asc(p, "unc", 3) == 0 && vim_ispathsep_nocolon(p[3])) { + info->type = kPathDeviceUNC; + p = path_skip_sep(p + 4); + info->root_off = (size_t)(p - path); + goto server; + } + info->root_off = (size_t)(p - path); + if (ASCII_ISALPHA(p[0]) && p[1] == ':') { + p += 2; + } + p = path_skip_sep(path_next_component(p)); + info->rest_off = (size_t)(p - path); + return true; + } + if (leading_slashes == 2) { + info->type = kPathUNC; + server: + p = path_skip_sep(path_next_component(p)); + p = path_skip_sep(path_next_component(p)); + info->rest_off = (size_t)(p - path); return true; } #endif + info->type = kPathGeneric; + info->rest_off = leading_slashes; + info->root_off = leading_slashes > 2 ? leading_slashes - 1 : 0; + info->prefix_off = info->root_off; return true; } diff --git a/src/nvim/os/fs_defs.h b/src/nvim/os/fs_defs.h index 1d8ab5bc06..a82e2b3e9a 100644 --- a/src/nvim/os/fs_defs.h +++ b/src/nvim/os/fs_defs.h @@ -6,19 +6,51 @@ /// @see https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats typedef enum { kPathUnknown = 0, - kPathDOS, ///< C:/foo/bar + kPathGeneric, ///< foo/bar or /foo/bar + kPathDrive, ///< C:/foo/bar kPathUNC, ///< //server/share/foo/bar kPathDevice, ///< //?/C:/foo/bar or //?/Volume{xxx}/foo/bar kPathDeviceUNC, ///< //?/UNC/server/share/foo/bar } PathType; /// Struct which encapsulates file stat and path information. +/// +/// A path is divided into three parts: prefix, logical root, rest. +/// Examples: +/// foo/bar generic relative path +/// ^------- rest +/// +/// /foo/bar generic absolute path +/// ^^------- rest +/// +-------- root & prefix +/// +/// C:foo/bar Windows drive path, relative path +/// ^ ^------- rest +/// +--------- root & prefix +/// +/// C:/foo/bar Windows drive path, absolute path +/// ^ ^------- rest +/// +---------- root & prefix +/// +/// //?/C:/foo/bar Windows device path +/// ^ ^ ^------- rest +/// +---------- root +/// +-------------- prefix +/// +/// //server/share/foo/bar Windows UNC path +/// ^ ^------- rest +/// +---------------------- root & prefix +/// +/// //?/UNC/server/share/foo/bar Windows device UNC path +/// ^ ^ ^------- rest +/// +-------------------- root +/// +---------------------------- prefix typedef struct { uv_stat_t stat; ///< @private - /// Offset of the root component after any path type prefix. Currently - /// only used for Widnows device paths. e.g. "//?/" and "//?/UNC/" - size_t root_off; + size_t prefix_off; ///< Offset where the type-determining prefix starts. + size_t root_off; ///< Offset where the logical root starts. + size_t rest_off; ///< Offset where the remaining path starts. PathType type; } FileInfo; diff --git a/test/functional/vimscript/fnamemodify_spec.lua b/test/functional/vimscript/fnamemodify_spec.lua index f3d160f9c5..b74176a637 100644 --- a/test/functional/vimscript/fnamemodify_spec.lua +++ b/test/functional/vimscript/fnamemodify_spec.lua @@ -106,44 +106,60 @@ describe('fnamemodify()', function() end) it('handles :h', function() + -- generic path eq('.', fnamemodify('hello.txt', ':h')) + eq('path/to', fnamemodify('path/to/hello.txt', ':h')) + eq('/', fnamemodify('/', ':h')) + eq('/', fnamemodify('/foo', ':h')) - eq_slashconvert('path/to', fnamemodify('path/to/hello.txt', ':h')) + -- collapses more than two leading slashes into a single slash + eq('/', fnamemodify('///', ':h')) + eq('/', fnamemodify('////', ':h')) + eq('/', fnamemodify('///foo', ':h')) + eq('/foo', fnamemodify('///foo/bar', ':h')) + eq('/foo', fnamemodify('///foo////bar', ':h')) + eq('/foo', fnamemodify('////foo/bar', ':h')) - if is_os('win') then - -- Current Windows behavior for slash-style UNC paths, such as "//foo/C$/". - eq('/', fnamemodify('/', ':h')) - eq('/', fnamemodify('/foo', ':h')) - eq('//', fnamemodify('//', ':h')) - eq('//', fnamemodify('//foo', ':h')) - eq('//foo', fnamemodify('//foo/bar', ':h')) - eq('//foo', fnamemodify('//foo///bar', ':h')) - eq('//foo', fnamemodify('//foo/C$', ':h')) - eq('//foo/C$', fnamemodify('//foo/C$/', ':h')) - eq('//foo/C$', fnamemodify('//foo/C$/bar', ':h')) - eq('///', fnamemodify('///', ':h')) - eq('////', fnamemodify('////', ':h')) - eq('///', fnamemodify('///foo', ':h')) - eq('///foo', fnamemodify('///foo/bar', ':h')) - eq('///foo', fnamemodify('///foo////bar', ':h')) - else - -- POSIX roots. - eq('/', fnamemodify('/', ':h')) - eq('/', fnamemodify('/foo', ':h')) + -- preserves exactly two leading slashes + eq('//', fnamemodify('//', ':h')) + if not is_os('win') then -- POSIX permits special handling for exactly two leading slashes. eq('//', fnamemodify('//', ':h')) eq('//', fnamemodify('//foo', ':h')) eq('//foo', fnamemodify('//foo/bar', ':h')) eq('//foo', fnamemodify('//foo///bar', ':h')) + else + eq('//server', fnamemodify('//server', ':h')) + eq('//server/share', fnamemodify('//server/share', ':h')) + eq('//server/share/', fnamemodify('//server/share/', ':h')) + eq('//server///share', fnamemodify('//server///share', ':h')) + eq('//server/share/', fnamemodify('//server/share/foo', ':h')) - -- More than two leading slashes are ordinary absolute paths. - eq('/', fnamemodify('///', ':h')) - eq('/', fnamemodify('////', ':h')) - eq('/', fnamemodify('///foo', ':h')) - eq('/foo', fnamemodify('///foo/bar', ':h')) - eq('/foo', fnamemodify('///foo////bar', ':h')) - eq('/foo', fnamemodify('////foo/bar', ':h')) + eq([[\\foo\C$]], fnamemodify([[\\foo\C$]], ':h')) + eq([[\\foo\C$\]], fnamemodify([[\\foo\C$\]], ':h')) + eq([[\\foo\C$\]], fnamemodify([[\\foo\C$\bar]], ':h')) + eq([[\\foo\C$\]], fnamemodify([[\\foo\C$\bar]], ':h:h')) + eq([[//foo\C$/]], fnamemodify([[//foo\C$/bar]], ':h')) + -- `C$` is a share name, not a file name + eq('', fnamemodify('//foo/C$/bar', ':h:t')) + + eq('C:', fnamemodify('C:foo', ':h')) + eq('C:/', fnamemodify('C:/foo', ':h')) + + eq('//?/C:/', fnamemodify('//?/C:/', ':h')) + eq('//?/C:/', fnamemodify('//?/C:/foo', ':h')) + eq( + '//?/Volume{b75e2c83-0000-0000-0000-602f00000000}/', + fnamemodify('//?/Volume{b75e2c83-0000-0000-0000-602f00000000}/foo', ':h') + ) + eq( + '//?/Volume{b75e2c83-0000-0000-0000-602f00000000}/', + fnamemodify('///?/Volume{b75e2c83-0000-0000-0000-602f00000000}/foo', ':h') + ) + eq('//?/UNC/server', fnamemodify('//?/UNC/server', ':h')) + eq('//?/UNC/server/share', fnamemodify('//?/UNC/server/share', ':h')) + eq('//?/UNC/server/share/', fnamemodify('//?/UNC/server/share/foo', ':h')) end end)