| Ticket UUID: | d40d8db3fb7f69b66c624caf6b3b4f3d74fbbab4 | ||
| Title: | Windows: [file normalize] drops a component when parent enumeration is denied (non-junction case) | ||
| Type: | Bug | Created on: | 2026-07-12 19:32:36 |
| Submitter: | anafalanx | Assigned to: | nobody |
| Subsystem: | 36. Pathname Management | Severity: | Important |
| Priority: | 5 Medium | Last modified: | 2026-07-16 12:00:24 |
| Status: | Closed | Closed by: | apnadkarni |
| Resolution: | Fixed | Closed on: | 2026-07-16 12:00:24 |
| Version: | 9.0.3 (also 8.6.17; code unchanged in 9.0.4) | ||
| Description: | ||||
|
On Windows, file normalize can silently remove a path component when the complete path is accessible by its exact name, but an intermediate directory may be traversed without being enumerated. For example, an existing path ending in:
is normalized to:
The returned normalized path no longer names the original file. This is a valid Windows ACL configuration. It also occurs with sandboxed processes that receive access to a known descendant without permission to enumerate its parent. Versions and environmentRuntime reproduced with:
Inspection of win/tclWinFile.c shows the same relevant branch in Tcl 9.0.4: Tk is not involved; the issue is in Tcl core. Related historical reportsThis is closely related to Tcl bugs #4738 / 3092089 and #5124 / 3587096. Those reports documented the same component loss—and the latter an init.tcl startup failure—when directory listing was denied around Windows junctions. They were closed as fixed in 2013 after a change to reparse-point access. The present reproducer uses only ordinary directories, not junctions or other reparse points, and still triggers the generic FindFirstFileW failure branch in Tcl 8.6.17 and 9.0.3. The same branch remains in the 9.0.4 source. This therefore appears to be a non-junction case that the 2013 reparse-point fix did not cover. ReproducerThis script creates a unique temporary fixture, adds a non-inheriting ListDirectory denial only to one fixture directory, runs Tcl, restores the original ACL, and removes the fixture. It does not require elevation. The temporary filesystem must support Windows ACLs. Save it as repro.ps1 and pass the path to a Windows tclsh.
Example invocation:
Actual resultWith Tcl 9.0.3:
The same component loss was reproduced with Tcl 8.6.17. Expected resultBecause the exact path exists and is accessible, normalization should retain every component:
If Tcl cannot discover the canonical spelling of app without enumerating its parent, preserving the supplied spelling would be preferable to deleting the component. Returning an error would also preserve path integrity better than returning a different path. Apparent causeIn TclpObjNormalizePath() in win/tclWinFile.c, GetFileAttributesExW() first establishes that the exact path prefix exists. Tcl then calls FindFirstFileW() to obtain the filesystem spelling of the current component. A direct Win32 probe against the same ACL fixture confirms:
The INVALID_HANDLE_VALUE branch does not inspect GetLastError(). It assumes the failure is the root-directory case described by its comment and appends only a slash:
For a normal component such as app, this silently replaces the component with a slash. ImpactThis is not limited to artificial ACLs. It can occur under application sandboxes and capability-based access arrangements where a process can open a known file but cannot list one of its ancestors. It was encountered with a standalone Tcl/Tk application located below a restricted user-profile directory. Tcl normalized the executable path before reopening it for ZIPFS mounting. The corrupted path prevented the embedded Tcl library from being mounted, so startup subsequently failed while looking for init.tcl. More generally, file normalize can return a nonexistent path for an existing, accessible file. Possible conservative fixFor a non-root component whose existence was already established by GetFileAttributesExW(), if FindFirstFileW() cannot retrieve its canonical spelling, one conservative response would be either:
The drive or UNC root case can be recognized structurally. For other FindFirstFileW() failures, inspecting GetLastError() and preserving the supplied component spelling for access-related failures—or returning an error—would avoid silently deleting the component. A not-found or race condition may need separate treatment. A Windows regression test using a non-inheriting ListDirectory denial would cover this configuration. Reported by Vincent Vercauteren. | ||||
| User Comments: | ||||
apnadkarni added on 2026-07-14 03:54:36:
The OP has suggested two possible behaviors:
Preserving the passed name will allow most code to "silently" work but it breaks the semantics of file normalize which promises to return a unique path. Returning an error would maintain the file normalize contract (we cannot normalize the path) but may break more code which expects file normalize on an accessible file to always succeed. Opinions on which option is preferable? jan.nijtmans added on 2026-07-14 10:49:39:
Slight preference for preserving the passed name. oehhar added on 2026-07-14 14:48:17:
I see the error useless. We want to access the given path from an arbitrary folder. This contract is probably not true, as the path depends on pwd, right? An addition "file pathtype $Path" may check, if it is really an absolute path. We may document the issue and go for preservation. On Android, we often have the case, that apps may not see the root folder. Would [file nativename [file normalize $Path]] also have the right meaning to the operation system? Or even exec [auto_execok [file nativename [file normalize $Path]]] (if $Path is for example a text file with ".txt" extension) ? Sorry for the questions... Harald apnadkarni added on 2026-07-16 12:00:24:
Fixed in [0ac314cb96]. Original path is copied if on-disk path cannot be retrieved. | ||||
