Files
bsncubed 6b52e83bdc Initial commit: haul — desktop SFTP client
A two-pane local/remote file browser over SSH, split out of dial so file
transfer lives in its own tool. The two share ~/.ssh/config as their base.

- internal/sshconf: ~/.ssh/config parsing, for the saved-host list.
- internal/sshx: dialling and auth — agent, keys, password, host-key
  verification, ProxyJump.
- internal/vfs: the FS interface both sides are driven through, implemented
  over the local disk and over SFTP.
- internal/xfer: recursive copy engine with progress reporting, cancellation,
  conflict policy, and resume of partial transfers.
- internal/ui: the Fyne GUI — connect window (saved hosts plus quick connect)
  and the two-pane browser session.

Build with `make build`; the Makefile locates the Go toolchain at ~/.local/go,
which is deliberately kept off PATH. Cross-compiling needs a per-target C
toolchain because Fyne is cgo, so `make dist` builds the native binary only —
the alternatives are documented in the Makefile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 23:21:21 +10:00

186 lines
6.2 KiB
Go

// 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)
}