Skip to content

Commit 146049a

Browse files
committed
feat: dry-run also tests writeability of files
1 parent 97ef15b commit 146049a

6 files changed

Lines changed: 159 additions & 18 deletions

File tree

cmd/uploadfun/main.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,9 @@ func parseArgs(args []string, stdout, stderr io.Writer) (*cliOptions, int) {
5656
"print the full event stream, including byte-level progress")
5757
fs.BoolVar(&opts.json, "json", false, "format output as newline-delimited JSON")
5858
fs.BoolVar(&opts.dryRun, "dry-run", false,
59-
"connect and authenticate to each endpoint and report how many files "+
60-
"would upload; no transfer, no delete, no writes")
59+
"connect to each endpoint, verify the target is writable via a "+
60+
"self-deleting probe file, and report how many files would upload; "+
61+
"never touches the actual files being sent")
6162
fs.BoolVar(&opts.noVerify, "no-verify", false, "disable post-upload size/hash verification")
6263
fs.BoolVar(&opts.version, "version", false, "print version and exit")
6364
fs.Usage = func() {

cmd/uploadfun/printer.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,11 @@ func (p *printer) handle(ev uploadfun.UploadEvent) {
122122
p.write(p.stderr, e, fmt.Sprintf("[%s] dry-run failed: %s", e.Endpoint, e.Err))
123123
return
124124
}
125-
msg := fmt.Sprintf("[%s] dry-run ok: would upload %d files", e.Endpoint, e.Files)
125+
msg := fmt.Sprintf(
126+
"[%s] dry-run ok: reachable and writable, would upload %d files",
127+
e.Endpoint,
128+
e.Files,
129+
)
126130
p.writeUnlessQuiet(e, msg)
127131
}
128132
}

dispatch.go

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package uploadfun
22

33
import (
44
"context"
5+
"crypto/rand"
56
"errors"
67
"fmt"
78
"os"
@@ -295,8 +296,9 @@ func (w *endpointWorker) sleepBeforeRetry(attempt int) {
295296
}
296297

297298
// runDryRun performs the --dry-run preflight for one endpoint: connect and
298-
// authenticate to prove the endpoint is reachable, disconnect, and report
299-
// how many files a real run would upload - never touching any file.
299+
// authenticate, prove the target directory is writable by round-tripping a
300+
// throwaway probe file, then report how many files a real run would upload -
301+
// never touching the actual files being sent.
300302
func runDryRun(
301303
ctx context.Context,
302304
up uploader,
@@ -311,10 +313,60 @@ func runDryRun(
311313
events <- DryRunEvent{Endpoint: ep.Name, Err: err}
312314
return
313315
}
314-
_ = up.Disconnect(ctx)
316+
defer func() { _ = up.Disconnect(ctx) }()
317+
318+
if err := probeWritable(ctx, up); err != nil {
319+
events <- DryRunEvent{Endpoint: ep.Name, Err: err}
320+
return
321+
}
315322
events <- DryRunEvent{Endpoint: ep.Name, Files: len(files)}
316323
}
317324

325+
// probeWritable proves the endpoint's target directory accepts writes by
326+
// uploading a tiny uniquely-named file and deleting it. This is the one
327+
// deliberate remote mutation a dry run makes; the probe exists on the
328+
// server only between its upload and its delete.
329+
func probeWritable(ctx context.Context, up uploader) error {
330+
local, err := newProbeFile()
331+
if err != nil {
332+
return err
333+
}
334+
defer func() { _ = os.Remove(local) }()
335+
336+
remote := probeRemoteName()
337+
if err := up.Upload(ctx, local, remote, func(sent, total int64) {}); err != nil {
338+
return fmt.Errorf("write probe: %w", err)
339+
}
340+
if err := up.Delete(ctx, remote); err != nil {
341+
return fmt.Errorf("write probe cleanup: left %q on server: %w", remote, err)
342+
}
343+
return nil
344+
}
345+
346+
// newProbeFile writes a throwaway local file for the write probe and
347+
// returns its path; the caller removes it.
348+
func newProbeFile() (string, error) {
349+
f, err := os.CreateTemp("", "uploadfun-probe-*")
350+
if err != nil {
351+
return "", err
352+
}
353+
defer func() { _ = f.Close() }()
354+
if _, err := f.WriteString("uploadfun dry-run write probe\n"); err != nil {
355+
_ = os.Remove(f.Name())
356+
return "", err
357+
}
358+
return f.Name(), nil
359+
}
360+
361+
// probeRemoteName returns a collision-resistant name so the probe never
362+
// clashes with a real upload or a concurrent endpoint's probe. It avoids a
363+
// leading dot on purpose - servers like pure-ftpd reject dotfile writes.
364+
func probeRemoteName() string {
365+
var b [8]byte
366+
_, _ = rand.Read(b[:])
367+
return fmt.Sprintf("uploadfun-probe-%x.tmp", b)
368+
}
369+
318370
func failAllFiles(ep Endpoint, files []string, err error, events chan<- UploadEvent) {
319371
for _, f := range files {
320372
events <- FileErrorEvent{Endpoint: ep.Name, File: f, Attempt: 1, Reason: err.Error(), Err: err}

dispatch_test.go

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -663,15 +663,47 @@ func TestDispatchDryRunSuccess(t *testing.T) {
663663
t.Errorf("expected Files to report %d planned uploads, got %d", len(files), dr.Files)
664664
}
665665

666-
if f.uploadCalls != 0 || f.deleteCalls != 0 {
667-
t.Errorf("expected no upload/delete calls during a dry run, got uploadCalls=%d deleteCalls=%d",
668-
f.uploadCalls, f.deleteCalls)
666+
if f.uploadCalls != 1 || f.deleteCalls != 1 {
667+
t.Errorf(
668+
"expected exactly 1 upload/delete for the write probe, got uploadCalls=%d deleteCalls=%d",
669+
f.uploadCalls,
670+
f.deleteCalls,
671+
)
669672
}
670673
if f.disconnectCalls != 1 {
671674
t.Errorf("expected exactly 1 disconnect, got %d", f.disconnectCalls)
672675
}
673676
}
674677

678+
func TestDispatchDryRunWriteProbeFailure(t *testing.T) {
679+
f := &fakeUploader{failUploadN: 999}
680+
withFakeUploader(t, f)
681+
682+
events := collectEvents(Upload(
683+
context.Background(), []string{"a.jpg"}, []Endpoint{testEndpoint("ep1")}, Options{DryRun: true},
684+
))
685+
686+
if len(events) != 1 {
687+
t.Fatalf("expected exactly 1 event, got %d: %+v", len(events), events)
688+
}
689+
dr, ok := events[0].(DryRunEvent)
690+
if !ok {
691+
t.Fatalf("expected a DryRunEvent, got %T", events[0])
692+
}
693+
if dr.Err == nil {
694+
t.Error("expected a write-probe error")
695+
}
696+
if dr.Files != 0 {
697+
t.Errorf("expected Files=0 when the write probe fails, got %d", dr.Files)
698+
}
699+
if f.deleteCalls != 0 {
700+
t.Errorf("expected no delete after a failed probe upload, got %d", f.deleteCalls)
701+
}
702+
if f.disconnectCalls != 1 {
703+
t.Errorf("expected exactly 1 disconnect after a probe failure, got %d", f.disconnectCalls)
704+
}
705+
}
706+
675707
func TestDispatchDryRunConnectFailure(t *testing.T) {
676708
f := &fakeUploader{failConnectN: 999}
677709
withFakeUploader(t, f)

integration_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,3 +234,53 @@ func TestIntegrationSFTPPermissionDeniedGivesUpEndToEnd(t *testing.T) {
234234
t.Errorf("expected the second file to be reported skipped, got %+v", givenUp.SkippedFiles)
235235
}
236236
}
237+
238+
// TestIntegrationDryRunWriteProbeEndToEnd proves the --dry-run write probe
239+
// against real servers: it succeeds on the writable FTPS target and, on the
240+
// SFTP endpoint whose chroot root is unwritable, catches the exact
241+
// permission problem TestIntegrationSFTPPermissionDeniedGivesUpEndToEnd
242+
// shows a real run only discovers mid-transfer - which is the whole point
243+
// of preflighting writability.
244+
func TestIntegrationDryRunWriteProbeEndToEnd(t *testing.T) {
245+
t.Run("passes on a writable endpoint", func(t *testing.T) {
246+
endpoints, err := LoadConfig(rootFTPSConfig(t, "true"))
247+
if err != nil {
248+
t.Fatalf("LoadConfig: %v", err)
249+
}
250+
251+
events := collectEvents(Upload(
252+
context.Background(), []string{"a.jpg", "b.jpg"}, endpoints, Options{DryRun: true},
253+
))
254+
255+
dr := singleDryRunEvent(t, events)
256+
if dr.Err != nil {
257+
t.Errorf("expected the write probe to pass on a writable endpoint, got %v", dr.Err)
258+
}
259+
if dr.Files != 2 {
260+
t.Errorf("expected Files=2, got %d", dr.Files)
261+
}
262+
})
263+
264+
t.Run("fails on an unwritable target", func(t *testing.T) {
265+
events := collectEvents(Upload(
266+
context.Background(), []string{"a.jpg"}, []Endpoint{rootSFTPEndpoint()}, Options{DryRun: true},
267+
))
268+
269+
dr := singleDryRunEvent(t, events)
270+
if dr.Err == nil {
271+
t.Error("expected the write probe to fail on an unwritable target")
272+
}
273+
})
274+
}
275+
276+
func singleDryRunEvent(t *testing.T, events []UploadEvent) DryRunEvent {
277+
t.Helper()
278+
if len(events) != 1 {
279+
t.Fatalf("expected exactly 1 event for a dry run, got %d: %+v", len(events), events)
280+
}
281+
dr, ok := events[0].(DryRunEvent)
282+
if !ok {
283+
t.Fatalf("expected a DryRunEvent, got %T", events[0])
284+
}
285+
return dr
286+
}

uploadfun.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,10 @@ type Options struct {
7878
// NoVerify disables the post-upload size/hash verification that's on
7979
// by default.
8080
NoVerify bool
81-
// DryRun connects and authenticates per endpoint and reports how many
82-
// files a real run would upload, without transferring, deleting, or
83-
// writing anything.
81+
// DryRun connects and authenticates per endpoint, verifies the target
82+
// directory is writable by round-tripping a throwaway probe file, and
83+
// reports how many files a real run would upload - without touching any
84+
// of the actual files being sent.
8485
DryRun bool
8586
}
8687

@@ -194,17 +195,18 @@ type EndpointDoneEvent struct {
194195
func (EndpointDoneEvent) uploadEvent() {}
195196

196197
// DryRunEvent reports the outcome of a --dry-run preflight for one
197-
// endpoint: connect and authenticate to prove it's reachable, then report
198-
// how many files a real run would upload, without transferring anything.
199-
// Exactly one is sent per endpoint when Options.DryRun is set, replacing
200-
// the per-file events.
198+
// endpoint: connect and authenticate to prove it's reachable, probe the
199+
// target directory for writability, then report how many files a real run
200+
// would upload. Exactly one is sent per endpoint when Options.DryRun is
201+
// set, replacing the per-file events.
201202
type DryRunEvent struct {
202203
Endpoint string `json:"endpoint"`
203204
// Files is how many files a real run would upload to this endpoint;
204205
// meaningful only when Err is nil.
205206
Files int `json:"files"`
206-
// Err is set if connecting or authenticating failed; nil means the
207-
// endpoint is reachable and Files reflects the planned upload.
207+
// Err is set if connecting, authenticating, or the write probe failed;
208+
// nil means the endpoint is reachable and writable and Files reflects
209+
// the planned upload.
208210
Err error `json:"-"`
209211
}
210212

0 commit comments

Comments
 (0)