@@ -2,6 +2,7 @@ package uploadfun
22
33import (
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.
300302func 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+
318370func 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 }
0 commit comments