From 6c1cd557ae3ea0bdd4c5d70dfb3bb66080b53946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Tue, 18 Aug 2026 11:13:38 +0200 Subject: [PATCH] feat: add IsReadable and IsWritable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two predicates for the question the package could not answer: not what a path *is*, but what you may do with it. The motivating case is a tool that validates a path it will later rewrite — a configuration file, a lock file — and would rather refuse at validation time than fail halfway through a write. `Exists` and `IsFile` both pass a read-only file happily. Both inspect the file mode rather than the calling process's effective access, and the documentation says so plainly. 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 available portably without cgo or a build tag. The doc comments point a caller who needs certainty at opening the file instead, and note that the answer can change between any check and the open anyway. Consistent with the rest of the package: paths are expanded first, and an empty path, a missing path and a non-regular file all report false. Tests cover both predicates across empty/readable/write-only/read-only/directory/missing, plus HOME expansion. One example and one benchmark, matching the existing style. --- doc.go | 8 +++ example_test.go | 33 ++++++++++++ fsx.go | 50 ++++++++++++++++++ fsx_test.go | 138 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 229 insertions(+) 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) + } +}