Skip to content

Commit d897d80

Browse files
authored
feature/taildrop: do not use m.opts.Dir for Android (tailscale#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 <[email protected]> restore tstest
1 parent 5865d0a commit d897d80

File tree

14 files changed

+555
-596
lines changed

14 files changed

+555
-596
lines changed

feature/taildrop/delete.go

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@ package taildrop
66
import (
77
"container/list"
88
"context"
9-
"io/fs"
109
"os"
11-
"path/filepath"
1210
"strings"
1311
"sync"
1412
"time"
@@ -28,7 +26,6 @@ const deleteDelay = time.Hour
2826
type fileDeleter struct {
2927
logf logger.Logf
3028
clock tstime.DefaultClock
31-
dir string
3229
event func(string) // called for certain events; for testing only
3330

3431
mu sync.Mutex
@@ -39,6 +36,7 @@ type fileDeleter struct {
3936
group syncs.WaitGroup
4037
shutdownCtx context.Context
4138
shutdown context.CancelFunc
39+
fs FileOps // must be used for all filesystem operations
4240
}
4341

4442
// deleteFile is a specific file to delete after deleteDelay.
@@ -50,15 +48,14 @@ type deleteFile struct {
5048
func (d *fileDeleter) Init(m *manager, eventHook func(string)) {
5149
d.logf = m.opts.Logf
5250
d.clock = m.opts.Clock
53-
d.dir = m.opts.Dir
5451
d.event = eventHook
52+
d.fs = m.opts.fileOps
5553

5654
d.byName = make(map[string]*list.Element)
5755
d.emptySignal = make(chan struct{})
5856
d.shutdownCtx, d.shutdown = context.WithCancel(context.Background())
5957

6058
// From a cold-start, load the list of partial and deleted files.
61-
//
6259
// Only run this if we have ever received at least one file
6360
// to avoid ever touching the taildrop directory on systems (e.g., MacOS)
6461
// that pop up a security dialog window upon first access.
@@ -71,38 +68,45 @@ func (d *fileDeleter) Init(m *manager, eventHook func(string)) {
7168
d.group.Go(func() {
7269
d.event("start full-scan")
7370
defer d.event("end full-scan")
74-
rangeDir(d.dir, func(de fs.DirEntry) bool {
71+
72+
if d.fs == nil {
73+
d.logf("deleter: nil FileOps")
74+
}
75+
76+
files, err := d.fs.ListFiles()
77+
if err != nil {
78+
d.logf("deleter: ListDir error: %v", err)
79+
return
80+
}
81+
for _, filename := range files {
7582
switch {
7683
case d.shutdownCtx.Err() != nil:
77-
return false // terminate early
78-
case !de.Type().IsRegular():
79-
return true
80-
case strings.HasSuffix(de.Name(), partialSuffix):
84+
return // terminate early
85+
case strings.HasSuffix(filename, partialSuffix):
8186
// Only enqueue the file for deletion if there is no active put.
82-
nameID := strings.TrimSuffix(de.Name(), partialSuffix)
87+
nameID := strings.TrimSuffix(filename, partialSuffix)
8388
if i := strings.LastIndexByte(nameID, '.'); i > 0 {
8489
key := incomingFileKey{clientID(nameID[i+len("."):]), nameID[:i]}
8590
m.incomingFiles.LoadFunc(key, func(_ *incomingFile, loaded bool) {
8691
if !loaded {
87-
d.Insert(de.Name())
92+
d.Insert(filename)
8893
}
8994
})
9095
} else {
91-
d.Insert(de.Name())
96+
d.Insert(filename)
9297
}
93-
case strings.HasSuffix(de.Name(), deletedSuffix):
98+
case strings.HasSuffix(filename, deletedSuffix):
9499
// Best-effort immediate deletion of deleted files.
95-
name := strings.TrimSuffix(de.Name(), deletedSuffix)
96-
if os.Remove(filepath.Join(d.dir, name)) == nil {
97-
if os.Remove(filepath.Join(d.dir, de.Name())) == nil {
98-
break
100+
name := strings.TrimSuffix(filename, deletedSuffix)
101+
if d.fs.Remove(name) == nil {
102+
if d.fs.Remove(filename) == nil {
103+
continue
99104
}
100105
}
101-
// Otherwise, enqueue the file for later deletion.
102-
d.Insert(de.Name())
106+
// Otherwise enqueue for later deletion.
107+
d.Insert(filename)
103108
}
104-
return true
105-
})
109+
}
106110
})
107111
}
108112

@@ -149,13 +153,13 @@ func (d *fileDeleter) waitAndDelete(wait time.Duration) {
149153

150154
// Delete the expired file.
151155
if name, ok := strings.CutSuffix(file.name, deletedSuffix); ok {
152-
if err := os.Remove(filepath.Join(d.dir, name)); err != nil && !os.IsNotExist(err) {
156+
if err := d.fs.Remove(name); err != nil && !os.IsNotExist(err) {
153157
d.logf("could not delete: %v", redactError(err))
154158
failed = append(failed, elem)
155159
continue
156160
}
157161
}
158-
if err := os.Remove(filepath.Join(d.dir, file.name)); err != nil && !os.IsNotExist(err) {
162+
if err := d.fs.Remove(file.name); err != nil && !os.IsNotExist(err) {
159163
d.logf("could not delete: %v", redactError(err))
160164
failed = append(failed, elem)
161165
continue

feature/taildrop/delete_test.go

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ package taildrop
55

66
import (
77
"os"
8-
"path/filepath"
98
"slices"
109
"testing"
1110
"time"
@@ -20,11 +19,20 @@ import (
2019

2120
func TestDeleter(t *testing.T) {
2221
dir := t.TempDir()
23-
must.Do(touchFile(filepath.Join(dir, "foo.partial")))
24-
must.Do(touchFile(filepath.Join(dir, "bar.partial")))
25-
must.Do(touchFile(filepath.Join(dir, "fizz")))
26-
must.Do(touchFile(filepath.Join(dir, "fizz.deleted")))
27-
must.Do(touchFile(filepath.Join(dir, "buzz.deleted"))) // lacks a matching "buzz" file
22+
var m manager
23+
var fd fileDeleter
24+
m.opts.Logf = t.Logf
25+
m.opts.Clock = tstime.DefaultClock{Clock: tstest.NewClock(tstest.ClockOpts{
26+
Start: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC),
27+
})}
28+
m.opts.State = must.Get(mem.New(nil, ""))
29+
m.opts.fileOps, _ = newFileOps(dir)
30+
31+
must.Do(m.touchFile("foo.partial"))
32+
must.Do(m.touchFile("bar.partial"))
33+
must.Do(m.touchFile("fizz"))
34+
must.Do(m.touchFile("fizz.deleted"))
35+
must.Do(m.touchFile("buzz.deleted")) // lacks a matching "buzz" file
2836

2937
checkDirectory := func(want ...string) {
3038
t.Helper()
@@ -69,12 +77,10 @@ func TestDeleter(t *testing.T) {
6977
}
7078
eventHook := func(event string) { eventsChan <- event }
7179

72-
var m manager
73-
var fd fileDeleter
7480
m.opts.Logf = t.Logf
7581
m.opts.Clock = tstime.DefaultClock{Clock: clock}
76-
m.opts.Dir = dir
7782
m.opts.State = must.Get(mem.New(nil, ""))
83+
m.opts.fileOps, _ = newFileOps(dir)
7884
must.Do(m.opts.State.WriteState(ipn.TaildropReceivedKey, []byte{1}))
7985
fd.Init(&m, eventHook)
8086
defer fd.Shutdown()
@@ -100,17 +106,17 @@ func TestDeleter(t *testing.T) {
100106
checkEvents("end waitAndDelete")
101107
checkDirectory()
102108

103-
must.Do(touchFile(filepath.Join(dir, "one.partial")))
109+
must.Do(m.touchFile("one.partial"))
104110
insert("one.partial")
105111
checkEvents("start waitAndDelete")
106112
advance(deleteDelay / 4)
107-
must.Do(touchFile(filepath.Join(dir, "two.partial")))
113+
must.Do(m.touchFile("two.partial"))
108114
insert("two.partial")
109115
advance(deleteDelay / 4)
110-
must.Do(touchFile(filepath.Join(dir, "three.partial")))
116+
must.Do(m.touchFile("three.partial"))
111117
insert("three.partial")
112118
advance(deleteDelay / 4)
113-
must.Do(touchFile(filepath.Join(dir, "four.partial")))
119+
must.Do(m.touchFile("four.partial"))
114120
insert("four.partial")
115121

116122
advance(deleteDelay / 4)
@@ -145,8 +151,8 @@ func TestDeleterInitWithoutTaildrop(t *testing.T) {
145151
var m manager
146152
var fd fileDeleter
147153
m.opts.Logf = t.Logf
148-
m.opts.Dir = t.TempDir()
149154
m.opts.State = must.Get(mem.New(nil, ""))
155+
m.opts.fileOps, _ = newFileOps(t.TempDir())
150156
fd.Init(&m, func(event string) { t.Errorf("unexpected event: %v", event) })
151157
fd.Shutdown()
152158
}

feature/taildrop/ext.go

Lines changed: 24 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import (
1010
"fmt"
1111
"io"
1212
"maps"
13-
"os"
1413
"path/filepath"
1514
"runtime"
1615
"slices"
@@ -75,7 +74,7 @@ type Extension struct {
7574

7675
// FileOps abstracts platform-specific file operations needed for file transfers.
7776
// This is currently being used for Android to use the Storage Access Framework.
78-
FileOps FileOps
77+
fileOps FileOps
7978

8079
nodeBackendForTest ipnext.NodeBackend // if non-nil, pretend we're this node state for tests
8180

@@ -89,30 +88,6 @@ type Extension struct {
8988
outgoingFiles map[string]*ipn.OutgoingFile
9089
}
9190

92-
// safDirectoryPrefix is used to determine if the directory is managed via SAF.
93-
const SafDirectoryPrefix = "content://"
94-
95-
// PutMode controls how Manager.PutFile writes files to storage.
96-
//
97-
// PutModeDirect – write files directly to a filesystem path (default).
98-
// PutModeAndroidSAF – use Android’s Storage Access Framework (SAF), where
99-
// the OS manages the underlying directory permissions.
100-
type PutMode int
101-
102-
const (
103-
PutModeDirect PutMode = iota
104-
PutModeAndroidSAF
105-
)
106-
107-
// FileOps defines platform-specific file operations.
108-
type FileOps interface {
109-
OpenFileWriter(filename string) (io.WriteCloser, string, error)
110-
111-
// RenamePartialFile finalizes a partial file.
112-
// It returns the new SAF URI as a string and an error.
113-
RenamePartialFile(partialUri, targetDirUri, targetName string) (string, error)
114-
}
115-
11691
func (e *Extension) Name() string {
11792
return "taildrop"
11893
}
@@ -176,23 +151,34 @@ func (e *Extension) onChangeProfile(profile ipn.LoginProfileView, _ ipn.PrefsVie
176151
return
177152
}
178153

179-
// If we have a netmap, create a taildrop manager.
180-
fileRoot, isDirectFileMode := e.fileRoot(uid, activeLogin)
181-
if fileRoot == "" {
182-
e.logf("no Taildrop directory configured")
183-
}
184-
mode := PutModeDirect
185-
if e.directFileRoot != "" && strings.HasPrefix(e.directFileRoot, SafDirectoryPrefix) {
186-
mode = PutModeAndroidSAF
154+
// Use the provided [FileOps] implementation (typically for SAF access on Android),
155+
// or create an [fsFileOps] instance rooted at fileRoot.
156+
//
157+
// A non-nil [FileOps] also implies that we are in DirectFileMode.
158+
fops := e.fileOps
159+
isDirectFileMode := fops != nil
160+
if fops == nil {
161+
var fileRoot string
162+
if fileRoot, isDirectFileMode = e.fileRoot(uid, activeLogin); fileRoot == "" {
163+
e.logf("no Taildrop directory configured")
164+
e.setMgrLocked(nil)
165+
return
166+
}
167+
168+
var err error
169+
if fops, err = newFileOps(fileRoot); err != nil {
170+
e.logf("taildrop: cannot create FileOps: %v", err)
171+
e.setMgrLocked(nil)
172+
return
173+
}
187174
}
175+
188176
e.setMgrLocked(managerOptions{
189177
Logf: e.logf,
190178
Clock: tstime.DefaultClock{Clock: e.sb.Clock()},
191179
State: e.stateStore,
192-
Dir: fileRoot,
193180
DirectFileMode: isDirectFileMode,
194-
FileOps: e.FileOps,
195-
Mode: mode,
181+
fileOps: fops,
196182
SendFileNotify: e.sendFileNotify,
197183
}.New())
198184
}
@@ -221,12 +207,7 @@ func (e *Extension) fileRoot(uid tailcfg.UserID, activeLogin string) (root strin
221207
baseDir := fmt.Sprintf("%s-uid-%d",
222208
strings.ReplaceAll(activeLogin, "@", "-"),
223209
uid)
224-
dir := filepath.Join(varRoot, "files", baseDir)
225-
if err := os.MkdirAll(dir, 0700); err != nil {
226-
e.logf("Taildrop disabled; error making directory: %v", err)
227-
return "", false
228-
}
229-
return dir, false
210+
return filepath.Join(varRoot, "files", baseDir), false
230211
}
231212

232213
// hasCapFileSharing reports whether the current node has the file sharing

feature/taildrop/fileops.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
package taildrop
5+
6+
import (
7+
"io"
8+
"io/fs"
9+
"os"
10+
)
11+
12+
// FileOps abstracts over both local‐FS paths and Android SAF URIs.
13+
type FileOps interface {
14+
// OpenWriter creates or truncates a file named relative to the receiver's root,
15+
// seeking to the specified offset. If the file does not exist, it is created with mode perm
16+
// on platforms that support it.
17+
//
18+
// It returns an [io.WriteCloser] and the file's absolute path, or an error.
19+
// This call may block. Callers should avoid holding locks when calling OpenWriter.
20+
OpenWriter(name string, offset int64, perm os.FileMode) (wc io.WriteCloser, path string, err error)
21+
22+
// Remove deletes a file or directory relative to the receiver's root.
23+
// It returns [io.ErrNotExist] if the file or directory does not exist.
24+
Remove(name string) error
25+
26+
// Rename atomically renames oldPath to a new file named newName,
27+
// returning the full new path or an error.
28+
Rename(oldPath, newName string) (newPath string, err error)
29+
30+
// ListFiles returns just the basenames of all regular files
31+
// in the root directory.
32+
ListFiles() ([]string, error)
33+
34+
// Stat returns the FileInfo for the given name or an error.
35+
Stat(name string) (fs.FileInfo, error)
36+
37+
// OpenReader opens the given basename for the given name or an error.
38+
OpenReader(name string) (io.ReadCloser, error)
39+
}
40+
41+
var newFileOps func(dir string) (FileOps, error)

0 commit comments

Comments
 (0)