Skip to content

Release Notes v0.11.0

Released: 2026-10-31

Highlights

Open a file a caller named, inside a directory, with the open itself confined. fs.OpenInRoot checks the name as ResolveInRoot does and then opens it through os.Root, so a symbolic link that leads out of the directory is refused at the moment of the open. There is no gap between the check and the open for a link to be swapped into, and the returned handle stays bound to the file it opened. fs.OpenRegularInRoot and fs.CapReader add the rest of a safe read: the handle is checked as a regular file within a size limit, and the read is capped so a file that grows after the check cannot exceed it.

New Features

fs.OpenInRoot

root, err := fs.RealDir(uploadRoot) // once, at start-up
if err != nil {
    return err
}
f, err := fs.OpenInRoot(root, req.Path)
if errors.Is(err, fs.ErrOutsideRoot) {
    return fmt.Errorf("upload path: %w", err)
}
if err != nil {
    return err // a missing file satisfies errors.Is(err, os.ErrNotExist)
}
defer f.Close()

The same names are refused as by ResolveInRoot, with an error wrapping fs.ErrOutsideRoot, on every platform: an empty name, a NUL, a .. element with either separator, and a rooted or drive-qualified name that is not inside the root. On top of that, a symbolic link in any element of the name that leads out of the root is refused by the open.

Where it differs from ResolveInRoot:

  • A link whose target is relative and stays inside the root is followed. A link whose target is an absolute path is refused, even when it points back into the root, because os.Root does not follow absolute links.
  • The file must exist. A missing file satisfies errors.Is(err, os.ErrNotExist) and does not wrap ErrOutsideRoot.
  • An absolute name is accepted only if it is spelled inside the root, with the root written the same way. Pass the result of RealDir as the root.

Use OpenInRoot when you will read the file, and ResolveInRoot when you need a path: to create a file that does not exist yet, or to hand to code that opens paths itself.

fs.OpenRegularInRoot and fs.CapReader

f, err := fs.OpenRegularInRoot(root, req.Path, maxBytes)
if err != nil {
    // fs.ErrOutsideRoot, os.ErrNotExist, fs.ErrNotRegular or fs.ErrTooLarge,
    // each testable with errors.Is
    return fmt.Errorf("upload path: %w", err)
}
defer f.Close()

body := fs.CapReader(f, maxBytes) // a file that grows after the check
data, err := io.ReadAll(body)      // ends with fs.ErrTooLarge

OpenRegularInRoot opens as OpenInRoot does and returns the file only if the handle it holds is a regular file of at most maxBytes. The check is made on the open handle, so it describes the file you will read, and the file is closed for you when it fails.

CapReader is io.LimitReader that reports fs.ErrTooLarge when the stream is longer than the limit, instead of stopping silently, so an oversized file cannot pass as a complete, shorter one. Bytes within the limit are delivered before the error. Hand the capped reader to anything that takes an io.Reader, such as an upload. A negative limit is treated as zero.

Documentation

  • The fs guide explains when to use OpenInRoot instead of ResolveInRoot, with a complete example.
  • The README, the getting-started table and the portable-tests guide list the new functions.

Installation

go get github.com/grokify/oscompat@v0.11.0

Compatibility

Additive only. An API comparison against v0.10.0 reports compatible changes only: the new functions and errors, and no incompatible changes. The module still has no dependencies outside the standard library; os.Root needs Go 1.24, and the module already requires Go 1.25.