Skip to content

Commit dc58a46

Browse files
committed
fixup! refactor(core.utils): close TOCTOU in atime touch via fd-based stat+utime
1 parent ba1282e commit dc58a46

1 file changed

Lines changed: 45 additions & 4 deletions

File tree

cuda_core/cuda/core/utils/_program_cache.py

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -921,6 +921,9 @@ def _stat_and_read_with_sharing_retry(path: Path) -> tuple[os.stat_result, bytes
921921
raise FileNotFoundError(path) from last_exc
922922

923923

924+
_UTIME_SUPPORTS_FD = os.utime in os.supports_fd
925+
926+
924927
def _touch_atime(path: Path, st_before: os.stat_result) -> None:
925928
"""Bump ``path``'s atime to "now", preserving its mtime, iff the
926929
file's stat still matches ``st_before``.
@@ -942,14 +945,52 @@ def _touch_atime(path: Path, st_before: os.stat_result) -> None:
942945
read and this touch, blindly applying ``st_before.st_mtime_ns``
943946
would roll the new entry's mtime back to the old value and confuse
944947
the eviction stat-guard (which checks ``(ino, size, mtime_ns)``)
945-
into deleting a freshly-committed file. A mismatch means the racing
946-
writer's reader will refresh atime when it next reads, so skipping
947-
here is safe.
948+
into deleting a freshly-committed file.
949+
950+
Where ``os.utime`` supports file descriptors (Linux, macOS), the
951+
fstat-then-utime pair runs against the same open fd: even if another
952+
writer replaces the path between our ``os.open`` and the ``fstat``,
953+
the fd still refers to the file we opened, so the comparison and the
954+
utime both target the same inode. This closes the residual TOCTOU
955+
window that a path-based stat + path-based utime would have.
956+
957+
On Windows, ``os.utime`` is path-only; the fallback re-stats the
958+
path and accepts a small TOCTOU window between the second stat and
959+
the utime. That window is microseconds and the worst-case outcome
960+
is the racing writer's mtime being rolled back by a few hundred
961+
nanoseconds -- the eviction stat-guard would then refuse to evict
962+
the slightly-stale entry, costing one cache miss (recompile) but
963+
not a corrupt eviction.
948964
949965
Best-effort: any ``OSError`` (read-only mount, restrictive ACLs,
950966
...) is swallowed -- size enforcement still bounds the cache, but
951967
eviction degrades toward FIFO.
952968
"""
969+
new_atime_ns = time.time_ns()
970+
if _UTIME_SUPPORTS_FD:
971+
try:
972+
fd = os.open(path, os.O_RDONLY)
973+
except OSError:
974+
return
975+
try:
976+
try:
977+
st_now = os.fstat(fd)
978+
except OSError:
979+
return
980+
if (st_now.st_ino, st_now.st_size, st_now.st_mtime_ns) != (
981+
st_before.st_ino,
982+
st_before.st_size,
983+
st_before.st_mtime_ns,
984+
):
985+
return
986+
with contextlib.suppress(OSError):
987+
os.utime(fd, ns=(new_atime_ns, st_before.st_mtime_ns))
988+
finally:
989+
os.close(fd)
990+
return
991+
992+
# Path-based fallback (Windows). Best-effort -- residual TOCTOU window
993+
# documented above.
953994
try:
954995
st_now = path.stat()
955996
except OSError:
@@ -961,7 +1002,7 @@ def _touch_atime(path: Path, st_before: os.stat_result) -> None:
9611002
):
9621003
return
9631004
with contextlib.suppress(OSError):
964-
os.utime(path, ns=(time.time_ns(), st_before.st_mtime_ns))
1005+
os.utime(path, ns=(new_atime_ns, st_before.st_mtime_ns))
9651006

9661007

9671008
def _prune_if_stat_unchanged(path: Path, st_before: os.stat_result) -> None:

0 commit comments

Comments
 (0)