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}