mirror of
https://github.com/tailscale/tailscale.git
synced 2025-08-19 17:37:32 +00:00
222 lines
5.4 KiB
Go
222 lines
5.4 KiB
Go
![]() |
// Copyright (c) Tailscale Inc & AUTHORS
|
|||
|
// 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 i := 0; i < maxRetries; i++ {
|
|||
|
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 SHA‑256 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
|
|||
|
}
|