Migrate ordinary I/O from confined file helpers
This commit is contained in:
@@ -1,148 +0,0 @@
|
||||
package fileops
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenFileConfinedRejectsNonRegularLeaf(t *testing.T) {
|
||||
leaf := filepath.Join(t.TempDir(), "diagnostic.log")
|
||||
if err := os.Mkdir(leaf, 0o700); err != nil {
|
||||
t.Fatalf("Mkdir() error = %v", err)
|
||||
}
|
||||
file, err := OpenFileConfined(leaf, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, WorkspaceFileMode)
|
||||
if file != nil {
|
||||
_ = file.Close()
|
||||
t.Fatal("OpenFileConfined() returned a file for a directory")
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "not a regular file") {
|
||||
t.Fatalf("OpenFileConfined() error = %v, want regular-file rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFileConfinedRejectsSymlinkWithoutTruncatingTarget(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("creating symlinks requires privileges that are not available on every Windows runner")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "target.log")
|
||||
const original = "outside content"
|
||||
if err := os.WriteFile(target, []byte(original), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(target) error = %v", err)
|
||||
}
|
||||
leaf := filepath.Join(dir, "diagnostic.log")
|
||||
if err := os.Symlink(target, leaf); err != nil {
|
||||
t.Fatalf("Symlink() error = %v", err)
|
||||
}
|
||||
|
||||
file, err := OpenFileConfined(leaf, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, WorkspaceFileMode)
|
||||
if file != nil {
|
||||
_ = file.Close()
|
||||
t.Fatal("OpenFileConfined() returned a file for a symbolic link")
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "symbolic link") {
|
||||
t.Fatalf("OpenFileConfined() error = %v, want symbolic-link rejection", err)
|
||||
}
|
||||
data, readErr := os.ReadFile(target)
|
||||
if readErr != nil {
|
||||
t.Fatalf("ReadFile(target) error = %v", readErr)
|
||||
}
|
||||
if string(data) != original {
|
||||
t.Fatalf("target content = %q, want %q", data, original)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFileConfinedDoesNotFollowLeafReplacedBeforeTruncate(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("creating symlinks requires privileges that are not available on every Windows runner")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "target.log")
|
||||
const original = "unrelated content"
|
||||
if err := os.WriteFile(target, []byte(original), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(target) error = %v", err)
|
||||
}
|
||||
leaf := filepath.Join(dir, "diagnostic.log")
|
||||
if err := os.WriteFile(leaf, []byte("old diagnostic"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(leaf) error = %v", err)
|
||||
}
|
||||
|
||||
file, err := openFileConfined(leaf, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, WorkspaceFileMode, func() error {
|
||||
if err := os.Remove(leaf); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Symlink(filepath.Base(target), leaf)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("openFileConfined() error = %v", err)
|
||||
}
|
||||
if _, err := file.WriteString("new diagnostic"); err != nil {
|
||||
_ = file.Close()
|
||||
t.Fatalf("WriteString() error = %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
|
||||
targetData, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(target) error = %v", err)
|
||||
}
|
||||
if string(targetData) != original {
|
||||
t.Fatalf("target content = %q, want %q", targetData, original)
|
||||
}
|
||||
leafData, err := os.ReadFile(leaf)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(leaf) error = %v", err)
|
||||
}
|
||||
if string(leafData) != "new diagnostic" {
|
||||
t.Fatalf("leaf content = %q, want replacement diagnostic", leafData)
|
||||
}
|
||||
info, err := os.Lstat(leaf)
|
||||
if err != nil {
|
||||
t.Fatalf("Lstat(leaf) error = %v", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
t.Fatalf("leaf mode = %v, want regular file", info.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFileConfinedRejectsLeafReplacedBeforeExistingFileOpen(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("creating symlinks requires privileges that are not available on every Windows runner")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "target.lock")
|
||||
const original = "unrelated lock content"
|
||||
if err := os.WriteFile(target, []byte(original), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(target) error = %v", err)
|
||||
}
|
||||
leaf := filepath.Join(dir, "session.lock")
|
||||
if err := os.WriteFile(leaf, []byte("old lock"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(leaf) error = %v", err)
|
||||
}
|
||||
|
||||
file, err := openFileConfined(leaf, os.O_RDWR|os.O_CREATE, WorkspaceFileMode, func() error {
|
||||
if err := os.Remove(leaf); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Symlink(filepath.Base(target), leaf)
|
||||
})
|
||||
if file != nil {
|
||||
_ = file.Close()
|
||||
t.Fatal("openFileConfined() returned a raced symbolic-link target")
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "changed while being opened") {
|
||||
t.Fatalf("openFileConfined() error = %v, want identity-change rejection", err)
|
||||
}
|
||||
targetData, readErr := os.ReadFile(target)
|
||||
if readErr != nil {
|
||||
t.Fatalf("ReadFile(target) error = %v", readErr)
|
||||
}
|
||||
if string(targetData) != original {
|
||||
t.Fatalf("target content = %q, want %q", targetData, original)
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package fileops
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||
)
|
||||
|
||||
// OpenConfinedRegularFile opens a declared file beneath root without following
|
||||
// symlinked ancestors or leaf entries. The returned descriptor remains valid if
|
||||
// the pathname is later replaced.
|
||||
func OpenConfinedRegularFile(rootPath, relativePath string) (*os.File, error) {
|
||||
rootPath = filepath.Clean(strings.TrimSpace(rootPath))
|
||||
if rootPath == "" || rootPath == "." {
|
||||
return nil, fmt.Errorf("source root is required")
|
||||
}
|
||||
relativePath, err := pathsafe.NormalizeRelativeDestination(relativePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("source path: %w", err)
|
||||
}
|
||||
|
||||
root, err := openConfinedDirectory(rootPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open source root: %w", err)
|
||||
}
|
||||
defer root.Close()
|
||||
|
||||
parts := strings.Split(filepath.FromSlash(relativePath), string(filepath.Separator))
|
||||
parent := root
|
||||
for _, part := range parts[:len(parts)-1] {
|
||||
child, err := openConfinedChild(parent, part, false, 0)
|
||||
if err != nil {
|
||||
if parent != root {
|
||||
_ = parent.Close()
|
||||
}
|
||||
return nil, fmt.Errorf("open source ancestor %q: %w", part, err)
|
||||
}
|
||||
if parent != root {
|
||||
_ = parent.Close()
|
||||
}
|
||||
parent = child
|
||||
}
|
||||
if parent != root {
|
||||
defer parent.Close()
|
||||
}
|
||||
|
||||
name := parts[len(parts)-1]
|
||||
declared, err := parent.Lstat(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect source %q: %w", relativePath, err)
|
||||
}
|
||||
if declared.Mode()&os.ModeSymlink != 0 || !declared.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("source %q is not a regular file", relativePath)
|
||||
}
|
||||
|
||||
file, err := parent.Open(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open source %q: %w", relativePath, err)
|
||||
}
|
||||
opened, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("inspect opened source %q: %w", relativePath, err)
|
||||
}
|
||||
current, err := parent.Lstat(name)
|
||||
if err != nil || current.Mode()&os.ModeSymlink != 0 || !current.Mode().IsRegular() ||
|
||||
!opened.Mode().IsRegular() || !os.SameFile(opened, declared) || !os.SameFile(opened, current) {
|
||||
_ = file.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reinspect source %q: %w", relativePath, err)
|
||||
}
|
||||
return nil, fmt.Errorf("source %q changed while being opened", relativePath)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package fileops
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// ValidateConfinedDirectory opens path without following symbolic links and
|
||||
// applies validate to the opened directory's metadata.
|
||||
func ValidateConfinedDirectory(path string, validate func(os.FileInfo) error) error {
|
||||
root, err := openConfinedDirectory(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = root.Close() }()
|
||||
info, err := root.Stat(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect root directory: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("root path is not a directory")
|
||||
}
|
||||
if validate != nil {
|
||||
return validate(info)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadRegularFileUnderRoot reads a bounded regular file after opening root and
|
||||
// every relative-path ancestor without following symbolic links. validateRoot
|
||||
// and validateFile may enforce caller-owned access policy while their handles
|
||||
// are still verified.
|
||||
func ReadRegularFileUnderRoot(
|
||||
rootPath, relativePath string,
|
||||
maxBytes int64,
|
||||
validateRoot, validateFile func(os.FileInfo) error,
|
||||
) ([]byte, error) {
|
||||
if maxBytes < 0 {
|
||||
return nil, fmt.Errorf("maximum byte count must not be negative")
|
||||
}
|
||||
if filepath.IsAbs(relativePath) {
|
||||
return nil, fmt.Errorf("relative file path must not be absolute")
|
||||
}
|
||||
parts, err := relativePathParts(relativePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid relative file path: %w", err)
|
||||
}
|
||||
root, err := openConfinedDirectory(rootPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = root.Close() }()
|
||||
rootInfo, err := root.Stat(".")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect root directory: %w", err)
|
||||
}
|
||||
if validateRoot != nil {
|
||||
if err := validateRoot(rootInfo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, part := range parts[:len(parts)-1] {
|
||||
child, err := openConfinedChild(root, part, false, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = root.Close()
|
||||
root = child
|
||||
}
|
||||
|
||||
name := parts[len(parts)-1]
|
||||
inspected, err := root.Lstat(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect file %q: %w", name, err)
|
||||
}
|
||||
if inspected.Mode()&os.ModeSymlink != 0 || !inspected.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("file %q is not a regular file", name)
|
||||
}
|
||||
file, err := root.Open(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open file %q: %w", name, err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
opened, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect opened file %q: %w", name, err)
|
||||
}
|
||||
current, err := root.Lstat(name)
|
||||
if err != nil || current.Mode()&os.ModeSymlink != 0 || !current.Mode().IsRegular() || !os.SameFile(opened, current) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reinspect file %q: %w", name, err)
|
||||
}
|
||||
return nil, fmt.Errorf("file %q changed while being opened", name)
|
||||
}
|
||||
if validateFile != nil {
|
||||
if err := validateFile(opened); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
content, err := io.ReadAll(io.LimitReader(file, maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read file %q: %w", name, err)
|
||||
}
|
||||
if int64(len(content)) > maxBytes {
|
||||
return nil, fmt.Errorf("file %q exceeds %d-byte limit", name, maxBytes)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// ReadRegularFile reads a bounded regular file without following symbolic links
|
||||
// in its parent hierarchy. It is intended for externally produced results;
|
||||
// callers own the limit and semantic validation contract.
|
||||
func ReadRegularFile(path string, maxBytes int64) ([]byte, error) {
|
||||
clean := filepath.Clean(path)
|
||||
if clean == "." || filepath.Base(clean) == "." {
|
||||
return nil, fmt.Errorf("file path is required")
|
||||
}
|
||||
return ReadRegularFileUnderRoot(filepath.Dir(clean), filepath.Base(clean), maxBytes, nil, nil)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package fileops
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadRegularFileEnforcesLimitAndRegularFileIdentity(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "result.json")
|
||||
if err := os.WriteFile(path, []byte("1234"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
data, err := ReadRegularFile(path, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadRegularFile(exact limit) error = %v", err)
|
||||
}
|
||||
if string(data) != "1234" {
|
||||
t.Fatalf("data = %q, want exact file content", data)
|
||||
}
|
||||
if _, err := ReadRegularFile(path, 3); err == nil || !strings.Contains(err.Error(), "3-byte limit") {
|
||||
t.Fatalf("ReadRegularFile(limit plus one) error = %v, want limit failure", err)
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatalf("Remove(file) error = %v", err)
|
||||
}
|
||||
if err := os.Mkdir(path, 0o700); err != nil {
|
||||
t.Fatalf("Mkdir(non-regular file) error = %v", err)
|
||||
}
|
||||
if _, err := ReadRegularFile(path, 4); err == nil || !strings.Contains(err.Error(), "not a regular file") {
|
||||
t.Fatalf("ReadRegularFile(directory) error = %v, want regular-file failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRegularFileRejectsSymlinkedPaths(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink behavior differs on windows")
|
||||
}
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
outsideFile := filepath.Join(outside, "result.json")
|
||||
if err := os.WriteFile(outsideFile, []byte("outside"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(outside) error = %v", err)
|
||||
}
|
||||
leaf := filepath.Join(root, "result.json")
|
||||
if err := os.Symlink(outsideFile, leaf); err != nil {
|
||||
t.Fatalf("Symlink(leaf) error = %v", err)
|
||||
}
|
||||
if _, err := ReadRegularFile(leaf, 64); err == nil {
|
||||
t.Fatal("ReadRegularFile(leaf symlink) error = nil, want rejection")
|
||||
}
|
||||
if err := os.Remove(leaf); err != nil {
|
||||
t.Fatalf("Remove(leaf symlink) error = %v", err)
|
||||
}
|
||||
linkedParent := filepath.Join(root, "linked")
|
||||
if err := os.Symlink(outside, linkedParent); err != nil {
|
||||
t.Fatalf("Symlink(parent) error = %v", err)
|
||||
}
|
||||
if _, err := ReadRegularFile(filepath.Join(linkedParent, "result.json"), 64); err == nil {
|
||||
t.Fatal("ReadRegularFile(symlinked parent) error = nil, want rejection")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user