gopass/pkg/fsutil/fsutil.go
google-labs-jules[bot] 86720090b6
docs: Add GoDoc to pkg and improve markdown files (#3251)
This change adds GoDoc comments to many of the public symbols in the
`pkg/` directory. It also includes various improvements to the
documentation in `README.md` and other markdown files in the `docs/`
directory.

This is a partial documentation effort, as requested by the user, to
get a pull request submitted quickly.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-09-22 19:37:15 +02:00

292 lines
6.7 KiB
Go

// Package fsutil provides some common file system utilities
// for gopass. It is used to handle file paths, directories, and files.
package fsutil
import (
"bufio"
"crypto/rand"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/gopasspw/gopass/pkg/appdir"
"github.com/gopasspw/gopass/pkg/debug"
)
var reCleanFilename = regexp.MustCompile(`[^\w\d@.-]`)
// CleanFilename strips all possibly suspicious characters from a filename.
// WARNING: NOT suitable for pathnames as slashes will be stripped as well!
func CleanFilename(in string) string {
return strings.Trim(reCleanFilename.ReplaceAllString(in, "_"), "_ ")
}
// ExpandHomedir expands the tilde to the users home dir (if present).
func ExpandHomedir(path string) string {
if len(path) > 1 && path[:2] == "~/" {
dir := filepath.Clean(appdir.UserHome() + path[1:])
debug.V(1).Log("Expanding %s to %s", path, dir)
return dir
}
debug.V(2).Log("No tilde found in %s", path)
return path
}
// CleanPath resolves common aliases in a path and cleans it as much as possible.
// It expands the tilde to the user's home directory and resolves relative paths.
func CleanPath(path string) string {
// Replace ~ with GOPASS_HOMEDIR if set (mainly for testing and experiments),
// otherwise replace ~ with user's homedir if set. We expect any reference
// to the user's homedir to be replaced with one of these two values.
if len(path) > 1 && path[:2] == "~/" {
if hd := os.Getenv("GOPASS_HOMEDIR"); hd != "" {
return filepath.Clean(hd + path[2:])
}
if home, err := os.UserHomeDir(); err == nil {
return filepath.Clean(home + path[1:])
}
}
if p, err := filepath.Abs(path); err == nil && !strings.HasPrefix(path, "~") {
return p
}
return filepath.Clean(path)
}
// IsDir checks if a certain path exists and is a directory.
func IsDir(path string) bool {
fi, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
// not found
return false
}
debug.Log("failed to check dir %s: %s\n", path, err)
return false
}
return fi.IsDir()
}
// IsFile checks if a certain path is actually a file.
func IsFile(path string) bool {
fi, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
// not found
return false
}
debug.Log("failed to check file %s: %s\n", path, err)
return false
}
return fi.Mode().IsRegular()
}
// IsNonEmptyFile checks if a certain path is a regular file and
// non-zero in size.
func IsNonEmptyFile(path string) bool {
fi, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
// not found
return false
}
debug.Log("failed to check file %s: %s\n", path, err)
return false
}
if !fi.Mode().IsRegular() {
return false
}
return fi.Size() > 0
}
// IsEmptyDir checks if a certain path is an empty directory.
func IsEmptyDir(path string) (bool, error) {
empty := true
if err := filepath.Walk(path, func(fp string, fi os.FileInfo, ferr error) error {
if ferr != nil {
return ferr
}
if fi.IsDir() && (fi.Name() == "." || fi.Name() == "..") {
return filepath.SkipDir
}
if !fi.IsDir() {
empty = false
}
return nil
}); err != nil {
return false, fmt.Errorf("failed to walk %s: %w", path, err)
}
return empty, nil
}
// Shred overwrites the given file with random data and deletes it.
// The file is overwritten `runs` times. The last run is with zeros.
func Shred(path string, runs int) error {
fh, err := os.OpenFile(path, os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("failed to open file %q: %w", path, err)
}
// ignore the error. this is only taking effect if we error out.
defer func() {
_ = fh.Close()
}()
fi, err := fh.Stat()
if err != nil {
return fmt.Errorf("failed to stat file %q: %w", path, err)
}
flen := fi.Size()
// overwrite using pseudo-random data n-1 times and
// use zeros in the last iteration
bufFn := func() []byte {
buf := make([]byte, 1024)
_, _ = rand.Read(buf)
return buf
}
for i := range runs {
if i >= runs-1 {
bufFn = func() []byte {
return make([]byte, 1024)
}
}
if _, err := fh.Seek(0, 0); err != nil {
return fmt.Errorf("failed to seek to 0,0: %w", err)
}
var written int64
for written < flen {
buf := bufFn()
n, err := fh.Write(buf[0:min(flen-written, int64(len(buf)))])
if err != nil {
if !errors.Is(err, io.EOF) {
return fmt.Errorf("failed to write to file: %w", err)
}
// end of file, should not happen
break
}
written += int64(n)
}
// if we fail to sync the written blocks to disk it'd be pointless
// do any further loops
if err := fh.Sync(); err != nil {
return fmt.Errorf("failed to sync to disk: %w", err)
}
}
if err := fh.Close(); err != nil {
return fmt.Errorf("failed to close file after writing: %w", err)
}
if err := os.Remove(path); err != nil {
return fmt.Errorf("failed to remove %s: %w", path, err)
}
return nil
}
// FileContains searches the given file for the search string and returns true
// iff it's an exact (substring) match.
func FileContains(path, needle string) bool {
fh, err := os.Open(path)
if err != nil {
debug.Log("failed to open %q for reading: %s", path, err)
return false
}
defer func() {
_ = fh.Close()
}()
s := bufio.NewScanner(fh)
for s.Scan() {
if strings.Contains(s.Text(), needle) {
return true
}
}
return false
}
// CopyFile copies a file from src to dst. Permissions will be preserved. It is expected to
// fail if the destination does exist but is not writeable.
func CopyFile(from, to string) error {
rdr, err := os.Open(from)
if err != nil {
return fmt.Errorf("failed to open file %q for reading: %w", from, err)
}
defer func() {
_ = rdr.Close()
}()
rdrStat, err := rdr.Stat()
if err != nil {
return fmt.Errorf("failed to stat open file %q: %w", from, err)
}
wrt, err := os.OpenFile(to, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, rdrStat.Mode())
if err != nil {
return fmt.Errorf("failed to open file %q for writing: %w", to, err)
}
defer func() {
_ = wrt.Close()
}()
n, err := io.Copy(wrt, rdr)
if err != nil {
return fmt.Errorf("failed to copy content of %q to %q: %w", from, to, err)
}
debug.Log("copied %d bytes from %q to %q", n, from, to)
// sync permission, applies in case the destination did exist but had different perms
if err := os.Chmod(to, rdrStat.Mode()); err != nil {
return fmt.Errorf("failed to sync permissions to %q: %w", to, err)
}
return nil
}
// CopyFileForce copies a file from src to dst. Permissions will be preserved. The destination
// is removed before copying to avoid permission issues.
func CopyFileForce(from, to string) error {
if IsFile(to) {
if err := os.Remove(to); err != nil {
return fmt.Errorf("failed to remove %q: %w", to, err)
}
}
return CopyFile(from, to)
}