remote

package
v1.7.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 17, 2026 License: GPL-3.0 Imports: 22 Imported by: 0

Documentation

Overview

Package remote is the filesystem the sftp tab browses: either this machine or a host reached over SFTP, behind one interface so the panel above cannot tell the difference.

Both sides of the tab are the same type, which is what makes "upload", "download" and "remote to remote" one operation instead of three.

Index

Constants

View Source
const LocalLabel = "local"

LocalLabel is what the local side is called in the host picker and the panel title. A name, not a path: "local" is a place you understand without reading.

View Source
const PeekCap = 64 * 1024

PeekCap is how much of a file a preview reads. Every byte crosses the network on a remote side, so this is a transfer budget rather than a memory one: 64 KiB is enough to answer "is this the file I think it is" and small enough that pressing the key on a multi-gigabyte log is not a mistake you have to wait out.

View Source
const SearchCap = 20000

SearchCap bounds a scan, in the same spirit as planCap: past this many entries a search is not the tool you want, and the walk stops rather than holding the connection open indefinitely. The UI says when it stopped early, because a truncated result set that claims to be complete is worse than a slow one.

Variables

View Source
var ErrPlanTooLarge = errors.New("too many files")

ErrPlanTooLarge says the walk hit the cap. The count is included so the message can be specific rather than "too many".

Functions

func CopyItem

func CopyItem(ctx context.Context, src, dst FS, it Item, onBytes func(int64)) error

CopyItem transfers one item, reporting bytes as they land.

A cancelled or failed file is REMOVED. A partial file that looks like the real one is the worst outcome here: the next thing that reads it gets truncated data with nothing to say it was truncated.

func Digest

func Digest(p string) ([32]byte, error)

Digest is how "you edited this" is told apart from "you looked at it and quit". Not mtime: an editor rewrites the file on :w even when every byte is the same, so mtime answers a different question than the one being asked.

func Exists

func Exists(f FS, p string) bool

Exists reports whether p is there. Transfers use it to find overwrites before starting, so the question is asked once for the whole batch rather than per file, halfway through.

func Fetch

func Fetch(ctx context.Context, f FS, p, dir string, onBytes func(int64)) (string, error)

Fetch copies p into dir under its own name and returns the local path.

The name is kept because editors read it: syntax rules, filetype detection and modelines all key off it, and nginx.conf opened as sshu-edit-417 is a different editing session than the one you asked for.

There is no size limit, deliberately. What edit needs is "never write back a partial read", and that is bought by reading the whole file or failing — not by refusing large ones. It streams, so size costs time and disk, never memory, and ctx is what makes the time cancellable.

func Join

func Join(dir, name string) string

Join builds a child path. It is package-level rather than a method because every FS uses slash paths, so there is nothing per-implementation about it.

func LocalPath

func LocalPath(f FS, p string) (string, bool)

LocalPath answers "can an editor be pointed straight at this file?".

When it can, there is no copy at all. Editing in place keeps the inode, so hard links, ownership and extended attributes survive — a round trip through a temp file and a rename would break all three, and on this machine there is no network failure to buy protection from.

func Parent

func Parent(dir string) string

Parent is dir's parent, or dir itself at the root — going up from "/" must not walk off the end.

func Peek

func Peek(f FS, p string, n int) ([]byte, error)

Peek reads at most n bytes from the start of p.

It is deliberately not Plan/CopyItem's path: nothing is written anywhere, and the caller wants the bytes in hand rather than streamed.

func RemoveAll

func RemoveAll(f FS, p string) error

RemoveAll deletes p, and everything under it if it is a directory.

It walks with Lstat and with the LISTING's IsDir, both of which report a symlink as a symlink. The link is unlinked; whatever it points at is left alone. Deleting the contents of a directory somebody merely linked to would be the worst kind of surprise this app could produce.

func SameTree

func SameTree(src FS, srcPath string, dst FS, dstDir string) bool

SameTree reports whether copying src into dstDir on the same filesystem would put a directory inside itself, which walks forever.

func Scan

func Scan(ctx context.Context, f FS, root string, dirs []string, limit int, emit func([]Entry)) (capped bool)

Scan walks dirs breadth-first and hands each directory's listing to emit, with every entry's Name rewritten as its path relative to root — so the caller can still build an absolute path with Join(root, Name) and does not have to know how deep a result came from.

Breadth-first is the whole point. Over SFTP each directory is a round trip, so the order results arrive in is the order the user waits in, and what someone is looking for is far more often two levels down than twenty. A depth-first walk would spend its first seconds inside whichever subtree happens to sort first, which from the outside is indistinguishable from being stuck.

A directory that will not list is skipped, not fatal. One unreadable directory is ordinary on someone else's machine, and dropping every result below it is not a reasonable answer to it.

It returns when the queue empties, when ctx is cancelled, or at limit; capped reports which of the last two ended it.

func StartDir

func StartDir(f FS, home string) string

StartDir is where a side should open when it connects.

A remote side opens at its home: there is nowhere else it could mean. THIS machine opens where sshu was launched, because that is the directory you were standing in when you decided you needed sshu — `cd ~/release && sshu` should already be looking at the release, not at a home directory full of dotfiles you then have to navigate out of.

sshu never changes its own working directory, so asking for it here is the same as having captured it at startup.

func WriteBack

func WriteBack(f FS, src, p string, mode fs.FileMode) error

WriteBack replaces p's contents with the file at src.

The bytes go to a sibling name first and are then moved over the target, so the file is never seen half-written: a link that drops mid-upload leaves the original intact rather than a truncated config that the next process to read it will believe.

Two cases step around that, and both are deliberate:

  • a SYMLINK is written through. Renaming over one replaces the link with a regular file — the dotfile you edited would quietly stop pointing at the repository it came from.
  • a directory that will not take a new file (your file, somebody else's directory) falls back to writing in place, because the alternative is not being able to save at all.

Each trades the atomic replace for a short window where the file is incomplete. In both, that is the smaller harm.

Types

type Entry

type Entry struct {
	Name    string
	IsDir   bool
	Size    int64
	Mode    fs.FileMode
	ModTime time.Time
}

Entry is one directory entry, reduced to what the browser draws.

type FS

type FS interface {
	// Label is what the panel title shows: "local", or the host's name.
	Label() string
	// Home is where a side starts.
	Home() (string, error)
	// List returns dir's entries, directories first then names, both
	// case-insensitively — the order a person scans in, not the order the
	// server happened to return.
	List(dir string) ([]Entry, error)
	// Stat is used to decide whether a path is a directory before entering it.
	Stat(p string) (Entry, error)
	// Lstat is Stat without following a symlink. Anything DESTRUCTIVE must use
	// it: a symlink to a directory stats as a directory, and a recursive delete
	// that believed Stat would walk into the target and empty it out.
	Lstat(p string) (Entry, error)

	// The write half, used only by transfers. It lives on the same interface so a
	// copy never has to know which end is local — upload, download and
	// remote-to-remote become one code path with different values.
	Open(p string) (io.ReadCloser, error)
	Create(p string, mode fs.FileMode) (io.WriteCloser, error)
	MkdirAll(p string) error
	Remove(p string) error
	// Rename moves a path within this filesystem. Callers check the destination
	// first: os.Rename would overwrite it and SFTP's would refuse, and one
	// operation must not depend on which end it lands on.
	Rename(from, to string) error
	// Replace is the other half of that split: move from ONTO to, whatever is
	// already there. It exists because an edit's write-back has to land in one
	// step — a file seen half-written is a file the next process to read it
	// believes.
	Replace(from, to string) error

	Close() error
}

FS is a browsable filesystem.

Paths are always slash-separated and absolute. The local implementation converts at its own boundary, so the UI never has to care which side it is talking to.

func Dial

func Dial(h store.Host, prompt HostKeyPrompt, timeout time.Duration) (FS, error)

Dial opens an SFTP session to h.

The host key is checked against ~/.ssh/known_hosts. An unknown host goes to prompt with its fingerprint; a host whose key has CHANGED is refused outright and never offered — that is the case known_hosts exists to catch, and turning it into a yes/no question trains people to answer yes. Dial opens an SFTP connection. timeout bounds the attempt, so a host that will never answer cannot hold a panel open forever; it comes from config.yaml by way of the UI, because a person on a slow link and a person on a LAN want different numbers and neither of them should have to rebuild sshu.

func DialPipe added in v1.7.0

func DialPipe(label string, cmd *exec.Cmd) (FS, error)

DialPipe speaks SFTP over a subprocess's stdin and stdout — the subprocess being `ssh -s <host> sftp`, which is how OpenSSH's own sftp client works.

It exists for the host whose auth is "sshconfig": everything about reaching that host — HostName, ProxyJump, the key, the agent, a passphrase — is in a file ssh reads and this package does not, so the only honest way to connect is to let ssh do it. Dial keeps speaking the protocol itself for the hosts whose secrets sshu holds; that path reads none of ~/.ssh/config, which is the asymmetry the Config panel already discloses, and it is not widened here — a host that told sshu its password did not ask ssh to get involved.

cmd is started here and owned by the returned FS: Close ends the subprocess. Whatever ssh printed to stderr is kept, and becomes the error text when the handshake fails — that is where ssh explains itself, in the words the Errors journal wants: "Permission denied", "Host key verification failed", "Connection timed out".

The caller decides what the subprocess IS, and that includes putting it in its own session (Setsid) so it cannot reach for the terminal sshu is drawing on: with no tty and SSH_ASKPASS set, every question ssh has goes to the helper instead. This function only knows that a session leader is killed as a group, so a ProxyJump's child goes with it.

func Local

func Local() FS

Local returns the local filesystem.

type HostKey added in v1.5.0

type HostKey struct {
	Type        string
	Key         string // base64 — the wire format, exactly as known_hosts stores it
	Fingerprint string
}

HostKey is what a server offered when asked to identify itself: the three things a known_hosts line needs, plus the fingerprint a person compares.

func ScanHostKey added in v1.5.0

func ScanHostKey(host string, port int, timeout time.Duration) (HostKey, error)

ScanHostKey opens a connection far enough to see the host key and no further.

This is what `ssh-keyscan` does, and what ssh itself does on a first connect before it asks whether to trust the fingerprint. It exists because the alternative way to add a known host is to type 68 characters of base64 that somebody has to have got from somewhere else anyway — a form nobody would use, guarding a file that matters.

Nothing is written here. The caller shows the fingerprint and asks.

type HostKeyPrompt

type HostKeyPrompt func(host, fingerprint string) bool

HostKeyPrompt is asked to approve a host that is not in known_hosts. Returning false aborts the connection.

It is a parameter rather than a policy baked in here because the decision is the user's, and this package has no way to ask them.

type Item

type Item struct {
	Src   string
	Dst   string
	Size  int64
	Mode  fs.FileMode
	IsDir bool
}

Item is one thing to create at the far end. Directories are items too: an empty directory has to arrive, and a file cannot be written before its parent exists.

func Conflicts

func Conflicts(dst FS, items []Item) []Item

Conflicts are the items whose destination already exists.

func Plan

func Plan(src FS, paths []string, dstDir string) ([]Item, int64, error)

Plan expands paths into the items a transfer will create under dstDir, recursing into directories, and returns the total bytes.

It runs before anything is written so the whole shape of the job is known up front: how many files, how many bytes, and which of them already exist at the far end. Discovering an overwrite mid-copy would mean asking a question with half the batch already committed.

type Stamp

type Stamp struct {
	Size    int64
	ModTime time.Time
}

Stamp is what a file looked like when it was read.

It is taken again before the write-back. Minutes can pass inside an editor, and somebody else can write to the same file during them; overwriting that silently is the one outcome this feature must not have.

func StampOf

func StampOf(f FS, p string) (Stamp, error)

StampOf reads p's stamp as it is now.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL
⌂ 换网址