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 <br@barrettruth.com>
This commit is contained in:
tao
2026-07-01 20:25:21 +08:00
committed by GitHub
parent 05d7040425
commit fa9b3381bc
5 changed files with 139 additions and 63 deletions

View File

@@ -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*

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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)