Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//
// - expanding user-facing paths that start with ~ or contain $HOME
// - checking whether paths exist and whether they are regular files or directories
// - checking whether a regular file is marked readable or writable by its owner
// - confirming that a path remains inside a base directory after normalization
// - matching file extensions case-insensitively
// - writing files atomically by replacing the destination with a temporary file
Expand All @@ -16,6 +17,13 @@
// replaces $HOME references with the HOME environment variable. If the home
// directory cannot be determined, the original path is returned unchanged.
//
// # Permission Predicates
//
// [IsReadable] and [IsWritable] inspect the file mode, not the effective access of the calling process.
// That is the portable answer the standard library can give: os.Stat exposes mode bits everywhere, while
// asking "can I open this?" needs access(2) or an attempted open. A caller that must be certain should
// open the file and handle the error — the answer can change between any check and the open regardless.
//
// # Atomic Writes
//
// [WriteFileAtomic] writes data to a temporary file in the destination directory,
Expand Down
33 changes: 33 additions & 0 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,36 @@ func ExampleWriteFileAtomic() {
// Output:
// ready
}

func ExampleIsWritable() {
dir, err := os.MkdirTemp("", "fsx-example-*")
if err != nil {
fmt.Println("error:", err)

return
}
defer os.RemoveAll(dir)

writable := filepath.Join(dir, "writable.yaml")
if err := os.WriteFile(writable, []byte("a: 1\n"), 0o644); err != nil {
fmt.Println("error:", err)

return
}

readOnly := filepath.Join(dir, "readonly.yaml")
if err := os.WriteFile(readOnly, []byte("a: 1\n"), 0o400); err != nil {
fmt.Println("error:", err)

return
}

fmt.Println(fsx.IsWritable(writable))
fmt.Println(fsx.IsWritable(readOnly))
fmt.Println(fsx.IsReadable(readOnly))

// Output:
// true
// false
// true
}
50 changes: 50 additions & 0 deletions fsx.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,56 @@ func IsWithin(base, target string) bool {
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}

// Owner permission bits inspected by [IsReadable] and [IsWritable].
const (
ownerRead = 0o400
ownerWrite = 0o200
)

// IsReadable reports whether path is a regular file whose owner-read bit is set.
//
// The check is on the file mode, not on the effective access of the calling process. That is the
// portable answer the standard library can give: os.Stat exposes mode bits on every platform, while
// asking "can *I* open this?" requires access(2) or an attempted open, neither of which is available
// portably without cgo or a platform build tag.
//
// So this answers "is this file marked readable by its owner?" — which is what a tool validating a
// configuration path it created wants to know. A caller that must be certain it can read the file should
// open it and handle the error; a predicate cannot promise more than the mode bits it read, and the
// answer can change between the check and the open regardless.
//
// An empty path, a missing path, and a path that is not a regular file all report false.
func IsReadable(path string) bool {
return hasOwnerBits(path, ownerRead)
}

// IsWritable reports whether path is a regular file whose owner-write bit is set.
//
// The same mode-bit semantics as [IsReadable] apply, and for the same reason. The case this exists for is
// a tool that will later rewrite a file it was handed — a configuration file, a lock file — and would
// rather refuse at validation time than fail halfway through a write.
//
// An empty path, a missing path, and a path that is not a regular file all report false.
func IsWritable(path string) bool {
return hasOwnerBits(path, ownerWrite)
}

// hasOwnerBits reports whether path is a regular file with all of the given mode bits set.
func hasOwnerBits(path string, bits os.FileMode) bool {
if path == "" {
return false
}

info, err := os.Stat(ExpandPath(path))
if err != nil {
return false
}

mode := info.Mode()

return mode.IsRegular() && mode&bits == bits
}

// HasExtension reports whether path has one of the provided extensions.
//
// Extension matching is case-insensitive. Extensions may be passed with or
Expand Down
138 changes: 138 additions & 0 deletions fsx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -481,3 +481,141 @@ func BenchmarkWriteFileAtomic(b *testing.B) {
}
}
}

func TestIsReadable(t *testing.T) {
tmpDir := t.TempDir()

readable := filepath.Join(tmpDir, "readable.txt")
if err := os.WriteFile(readable, []byte("content"), 0o644); err != nil {
t.Fatalf("os.WriteFile() error = %v", err)
}

unreadable := filepath.Join(tmpDir, "unreadable.txt")
if err := os.WriteFile(unreadable, []byte("content"), 0o200); err != nil {
t.Fatalf("os.WriteFile() error = %v", err)
}

tests := []struct {
name string
path string
want bool
}{
{
name: "empty path",
path: "",
want: false,
},
{
name: "readable file",
path: readable,
want: true,
},
{
name: "write-only file",
path: unreadable,
want: false,
},
{
name: "directory",
path: tmpDir,
want: false,
},
{
name: "missing path",
path: filepath.Join(tmpDir, "missing.txt"),
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsReadable(tt.path); got != tt.want {
t.Errorf("IsReadable(%q) = %t, want %t", tt.path, got, tt.want)
}
})
}
}

func TestIsWritable(t *testing.T) {
tmpDir := t.TempDir()

writable := filepath.Join(tmpDir, "writable.txt")
if err := os.WriteFile(writable, []byte("content"), 0o644); err != nil {
t.Fatalf("os.WriteFile() error = %v", err)
}

readOnly := filepath.Join(tmpDir, "readonly.txt")
if err := os.WriteFile(readOnly, []byte("content"), 0o400); err != nil {
t.Fatalf("os.WriteFile() error = %v", err)
}

tests := []struct {
name string
path string
want bool
}{
{
name: "empty path",
path: "",
want: false,
},
{
name: "writable file",
path: writable,
want: true,
},
{
name: "read-only file",
path: readOnly,
want: false,
},
{
name: "directory",
path: tmpDir,
want: false,
},
{
name: "missing path",
path: filepath.Join(tmpDir, "missing.txt"),
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsWritable(tt.path); got != tt.want {
t.Errorf("IsWritable(%q) = %t, want %t", tt.path, got, tt.want)
}
})
}
}

// Both predicates expand the path first, like every other function in the package.
func TestIsReadableWritableExpandsHOME(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

path := filepath.Join(home, "config.yaml")
if err := os.WriteFile(path, []byte("content"), 0o644); err != nil {
t.Fatalf("os.WriteFile() error = %v", err)
}

if !IsReadable("$HOME/config.yaml") {
t.Error(`IsReadable("$HOME/config.yaml") = false, want true`)
}

if !IsWritable("~/config.yaml") {
t.Error(`IsWritable("~/config.yaml") = false, want true`)
}
}

func BenchmarkIsWritable(b *testing.B) {
path := filepath.Join(b.TempDir(), "data.txt")
if err := os.WriteFile(path, []byte("content"), 0o644); err != nil {
b.Fatalf("os.WriteFile() error = %v", err)
}

for b.Loop() {
IsWritable(path)
}
}