Files
tailscale/feature/taildrop/fileops_fs.go
Brad Fitzpatrick bd2a2d53d3 all: use Go 1.26 things, run most gofix modernizers
I omitted a lot of the min/max modernizers because they didn't
result in more clear code.

Some of it's older "for x := range 123".

Also: errors.AsType, any, fmt.Appendf, etc.

Updates #18682

Change-Id: I83a451577f33877f962766a5b65ce86f7696471c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-03-06 13:32:03 -08:00

222 lines
5.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !android
package taildrop
import (
"bytes"
"crypto/sha256"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"sync"
"unicode/utf8"
)
var renameMu sync.Mutex
// fsFileOps implements FileOps using the local filesystem rooted at a directory.
// It is used on non-Android platforms.
type fsFileOps struct{ rootDir string }
func init() {
newFileOps = func(dir string) (FileOps, error) {
if dir == "" {
return nil, errors.New("rootDir cannot be empty")
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("mkdir %q: %w", dir, err)
}
return fsFileOps{rootDir: dir}, nil
}
}
func (f fsFileOps) OpenWriter(name string, offset int64, perm os.FileMode) (io.WriteCloser, string, error) {
path, err := joinDir(f.rootDir, name)
if err != nil {
return nil, "", err
}
if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, "", err
}
fi, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, perm)
if err != nil {
return nil, "", err
}
if offset != 0 {
curr, err := fi.Seek(0, io.SeekEnd)
if err != nil {
fi.Close()
return nil, "", err
}
if offset < 0 || offset > curr {
fi.Close()
return nil, "", fmt.Errorf("offset %d out of range", offset)
}
if _, err := fi.Seek(offset, io.SeekStart); err != nil {
fi.Close()
return nil, "", err
}
if err := fi.Truncate(offset); err != nil {
fi.Close()
return nil, "", err
}
}
return fi, path, nil
}
func (f fsFileOps) Remove(name string) error {
path, err := joinDir(f.rootDir, name)
if err != nil {
return err
}
return os.Remove(path)
}
// Rename moves the partial file into its final name.
// newName must be a base name (not absolute or containing path separators).
// It will retry up to 10 times, de-dup same-checksum files, etc.
func (f fsFileOps) Rename(oldPath, newName string) (newPath string, err error) {
var dst string
if filepath.IsAbs(newName) || strings.ContainsRune(newName, os.PathSeparator) {
return "", fmt.Errorf("invalid newName %q: must not be an absolute path or contain path separators", newName)
}
dst = filepath.Join(f.rootDir, newName)
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
return "", err
}
st, err := os.Stat(oldPath)
if err != nil {
return "", err
}
wantSize := st.Size()
const maxRetries = 10
for range maxRetries {
renameMu.Lock()
fi, statErr := os.Stat(dst)
// Atomically rename the partial file as the destination file if it doesn't exist.
// Otherwise, it returns the length of the current destination file.
// The operation is atomic.
if os.IsNotExist(statErr) {
err = os.Rename(oldPath, dst)
renameMu.Unlock()
if err != nil {
return "", err
}
return dst, nil
}
if statErr != nil {
renameMu.Unlock()
return "", statErr
}
gotSize := fi.Size()
renameMu.Unlock()
// Avoid the final rename if a destination file has the same contents.
//
// Note: this is best effort and copying files from iOS from the Media Library
// results in processing on the iOS side which means the size and shas of the
// same file can be different.
if gotSize == wantSize {
sumP, err := sha256File(oldPath)
if err != nil {
return "", err
}
sumD, err := sha256File(dst)
if err != nil {
return "", err
}
if bytes.Equal(sumP[:], sumD[:]) {
if err := os.Remove(oldPath); err != nil {
return "", err
}
return dst, nil
}
}
// Choose a new destination filename and try again.
dst = filepath.Join(filepath.Dir(dst), nextFilename(filepath.Base(dst)))
}
return "", fmt.Errorf("too many retries trying to rename %q to %q", oldPath, newName)
}
// sha256File computes the SHA256 of a file.
func sha256File(path string) (sum [sha256.Size]byte, _ error) {
f, err := os.Open(path)
if err != nil {
return sum, err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return sum, err
}
copy(sum[:], h.Sum(nil))
return sum, nil
}
func (f fsFileOps) ListFiles() ([]string, error) {
entries, err := os.ReadDir(f.rootDir)
if err != nil {
return nil, err
}
var names []string
for _, e := range entries {
if e.Type().IsRegular() {
names = append(names, e.Name())
}
}
return names, nil
}
func (f fsFileOps) Stat(name string) (fs.FileInfo, error) {
path, err := joinDir(f.rootDir, name)
if err != nil {
return nil, err
}
return os.Stat(path)
}
func (f fsFileOps) OpenReader(name string) (io.ReadCloser, error) {
path, err := joinDir(f.rootDir, name)
if err != nil {
return nil, err
}
return os.Open(path)
}
// joinDir is like [filepath.Join] but returns an error if baseName is too long,
// is a relative path instead of a basename, or is otherwise invalid or unsafe for incoming files.
func joinDir(dir, baseName string) (string, error) {
if !utf8.ValidString(baseName) ||
strings.TrimSpace(baseName) != baseName ||
len(baseName) > 255 {
return "", ErrInvalidFileName
}
// TODO: validate unicode normalization form too? Varies by platform.
clean := path.Clean(baseName)
if clean != baseName || clean == "." || clean == ".." {
return "", ErrInvalidFileName
}
for _, r := range baseName {
if !validFilenameRune(r) {
return "", ErrInvalidFileName
}
}
if !filepath.IsLocal(baseName) {
return "", ErrInvalidFileName
}
return filepath.Join(dir, baseName), nil
}