// Package safeio reads and writes local files without following symlinked path components. package safeio import ( "crypto/rand" "encoding/hex" "errors" "io" "os" "path/filepath" "sort" "strings" "unicode/utf8" ) var ErrUnsafeFile = errors.New("unsafe file") // Pagination must never allocate or iterate indefinitely over a hostile private directory. This // is deliberately independent from an auth record page: it permits many bounded pages while // putting a fixed upper bound on one sorted keyset scan. const maximumPrivateDirectoryPageScanEntries = 16384 // EnsurePrivateDirectory creates only the final canonical directory with the platform's // owner-only protection, or validates an existing directory has that protection. func EnsurePrivateDirectory(path string) error { exists, err := PreflightPrivateDirectory(path) if err != nil { return err } if exists { return ValidatePrivateDirectory(path) } if err := createPrivateDirectory(path); err != nil && !errors.Is(err, os.ErrExist) { return ErrUnsafeFile } return ValidatePrivateDirectory(path) } // PreflightPrivateDirectory validates every existing path component without following links or // reparse points. A missing final component is safe to create later; missing intermediates are not. // This function never creates, removes, chmods, or changes an ACL. func PreflightPrivateDirectory(path string) (bool, error) { if err := ValidateCanonicalPath(path); err != nil { return false, ErrUnsafeFile } exists, err := preflightPrivateDirectory(path) if err != nil { return false, ErrUnsafeFile } return exists, nil } // ValidateCanonicalPath rejects relative or lexically non-canonical paths before they are opened. func ValidateCanonicalPath(path string) error { if !filepath.IsAbs(path) || filepath.Clean(path) != path || strings.Contains(path, string(filepath.Separator)+".."+string(filepath.Separator)) { return ErrUnsafeFile } return nil } func readBoundedRegularFile(path string, file *os.File, maximum int64) ([]byte, error) { if maximum < 0 || maximum == int64(^uint64(0)>>1) { return nil, ErrUnsafeFile } info, err := file.Stat() if err != nil || !info.Mode().IsRegular() || !hasSingleLink(info) { return nil, ErrUnsafeFile } contents, err := io.ReadAll(io.LimitReader(file, maximum+1)) if err != nil || int64(len(contents)) > maximum { return nil, ErrUnsafeFile } after, err := file.Stat() if err != nil || !after.Mode().IsRegular() || !hasSingleLink(after) || !os.SameFile(info, after) { return nil, ErrUnsafeFile } current, err := os.Lstat(path) if err != nil || !current.Mode().IsRegular() || current.Mode()&os.ModeSymlink != 0 || !hasSingleLink(current) || !os.SameFile(info, current) { return nil, ErrUnsafeFile } return contents, nil } func ReadCanonicalUTF8(path string, maximum int64) (string, error) { contents, err := ReadCanonicalRegular(path, maximum) if err != nil { return "", err } if !utf8.Valid(contents) { return "", ErrUnsafeFile } return string(contents), nil } // ReadCanonicalPrivateRegular reads one owner-only private record and revalidates its metadata // after the bounded read. It intentionally rejects ordinary hard links; OIDC's explicitly named // atomic claim pair uses ReadCanonicalPrivateClaim instead. func ReadCanonicalPrivateRegular(path string, maximum int64) ([]byte, error) { return readCanonicalPrivateRegular(path, maximum) } // PrivateDirectoryEntry is a bounded, untrusted directory listing item. Callers must still // validate each filename and record before using it. type PrivateDirectoryEntry struct { Name string `json:"name"` ModifiedUnixMs int64 `json:"modifiedUnixMs"` } // PrivateDirectoryPage is one lexically ordered, keyset-paginated directory page. More is true // only when the page is full and another validated entry follows AfterName. type PrivateDirectoryPage struct { Entries []PrivateDirectoryEntry More bool } func privateDirectoryScanSnapshot(path string) (os.FileInfo, error) { if err := ValidatePrivateDirectory(path); err != nil { return nil, ErrUnsafeFile } info, err := os.Lstat(path) if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return nil, ErrUnsafeFile } return info, nil } func samePrivateDirectoryScanSnapshot(left, right os.FileInfo) bool { return left != nil && right != nil && os.SameFile(left, right) && left.Mode() == right.Mode() && left.ModTime().Equal(right.ModTime()) } // ListCanonicalPrivateDirectory lists regular, non-symlinked direct children from an owner-only // directory. It returns no content and bounds the number of entries before allocating output. func ListCanonicalPrivateDirectory(path string, maximumEntries int) ([]PrivateDirectoryEntry, error) { if maximumEntries < 1 || maximumEntries > 4096 { return nil, ErrUnsafeFile } if err := ValidatePrivateDirectory(path); err != nil { return nil, ErrUnsafeFile } directory, err := os.Open(path) if err != nil { return nil, ErrUnsafeFile } defer directory.Close() entries, err := directory.ReadDir(maximumEntries + 1) if err != nil && !errors.Is(err, io.EOF) { return nil, ErrUnsafeFile } if len(entries) > maximumEntries { return nil, ErrUnsafeFile } result := make([]PrivateDirectoryEntry, 0, len(entries)) for _, entry := range entries { if entry.Name() == "" || strings.Contains(entry.Name(), string(filepath.Separator)) { return nil, ErrUnsafeFile } info, err := os.Lstat(filepath.Join(path, entry.Name())) if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { return nil, ErrUnsafeFile } result = append(result, PrivateDirectoryEntry{Name: entry.Name(), ModifiedUnixMs: info.ModTime().UnixMilli()}) } sort.Slice(result, func(left, right int) bool { return result[left].Name < result[right].Name }) if err := ValidatePrivateDirectory(path); err != nil { return nil, ErrUnsafeFile } return result, nil } // ListCanonicalPrivateDirectoryPage scans a private directory without materializing a complete // output listing, validates every encountered child, and keeps only the next bounded lexical page. // The caller supplies the filename grammar because safeio is intentionally storage-format agnostic. func ListCanonicalPrivateDirectoryPage( path string, maximumEntries int, afterName string, validName func(string) bool, ) (PrivateDirectoryPage, error) { if maximumEntries < 1 || maximumEntries > 4096 || validName == nil || (afterName != "" && !validName(afterName)) { return PrivateDirectoryPage{}, ErrUnsafeFile } before, err := privateDirectoryScanSnapshot(path) if err != nil { return PrivateDirectoryPage{}, err } directory, err := os.Open(path) if err != nil { return PrivateDirectoryPage{}, ErrUnsafeFile } defer directory.Close() seen := make(map[string]struct{}, maximumEntries+1) selected := make([]PrivateDirectoryEntry, 0, maximumEntries+1) scanned := 0 for { entries, readErr := directory.ReadDir(1) if readErr != nil && !errors.Is(readErr, io.EOF) { return PrivateDirectoryPage{}, ErrUnsafeFile } if len(entries) == 0 { break } if len(entries) != 1 { return PrivateDirectoryPage{}, ErrUnsafeFile } scanned++ if scanned > maximumPrivateDirectoryPageScanEntries { return PrivateDirectoryPage{}, ErrUnsafeFile } name := entries[0].Name() if name == "" || strings.Contains(name, string(filepath.Separator)) || !validName(name) { return PrivateDirectoryPage{}, ErrUnsafeFile } if _, duplicate := seen[name]; duplicate { return PrivateDirectoryPage{}, ErrUnsafeFile } seen[name] = struct{}{} entryPath := filepath.Join(path, name) if err := ValidatePrivateRegular(entryPath); err != nil { return PrivateDirectoryPage{}, ErrUnsafeFile } info, err := os.Lstat(entryPath) if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || !hasSingleLink(info) { return PrivateDirectoryPage{}, ErrUnsafeFile } if name > afterName { selected = appendBoundedPrivateDirectoryEntry(selected, PrivateDirectoryEntry{ Name: name, ModifiedUnixMs: info.ModTime().UnixMilli(), }, maximumEntries+1) } if errors.Is(readErr, io.EOF) { break } } after, err := privateDirectoryScanSnapshot(path) if err != nil || !samePrivateDirectoryScanSnapshot(before, after) { return PrivateDirectoryPage{}, ErrUnsafeFile } sort.Slice(selected, func(left, right int) bool { return selected[left].Name < selected[right].Name }) more := len(selected) > maximumEntries if more { selected = selected[:maximumEntries] } return PrivateDirectoryPage{Entries: selected, More: more}, nil } func appendBoundedPrivateDirectoryEntry( entries []PrivateDirectoryEntry, entry PrivateDirectoryEntry, maximumEntries int, ) []PrivateDirectoryEntry { if len(entries) < maximumEntries { return append(entries, entry) } maximum := 0 for index := 1; index < len(entries); index++ { if entries[index].Name > entries[maximum].Name { maximum = index } } if entry.Name < entries[maximum].Name { entries[maximum] = entry } return entries } func WriteCanonicalNewFile(path string, contents []byte, mode os.FileMode) error { return writeCanonicalNewFile(path, contents, mode, false) } // WriteCanonicalNewPrivateFile is the authentication-storage variant of exclusive file // creation. It requires the final parent directory to already have platform-specific owner-only // protection and preserves that check while the platform primitive opens the parent. func WriteCanonicalNewPrivateFile(path string, contents []byte, mode os.FileMode) error { return writeCanonicalNewFile(path, contents, mode, true) } // CreateCanonicalNewPrivateFile exclusively creates an owner-private regular file under an // already private parent and returns a read/write handle for streamed contents. Callers must close // the returned handle and remove the file if their stream fails. func CreateCanonicalNewPrivateFile(path string) (*os.File, error) { if err := validateCanonicalNewFile(path); err != nil { return nil, err } file, err := createCanonicalNewPrivateParentReadWriteFile(path, 0o600) if err != nil { return nil, ErrUnsafeFile } return file, nil } func writeCanonicalNewFile(path string, contents []byte, mode os.FileMode, requirePrivateParent bool) error { if err := validateCanonicalNewFile(path); err != nil { return err } var ( file *os.File err error ) if requirePrivateParent { file, err = createCanonicalNewPrivateParentFile(path, mode) } else { file, err = createCanonicalNewPrivateFile(path, mode) } if err != nil { return ErrUnsafeFile } if _, err := file.Write(contents); err != nil { _ = file.Close() _ = RemoveCanonicalPrivateRegular(path) return ErrUnsafeFile } if err := file.Sync(); err != nil { _ = file.Close() _ = RemoveCanonicalPrivateRegular(path) return ErrUnsafeFile } if err := file.Close(); err != nil { _ = RemoveCanonicalPrivateRegular(path) return ErrUnsafeFile } return nil } // validateCanonicalNewFile performs only lexical validation. Platform creators retain and // inspect the actual parent handle before mutation; pathname existence or parent checks here // would be stale by the time an exclusive create reaches the filesystem. func validateCanonicalNewFile(path string) error { if err := ValidateCanonicalPath(path); err != nil { return err } return nil } // ReplaceCanonicalRegular durably replaces one existing private regular file without following // symlinked path components. Platform implementations keep the temporary file in the target // directory and use the platform's atomic replace primitive. func ReplaceCanonicalRegular(path string, contents []byte, mode os.FileMode) error { if err := ValidateCanonicalPath(path); err != nil || mode.Perm() != 0o600 || mode&os.ModeType != 0 { return ErrUnsafeFile } return replaceCanonicalRegular(path, contents) } // RemoveCanonicalPrivateRegular removes one existing private regular file without following a // symlinked path component. It is intended only for rolling back a file this process published. func RemoveCanonicalPrivateRegular(path string) error { if err := ValidatePrivateRegular(path); err != nil { return ErrUnsafeFile } return removeCanonicalPrivateRegular(path) } // ClaimCanonicalPrivateRegular atomically creates a second, explicit private hard link to one // existing record. It is used only for digest-named, single-use OIDC state claims. func ClaimCanonicalPrivateRegular(source, claim string) (bool, error) { unlock := lockCanonicalPrivateClaimOperation() defer unlock() if err := validateClaimPaths(source, claim); err != nil { return false, ErrUnsafeFile } return claimCanonicalPrivateRegular(source, claim) } // ReadCanonicalPrivateClaim reads a verified two-link source/claim pair. found=false means the // state has already been consumed or a winning process is between its two removal steps. func ReadCanonicalPrivateClaim(source, claim string, maximum int64) ([]byte, bool, error) { unlock := lockCanonicalPrivateClaimOperation() defer unlock() if maximum < 0 || maximum == int64(^uint64(0)>>1) || validateClaimPaths(source, claim) != nil { return nil, false, ErrUnsafeFile } return readCanonicalPrivateClaim(source, claim, maximum) } // RemoveCanonicalPrivateClaim removes exactly a verified two-link source/claim pair. func RemoveCanonicalPrivateClaim(source, claim string) (bool, error) { unlock := lockCanonicalPrivateClaimOperation() defer unlock() if err := validateClaimPaths(source, claim); err != nil { return false, ErrUnsafeFile } return removeCanonicalPrivateClaim(source, claim) } func validateClaimPaths(source, claim string) error { if ValidateCanonicalPath(source) != nil || ValidateCanonicalPath(claim) != nil || filepath.Dir(source) != filepath.Dir(claim) { return ErrUnsafeFile } if err := ValidatePrivateDirectory(filepath.Dir(source)); err != nil { return ErrUnsafeFile } return nil } func randomTemporaryName() (string, error) { bytes := make([]byte, 16) if _, err := rand.Read(bytes); err != nil { return "", err } return ".tht-auth-" + hex.EncodeToString(bytes) + ".tmp", nil }