main pico / pkg / send / protocols / sftp / handler.go
Antonio Mika  ·  2026-10-10
  1package sftp
  2
  3import (
  4	"bytes"
  5	"crypto/sha256"
  6	"errors"
  7	"fmt"
  8	"io"
  9	"io/fs"
 10	"os"
 11	"path/filepath"
 12	"slices"
 13	"strings"
 14	"sync"
 15	"time"
 16
 17	"github.com/picosh/pico/pkg/pssh"
 18	"github.com/picosh/pico/pkg/send/utils"
 19	"github.com/pkg/sftp"
 20)
 21
 22type listerat []os.FileInfo
 23
 24func (f listerat) ListAt(ls []os.FileInfo, offset int64) (int, error) {
 25	var n int
 26	if offset >= int64(len(f)) {
 27		return 0, io.EOF
 28	}
 29	n = copy(ls, f[offset:])
 30	if n < len(ls) {
 31		return n, io.EOF
 32	}
 33	return n, nil
 34}
 35
 36type handler struct {
 37	session      *pssh.SSHServerConnSession
 38	writeHandler utils.CopyFromClientHandler
 39
 40	mu      sync.Mutex
 41	pending map[string]*pendingFile
 42	// ignored counts open handles to files the handler ignores. A stat of
 43	// one is refused so a mounted filesystem reports that it wasn't saved.
 44	ignored map[string]int
 45	// hidden holds stored files renamed away while open, by the open file
 46	// that will replace them. They look deleted to this session.
 47	hidden map[string]*pendingFile
 48}
 49
 50func (f *handler) pendingFile(name string) *pendingFile {
 51	f.mu.Lock()
 52	defer f.mu.Unlock()
 53	return f.pending[name]
 54}
 55
 56func (f *handler) isHidden(name string) bool {
 57	f.mu.Lock()
 58	defer f.mu.Unlock()
 59	return f.hidden[name] != nil
 60}
 61
 62// release returns the files p was renamed from that are still waiting on
 63// it, and shows them again. The caller holds f.mu.
 64func (f *handler) release(p *pendingFile) []renamedFile {
 65	var out []renamedFile
 66	for _, old := range p.replaces {
 67		if f.hidden[old.path] == p {
 68			delete(f.hidden, old.path)
 69			out = append(out, old)
 70		}
 71	}
 72	p.replaces = nil
 73	return out
 74}
 75
 76func (f *handler) Filecmd(r *sftp.Request) error {
 77	switch r.Method {
 78	case "Rmdir", "Remove":
 79		entry := toFileEntry(r)
 80
 81		if r.Method == "Rmdir" {
 82			entry.Mode = os.ModeDir
 83			return f.writeHandler.Delete(f.session, entry)
 84		}
 85
 86		f.mu.Lock()
 87		hidden := f.hidden[r.Filepath] != nil
 88		p, ok := f.pending[r.Filepath]
 89		var replaced []renamedFile
 90		if ok {
 91			delete(f.pending, r.Filepath)
 92			replaced = f.release(p)
 93		}
 94		f.mu.Unlock()
 95		if hidden && !ok {
 96			return os.ErrNotExist
 97		}
 98		// Removing a renamed file removes what it was renamed from.
 99		for _, old := range replaced {
100			if err := f.deleteIfUnchanged(old.path, old.sum); err != nil {
101				return err
102			}
103		}
104		if ok {
105			// An open file may have been renamed over a stored one.
106			stored, _, err := f.stored(entry, false)
107			if err != nil || !stored {
108				return err
109			}
110		}
111		return f.writeHandler.Delete(f.session, entry)
112	case "Mkdir":
113		entry := toFileEntry(r)
114
115		entry.Mode = os.ModeDir
116
117		_, err := f.writeHandler.Write(f.session, entry)
118
119		return err
120	case "Rename":
121		return f.rename(r.Filepath, r.Target, false)
122	case "Setstat":
123		// Only an open file's size can change, as when an editor
124		// truncates what it is writing.
125		if p := f.pendingFile(r.Filepath); p != nil && r.AttrFlags().Size {
126			f.mu.Lock()
127			p.dirty = true
128			f.mu.Unlock()
129			p.buf.truncate(int64(r.Attributes().Size))
130		}
131		return nil
132	}
133	return errors.New("unsupported")
134}
135
136func (f *handler) Filelist(r *sftp.Request) (sftp.ListerAt, error) {
137	return f.filelist(r, true)
138}
139
140// Lstat is a stat that leaves out implicit directories. sshfs looks paths
141// up with it, and a mkdir there would fail if they showed as existing.
142func (f *handler) Lstat(r *sftp.Request) (sftp.ListerAt, error) {
143	return f.filelist(r, false)
144}
145
146func (f *handler) filelist(r *sftp.Request, implicit bool) (sftp.ListerAt, error) {
147	switch r.Method {
148	case "List", "Stat", "Lstat":
149		list := r.Method == "List"
150
151		if !list {
152			f.mu.Lock()
153			p, ignored := f.pending[r.Filepath], f.ignored[r.Filepath] > 0
154			hidden := f.hidden[r.Filepath] != nil
155			f.mu.Unlock()
156			if ignored {
157				return nil, os.ErrPermission
158			}
159			if hidden && p == nil {
160				return nil, os.ErrNotExist
161			}
162			if p != nil {
163				return listerat{p.info(filepath.Base(r.Filepath))}, nil
164			}
165		}
166
167		listData, err := f.writeHandler.List(f.session, r.Filepath, list, false)
168		if err != nil {
169			return nil, err
170		}
171		if list {
172			listData = f.withPending(r.Filepath, listData)
173		}
174
175		// an empty string or exact match from filepath base name is what we want
176		if !list {
177			listData = slices.DeleteFunc(listData, func(f os.FileInfo) bool {
178				return f.Name() != "" && f.Name() != filepath.Base(r.Filepath)
179			})
180			if d, ok := f.writeHandler.(utils.ImplicitDirs); ok && implicit && len(listData) == 0 && d.IsImplicitDir(f.session, r.Filepath) {
181				listData = []os.FileInfo{&utils.VirtualFile{FName: filepath.Base(r.Filepath), FIsDir: true}}
182			}
183		}
184
185		if r.Filepath == "/" {
186			listData = slices.DeleteFunc(listData, func(f os.FileInfo) bool {
187				return f.Name() == "/"
188			})
189			listData = slices.Insert(listData, 0, os.FileInfo(&utils.VirtualFile{
190				FName:  ".",
191				FIsDir: true,
192			}))
193		}
194
195		return listerat(listData), nil
196	}
197
198	return nil, errors.New("unsupported")
199}
200
201// PosixRename is a rename that replaces an existing file at the target.
202func (f *handler) PosixRename(r *sftp.Request) error {
203	return f.rename(r.Filepath, r.Target, true)
204}
205
206// rename moves a file or a directory. A handler that implements
207// utils.FileRenamer moves a file in place. Otherwise each file is written
208// to its new path through the handler, which applies its usual checks, and
209// the sources are deleted once every write succeeded.
210func (f *handler) rename(from, to string, replace bool) error {
211	if from == to {
212		return nil
213	}
214	if strings.HasPrefix(to, from+"/") {
215		return fmt.Errorf("cannot move %s into itself", from)
216	}
217	if moved, err := f.renamePending(from, to, replace); moved || err != nil {
218		return err
219	}
220	if f.isHidden(from) {
221		return os.ErrNotExist
222	}
223	toHidden := f.isHidden(to)
224
225	isFile, _, err := f.stored(&utils.FileEntry{Filepath: from}, false)
226	if err != nil {
227		return err
228	}
229	files, dirs := []string{"."}, []string(nil)
230	if !isFile {
231		if files, dirs, err = f.tree(from); err != nil {
232			return err
233		}
234		if len(files)+len(dirs) == 0 {
235			if ok, err := f.isDir(from); err != nil || !ok {
236				return errors.Join(os.ErrNotExist, err)
237			}
238		}
239	}
240	if !replace || !isFile {
241		exists, _, err := f.stored(&utils.FileEntry{Filepath: to}, false)
242		if err != nil {
243			return err
244		}
245		exists = exists && !toHidden
246		belowFiles, belowDirs, err := f.tree(to)
247		if err != nil {
248			return err
249		}
250		if exists || len(belowFiles)+len(belowDirs) > 0 {
251			return os.ErrExist
252		}
253	}
254
255	if isFile {
256		if r, ok := f.writeHandler.(utils.FileRenamer); ok {
257			err := r.Rename(f.session, &utils.FileEntry{Filepath: from}, &utils.FileEntry{Filepath: to})
258			if !errors.Is(err, errors.ErrUnsupported) {
259				if err == nil {
260					f.mu.Lock()
261					delete(f.hidden, to)
262					f.mu.Unlock()
263				}
264				return err
265			}
266		}
267	}
268
269	if !isFile {
270		for _, d := range append([]string{"."}, dirs...) {
271			if _, err := f.writeHandler.Write(f.session, &utils.FileEntry{Filepath: filepath.Join(to, d), Mode: os.ModeDir}); err != nil {
272				return err
273			}
274		}
275	}
276	var written []string
277	sums := make([][]byte, len(files))
278	for i, name := range files {
279		dst := filepath.Join(to, name)
280		sum, err := f.copyFile(filepath.Join(from, name), dst)
281		if err != nil {
282			for _, w := range written {
283				_ = f.writeHandler.Delete(f.session, &utils.FileEntry{Filepath: w})
284			}
285			return err
286		}
287		sums[i] = sum
288		written = append(written, dst)
289	}
290	for i, name := range files {
291		if err := f.deleteIfUnchanged(filepath.Join(from, name), sums[i]); err != nil {
292			return err
293		}
294	}
295	if !isFile {
296		// Children before parents.
297		slices.SortFunc(dirs, func(a, b string) int { return strings.Count(b, "/") - strings.Count(a, "/") })
298		for _, d := range append(dirs, ".") {
299			_ = f.writeHandler.Delete(f.session, &utils.FileEntry{Filepath: filepath.Join(from, d), Mode: os.ModeDir})
300		}
301	}
302	f.mu.Lock()
303	delete(f.hidden, to)
304	f.mu.Unlock()
305	return nil
306}
307
308// renamePending moves a file that is open for writing, which happens when
309// sshfs delivers the close after a rename. Its contents are stored at the
310// new path when it closes, and a stored source is kept until then.
311func (f *handler) renamePending(from, to string, replace bool) (bool, error) {
312	f.mu.Lock()
313	p, ok := f.pending[from]
314	if !ok {
315		defer f.mu.Unlock()
316		for name := range f.pending {
317			if strings.HasPrefix(name, from+"/") {
318				return false, fmt.Errorf("cannot move %s while %s is open for writing", from, name)
319			}
320		}
321		return false, nil
322	}
323	_, toOpen := f.pending[to]
324	toHidden := f.hidden[to] != nil
325	stored := p.stored
326	f.mu.Unlock()
327	if v, ok := f.writeHandler.(utils.WriteValidator); ok {
328		if err := v.ValidateWrite(f.session, &utils.FileEntry{Filepath: to, Mode: p.entry.Mode}); err != nil {
329			return true, err
330		}
331	}
332	if !replace {
333		exists, _, err := f.stored(&utils.FileEntry{Filepath: to}, false)
334		if err != nil {
335			return true, err
336		}
337		if (exists && !toHidden) || toOpen {
338			return true, os.ErrExist
339		}
340	}
341
342	var sum []byte
343	if stored {
344		var err error
345		if sum, stored, err = f.checksum(from); err != nil {
346			return true, err
347		}
348	}
349
350	f.mu.Lock()
351	defer f.mu.Unlock()
352	delete(f.pending, from)
353	delete(f.hidden, to)
354	f.pending[to] = p
355	p.path = to
356	p.dirty = true
357	if stored {
358		p.replaces = append(p.replaces, renamedFile{path: from, sum: sum})
359		f.hidden[from] = p
360	}
361	p.stored = false
362	return true, nil
363}
364
365// copyFile copies a stored file and returns the checksum of what it copied.
366func (f *handler) copyFile(from, to string) ([]byte, error) {
367	info, r, err := f.writeHandler.Read(f.session, &utils.FileEntry{Filepath: from})
368	if err != nil {
369		return nil, err
370	}
371	defer func() { _ = r.Close() }()
372	h := sha256.New()
373	msg, err := f.writeHandler.Write(f.session, &utils.FileEntry{
374		Filepath: to,
375		Mode:     info.Mode().Perm(),
376		Size:     info.Size(),
377		Mtime:    info.ModTime().Unix(),
378		Atime:    info.ModTime().Unix(),
379		Reader:   io.TeeReader(r, h),
380	})
381	if err != nil {
382		return nil, fmt.Errorf("%s: %w", to, err)
383	}
384	if msg != "" {
385		_, _ = fmt.Fprintf(f.session.Stderr(), "%s\r\n", msg)
386	}
387	return h.Sum(nil), nil
388}
389
390// checksum hashes the stored file at name.
391func (f *handler) checksum(name string) ([]byte, bool, error) {
392	stored, data, err := f.stored(&utils.FileEntry{Filepath: name}, true)
393	if err != nil || !stored {
394		return nil, stored, err
395	}
396	sum := sha256.Sum256(data)
397	return sum[:], true, nil
398}
399
400// deleteIfUnchanged deletes a stored file unless another session replaced
401// it since it had sum. Storage has no conditional delete, so a write
402// between the check and the delete can still be lost.
403func (f *handler) deleteIfUnchanged(name string, sum []byte) error {
404	cur, stored, err := f.checksum(name)
405	if err != nil || !stored || !bytes.Equal(cur, sum) {
406		return err
407	}
408	return f.writeHandler.Delete(f.session, &utils.FileEntry{Filepath: name})
409}
410
411// tree returns the files and directories below dir, relative to it.
412func (f *handler) tree(dir string) (files, dirs []string, err error) {
413	entries, err := f.writeHandler.List(f.session, dir, true, true)
414	if err != nil {
415		return nil, nil, err
416	}
417	for _, e := range entries {
418		name := strings.Trim(e.Name(), "/")
419		switch {
420		case name == "" || name == ".":
421		case e.IsDir():
422			dirs = append(dirs, name)
423		default:
424			files = append(files, name)
425		}
426	}
427	return files, dirs, nil
428}
429
430func (f *handler) isDir(name string) (bool, error) {
431	entries, err := f.writeHandler.List(f.session, filepath.Dir(name), true, false)
432	if err != nil {
433		return false, err
434	}
435	base := filepath.Base(name)
436	return slices.ContainsFunc(entries, func(e os.FileInfo) bool {
437		return e.IsDir() && strings.Trim(e.Name(), "/") == base
438	}), nil
439}
440
441// withPending adds the files open for writing in dir to its listing and
442// drops the ones renamed away.
443func (f *handler) withPending(dir string, listData []os.FileInfo) []os.FileInfo {
444	f.mu.Lock()
445	defer f.mu.Unlock()
446	for name := range f.hidden {
447		if filepath.Dir(name) == filepath.Clean(dir) {
448			base := filepath.Base(name)
449			listData = slices.DeleteFunc(listData, func(info os.FileInfo) bool { return info.Name() == base })
450		}
451	}
452	for name, p := range f.pending {
453		if filepath.Dir(name) != filepath.Clean(dir) {
454			continue
455		}
456		base := filepath.Base(name)
457		listData = slices.DeleteFunc(listData, func(info os.FileInfo) bool { return info.Name() == base })
458		listData = append(listData, p.info(base))
459	}
460	return listData
461}
462
463// stored returns the contents of the file at name if storage has it.
464// Contents are only read when withContents is set.
465func (f *handler) stored(entry *utils.FileEntry, withContents bool) (bool, []byte, error) {
466	list, err := f.writeHandler.List(f.session, entry.Filepath, false, false)
467	if err != nil {
468		return false, nil, err
469	}
470	base := filepath.Base(entry.Filepath)
471	if !slices.ContainsFunc(list, func(info os.FileInfo) bool { return !info.IsDir() && info.Name() == base }) {
472		return false, nil, nil
473	}
474	if !withContents {
475		return true, nil, nil
476	}
477	_, r, err := f.writeHandler.Read(f.session, entry)
478	if err != nil {
479		return false, nil, err
480	}
481	defer func() { _ = r.Close() }()
482	data, err := io.ReadAll(r)
483	return true, data, err
484}
485
486func toFileEntry(r *sftp.Request) *utils.FileEntry {
487	attrs := r.Attributes()
488	var size int64 = 0
489	var mtime int64 = 0
490	var atime int64 = 0
491	var mode fs.FileMode
492	if attrs != nil {
493		mode = attrs.FileMode()
494		size = int64(attrs.Size)
495		mtime = int64(attrs.Mtime)
496		atime = int64(attrs.Atime)
497	}
498
499	entry := &utils.FileEntry{
500		Filepath: r.Filepath,
501		Mode:     mode,
502		Size:     size,
503		Mtime:    mtime,
504		Atime:    atime,
505	}
506	return entry
507}
508
509// Filewrite opens a file for writing. Nothing is stored until the last
510// handle to it closes, so a rejected upload leaves storage untouched.
511func (f *handler) Filewrite(r *sftp.Request) (io.WriterAt, error) {
512	entry := toFileEntry(r)
513	if v, ok := f.writeHandler.(utils.WriteValidator); ok {
514		if err := v.ValidateWrite(f.session, entry); err != nil {
515			if errors.Is(err, utils.ErrFileIgnored) {
516				_, _ = fmt.Fprintf(f.session.Stderr(), "%s\r\n", err)
517				f.mu.Lock()
518				f.ignored[r.Filepath]++
519				f.mu.Unlock()
520				return ignoredWriter{handler: f, path: r.Filepath}, nil
521			}
522			return nil, err
523		}
524	}
525
526	trunc := r.Pflags().Trunc
527	f.mu.Lock()
528	defer f.mu.Unlock()
529	if p, ok := f.pending[r.Filepath]; ok {
530		p.handles++
531		if trunc {
532			p.buf.truncate(0)
533			p.dirty = true
534		}
535		return &pendingWriter{handler: f, file: p}, nil
536	}
537
538	// A file renamed away while open is gone, and this one takes its path.
539	var stored bool
540	var data []byte
541	if f.hidden[r.Filepath] != nil {
542		delete(f.hidden, r.Filepath)
543		trunc = true
544	} else {
545		// Opening without truncating keeps what is stored, as touch does.
546		var err error
547		if stored, data, err = f.stored(entry, !trunc); err != nil {
548			return nil, err
549		}
550	}
551	p := &pendingFile{path: r.Filepath, entry: entry, buf: &buffer{}, modTime: time.Now(), handles: 1, stored: stored, dirty: trunc}
552	if _, err := p.buf.WriteAt(data, 0); err != nil {
553		return nil, err
554	}
555	f.pending[r.Filepath] = p
556	return &pendingWriter{handler: f, file: p}, nil
557}
558
559func (f *handler) Fileread(r *sftp.Request) (io.ReaderAt, error) {
560	if r.Filepath == "/" {
561		return nil, os.ErrInvalid
562	}
563
564	if p := f.pendingFile(r.Filepath); p != nil {
565		return bytes.NewReader(p.buf.bytes()), nil
566	}
567	if f.isHidden(r.Filepath) {
568		return nil, os.ErrNotExist
569	}
570
571	fileEntry := toFileEntry(r)
572	_, reader, err := f.writeHandler.Read(f.session, fileEntry)
573
574	return reader, err
575}
576
577type handlererr struct {
578	Handler *handler
579}
580
581func (f *handlererr) Filecmd(r *sftp.Request) error {
582	err := f.Handler.Filecmd(r)
583	if err != nil {
584		_, _ = fmt.Fprintln(f.Handler.session.Stderr(), err)
585	}
586	return err
587}
588func (f *handlererr) PosixRename(r *sftp.Request) error {
589	err := f.Handler.PosixRename(r)
590	if err != nil {
591		_, _ = fmt.Fprintln(f.Handler.session.Stderr(), err)
592	}
593	return err
594}
595func (f *handlererr) Filelist(r *sftp.Request) (sftp.ListerAt, error) {
596	result, err := f.Handler.Filelist(r)
597	if err != nil {
598		_, _ = fmt.Fprintln(f.Handler.session.Stderr(), err)
599	}
600	return result, err
601}
602func (f *handlererr) Lstat(r *sftp.Request) (sftp.ListerAt, error) {
603	result, err := f.Handler.Lstat(r)
604	if err != nil {
605		_, _ = fmt.Fprintln(f.Handler.session.Stderr(), err)
606	}
607	return result, err
608}
609func (f *handlererr) Filewrite(r *sftp.Request) (io.WriterAt, error) {
610	result, err := f.Handler.Filewrite(r)
611	if err != nil {
612		_, _ = fmt.Fprintln(f.Handler.session.Stderr(), err)
613	}
614	return result, err
615}
616func (f *handlererr) Fileread(r *sftp.Request) (io.ReaderAt, error) {
617	result, err := f.Handler.Fileread(r)
618	if err != nil {
619		_, _ = fmt.Fprintln(f.Handler.session.Stderr(), err)
620	}
621	return result, err
622}