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 tstest
This commit is contained in:
kari-ts
2025-08-01 15:10:00 -07:00
committed by GitHub
parent 5865d0a61a
commit d897d809d6
14 changed files with 561 additions and 602 deletions
+39 -235
View File
@@ -4,11 +4,8 @@
package taildrop
import (
"crypto/sha256"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"time"
@@ -73,9 +70,10 @@ func (f *incomingFile) Write(p []byte) (n int, err error) {
// specific partial file. This allows the client to determine whether to resume
// a partial file. While resuming, PutFile may be called again with a non-zero
// offset to specify where to resume receiving data at.
func (m *manager) PutFile(id clientID, baseName string, r io.Reader, offset, length int64) (int64, error) {
func (m *manager) PutFile(id clientID, baseName string, r io.Reader, offset, length int64) (fileLength int64, err error) {
switch {
case m == nil || m.opts.Dir == "":
case m == nil || m.opts.fileOps == nil:
return 0, ErrNoTaildrop
case !envknob.CanTaildrop():
return 0, ErrNoTaildrop
@@ -83,48 +81,48 @@ func (m *manager) PutFile(id clientID, baseName string, r io.Reader, offset, len
return 0, ErrNotAccessible
}
//Compute dstPath & avoid midupload deletion
var dstPath string
if m.opts.Mode == PutModeDirect {
var err error
dstPath, err = joinDir(m.opts.Dir, baseName)
if err != nil {
return 0, err
}
} else {
// In SAF mode, we simply use the baseName as the destination "path"
// (the actual directory is managed by SAF).
dstPath = baseName
if err := validateBaseName(baseName); err != nil {
return 0, err
}
m.deleter.Remove(filepath.Base(dstPath)) // avoid deleting the partial file while receiving
// Check whether there is an in-progress transfer for the file.
partialFileKey := incomingFileKey{id, baseName}
inFile, loaded := m.incomingFiles.LoadOrInit(partialFileKey, func() *incomingFile {
return &incomingFile{
clock: m.opts.Clock,
started: m.opts.Clock.Now(),
size: length,
sendFileNotify: m.opts.SendFileNotify,
}
})
if loaded {
return 0, ErrFileExists
}
defer m.incomingFiles.Delete(partialFileKey)
// and make sure we don't delete it while uploading:
m.deleter.Remove(baseName)
// Open writer & populate inFile paths
wc, partialPath, err := m.openWriterAndPaths(id, m.opts.Mode, inFile, baseName, dstPath, offset)
// Create (if not already) the partial file with read-write permissions.
partialName := baseName + id.partialSuffix()
wc, partialPath, err := m.opts.fileOps.OpenWriter(partialName, offset, 0o666)
if err != nil {
return 0, m.redactAndLogError("Create", err)
}
defer func() {
wc.Close()
if err != nil {
m.deleter.Insert(filepath.Base(partialPath)) // mark partial file for eventual deletion
m.deleter.Insert(partialName) // mark partial file for eventual deletion
}
}()
// Check whether there is an in-progress transfer for the file.
inFileKey := incomingFileKey{id, baseName}
inFile, loaded := m.incomingFiles.LoadOrInit(inFileKey, func() *incomingFile {
inFile := &incomingFile{
clock: m.opts.Clock,
started: m.opts.Clock.Now(),
size: length,
sendFileNotify: m.opts.SendFileNotify,
}
if m.opts.DirectFileMode {
inFile.partialPath = partialPath
}
return inFile
})
inFile.w = wc
if loaded {
return 0, ErrFileExists
}
defer m.incomingFiles.Delete(inFileKey)
// Record that we have started to receive at least one file.
// This is used by the deleter upon a cold-start to scan the directory
// for any files that need to be deleted.
@@ -148,220 +146,26 @@ func (m *manager) PutFile(id clientID, baseName string, r io.Reader, offset, len
return 0, m.redactAndLogError("Close", err)
}
fileLength := offset + copyLength
fileLength = offset + copyLength
inFile.mu.Lock()
inFile.done = true
inFile.mu.Unlock()
// Finalize rename
switch m.opts.Mode {
case PutModeDirect:
var finalDst string
finalDst, err = m.finalizeDirect(inFile, partialPath, dstPath, fileLength)
if err != nil {
return 0, m.redactAndLogError("Rename", err)
}
inFile.finalPath = finalDst
case PutModeAndroidSAF:
if err = m.finalizeSAF(partialPath, baseName); err != nil {
return 0, m.redactAndLogError("Rename", err)
}
// 6) Finalize (rename/move) the partial into place via FileOps.Rename
finalPath, err := m.opts.fileOps.Rename(partialPath, baseName)
if err != nil {
return 0, m.redactAndLogError("Rename", err)
}
inFile.finalPath = finalPath
m.totalReceived.Add(1)
m.opts.SendFileNotify()
return fileLength, nil
}
// openWriterAndPaths opens the correct writer, seeks/truncates if needed,
// and sets inFile.partialPath & inFile.finalPath for later cleanup/rename.
// The caller is responsible for closing the file on completion.
func (m *manager) openWriterAndPaths(
id clientID,
mode PutMode,
inFile *incomingFile,
baseName string,
dstPath string,
offset int64,
) (wc io.WriteCloser, partialPath string, err error) {
switch mode {
case PutModeDirect:
partialPath = dstPath + id.partialSuffix()
f, err := os.OpenFile(partialPath, os.O_CREATE|os.O_RDWR, 0o666)
if err != nil {
return nil, "", m.redactAndLogError("Create", err)
}
if offset != 0 {
curr, err := f.Seek(0, io.SeekEnd)
if err != nil {
f.Close()
return nil, "", m.redactAndLogError("Seek", err)
}
if offset < 0 || offset > curr {
f.Close()
return nil, "", m.redactAndLogError("Seek", fmt.Errorf("offset %d out of range", offset))
}
if _, err := f.Seek(offset, io.SeekStart); err != nil {
f.Close()
return nil, "", m.redactAndLogError("Seek", err)
}
if err := f.Truncate(offset); err != nil {
f.Close()
return nil, "", m.redactAndLogError("Truncate", err)
}
}
inFile.w = f
wc = f
inFile.partialPath = partialPath
inFile.finalPath = dstPath
return wc, partialPath, nil
case PutModeAndroidSAF:
if m.opts.FileOps == nil {
return nil, "", m.redactAndLogError("Create (SAF)", fmt.Errorf("missing FileOps"))
}
writer, uri, err := m.opts.FileOps.OpenFileWriter(baseName)
if err != nil {
return nil, "", m.redactAndLogError("Create (SAF)", fmt.Errorf("failed to open file for writing via SAF"))
}
if writer == nil || uri == "" {
return nil, "", fmt.Errorf("invalid SAF writer or URI")
}
// SAF mode does not support resuming, so enforce offset == 0.
if offset != 0 {
writer.Close()
return nil, "", m.redactAndLogError("Seek", fmt.Errorf("resuming is not supported in SAF mode"))
}
inFile.w = writer
wc = writer
partialPath = uri
inFile.partialPath = uri
inFile.finalPath = baseName
return wc, partialPath, nil
default:
return nil, "", fmt.Errorf("unsupported PutMode: %v", mode)
}
}
// finalizeDirect atomically renames or dedups the partial file, retrying
// under new names up to 10 times. It returns the final path that succeeded.
func (m *manager) finalizeDirect(
inFile *incomingFile,
partialPath string,
initialDst string,
fileLength int64,
) (string, error) {
var (
once sync.Once
cachedSum [sha256.Size]byte
cacheErr error
computeSum = func() ([sha256.Size]byte, error) {
once.Do(func() { cachedSum, cacheErr = sha256File(partialPath) })
return cachedSum, cacheErr
}
)
dstPath := initialDst
const maxRetries = 10
for i := 0; i < maxRetries; i++ {
// 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.
lengthOnDisk, err := func() (int64, error) {
m.renameMu.Lock()
defer m.renameMu.Unlock()
fi, statErr := os.Stat(dstPath)
if os.IsNotExist(statErr) {
// dst missing → rename partial into place
return -1, os.Rename(partialPath, dstPath)
}
if statErr != nil {
return -1, statErr
}
return fi.Size(), nil
}()
if err != nil {
return "", err
}
if lengthOnDisk < 0 {
// successfully moved
inFile.finalPath = dstPath
return dstPath, nil
}
// 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 lengthOnDisk == fileLength {
partSum, err := computeSum()
if err != nil {
return "", err
}
dstSum, err := sha256File(dstPath)
if err != nil {
return "", err
}
if partSum == dstSum {
// same content → drop the partial
if err := os.Remove(partialPath); err != nil {
return "", err
}
inFile.finalPath = dstPath
return dstPath, nil
}
}
// Choose a new destination filename and try again.
dstPath = nextFilename(dstPath)
}
return "", fmt.Errorf("too many retries trying to rename a partial file %q", initialDst)
}
// finalizeSAF retries RenamePartialFile up to 10 times, generating a new
// name on each failure until the SAF URI changes.
func (m *manager) finalizeSAF(
partialPath, finalName string,
) error {
if m.opts.FileOps == nil {
return fmt.Errorf("missing FileOps for SAF finalize")
}
const maxTries = 10
name := finalName
for i := 0; i < maxTries; i++ {
newURI, err := m.opts.FileOps.RenamePartialFile(partialPath, m.opts.Dir, name)
if err != nil {
return err
}
if newURI != "" && newURI != name {
return nil
}
name = nextFilename(name)
}
return fmt.Errorf("failed to finalize SAF file after %d retries", maxTries)
}
func (m *manager) redactAndLogError(stage string, err error) error {
err = redactError(err)
m.opts.Logf("put %s error: %v", stage, err)
return err
}
func sha256File(file string) (out [sha256.Size]byte, err error) {
h := sha256.New()
f, err := os.Open(file)
if err != nil {
return out, err
}
defer f.Close()
if _, err := io.Copy(h, f); err != nil {
return out, err
}
return [sha256.Size]byte(h.Sum(nil)), nil
}