// Package vfs is the filesystem abstraction both sides of haul are driven // through: the local disk and a remote SFTP connection look identical to the // transfer engine (internal/xfer) and to the browser panes (internal/ui). // // The two differ in more than their I/O calls — local paths use the native // separator and a platform-specific root, remote paths are always "/"-separated // — so path manipulation is part of the interface rather than something callers // are trusted to get right per side. package vfs import ( "io" "os" "path" "path/filepath" "strings" "github.com/pkg/sftp" ) // FS is the filesystem surface haul needs. Read/write methods cover the copy // engine; the path helpers and the mutating operations cover the browser. type FS interface { // Open opens a file for reading with seek support (needed to resume). Open(p string) (io.ReadSeekCloser, error) // Create truncates/creates a file for writing. Create(p string) (io.WriteCloser, error) // OpenAt opens an existing file for writing positioned at offset, used to // resume a partial transfer. (SFTP O_APPEND is unreliable — pkg/sftp writes // at an explicit offset — so we seek instead.) OpenAt(p string, offset int64) (io.WriteCloser, error) Stat(p string) (os.FileInfo, error) ReadDir(p string) ([]os.FileInfo, error) MkdirAll(p string) error Join(elem ...string) string Base(p string) string // Dir returns p's parent directory. Dir(p string) string // IsRoot reports whether p has no parent to ascend to. IsRoot(p string) bool // Home is the directory a pane opens at: the user's home, falling back to // the working directory and then the root. Home() string // Mkdir creates a single directory. Mkdir(p string) error // Remove deletes a file, or a directory and everything under it. Remove(p string) error // Rename moves oldPath to newPath within this filesystem. Rename(oldPath, newPath string) error } // Local is the local disk, using os + filepath (native separators). type Local struct{} func (Local) Open(p string) (io.ReadSeekCloser, error) { return os.Open(p) } func (Local) Create(p string) (io.WriteCloser, error) { return os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) } func (Local) OpenAt(p string, offset int64) (io.WriteCloser, error) { f, err := os.OpenFile(p, os.O_WRONLY, 0o644) if err != nil { return nil, err } if _, err := f.Seek(offset, io.SeekStart); err != nil { f.Close() return nil, err } return f, nil } func (Local) Stat(p string) (os.FileInfo, error) { return os.Stat(p) } func (Local) MkdirAll(p string) error { return os.MkdirAll(p, 0o755) } func (Local) Join(elem ...string) string { return filepath.Join(elem...) } func (Local) Base(p string) string { return filepath.Base(p) } func (Local) ReadDir(p string) ([]os.FileInfo, error) { return readDirInfos(p) } func (Local) Dir(p string) string { return filepath.Dir(p) } // IsRoot is true when p is its own parent, which is how both "/" and "C:\" // behave under filepath.Dir. func (Local) IsRoot(p string) bool { return p == filepath.Dir(p) } func (l Local) Home() string { if home, err := os.UserHomeDir(); err == nil { return home } if wd, err := os.Getwd(); err == nil { return wd } return string(filepath.Separator) } func (Local) Mkdir(p string) error { return os.Mkdir(p, 0o755) } func (Local) Remove(p string) error { return os.RemoveAll(p) } func (Local) Rename(a, b string) error { return os.Rename(a, b) } func readDirInfos(p string) ([]os.FileInfo, error) { entries, err := os.ReadDir(p) if err != nil { return nil, err } out := make([]os.FileInfo, 0, len(entries)) for _, e := range entries { fi, err := e.Info() if err != nil { // A file that vanished between the listing and the stat isn't an // error worth failing the whole directory over. continue } out = append(out, fi) } return out, nil } // Remote is the SFTP side, using the "/"-separated path package. type Remote struct{ C *sftp.Client } func (r Remote) Open(p string) (io.ReadSeekCloser, error) { return r.C.Open(p) } func (r Remote) Create(p string) (io.WriteCloser, error) { return r.C.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_TRUNC) } func (r Remote) OpenAt(p string, offset int64) (io.WriteCloser, error) { f, err := r.C.OpenFile(p, os.O_WRONLY) if err != nil { return nil, err } if _, err := f.Seek(offset, io.SeekStart); err != nil { f.Close() return nil, err } return f, nil } func (r Remote) Stat(p string) (os.FileInfo, error) { return r.C.Stat(p) } func (r Remote) MkdirAll(p string) error { return r.C.MkdirAll(p) } func (r Remote) Join(elem ...string) string { return path.Join(elem...) } func (r Remote) Base(p string) string { return path.Base(p) } func (r Remote) ReadDir(p string) ([]os.FileInfo, error) { return r.C.ReadDir(p) } func (r Remote) Dir(p string) string { return path.Dir(p) } func (r Remote) IsRoot(p string) bool { return p == "/" || p == "" } // Home asks the server where it put us; the SFTP subsystem starts in the // account's home directory, so Getwd is the answer without shelling out. func (r Remote) Home() string { if wd, err := r.C.Getwd(); err == nil && wd != "" { return wd } return "/" } func (r Remote) Mkdir(p string) error { return r.C.Mkdir(p) } func (r Remote) Rename(a, b string) error { return r.C.Rename(a, b) } // Remove deletes p, recursing into directories: SFTP's RemoveDirectory only // works on empty directories, so the tree has to be walked bottom-up. func (r Remote) Remove(p string) error { fi, err := r.C.Stat(p) if err != nil { return err } if !fi.IsDir() { return r.C.Remove(p) } entries, err := r.C.ReadDir(p) if err != nil { return err } for _, e := range entries { if err := r.Remove(path.Join(p, e.Name())); err != nil { return err } } return r.C.RemoveDirectory(p) } // CleanPath normalises user-typed input for fs: trimming stray whitespace and // collapsing "." and ".." segments, leaving a path fs can Stat. func CleanPath(fs FS, p string) string { p = strings.TrimSpace(p) if p == "" { return fs.Home() } if _, ok := fs.(Remote); ok { return path.Clean(p) } return filepath.Clean(p) }