pathchain

package
v0.14.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 30, 2026 License: MIT Imports: 6 Imported by: 0

README

pathchain

Path-resolution primitive for the SDK kernel. Stdlib-only.

import "github.com/kitsunium/sdk/internal/kernel/pathchain"

steps, err := pathchain.Resolve("/pub/myapp/locks")
if err != nil {
	return err
}
for _, step := range steps {
	if step.Indirect && step.Container.Perm()&0o002 != 0 {
		// This component is a symbolic link, and the directory holding it is
		// world-writable — anyone could have planted it.
		return refuse(step.Path, step.Target)
	}
}

os.Stat says what is at the END of a path and os.Lstat says the same without following its LAST component. Neither can say whether the path arrived where it did by going through somebody else's redirection, and O_NOFOLLOW has the same limit for the same reason — it is a kernel flag about the final component only.

Resolve walks the path from the filesystem root holding an open directory handle at every step, asking each component's own directory what that component is: an lstat relative to a descriptor rather than a fresh lookup of a path string. It returns one StepValue per component, each carrying the mode of the directory it was found in — because a symbolic link is not evidence of anything on its own (/tmp is one on macOS, /var/run is one on most Linux distributions) and what separates those from an attack is who could have created it.

It refuses nothing. A refusal needs a policy and a policy needs a domain; this package produces the measurement a policy cannot be written without.

A component that does not exist ends the walk with a nil error, so a caller can audit a directory it is about to create.

The directory handles are os.Root, whose Unix implementation is openat(dirfd, name, O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY) and whose Windows implementation passes O_NOFOLLOW_ANY. syscall.Openat, the obvious primitive, exists in go1.27's syscall for linux, aix and wasip1 only — darwin does not even define SYS_OPENAT — so a hand-rolled walk would have served five kernels and silently skipped the sixth.

See ADR 0083 for the design rationale and the layering contract.

Documentation

Overview

Package pathchain resolves a path one component at a time and reports what each component IS, instead of only what the whole path finally names.

The question os.Stat cannot be asked

os.Stat answers "what is at the end of this path". os.Lstat answers the same question without following the LAST component. Neither can say whether the path arrived where it did by going through somebody else's redirection, and that is the question a program asks when the path names a resource whose identity is a security property — a lock file, a state directory, a socket.

O_NOFOLLOW has the same limit and it is a kernel limit, not a Go one: it governs the final component only. A link planted at a PARENT component is traversed by every open, whatever flags it carries.

What this package does instead

Resolve walks the path from the filesystem root, holding an open directory handle at every step, and asks each component's own directory what that component is — lstat relative to a descriptor, never a fresh lookup of a path string. It returns one StepValue per component, in order, each carrying the mode of the directory it was found in.

It does not refuse anything. A refusal needs a policy, a policy needs a domain, and this package has neither; what it has is the measurement a policy cannot be written without. The two callers this was built for want opposite verdicts on the same shape — /var/run being a symbolic link is a distribution's decision, /tmp/myapp being one may be an attack — and the difference is in StepValue.Container, not in the link.

What it rests on, and what that costs

The directory handles are os.Root, whose Unix implementation is openat(dirfd, name, O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY) and whose Windows implementation passes O_NOFOLLOW_ANY — read in go1.27's src/os/root_unix.go and src/os/root_windows.go rather than assumed. That matters because syscall.Openat, the obvious primitive, exists in go1.27's syscall package for linux, aix and wasip1 ONLY: darwin reaches the kernel through libc trampolines that are internal to the standard library and does not even define SYS_OPENAT, so a hand-rolled walk would have served five kernels and silently skipped the sixth. os.Root is the standard library's own openat walk and it is available on every platform the SDK builds for.

The cost of borrowing it is that os.Root resolves a symbolic link whose target stays inside the root and refuses one whose target leaves it, and neither behaviour is what a walk wants. So this package never asks os.Root to traverse an indirection: it detects one with Lstat, reads it with Readlink, and restarts the walk itself at the target. os.Root is used for exactly one thing — descending into a component already known to be a real directory.

Package pathchain — the walk's POSITION: one open directory handle per component already entered, so every question is asked of a descriptor this process holds rather than of a path string that could resolve elsewhere.

Package pathchain — one COMPONENT of a resolved path, described rather than traversed, with the mode of the directory it was found in beside it.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type StepValue

type StepValue struct {
	// Path is the path as resolved up to and including this component. It
	// reflects the indirections already followed, so it is where the component
	// actually lives rather than what the caller typed.
	Path string
	// Name is this component alone, with no separator.
	Name string
	// Container is the mode of the directory this component was looked up in.
	//
	// On Unix its permission bits answer "who could have created this entry".
	// On Windows os.Stat synthesises the bits from one read-only attribute, so
	// they answer nothing there and a caller needing that answer must get it
	// from the platform's own access-control model. This package reports what
	// it read and takes no position on what it means.
	Container fs.FileMode
	// Mode is the mode of the component itself, as lstat reports it: an
	// indirection is described here, never traversed to describe its target.
	Mode fs.FileMode
	// Target is where the indirection points, exactly as the filesystem stores
	// it — relative or absolute, unresolved. It is empty unless Indirect.
	Target string
	// Indirect records that this component is a symbolic link on Unix or a
	// reparse point on Windows, so resolving the path CONTINUED somewhere the
	// component's own name does not say.
	Indirect bool
}

StepValue describes one component of a resolved path: what that component IS, and who could have put it there.

The second half is the reason this type exists. Knowing that a component is a symbolic link answers almost nothing on its own, because the answer is "yes" for /tmp on macOS, for /var/run on most Linux distributions, and for C:\Users\All Users on Windows — all of them legitimate, all of them planted by the operating system's installer. What separates those from an attack is not the link, it is the DIRECTORY THE LINK LIVES IN: a link in a directory only root can write was put there by root, and a link in a world-writable directory was put there by anybody at all.

So every step carries Container, the mode of the directory the component was looked up in, read by fstat on a handle this package held open rather than by a second path lookup that could resolve somewhere else.

func Resolve

func Resolve(path string) (steps []StepValue, err error)

Resolve walks path from the filesystem root and returns one StepValue per component, in the order they were traversed.

A relative path is made absolute against the working directory first, so the chain always starts at a root and a caller never has to.

It stops where the path stops existing, and that is not an error

A component that does not exist ends the walk and returns the steps taken so far with a nil error. That is deliberate: the caller this was built for audits a directory it is ABOUT to create, and "the last two components are not there yet" is the ordinary case rather than a fault. Every other failure — a permission denied, a component that is not a directory, an indirection loop — is returned as the *os.PathError the filesystem produced, unwrapped, so the caller's own error vocabulary can wrap it once.

The chain describes where resolution WENT

A step's Path reflects the indirections already followed. Given /pub/app -> /srv/app and a walk of /pub/app/locks, the third step's Path is /srv/app/locks and not /pub/app/locks, because the third component genuinely lives in /srv/app and a caller deciding who could have replaced it must ask about that directory.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL