Documentation
¶
Overview ¶
Package sftpsync recursively transfers a directory tree over an SFTP connection that the caller already owns.
Status ¶
Pre-1.0 (v0.1.0), in development. The safety properties are tested but the library has not yet been used in anger. Expect API churn in minor versions; breaking changes are called out loudly in CHANGELOG.md. Bug reports welcome — https://github.com/hollis-labs/go-sftpsync/issues is the fastest way to get something fixed.
It exists because github.com/pkg/sftp gives you the primitives — Walk, ReadDir, MkdirAll, Chtimes — and leaves recursion, safety and options to you, and because every recursive alternative speaks the SCP protocol that OpenSSH has deprecated. The value here is not the wrapping; it is the opinions:
- Permission bits are carried across, so an uploaded script arrives executable.
- A symlink whose target resolves outside the sync root is refused, not followed. See ErrSymlinkEscape.
- Every file is written to a sibling temp name and renamed into place, so an interrupted transfer never replaces a good file with half of a new one.
- Cancellation is honoured between files and inside one. An io.Copy over a dropped VPN stops when the context says so, not when TCP notices.
- The return value is a Result — what moved, what was skipped, how many bytes, how long — because callers show this to operators.
- WithDryRun walks and reports without writing, so a caller can preview an operation without reimplementing the walk.
Connections ¶
This package takes a connection; it does not make one. Upload and Download accept a live *sftp.Client, UploadOverSSH and DownloadOverSSH a live *ssh.Client. Nothing here reads ~/.ssh/config, accepts credentials, or dials. Authentication, host-key policy and connection lifetime belong to the caller, who already has opinions about all three; a library that dialled again would be a second transport with different failure modes from the one in use.
No delta transfer ¶
Every byte is copied every time. There is no rolling checksum, no size-and-mtime skip, and no deletion of files that exist only at the destination. That is the right trade for config files, env files, compose stacks and agent definitions, and the wrong one for a 600MB image. If you are moving something large and mostly unchanged, use rsync.
Index ¶
- Variables
- type Action
- type Direction
- type Entry
- type Option
- type PathError
- type Result
- func Download(ctx context.Context, sc *sftp.Client, remoteDir, localDir string, ...) (*Result, error)
- func DownloadOverSSH(ctx context.Context, c *ssh.Client, remoteDir, localDir string, opts ...Option) (*Result, error)
- func Upload(ctx context.Context, sc *sftp.Client, localDir, remoteDir string, ...) (*Result, error)
- func UploadOverSSH(ctx context.Context, c *ssh.Client, localDir, remoteDir string, opts ...Option) (*Result, error)
- type Side
- type SymlinkPolicy
Constants ¶
This section is empty.
Variables ¶
var ( // ErrSymlinkEscape reports a symlink whose target resolves outside the // sync root. It is a sentinel rather than a plain error because a caller // acts on it differently from an I/O failure: an escape is a property of // the tree that retrying will not fix, and is worth naming to an operator. // // Wrapped in a [*PathError] that names the link, so the offending path is // recoverable as well as the condition. ErrSymlinkEscape = errors.New("symlink target escapes the sync root") // ErrNotDirectory reports that a sync root is not a directory. This // package transfers trees; for a single file, pkg/sftp is enough on its own. ErrNotDirectory = errors.New("not a directory") // ErrMaxDepthExceeded reports that the walk descended past the configured // limit (see [WithMaxDepth]). Under [SymlinkDereferenceIncludingOutsideRoot] // this is also how a symlink loop terminates. ErrMaxDepthExceeded = errors.New("maximum directory depth exceeded") )
Functions ¶
This section is empty.
Types ¶
type Action ¶
type Action string
Action is what happened to one entry in the tree.
const ( // ActionFile is a regular file whose contents were transferred. ActionFile Action = "file" // ActionDir is a directory that exists at the destination and was walked. ActionDir Action = "dir" // ActionSymlink is a symlink recreated at the destination. ActionSymlink Action = "symlink" // ActionSkip is an entry deliberately not transferred. [Entry.Reason] // says why. ActionSkip Action = "skip" )
type Entry ¶
type Entry struct {
// Path is relative to the sync root and always slash-separated, on both
// sides and on every OS, so it is comparable between the two ends. The
// root itself is ".".
Path string
// Action is what happened, or what would have happened under a dry run.
Action Action
// Mode is the permission bits the destination entry was given. It is zero
// for [ActionSymlink], where nothing is chmodded — a link's own bits are
// not portable and are not carried. For [ActionSkip] it is the source's,
// for information.
Mode os.FileMode
// Size is the byte count, for regular files only.
Size int64
// Target is the symlink target as written, when Action is [ActionSymlink].
Target string
// Reason explains the skip, when Action is [ActionSkip]. Empty otherwise.
Reason string
}
Entry is one thing the walk found. The set of entries is identical for a dry run and for the transfer it previews.
type Option ¶
type Option func(*config)
Option configures a transfer. Options are functional so that adding one does not break callers, and so that a call site names only what it changes.
func WithDirMode ¶
WithDirMode sets the permission bits given to created directories when WithPreserveMode is off. Default: 0755. Ignored while preserving.
func WithDryRun ¶
WithDryRun walks the source and reports what would happen without creating, writing, renaming or changing anything at the destination. Default: false.
It is an option on the transfer functions rather than a separate Plan call on purpose: a preview is only worth showing an operator if it came from the same walk as the transfer, and a second entry point is a second thing to keep in step.
func WithFileMode ¶
WithFileMode sets the permission bits given to transferred files when WithPreserveMode is off. Default: 0644. Ignored while preserving.
func WithMaxDepth ¶
WithMaxDepth bounds how far below the sync root the walk descends, counting the root as depth 0. Default: 64. A value below 1 restores the default.
Exceeding it is ErrMaxDepthExceeded rather than a silent truncation: a tree deeper than the limit is a tree this package has not fully copied, and a caller must not be told otherwise.
func WithPreserveModTime ¶
WithPreserveModTime carries the source modification time to the destination. Default: false.
Off by default because a transferred file is new at the destination and most callers want its mtime to say so. Turn it on when something downstream compares timestamps across the two sides.
func WithPreserveMode ¶
WithPreserveMode carries the source permission bits to the destination. Default: true.
It is on by default because the alternative surfaces an hour later: an uploaded script that arrives 0644 fails at the moment something tries to run it, far from the transfer that caused it. With it off, files get WithFileMode and directories WithDirMode.
func WithSymlinkPolicy ¶
func WithSymlinkPolicy(p SymlinkPolicy) Option
WithSymlinkPolicy chooses what symlinks inside the tree mean. Default: SymlinkReplicate.
type PathError ¶
type PathError struct {
// Op is the operation that failed: "open", "create", "mkdir", "readdir",
// "lstat", "stat", "copy", "close", "chmod", "chtimes", "symlink",
// "rename" or "walk".
Op string
// Side is the machine Path lives on.
Side Side
// Path is the absolute path on Side, as this package addressed it.
Path string
// Err is the underlying cause.
Err error
}
PathError is the error every operation in this package returns. It names the operation, the side it happened on and the path it happened to, and unwraps to the underlying cause — so errors.Is against ErrSymlinkEscape, context.Canceled or fs.ErrPermission all work through it.
type Result ¶
type Result struct {
// Direction is which way the transfer went.
Direction Direction
// Source and Destination are the two roots, cleaned.
Source string
Destination string
// DryRun reports whether anything was actually written.
DryRun bool
// Entries is every entry the walk reached, in the order it reached them:
// a directory before its contents, and names within a directory sorted,
// so two runs over an unchanged tree report the same thing.
Entries []Entry
// Files, Dirs, Symlinks and Skipped count Entries by action.
Files int
Dirs int
Symlinks int
Skipped int
// Bytes is the total size of the files transferred.
Bytes int64
// Duration is how long the walk took, including the failed attempt when
// the transfer returned an error.
Duration time.Duration
}
Result is what happened. Transfers return one even when they fail, so a caller can tell an operator how far the transfer got before it stopped — which is the difference between "nothing moved" and "half the tree moved".
func Download ¶
func Download(ctx context.Context, sc *sftp.Client, remoteDir, localDir string, opts ...Option) (*Result, error)
Download recursively copies remoteDir to localDir over sc, which must be a live client the caller opened and continues to own.
It is the same walk as Upload with the two ends exchanged — the same escape check, the same temp-and-rename writer, the same cancellation. That is deliberate: the download path is where "it is only reading" quietly becomes "it wrote outside the destination", and one implementation cannot drift into two sets of safety properties.
func DownloadOverSSH ¶
func DownloadOverSSH(ctx context.Context, c *ssh.Client, remoteDir, localDir string, opts ...Option) (*Result, error)
DownloadOverSSH opens an SFTP subsystem on c, runs Download across it and closes the subsystem again. c is left open; see UploadOverSSH.
func Upload ¶
func Upload(ctx context.Context, sc *sftp.Client, localDir, remoteDir string, opts ...Option) (*Result, error)
Upload recursively copies localDir to remoteDir over sc, which must be a live client the caller opened and continues to own. Nothing here dials, authenticates, or reads an SSH config.
remoteDir is created if it does not exist, taking localDir's mode; if it already exists its own mode is left alone. Everything below it is created as needed.
A *Result is returned even when err is non-nil, describing how far the transfer got. Errors are *PathError and name the side and the path.
func UploadOverSSH ¶
func UploadOverSSH(ctx context.Context, c *ssh.Client, localDir, remoteDir string, opts ...Option) (*Result, error)
UploadOverSSH opens an SFTP subsystem on c, runs Upload across it and closes the subsystem again. c itself is neither opened nor closed by this package — it stays exactly as the caller left it, still usable for sessions and for further transfers.
Use it when a transfer is a one-off. When several transfers share a connection, open one *sftp.Client yourself and call Upload, so they share a subsystem too.
type Side ¶
type Side string
Side names which end of a transfer a path lives on. Every error this package returns carries one. "permission denied" without a side sends an operator to the wrong machine, which is the failure mode this package exists to avoid.
type SymlinkPolicy ¶
type SymlinkPolicy int
SymlinkPolicy decides what a symlink inside the tree means.
The default refuses to follow anything out of the sync root. The policy that lifts that refusal is named so that choosing it reads as a decision at the call site rather than as a tuning knob.
const ( // SymlinkReplicate recreates a symlink on the destination with the same // target, and refuses one whose target escapes the sync root with // [ErrSymlinkEscape]. Nothing is read through the link, so a link to a // 40GB file or to a device node costs nothing and a loop is impossible. // // An absolute target counts as an escape even when it happens to point // inside the source root: the same absolute path on the other machine is // a different place, and this package will not guess which one you meant. // // This is the default. SymlinkReplicate SymlinkPolicy = iota // SymlinkSkip transfers no symlinks at all and records each one in the // [Result] as skipped. Nothing is read and nothing is created, so an // escaping link is not an error under this policy — it is simply not // copied. Use it when the destination must contain only regular files. SymlinkSkip // SymlinkDereferenceIncludingOutsideRoot follows every symlink and copies // what it points at, including targets outside the sync root. A link to // /etc/shadow copies /etc/shadow. // // This is the escape hatch, and the name is the warning. Choose it only // when you control the tree and know that following is what you want. // Symlink loops terminate at [WithMaxDepth] with [ErrMaxDepthExceeded] // rather than running forever. SymlinkDereferenceIncludingOutsideRoot )
func (SymlinkPolicy) String ¶
func (p SymlinkPolicy) String() string
String implements fmt.Stringer.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
examples
|
|
|
overssh
command
Package main demonstrates the shape a real caller uses: you own the connection, this library only uses it.
|
Package main demonstrates the shape a real caller uses: you own the connection, this library only uses it. |
|
preview
command
Package main is a runnable end-to-end demonstration of go-sftpsync: it previews a directory transfer, then performs it, and prints both reports.
|
Package main is a runnable end-to-end demonstration of go-sftpsync: it previews a directory transfer, then performs it, and prints both reports. |