feature/taildrop: do not use m.opts.Dir for Android (#16316)
In Android, we are prompting the user to select a Taildrop directory when they first receive a Taildrop: we block writes on Taildrop dir selection. This means that we cannot use Dir inside managerOptions, since the http request would not get the new Taildrop extension. This PR removes, in the Android case, the reliance on m.opts.Dir, and instead has FileOps hold the correct directory. This expands FileOps to be the Taildrop interface for all file system operations. Updates tailscale/corp#29211 Signed-off-by: kari-ts <kari@tailscale.com> restore tstestmain
parent
5865d0a61a
commit
d897d809d6
@ -0,0 +1,41 @@ |
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package taildrop |
||||||
|
|
||||||
|
import ( |
||||||
|
"io" |
||||||
|
"io/fs" |
||||||
|
"os" |
||||||
|
) |
||||||
|
|
||||||
|
// FileOps abstracts over both local‐FS paths and Android SAF URIs.
|
||||||
|
type FileOps interface { |
||||||
|
// OpenWriter creates or truncates a file named relative to the receiver's root,
|
||||||
|
// seeking to the specified offset. If the file does not exist, it is created with mode perm
|
||||||
|
// on platforms that support it.
|
||||||
|
//
|
||||||
|
// It returns an [io.WriteCloser] and the file's absolute path, or an error.
|
||||||
|
// This call may block. Callers should avoid holding locks when calling OpenWriter.
|
||||||
|
OpenWriter(name string, offset int64, perm os.FileMode) (wc io.WriteCloser, path string, err error) |
||||||
|
|
||||||
|
// Remove deletes a file or directory relative to the receiver's root.
|
||||||
|
// It returns [io.ErrNotExist] if the file or directory does not exist.
|
||||||
|
Remove(name string) error |
||||||
|
|
||||||
|
// Rename atomically renames oldPath to a new file named newName,
|
||||||
|
// returning the full new path or an error.
|
||||||
|
Rename(oldPath, newName string) (newPath string, err error) |
||||||
|
|
||||||
|
// ListFiles returns just the basenames of all regular files
|
||||||
|
// in the root directory.
|
||||||
|
ListFiles() ([]string, error) |
||||||
|
|
||||||
|
// Stat returns the FileInfo for the given name or an error.
|
||||||
|
Stat(name string) (fs.FileInfo, error) |
||||||
|
|
||||||
|
// OpenReader opens the given basename for the given name or an error.
|
||||||
|
OpenReader(name string) (io.ReadCloser, error) |
||||||
|
} |
||||||
|
|
||||||
|
var newFileOps func(dir string) (FileOps, error) |
||||||
@ -0,0 +1,221 @@ |
|||||||
|
// 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 |
||||||
|
} |
||||||
Loading…
Reference in new issue