4848// dropped rather than wrapped. Customer-facing RU: no тире.
4949var errReadSubsystemsRoot = errors .New ("не удалось прочитать каталог подсистем дампа" )
5050
51+ // errNotDirectory marks a dump path that occupies a directory position but is not a
52+ // directory: a FIFO, socket, device, plain file, or a symlink standing in for the
53+ // directory. Opening such a position unconditionally is the DoS this guard closes: a
54+ // writer-less FIFO at a directory position blocks the open forever and, unlike a
55+ // bounded read, a blocked open() cannot be interrupted by ctx. Every directory read
56+ // refuses a non-directory BEFORE that blocking open, mirroring the subsystem-file
57+ // guard, and NAMES the refusal so the drop is never silent.
58+ var errNotDirectory = errors .New ("dump path is not a directory" )
59+
60+ // errDumpDirNotDirectory is the path-free RU refusal returned by ParseAllSubsystemsCtx
61+ // when dumpDir ITSELF is a non-directory node (a FIFO, socket, device, plain file, or a
62+ // symlink resolving to one). It is errNotDirectory's guard one level up, at the dump
63+ // root: os.OpenRoot(dumpDir) on a writer-less FIFO blocks on an open() that ctx cannot
64+ // interrupt, so the node type is checked with os.Stat (which does not open it) BEFORE
65+ // the open. Customer-facing RU: no тире, never an absolute path.
66+ var errDumpDirNotDirectory = errors .New ("каталог дампа имеет неверный тип" )
67+
68+ // dumpDirIsNonDir reports whether dumpDir exists but is NOT a directory: a FIFO,
69+ // socket, device, plain file, or a symlink resolving to one of those. os.Stat follows a
70+ // symlink and, crucially, does NOT open the node, so it returns immediately on a
71+ // writer-less FIFO where os.OpenRoot would block on the ctx-uninterruptible open (the
72+ // DoS this guards). A missing path or a permission error (err != nil) and a genuine
73+ // directory (including a symlink to a directory) all report false, so every
74+ // os.OpenRoot(dumpDir) entry point falls through to its existing missing / unreadable /
75+ // symlink-to-directory contract unchanged; only a non-directory dumpDir is refused
76+ // before the blocking open.
77+ func dumpDirIsNonDir (dumpDir string ) bool {
78+ fi , err := os .Stat (dumpDir )
79+ return err == nil && ! fi .IsDir ()
80+ }
81+
5182// Subsystem is one node of the dump's subsystem forest: its canonical full path,
5283// display synonym, direct member composition (Content, canonical RU full names)
5384// and any nested child subsystems.
@@ -266,6 +297,9 @@ const (
266297// so the walk returns an empty tree; a present but unreadable Subsystems/ yields
267298// the path-free errReadSubsystemsRoot.
268299func detectSubsystemLayout (dumpDir string ) (subsystemLayout , error ) {
300+ if dumpDirIsNonDir (dumpDir ) {
301+ return layoutExt , nil // non-directory dumpDir: default layout, empty tree (never open it)
302+ }
269303 root , err := os .OpenRoot (dumpDir )
270304 if err != nil {
271305 return layoutExt , nil
@@ -285,9 +319,11 @@ func detectLayoutInRoot(root *os.Root) (subsystemLayout, error) {
285319 if errors .Is (err , os .ErrPermission ) {
286320 return layoutExt , errReadSubsystemsRoot // present but unreadable: path-free
287321 }
288- // Containment refusal (an escaping Subsystems/ symlink, os.Root "path
289- // escapes") or any other read error: never probe outside; default layout so
290- // the walk returns an empty tree.
322+ // Containment refusal (an escaping Subsystems/ symlink, os.Root "path escapes"),
323+ // a non-directory Subsystems position (errNotDirectory: a FIFO/socket/device/
324+ // plain file, refused BEFORE the blocking open by openDirInRoot so detection
325+ // never hangs), or any other read error: never probe outside; default layout so
326+ // the walk returns an empty tree (the walk re-encounters and NAMES it).
291327 return layoutExt , nil
292328 }
293329 for _ , e := range entries {
@@ -327,13 +363,18 @@ func ParseAllSubsystems(dumpDir string) ([]Subsystem, error) {
327363// (warnings) alongside the parsed tree. Hierarchical uses a disk-walk recursion;
328364// Ext preserves the nested-children-from-XML behaviour. Every filesystem access is
329365// confined to the dump via an os.Root (a crafted symlink that escapes the dump at
330- // ANY path component is refused by the OS primitive, never followed), recursion is
331- // depth-capped, and each dropped subsystem is NAMED in warnings (never silently
332- // dropped).
366+ // ANY path component is refused by the OS primitive, never followed), every directory
367+ // AND file position is type-checked before it is opened (a writer-less FIFO planted at
368+ // any position is refused before the blocking open rather than hanging the walk),
369+ // recursion is depth-capped, and each dropped subsystem or directory is NAMED in
370+ // warnings (never silently dropped).
333371func ParseAllSubsystemsCtx (ctx context.Context , dumpDir string ) ([]Subsystem , []string , error ) {
334372 if err := ctx .Err (); err != nil {
335373 return nil , nil , err
336374 }
375+ if dumpDirIsNonDir (dumpDir ) {
376+ return nil , nil , errDumpDirNotDirectory // dumpDir itself is a FIFO/socket/device/file: refuse before the blocking open
377+ }
337378 root , err := os .OpenRoot (dumpDir )
338379 if err != nil {
339380 if errors .Is (err , os .ErrNotExist ) {
@@ -381,20 +422,30 @@ func (w *subsystemWalker) warn(name, reason string) {
381422 w .warnings = append (w .warnings , fmt .Sprintf ("подсистема %s: %s" , n , reason ))
382423}
383424
425+ // warnSubsystemsRoot NAMES a refused top-level Subsystems catalog: a non-directory at
426+ // the Subsystems position (a FIFO, socket, device, plain file, or a symlink standing in
427+ // for it) drops the entire tree, which must never be silent. Path-free, no тире.
428+ func (w * subsystemWalker ) warnSubsystemsRoot () {
429+ w .warnings = append (w .warnings , "каталог подсистем дампа имеет неверный тип и пропущен" )
430+ }
431+
384432// readDir reads a dump-relative directory confined to the walker's os.Root.
385433func (w * subsystemWalker ) readDir (rel string ) ([]os.DirEntry , error ) {
386434 return readDirInRoot (w .root , rel )
387435}
388436
389437// readDirInRoot reads a directory confined to root and returns its entries sorted
390438// by name (matching os.ReadDir), so the hierarchical walk's on-disk child ordering
391- // is deterministic. os.Root confines EVERY path component beneath the root using
392- // the OS primitive (openat2 RESOLVE_BENEATH on Linux, equivalents elsewhere), so an
393- // escaping symlink at ANY depth is refused with a "path escapes" error rather than
439+ // is deterministic. The directory is opened through openDirInRoot, which refuses any
440+ // non-directory at that position (a FIFO/socket/device/plain file, or a symlink
441+ // standing in for the directory) BEFORE the blocking open, so a planted writer-less
442+ // FIFO at a directory position can never hang the walk. os.Root confines EVERY path
443+ // component beneath the root using the OS primitive (openat2 RESOLVE_BENEATH on Linux,
444+ // equivalents elsewhere), so an escaping symlink at ANY depth is refused rather than
394445// followed out of the dump. os.File.ReadDir (unlike os.ReadDir) returns entries in
395446// directory order, so they are sorted here.
396447func readDirInRoot (root * os.Root , rel string ) ([]os.DirEntry , error ) {
397- f , err := root . Open ( rel )
448+ f , err := openDirInRoot ( root , rel )
398449 if err != nil {
399450 return nil , err
400451 }
@@ -407,6 +458,38 @@ func readDirInRoot(root *os.Root, rel string) ([]os.DirEntry, error) {
407458 return entries , nil
408459}
409460
461+ // openDirInRoot opens rel as a directory confined to root, refusing any non-directory
462+ // at that position. It mirrors openSubsystemFile's guard, for directories: it lstats
463+ // and requires Mode().IsDir() BEFORE the open, so a writer-less FIFO, socket, device,
464+ // or a symlink standing in for the directory can never reach the blocking open() that
465+ // ctx cannot interrupt. It then opens with O_NONBLOCK on unix, so a directory swapped
466+ // for a FIFO in the check->use window still returns immediately instead of blocking,
467+ // and fstats the descriptor to require it be the very directory the lstat saw (IsDir
468+ // plus os.SameFile), which closes that TOCTOU window. Containment across every path
469+ // component is enforced by os.Root, so an escaping symlink at ANY depth is refused
470+ // rather than followed. A genuinely absent path returns os.ErrNotExist (callers treat
471+ // it as a normal empty position); a non-directory returns errNotDirectory (callers
472+ // NAME it); permission and containment ("path escapes") errors propagate unchanged.
473+ func openDirInRoot (root * os.Root , rel string ) (* os.File , error ) {
474+ li , err := root .Lstat (rel )
475+ if err != nil {
476+ return nil , err
477+ }
478+ if ! li .Mode ().IsDir () {
479+ return nil , errNotDirectory
480+ }
481+ f , err := root .OpenFile (rel , os .O_RDONLY | nonblockOpenFlag , 0 )
482+ if err != nil {
483+ return nil , err
484+ }
485+ fi , err := f .Stat ()
486+ if err != nil || ! fi .IsDir () || ! os .SameFile (li , fi ) {
487+ _ = f .Close ()
488+ return nil , errNotDirectory
489+ }
490+ return f , nil
491+ }
492+
410493// openSubsystemFile opens a dump-relative subsystem file for reading, confined to
411494// the walker's os.Root, and returns it only when it is a plain regular file.
412495// Containment across every path component is enforced by os.Root: an escaping
@@ -475,6 +558,10 @@ func (w *subsystemWalker) walkExt(relRoot string) ([]Subsystem, error) {
475558 if errors .Is (err , os .ErrPermission ) {
476559 return nil , errReadSubsystemsRoot // path-free
477560 }
561+ if errors .Is (err , errNotDirectory ) {
562+ w .warnSubsystemsRoot () // a non-directory Subsystems position: NAME the drop
563+ return nil , nil
564+ }
478565 return nil , nil // containment refusal or other: empty tree, never leak
479566 }
480567 out := make ([]Subsystem , 0 , len (entries ))
@@ -532,8 +619,19 @@ func (w *subsystemWalker) walkHierarchical(relRoot, parentPath string, depth int
532619 if errors .Is (err , os .ErrPermission ) {
533620 return nil , errReadSubsystemsRoot // path-free, top level only
534621 }
622+ if errors .Is (err , errNotDirectory ) {
623+ w .warnSubsystemsRoot () // a non-directory Subsystems position: NAME the drop
624+ return nil , nil
625+ }
535626 return nil , nil // containment refusal or other at top: empty tree
536627 }
628+ if errors .Is (err , errNotDirectory ) {
629+ // A non-directory at the recursion position (a FIFO/socket/device, or a
630+ // symlink standing in for the child Subsystems/ directory): refused BEFORE
631+ // the blocking open by openDirInRoot; skip its subtree, NAMED by its parent.
632+ w .warn (parentPath , "вложенный каталог подсистем имеет неверный тип и пропущен" )
633+ return nil , nil
634+ }
537635 // A nested recursion directory that is unreadable or escapes the dump (an
538636 // intermediate directory symlink refused by os.Root): skip its subtree, NAMED.
539637 w .warn (parentPath , "каталог подсистемы недоступен и пропущен" )
0 commit comments