diff --git a/doc.go b/doc.go index 3ade8ca..0ad0959 100644 --- a/doc.go +++ b/doc.go @@ -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 @@ -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, diff --git a/example_test.go b/example_test.go index cca76c1..a566d9b 100644 --- a/example_test.go +++ b/example_test.go @@ -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 +} diff --git a/fsx.go b/fsx.go index 98b650b..6b0e753 100644 --- a/fsx.go +++ b/fsx.go @@ -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 diff --git a/fsx_test.go b/fsx_test.go index decdff0..0a5fc08 100644 --- a/fsx_test.go +++ b/fsx_test.go @@ -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) + } +}