From cd08e6dd1f56db116f7f38f93aa5ec72b1e35e7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sun, 17 May 2026 13:49:19 +0200 Subject: [PATCH] feat: first implementation --- .github/codeql/codeql-config.yml | 5 + .github/copilot-instructions.md | 63 ++++ .github/dependabot.yml | 11 + .github/release.yml | 16 + .github/workflows/codeql.yml | 44 +++ .github/workflows/main.yml | 81 ++++++ .github/workflows/pr.yml | 58 ++++ .github/workflows/release.yml | 69 +++++ .golangci.yaml | 21 ++ README.md | 249 +++++++++++++++- SECURITY.md | 15 + doc.go | 30 ++ example_test.go | 69 +++++ fsx.go | 191 ++++++++++++ fsx_test.go | 483 +++++++++++++++++++++++++++++++ go.mod | 3 + 16 files changed, 1407 insertions(+), 1 deletion(-) create mode 100644 .github/codeql/codeql-config.yml create mode 100644 .github/copilot-instructions.md create mode 100644 .github/dependabot.yml create mode 100644 .github/release.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/main.yml create mode 100644 .github/workflows/pr.yml create mode 100644 .github/workflows/release.yml create mode 100644 .golangci.yaml create mode 100644 SECURITY.md create mode 100644 doc.go create mode 100644 example_test.go create mode 100644 fsx.go create mode 100644 fsx_test.go create mode 100644 go.mod diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..93833f2 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,5 @@ +name: CodeQL config + +paths-ignore: + - mocks + - testdata diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..5abad73 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,63 @@ +# Development Guidelines + +This document contains the critical information about working with the project codebase. +Follow these guidelines precisely to ensure consistency and maintainability of the code. + +## Stack + +- Language: Go (Go 1.26.3) +- Framework: Go standard library +- Testing: Go's built-in testing package +- Dependency Management: Go modules +- Version Control: Git +- Documentation: go doc +- Code Review: Pull requests on GitHub +- CI/CD: GitHub Actions + +## Project Structure + +Since this is a small Go library, files are organized in the root directory following standard Go package layout. + +- Library files are located in the root directory. +- `.github/` contains GitHub-specific files such as workflows for CI/CD. +- `.gitignore` specifies files and directories to be ignored by Git. +- `LICENSE` is the license file for the project. +- `README.md` provides an overview of the project, installation instructions, usage examples, and other relevant information. +- `go.mod` declares the module and Go toolchain version. +- `go.sum` should not exist unless external dependencies are intentionally introduced. +- `*.go` files contain the main source code of the library. +- `*_test.go` files contain tests, benchmarks, and executable examples. + +## Code Style + +- Follow Go's idiomatic style defined in + - + - + - + - +- Keep package names short, lowercase, and non-stuttering. +- Use meaningful names for variables, functions, and packages. +- Keep functions small and focused on a single task. +- Add comments for exported identifiers and complex behavior. +- Do not use `interface{}`; use `any` when an unconstrained type is required. +- Do not add external module dependencies without explicit approval. + +## Go 1.26 Practices + +- Prefer `errors.AsType[T](err)` over `errors.As(err, &target)` in new code. +- Use `new(value)` when allocating and initializing pointer values. +- Use `for b.Loop()` in benchmarks. +- Run `go fix ./...` after larger changes to apply available modernizations. + +## Post-Change Checklist + +Use standard Go commands after making changes: + +```bash +go fix ./... +go fmt ./... +go vet ./... +betteralign -apply ./... +go test -race -coverprofile=/tmp/fsx-coverage.txt -covermode=atomic ./... +go build ./... +``` diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d163711 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..a032ef7 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,16 @@ +changelog: + categories: + - title: Breaking Changes + labels: + - Semver-Major + - breaking-change + - title: New Features + labels: + - Semver-Minor + - enhancement + - title: Security + labels: + - security + - title: Other Changes + labels: + - "*" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..bbee9a4 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,44 @@ +name: CodeQL Advanced + +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: "10 12 * * 3" + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + packages: read + security-events: write + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: go + build-mode: autobuild + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + config-file: ./.github/codeql/codeql-config.yml + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: /language:${{ matrix.language }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..1ca7672 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,81 @@ +name: Main + +on: + push: + branches: + - main + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: ./go.mod + cache: true + + - name: Summary Information + run: | + echo "# Push Summary" > "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Repository:** ${{ github.repository }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Push:** ${{ github.event.head_commit.message }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Author:** ${{ github.event.head_commit.author.name }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Branch:** ${{ github.ref }}" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + - name: Tools and versions + run: | + echo "## Tools and versions" >> "$GITHUB_STEP_SUMMARY" + echo "**Ubuntu Version:** $(lsb_release -ds)" >> "$GITHUB_STEP_SUMMARY" + echo "**Bash Version:** $(bash --version | head -n 1 | awk '{print $4}')" >> "$GITHUB_STEP_SUMMARY" + echo "**Git Version:** $(git --version | awk '{print $3}')" >> "$GITHUB_STEP_SUMMARY" + echo "**Go Version:** $(go version | awk '{print $3}')" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + - name: Format check + run: | + echo "## Format check" >> "$GITHUB_STEP_SUMMARY" + files=$(gofmt -l .) + if [ -n "$files" ]; then + echo "$files" + echo "The files above need gofmt." >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + echo "All Go files are gofmt-formatted." >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + - name: Vet + run: | + echo "## Vet" >> "$GITHUB_STEP_SUMMARY" + go vet ./... | tee -a "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + - name: Test + run: | + echo "## Test report" >> "$GITHUB_STEP_SUMMARY" + go test -race -coverprofile=coverage.txt -covermode=atomic ./... | tee -a "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + - name: Test coverage + run: | + echo "## Test coverage" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + go tool cover -func=coverage.txt | tee -a "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + total_coverage=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}') + echo "**Total Coverage:** $total_coverage" >> "$GITHUB_STEP_SUMMARY" + + - name: Build + run: | + echo "## Build" >> "$GITHUB_STEP_SUMMARY" + go build ./... | tee -a "$GITHUB_STEP_SUMMARY" + echo "Build completed successfully." >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..d6be3ae --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,58 @@ +name: Pull Request + +on: + pull_request: + branches: + - main + +permissions: + contents: read + pull-requests: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: ./go.mod + cache: true + + - name: Summary Information + run: | + echo "# Pull Request Summary" > "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Repository:** ${{ github.repository }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Pull Request:** ${{ github.event.pull_request.title }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Author:** ${{ github.event.pull_request.user.login }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Branch:** ${{ github.event.pull_request.head.ref }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Base:** ${{ github.event.pull_request.base.ref }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Commits:** ${{ github.event.pull_request.commits }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Changed Files:** ${{ github.event.pull_request.changed_files }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Additions:** ${{ github.event.pull_request.additions }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Deletions:** ${{ github.event.pull_request.deletions }}" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + - name: Format check + run: | + files=$(gofmt -l .) + if [ -n "$files" ]; then + echo "$files" + exit 1 + fi + + - name: Vet + run: go vet ./... + + - name: Test + run: go test -race -coverprofile=coverage.txt -covermode=atomic ./... + + - name: Test coverage + run: go tool cover -func=coverage.txt + + - name: Build + run: go build ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..04878c1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,69 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +permissions: + actions: write + contents: write + id-token: write + packages: write + pull-requests: read + security-events: write + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: ./go.mod + cache: true + + - name: Summary Information + run: | + echo "# Release Summary" > "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Repository:** ${{ github.repository }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Actor:** ${{ github.triggering_actor }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Commit ID:** ${{ github.sha }}" >> "$GITHUB_STEP_SUMMARY" + echo "**Tag:** ${{ github.ref_name }}" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + - name: Format check + run: | + files=$(gofmt -l .) + if [ -n "$files" ]; then + echo "$files" + exit 1 + fi + + - name: Vet + run: go vet ./... + + - name: Test + run: go test -race -coverprofile=coverage.txt -covermode=atomic ./... + + - name: Test coverage + run: | + echo "## Test coverage" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + go tool cover -func=coverage.txt | tee -a "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + + - name: Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: ${{ github.ref_name }} + draft: false + prerelease: false + generate_release_notes: true + make_latest: true diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..e4af670 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,21 @@ +version: "2" +linters: + enable: + - errcheck + - ineffassign + - staticcheck + - unused + + settings: + errcheck: + check-type-assertions: false + check-blank: false + disable-default-exclusions: true + exclude-functions: + - (*os.File).Close + - (io.Closer).Close + - fmt.Print + - fmt.Printf + - fmt.Println + - fmt.Fprint + - fmt.Fprintf diff --git a/README.md b/README.md index a183dbd..d650843 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,249 @@ # fsx -fsx provides small filesystem helpers for expanded user paths, containment checks, file predicates, and atomic writes. + +[![main branch](https://github.com/slashdevops/fsx/actions/workflows/main.yml/badge.svg)](https://github.com/slashdevops/fsx/actions/workflows/main.yml) +![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/slashdevops/fsx?style=plastic) +[![Go Reference](https://pkg.go.dev/badge/github.com/slashdevops/fsx.svg)](https://pkg.go.dev/github.com/slashdevops/fsx) +[![Go Report Card](https://goreportcard.com/badge/github.com/slashdevops/fsx)](https://goreportcard.com/report/github.com/slashdevops/fsx) +[![license](https://img.shields.io/github/license/slashdevops/fsx.svg)](https://github.com/slashdevops/fsx/blob/main/LICENSE) +[![Release](https://github.com/slashdevops/fsx/actions/workflows/release.yml/badge.svg)](https://github.com/slashdevops/fsx/actions/workflows/release.yml) + +`fsx` is a small Go package for filesystem helpers that sit just above `os` and `path/filepath`. It is designed for applications that need compact, easy-to-review helpers for expanded user paths, containment checks, file predicates, extension matching, and atomic writes. + +The package is standard-library-only and intentionally keeps one short package name instead of exposing broad `utils` packages. + +## Features + +- User path expansion for leading `~` and `$HOME` +- Path existence checks for files, directories, and any filesystem entry +- Directory containment checks that normalize relative traversal +- Case-insensitive file extension matching +- Atomic file replacement with permissions +- Zero third-party dependencies +- Apache-2.0 licensed + +## Installation + +```sh +go get github.com/slashdevops/fsx +``` + +Update to the latest available version: + +```sh +go get -u github.com/slashdevops/fsx +``` + +## Requirements + +- Go 1.26.3 or newer +- No external Go modules + +## Quick Start + +```go +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + + "github.com/slashdevops/fsx" +) + +func main() { + configPath := fsx.ExpandPath("~/app/config.yaml") + if !fsx.HasExtension(configPath, "yaml") { + log.Fatal("config file must be YAML") + } + + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + log.Fatal(err) + } + + if err := fsx.WriteFileAtomic(configPath, []byte("enabled: true\n"), 0o600); err != nil { + log.Fatal(err) + } + + fmt.Println(filepath.Base(configPath)) +} +``` + +## API + +### ExpandPath + +```go +func ExpandPath(path string) string +``` + +`ExpandPath` replaces a leading `~` with the current user's home directory and replaces `$HOME` references with the `HOME` environment variable. Empty paths are returned unchanged. + +```go +configPath := fsx.ExpandPath("~/app/config.yaml") +``` + +### Exists + +```go +func Exists(path string) bool +``` + +`Exists` reports whether a file, directory, or other filesystem entry exists. The path is expanded before it is checked. + +```go +if fsx.Exists("~/app/config.yaml") { + // read config +} +``` + +### IsDir + +```go +func IsDir(path string) bool +``` + +`IsDir` reports whether the expanded path exists and is a directory. + +```go +if !fsx.IsDir("~/app") { + return errors.New("app directory does not exist") +} +``` + +### IsFile + +```go +func IsFile(path string) bool +``` + +`IsFile` reports whether the expanded path exists and is a regular file. + +```go +if !fsx.IsFile("~/app/config.yaml") { + return errors.New("config file does not exist") +} +``` + +### IsWithin + +```go +func IsWithin(base, target string) bool +``` + +`IsWithin` reports whether `target` resolves to a path contained inside `base`. Both paths are expanded, cleaned, and made absolute before comparison. A target equal to base is considered within base. + +This is useful before deleting or overwriting paths read from user-editable state files. + +```go +if !fsx.IsWithin(outputDir, stateFilePath) { + return fmt.Errorf("refusing to remove file outside output directory") +} +``` + +### HasExtension + +```go +func HasExtension(path string, extensions ...string) bool +``` + +`HasExtension` reports whether `path` has one of the provided extensions. Matching is case-insensitive, extensions may be passed with or without a leading dot, and the path does not need to exist. + +```go +if !fsx.HasExtension("config.YAML", "yaml", "yml") { + return errors.New("config must be YAML") +} +``` + +### Dir + +```go +func Dir(path string) string +``` + +`Dir` returns the directory component of `path` after expanding it. + +```go +dir := fsx.Dir("~/app/config.yaml") +``` + +### WriteFileAtomic + +```go +func WriteFileAtomic(path string, data []byte, perm os.FileMode) error +``` + +`WriteFileAtomic` writes data to a temporary file in the destination directory, sets the requested permissions, and renames the temporary file over the target path. + +The destination directory must already exist. If any step fails before the rename, the temporary file is removed. + +```go +if err := fsx.WriteFileAtomic("~/app/config.yaml", data, 0o600); err != nil { + return fmt.Errorf("save config: %w", err) +} +``` + +## Quality Automation + +The repository follows the same GitHub quality practices used by `slashdevops/e5t`: + +- `main.yml` validates pushes to `main` with format checks, `go vet`, race-enabled tests, coverage, and `go build`. +- `pr.yml` runs the same quality gate for pull requests targeting `main`. +- `codeql.yml` runs CodeQL for Go and GitHub Actions on pushes, pull requests, and a weekly schedule. +- `release.yml` validates tagged releases and publishes GitHub releases with generated notes. +- `dependabot.yml` checks Go module and GitHub Actions updates weekly. + +## Project Structure + +```text +. +|-- .github/ GitHub Actions, CodeQL, Dependabot, and release metadata +|-- .golangci.yaml Optional local golangci-lint configuration +|-- doc.go Package documentation rendered by pkg.go.dev +|-- example_test.go Executable Go examples for documentation +|-- fsx.go Public filesystem API +|-- fsx_test.go Unit tests and benchmarks +|-- go.mod Module definition with no external requirements +|-- LICENSE Apache License 2.0 +|-- README.md Project overview and usage guide +`-- SECURITY.md Vulnerability reporting policy +``` + +## Testing + +Run the test suite: + +```sh +go test ./... +``` + +Run benchmarks: + +```sh +go test -bench=. ./... +``` + +Check test coverage: + +```sh +go test -cover ./... +``` + +Run the same local quality checks used by CI: + +```sh +go fix ./... +go fmt ./... +go vet ./... +go test -race -coverprofile=/tmp/fsx-coverage.txt -covermode=atomic ./... +go build ./... +``` + +## License + +`fsx` is licensed under the [Apache License 2.0](LICENSE). + +## Contributing + +Issues and pull requests are welcome at [github.com/slashdevops/fsx](https://github.com/slashdevops/fsx). Please keep changes small, idiomatic, tested, documented, and dependency-free unless there is a clear reason to expand the project scope. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b336307 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# Security Policy + +This project uses GitHub CodeQL to scan for security vulnerabilities. + +[![CodeQL Advanced](https://github.com/slashdevops/fsx/actions/workflows/codeql.yml/badge.svg)](https://github.com/slashdevops/fsx/actions/workflows/codeql.yml) + +## Supported Versions + +| Version | Supported | +| ------- | --------- | +| 0.0.x | Yes | + +## Reporting a Vulnerability + +Please report vulnerabilities through GitHub issues or the repository security advisory flow when available. Avoid posting sensitive exploit details publicly before maintainers have had time to respond. diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..96f8f3b --- /dev/null +++ b/doc.go @@ -0,0 +1,30 @@ +// Package fsx provides small filesystem helpers built only on the Go standard +// library. +// +// The package focuses on behavior that is useful across command-line tools and +// services: +// +// - expanding user-facing paths that start with ~ or contain $HOME +// - checking whether paths exist and whether they are regular files or directories +// - 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 +// +// # Path Expansion +// +// [ExpandPath] expands a leading ~ with the current user's home directory and +// replaces $HOME references with the HOME environment variable. If the home +// directory cannot be determined, the original path is returned unchanged. +// +// # Atomic Writes +// +// [WriteFileAtomic] writes data to a temporary file in the destination directory, +// sets the requested permissions, and renames the temporary file over the target. +// This prevents readers from observing partially written file contents on the +// same filesystem. +// +// # Dependencies +// +// fsx has zero third-party dependencies. It uses os, path/filepath, strings, and +// other standard library packages. +package fsx diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..cca76c1 --- /dev/null +++ b/example_test.go @@ -0,0 +1,69 @@ +package fsx_test + +import ( + "fmt" + "log" + "os" + "path/filepath" + + "github.com/slashdevops/fsx" +) + +// ExampleExpandPath demonstrates expanding a user-facing path. +func ExampleExpandPath() { + expanded := fsx.ExpandPath("~/config.yaml") + fmt.Println(filepath.IsAbs(expanded)) + + // Output: + // true +} + +// ExampleIsWithin demonstrates a containment check before deleting a file. +func ExampleIsWithin() { + base := filepath.Join(os.TempDir(), "workspace") + target := filepath.Join(base, "page.md") + + fmt.Println(fsx.IsWithin(base, target)) + fmt.Println(fsx.IsWithin(base, filepath.Join(base, "..", "escape.md"))) + + // Output: + // true + // false +} + +// ExampleHasExtension demonstrates extension matching. +func ExampleHasExtension() { + fmt.Println(fsx.HasExtension("config.YAML", "yaml", "json")) + fmt.Println(fsx.HasExtension("README", "md")) + + // Output: + // true + // false +} + +// ExampleWriteFileAtomic demonstrates an atomic file replacement. +func ExampleWriteFileAtomic() { + dir, err := os.MkdirTemp("", "fsx-example-*") + if err != nil { + log.Fatal(err) + } + defer func() { + if err := os.RemoveAll(dir); err != nil { + log.Fatal(err) + } + }() + + path := filepath.Join(dir, "config.txt") + if err := fsx.WriteFileAtomic(path, []byte("ready"), 0o600); err != nil { + log.Fatal(err) + } + + data, err := os.ReadFile(path) + if err != nil { + log.Fatal(err) + } + fmt.Println(string(data)) + + // Output: + // ready +} diff --git a/fsx.go b/fsx.go new file mode 100644 index 0000000..98b650b --- /dev/null +++ b/fsx.go @@ -0,0 +1,191 @@ +package fsx + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// ExpandPath expands a user-facing filesystem path. +// +// It replaces a leading ~ with the current user's home directory and replaces +// all $HOME references with the HOME environment variable. Empty paths are +// returned unchanged. If the home directory cannot be determined, the original +// path is returned unchanged. +func ExpandPath(path string) string { + if path == "" { + return path + } + + if strings.HasPrefix(path, "~") { + homeDir, err := os.UserHomeDir() + if err != nil { + return path + } + + return homeDir + path[1:] + } + + if strings.Contains(path, "$HOME") { + homeDir := os.Getenv("HOME") + if homeDir != "" { + return strings.ReplaceAll(path, "$HOME", homeDir) + } + } + + return path +} + +// Exists reports whether a file, directory, or other filesystem entry exists. +// +// The path is expanded with [ExpandPath] before it is checked. Empty paths and +// paths that cannot be statted return false. +func Exists(path string) bool { + if path == "" { + return false + } + + _, err := os.Stat(ExpandPath(path)) + return err == nil +} + +// IsDir reports whether path exists and is a directory. +// +// The path is expanded with [ExpandPath] before it is checked. +func IsDir(path string) bool { + if path == "" { + return false + } + + info, err := os.Stat(ExpandPath(path)) + if err != nil { + return false + } + + return info.IsDir() +} + +// IsFile reports whether path exists and is a regular file. +// +// The path is expanded with [ExpandPath] before it is checked. +func IsFile(path string) bool { + if path == "" { + return false + } + + info, err := os.Stat(ExpandPath(path)) + if err != nil { + return false + } + + return info.Mode().IsRegular() +} + +// IsWithin reports whether target resolves to a path contained inside base. +// +// Both paths are expanded, cleaned, and made absolute before comparison. The +// function returns false when either path is empty, either path cannot be +// resolved, or the relative path from base to target escapes base with "..". +// A target equal to base is considered within base. +func IsWithin(base, target string) bool { + if base == "" || target == "" { + return false + } + + absBase, err := filepath.Abs(ExpandPath(base)) + if err != nil { + return false + } + absTarget, err := filepath.Abs(ExpandPath(target)) + if err != nil { + return false + } + + rel, err := filepath.Rel(absBase, absTarget) + if err != nil { + return false + } + + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +// HasExtension reports whether path has one of the provided extensions. +// +// Extension matching is case-insensitive. Extensions may be passed with or +// without a leading dot. The path does not need to exist. +func HasExtension(path string, extensions ...string) bool { + if path == "" || len(extensions) == 0 { + return false + } + + ext := strings.ToLower(filepath.Ext(path)) + if ext == "" { + return false + } + + for _, candidate := range extensions { + candidate = strings.TrimSpace(candidate) + if candidate == "" { + continue + } + if !strings.HasPrefix(candidate, ".") { + candidate = "." + candidate + } + if ext == strings.ToLower(candidate) { + return true + } + } + + return false +} + +// Dir returns the directory component of path after expanding it with +// [ExpandPath]. +func Dir(path string) string { + return filepath.Dir(ExpandPath(path)) +} + +// WriteFileAtomic writes data to path by replacing it with a completed temporary +// file in the same directory. +// +// The destination directory must already exist. The temporary file is created in +// that directory, written, closed, chmodded to perm, and renamed to path. If any +// step fails before the rename, the temporary file is removed. +func WriteFileAtomic(path string, data []byte, perm os.FileMode) error { + if path == "" { + return fmt.Errorf("path is empty") + } + + path = ExpandPath(path) + dir := filepath.Dir(path) + tempFile, err := os.CreateTemp(dir, ".fsx-*") + if err != nil { + return fmt.Errorf("create temporary file: %w", err) + } + + tempPath := tempFile.Name() + removeTemp := true + defer func() { + if removeTemp { + _ = os.Remove(tempPath) + } + }() + + if _, err := tempFile.Write(data); err != nil { + _ = tempFile.Close() + return fmt.Errorf("write temporary file: %w", err) + } + if err := tempFile.Close(); err != nil { + return fmt.Errorf("close temporary file: %w", err) + } + if err := os.Chmod(tempPath, perm); err != nil { + return fmt.Errorf("set temporary file permissions: %w", err) + } + if err := os.Rename(tempPath, path); err != nil { + return fmt.Errorf("rename temporary file: %w", err) + } + + removeTemp = false + return nil +} diff --git a/fsx_test.go b/fsx_test.go new file mode 100644 index 0000000..decdff0 --- /dev/null +++ b/fsx_test.go @@ -0,0 +1,483 @@ +package fsx + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestExpandPath(t *testing.T) { + homeDir, err := os.UserHomeDir() + if err != nil { + t.Fatalf("os.UserHomeDir() error = %v", err) + } + + t.Setenv("HOME", homeDir) + + tests := []struct { + name string + path string + want string + }{ + { + name: "empty path", + path: "", + want: "", + }, + { + name: "tilde only", + path: "~", + want: homeDir, + }, + { + name: "tilde prefix", + path: "~/config.yaml", + want: filepath.Join(homeDir, "config.yaml"), + }, + { + name: "HOME only", + path: "$HOME", + want: homeDir, + }, + { + name: "HOME prefix", + path: "$HOME/config.yaml", + want: filepath.Join(homeDir, "config.yaml"), + }, + { + name: "multiple HOME references", + path: "$HOME/$HOME/test", + want: homeDir + string(filepath.Separator) + homeDir + string(filepath.Separator) + "test", + }, + { + name: "plain absolute path", + path: filepath.FromSlash("/etc/config.yaml"), + want: filepath.FromSlash("/etc/config.yaml"), + }, + { + name: "relative path", + path: filepath.FromSlash("config/file.yaml"), + want: filepath.FromSlash("config/file.yaml"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ExpandPath(tt.path); got != tt.want { + t.Errorf("ExpandPath(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestExpandPathKeepsHOMEWhenUnset(t *testing.T) { + t.Setenv("HOME", "") + + const path = "$HOME/config.yaml" + if got := ExpandPath(path); got != path { + t.Errorf("ExpandPath(%q) = %q, want original path", path, got) + } +} + +func TestExists(t *testing.T) { + tmpDir := t.TempDir() + file := filepath.Join(tmpDir, "test.txt") + if err := os.WriteFile(file, []byte("content"), 0o644); err != nil { + t.Fatalf("os.WriteFile() error = %v", err) + } + + tests := []struct { + name string + path string + want bool + }{ + { + name: "empty path", + path: "", + want: false, + }, + { + name: "existing file", + path: file, + want: true, + }, + { + name: "existing directory", + path: tmpDir, + want: true, + }, + { + 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 := Exists(tt.path); got != tt.want { + t.Errorf("Exists(%q) = %t, want %t", tt.path, got, tt.want) + } + }) + } +} + +func TestIsDir(t *testing.T) { + tmpDir := t.TempDir() + file := filepath.Join(tmpDir, "test.txt") + if err := os.WriteFile(file, []byte("content"), 0o644); err != nil { + t.Fatalf("os.WriteFile() error = %v", err) + } + + tests := []struct { + name string + path string + want bool + }{ + { + name: "empty path", + path: "", + want: false, + }, + { + name: "directory", + path: tmpDir, + want: true, + }, + { + name: "file", + path: file, + want: false, + }, + { + name: "missing path", + path: filepath.Join(tmpDir, "missing"), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsDir(tt.path); got != tt.want { + t.Errorf("IsDir(%q) = %t, want %t", tt.path, got, tt.want) + } + }) + } +} + +func TestIsFile(t *testing.T) { + tmpDir := t.TempDir() + file := filepath.Join(tmpDir, "test.txt") + if err := os.WriteFile(file, []byte("content"), 0o644); err != nil { + t.Fatalf("os.WriteFile() error = %v", err) + } + + tests := []struct { + name string + path string + want bool + }{ + { + name: "empty path", + path: "", + want: false, + }, + { + name: "regular file", + path: file, + want: true, + }, + { + 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 := IsFile(tt.path); got != tt.want { + t.Errorf("IsFile(%q) = %t, want %t", tt.path, got, tt.want) + } + }) + } +} + +func TestIsWithin(t *testing.T) { + tmpDir := t.TempDir() + base := filepath.Join(tmpDir, "base") + if err := os.MkdirAll(filepath.Join(base, "nested"), 0o755); err != nil { + t.Fatalf("os.MkdirAll() error = %v", err) + } + + tests := []struct { + name string + base string + target string + want bool + }{ + { + name: "same path", + base: base, + target: base, + want: true, + }, + { + name: "file directly inside base", + base: base, + target: filepath.Join(base, "file.txt"), + want: true, + }, + { + name: "nested file inside base", + base: base, + target: filepath.Join(base, "nested", "file.txt"), + want: true, + }, + { + name: "sibling path outside base", + base: base, + target: filepath.Join(tmpDir, "sibling", "file.txt"), + want: false, + }, + { + name: "parent traversal outside base", + base: base, + target: filepath.Join(base, "..", "sibling", "file.txt"), + want: false, + }, + { + name: "empty base", + base: "", + target: filepath.Join(base, "file.txt"), + want: false, + }, + { + name: "empty target", + base: base, + target: "", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsWithin(tt.base, tt.target); got != tt.want { + t.Errorf("IsWithin(%q, %q) = %t, want %t", tt.base, tt.target, got, tt.want) + } + }) + } +} + +func TestHasExtension(t *testing.T) { + tests := []struct { + name string + path string + extensions []string + want bool + }{ + { + name: "empty path", + path: "", + extensions: []string{"txt"}, + want: false, + }, + { + name: "no extensions", + path: "file.txt", + extensions: nil, + want: false, + }, + { + name: "extension without dot", + path: "file.txt", + extensions: []string{"txt"}, + want: true, + }, + { + name: "extension with dot", + path: "file.txt", + extensions: []string{".txt"}, + want: true, + }, + { + name: "case insensitive", + path: "file.YAML", + extensions: []string{"yaml"}, + want: true, + }, + { + name: "one of many extensions", + path: "file.md", + extensions: []string{"txt", "md"}, + want: true, + }, + { + name: "mismatch", + path: "file.md", + extensions: []string{"txt", "yaml"}, + want: false, + }, + { + name: "path without extension", + path: "Makefile", + extensions: []string{"txt"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := HasExtension(tt.path, tt.extensions...); got != tt.want { + t.Errorf("HasExtension(%q, %q) = %t, want %t", tt.path, tt.extensions, got, tt.want) + } + }) + } +} + +func TestDir(t *testing.T) { + homeDir, err := os.UserHomeDir() + if err != nil { + t.Fatalf("os.UserHomeDir() error = %v", err) + } + + t.Setenv("HOME", homeDir) + + tests := []struct { + name string + path string + want string + }{ + { + name: "absolute file", + path: filepath.FromSlash("/etc/config/file.yaml"), + want: filepath.FromSlash("/etc/config"), + }, + { + name: "relative file", + path: filepath.FromSlash("config/file.yaml"), + want: "config", + }, + { + name: "tilde path", + path: "~/config/file.yaml", + want: filepath.Join(homeDir, "config"), + }, + { + name: "HOME path", + path: "$HOME/config/file.yaml", + want: filepath.Join(homeDir, "config"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Dir(tt.path); got != tt.want { + t.Errorf("Dir(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestWriteFileAtomic(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "data.txt") + data := []byte("atomic content") + + if err := WriteFileAtomic(path, data, 0o600); err != nil { + t.Fatalf("WriteFileAtomic() error = %v", err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("os.ReadFile() error = %v", err) + } + if string(got) != string(data) { + t.Errorf("os.ReadFile() = %q, want %q", got, data) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("os.Stat() error = %v", err) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { + t.Errorf("file permissions = %o, want 600", info.Mode().Perm()) + } +} + +func TestWriteFileAtomicReplacesExistingFile(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "data.txt") + if err := os.WriteFile(path, []byte("old"), 0o644); err != nil { + t.Fatalf("os.WriteFile() error = %v", err) + } + + if err := WriteFileAtomic(path, []byte("new"), 0o644); err != nil { + t.Fatalf("WriteFileAtomic() error = %v", err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("os.ReadFile() error = %v", err) + } + if string(got) != "new" { + t.Errorf("os.ReadFile() = %q, want %q", got, "new") + } +} + +func TestWriteFileAtomicEmptyPath(t *testing.T) { + err := WriteFileAtomic("", []byte("content"), 0o644) + if err == nil { + t.Fatal("WriteFileAtomic() error = nil, want non-nil error") + } + if !strings.Contains(err.Error(), "path is empty") { + t.Errorf("WriteFileAtomic() error = %v, want empty path error", err) + } +} + +func TestWriteFileAtomicMissingDirectory(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing", "data.txt") + err := WriteFileAtomic(path, []byte("content"), 0o644) + if err == nil { + t.Fatal("WriteFileAtomic() error = nil, want non-nil error") + } + if !errors.Is(err, os.ErrNotExist) { + t.Errorf("WriteFileAtomic() error = %v, want os.ErrNotExist", err) + } +} + +func BenchmarkExpandPath(b *testing.B) { + for b.Loop() { + ExpandPath("~/config.yaml") + } +} + +func BenchmarkExists(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() { + Exists(path) + } +} + +func BenchmarkWriteFileAtomic(b *testing.B) { + path := filepath.Join(b.TempDir(), "data.txt") + data := []byte("content") + + for b.Loop() { + if err := WriteFileAtomic(path, data, 0o644); err != nil { + b.Fatalf("WriteFileAtomic() error = %v", err) + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8d69c3c --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/slashdevops/fsx + +go 1.26.3