aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--ccp.go121
-rw-r--r--go.mod19
-rw-r--r--go.sum118
-rw-r--r--internal/cp/cp.go237
-rw-r--r--internal/wfs/osfs/osfs.go58
-rw-r--r--internal/wfs/sftpfs/sftpfs.go234
-rw-r--r--internal/wfs/wfs.go86
7 files changed, 730 insertions, 143 deletions
diff --git a/ccp.go b/ccp.go
index e2cdacf..ced1b27 100644
--- a/ccp.go
+++ b/ccp.go
@@ -1,10 +1,10 @@
package main
import (
+ "errors"
"flag"
"fmt"
"os"
- "path/filepath"
"strings"
"sync/atomic"
"time"
@@ -13,6 +13,8 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"gitlab.com/rhogenson/ccp/internal/cp"
+ "gitlab.com/rhogenson/ccp/internal/wfs/osfs"
+ "gitlab.com/rhogenson/ccp/internal/wfs/sftpfs"
"gitlab.com/rhogenson/deque"
)
@@ -27,23 +29,25 @@ type model struct {
progress progress.Model
msgs chan tea.Msg
- srcs []string
- dst string
+ srcs []cp.FSPath
+ dst cp.FSPath
max int64
current atomic.Int64
measurements deque.Deque[measurement]
- copyingFiles map[string]bool
+ copyingFiles map[string]string
copyingFile string
errs []string
}
type (
- tickMsg struct{}
+ tickMsg time.Time
maxMsg int64
- fileStartMsg string
- fileDoneMsg struct {
+ fileStartMsg struct {
+ from, to string
+ }
+ fileDoneMsg struct {
name string
err error
}
@@ -57,13 +61,13 @@ func (m *model) listen() tea.Cmd {
}
func tick() tea.Cmd {
- return tea.Tick(10*time.Millisecond, func(time.Time) tea.Msg { return tickMsg{} })
+ return tea.Tick(10*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) })
}
func (m *model) Init() tea.Cmd {
return tea.Batch(
func() tea.Msg {
- cp.Copy(m.srcs, m.dst, *f, m)
+ cp.Copy(m, m.srcs, m.dst, *f)
return doneMsg{}
},
m.listen(),
@@ -76,10 +80,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.max = int64(msg)
return m, m.listen()
case fileStartMsg:
- name := string(msg)
- m.copyingFiles[name] = true
+ m.copyingFiles[msg.from] = msg.to
if m.copyingFile == "" {
- m.copyingFile = name
+ m.copyingFile = msg.from
}
return m, m.listen()
case fileDoneMsg:
@@ -102,12 +105,14 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
tea.Quit)
case tickMsg:
- now := time.Now()
- for m.measurements.Len() > 1 && now.Sub(m.measurements.At(0).t) > 2*time.Minute {
- m.measurements.PopFront()
- }
n := m.current.Load()
- m.measurements.PushBack(measurement{now, n})
+ now := time.Time(msg)
+ if m.measurements.Len() == 0 || now.Sub(m.measurements.At(m.measurements.Len()-1).t) > 500*time.Millisecond {
+ for m.measurements.Len() > 1 && now.Sub(m.measurements.At(0).t) > 2*time.Minute {
+ m.measurements.PopFront()
+ }
+ m.measurements.PushBack(measurement{now, n})
+ }
cmds := []tea.Cmd{tick()}
if m.max > 0 {
cmds = append(cmds, m.progress.SetPercent(float64(n)/float64(m.max)))
@@ -132,7 +137,7 @@ var warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Render
func (m *model) View() string {
copying := ""
if m.copyingFile != "" {
- copying = "Copying " + m.copyingFile + "..."
+ copying = m.copyingFile + " -> " + m.copyingFiles[m.copyingFile]
}
etaStr := "calculating..."
if m.max > 0 && m.measurements.Len() > 1 {
@@ -159,50 +164,88 @@ func (m *model) Progress(n int64) {
m.current.Add(n)
}
-func (m *model) FileStart(name string) {
- m.msgs <- fileStartMsg(name)
+func (m *model) FileStart(from, to string) {
+ m.msgs <- fileStartMsg{from, to}
}
func (m *model) FileDone(name string, err error) {
m.msgs <- fileDoneMsg{name, err}
}
-func main() {
- flag.Usage = func() {
- fmt.Fprintf(os.Stderr, `Usage: ccp [OPTION]... SOURCE DEST
- or: ccp [OPTION]... SOURCE... DIRECTORY
-
-Copy SOURCE to DEST, or multiple SOURCE(s) to DIRECTORY.
-
-`)
- flag.PrintDefaults()
+func splitHostPath(target string) (string, string) {
+ i := strings.IndexAny(target, ":/")
+ if i < 0 || target[i] == '/' {
+ return "", target
}
- flag.Parse()
+ return target[:i], target[i+1:]
+}
+func run() error {
args := flag.Args()
if len(args) < 2 {
- fmt.Fprintln(os.Stderr, "usage error")
- os.Exit(2)
+ return errors.New("usage error")
}
- srcs, dst := args[:len(args)-1], args[len(args)-1]
- if len(srcs) == 1 {
- if stat, err := os.Stat(dst); err == nil && stat.IsDir() {
- dst = filepath.Join(dst, filepath.Base(srcs[0]))
+ srcTargets, dstTarget := args[:len(args)-1], args[len(args)-1]
+ sftpHosts := make(map[string]*sftpfs.FS)
+ for _, tgt := range append(srcTargets, dstTarget) {
+ host, _ := splitHostPath(tgt)
+ if host == "" || sftpHosts[host] != nil {
+ continue
}
+ fs, err := sftpfs.Dial(host)
+ if err != nil {
+ return err
+ }
+ defer fs.Close()
+ sftpHosts[host] = fs
+ }
+ srcs := make([]cp.FSPath, len(srcTargets))
+ for i, tgt := range srcTargets {
+ host, path := splitHostPath(tgt)
+ if host == "" {
+ srcs[i] = cp.FSPath{FS: osfs.FS{}, Path: path}
+ } else {
+ srcs[i] = cp.FSPath{FS: sftpHosts[host], Path: path}
+ }
+ }
+ dstHost, dstPath := splitHostPath(dstTarget)
+ var dst cp.FSPath
+ if dstHost == "" {
+ dst = cp.FSPath{FS: osfs.FS{}, Path: dstPath}
+ } else {
+ dst = cp.FSPath{FS: sftpHosts[dstHost], Path: dstPath}
}
m := &model{
progress: progress.New(progress.WithDefaultGradient(), progress.WithoutPercentage()),
msgs: make(chan tea.Msg),
- copyingFiles: make(map[string]bool),
+ copyingFiles: make(map[string]string),
srcs: srcs,
dst: dst,
}
if _, err := tea.NewProgram(m, tea.WithInput(nil), tea.WithOutput(os.Stderr)).Run(); err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
+ return err
}
if len(m.errs) > 0 {
+ return errors.New("exiting with one or more errors")
+ }
+ return nil
+}
+
+func main() {
+ flag.Usage = func() {
+ fmt.Fprintf(os.Stderr, `Usage: ccp [OPTION]... SOURCE DEST
+ or: ccp [OPTION]... SOURCE... DIRECTORY
+
+Copy SOURCE to DEST, or multiple SOURCE(s) to DIRECTORY.
+
+`)
+ flag.PrintDefaults()
+ }
+ flag.Parse()
+
+ if err := run(); err != nil {
+ fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
diff --git a/go.mod b/go.mod
index af28621..816c326 100644
--- a/go.mod
+++ b/go.mod
@@ -3,27 +3,34 @@ module gitlab.com/rhogenson/ccp
go 1.24.1
require (
- github.com/charmbracelet/bubbles v0.20.0
+ github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.4
+ github.com/charmbracelet/lipgloss v1.1.0
+ github.com/pkg/sftp v1.13.9
gitlab.com/rhogenson/deque v0.0.0-20250406161547-43e0f85d8030
+ golang.org/x/crypto v0.37.0
+ golang.org/x/term v0.31.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+ github.com/charmbracelet/colorprofile v0.3.0 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
- github.com/charmbracelet/lipgloss v1.0.0 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
+ github.com/kr/fs v0.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
- github.com/muesli/termenv v0.15.2 // indirect
+ github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
- golang.org/x/sync v0.12.0 // indirect
- golang.org/x/sys v0.31.0 // indirect
- golang.org/x/text v0.23.0 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ golang.org/x/sync v0.13.0 // indirect
+ golang.org/x/sys v0.32.0 // indirect
+ golang.org/x/text v0.24.0 // indirect
)
diff --git a/go.sum b/go.sum
index 8c77e10..f73ca01 100644
--- a/go.sum
+++ b/go.sum
@@ -1,19 +1,29 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
-github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
-github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
+github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
+github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
+github.com/charmbracelet/colorprofile v0.3.0 h1:KtLh9uuu1RCt+Hml4s6Hz+kB1PfV3wi++1h5ia65yKQ=
+github.com/charmbracelet/colorprofile v0.3.0/go.mod h1:oHJ340RS2nmG1zRGPmhJKJ/jf4FPNNk0P39/wBPA1G0=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
-github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
-github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
+github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
+github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
+github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -26,18 +36,102 @@ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
-github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
-github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw=
+github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
gitlab.com/rhogenson/deque v0.0.0-20250406161547-43e0f85d8030 h1:ElfzUwCcfm4HHxJkMfoEn5w8VOoI+XRYfa9Vdo7Plno=
gitlab.com/rhogenson/deque v0.0.0-20250406161547-43e0f85d8030/go.mod h1:+JcaVhyJ7VgoRMo4PsMgOCDoVnIOLts4CkDEAYkU4o4=
-golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
-golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
+golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
+golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
+golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
+golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
-golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
-golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
-golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
+golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
+golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
+golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
+golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
+golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
+golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
+golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
+golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/cp/cp.go b/internal/cp/cp.go
index c19627f..d944f9c 100644
--- a/internal/cp/cp.go
+++ b/internal/cp/cp.go
@@ -5,21 +5,36 @@ import (
"fmt"
"io"
"io/fs"
- "os"
- "path/filepath"
+ "path"
+ "sync"
+
+ "gitlab.com/rhogenson/ccp/internal/wfs"
+ "gitlab.com/rhogenson/ccp/internal/wfs/sftpfs"
)
type Progress interface {
Max(int64)
Progress(int64)
- FileStart(string)
+ FileStart(string, string)
FileDone(string, error)
}
-func size(files []string) int64 {
+type FSPath struct {
+ FS wfs.FS
+ Path string
+}
+
+func (p FSPath) String() string {
+ if fsys, ok := p.FS.(*sftpfs.FS); ok {
+ return fsys.User + "@" + fsys.Host + ":" + p.Path
+ }
+ return p.Path
+}
+
+func size(srcs []FSPath) int64 {
var n int64 = 0
- for _, f := range files {
- filepath.WalkDir(f, func(path string, d fs.DirEntry, err error) error {
+ for _, src := range srcs {
+ fs.WalkDir(src.FS, src.Path, func(_ string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
@@ -30,7 +45,7 @@ func size(files []string) int64 {
return nil
}
n += 1 + stat.Size()
- case os.ModeSymlink, os.ModeDir:
+ case fs.ModeSymlink, fs.ModeDir:
n++
}
return nil
@@ -39,15 +54,40 @@ func size(files []string) int64 {
return n
}
-func fileExists(path string) bool {
- _, err := os.Lstat(path)
- return !errors.Is(err, os.ErrNotExist)
+func fileExists(path FSPath) bool {
+ _, err := wfs.Lstat(path.FS, path.Path)
+ return !errors.Is(err, fs.ErrNotExist)
+}
+
+type copier struct {
+ p Progress
+ sem chan struct{}
+
+ force bool
+}
+
+func (c *copier) g(fn func()) {
+ c.sem <- struct{}{}
+ go func() {
+ defer func() { <-c.sem }()
+ fn()
+ }()
+}
+
+func (c *copier) openWithRetry(path FSPath, fn func() error) error {
+ if err := fn(); err == nil || !c.force || !fileExists(path) {
+ return err
+ }
+ if err := wfs.RemoveAll(path.FS, path.Path); err != nil {
+ return err
+ }
+ return fn()
}
-func copyRegularFile(src, dst string, force bool, progress Progress) (err error) {
- progress.FileStart(src)
+func (c *copier) copyRegularFile(src, dst FSPath) error {
+ c.p.FileStart(src.String(), dst.String())
- in, err := os.Open(src)
+ in, err := src.FS.Open(src.Path)
if err != nil {
return err
}
@@ -56,117 +96,142 @@ func copyRegularFile(src, dst string, force bool, progress Progress) (err error)
if err != nil {
return err
}
- out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, stat.Mode().Perm())
- if err != nil {
- if !force || !fileExists(dst) {
- return err
- }
- if err := os.RemoveAll(dst); err != nil {
- return err
- }
- out, err = os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, stat.Mode().Perm())
- if err != nil {
- return err
- }
+ var out io.WriteCloser
+ if err := c.openWithRetry(dst, func() error {
+ var err error
+ out, err = dst.FS.Create(dst.Path, stat.Mode().Perm())
+ return err
+ }); err != nil {
+ return err
}
- defer out.Close()
for {
n, err := io.CopyN(out, in, 1024*1024)
if n > 0 {
- progress.Progress(n)
+ c.p.Progress(n)
}
if err != nil {
if err == io.EOF {
break
}
+ out.Close()
return err
}
}
if err := out.Close(); err != nil {
return err
}
- progress.Progress(1)
+ c.p.Progress(1)
+ c.p.FileDone(src.String(), nil)
return nil
}
-func copySpecialFile(src string, d fs.DirEntry, dst string, force bool, progress Progress) error {
- switch d.Type() {
- case fs.ModeSymlink:
- target, err := os.Readlink(src)
- if err != nil {
- return err
+func (c *copier) copySymlink(src FSPath, d fs.DirEntry, dst FSPath) error {
+ target, err := wfs.ReadLink(src.FS, src.Path)
+ if err != nil {
+ return err
+ }
+ if err := c.openWithRetry(dst, func() error {
+ return dst.FS.Symlink(target, dst.Path)
+ }); err != nil {
+ return err
+ }
+ c.p.Progress(1)
+ return nil
+}
+
+func (c *copier) copyDir(src FSPath, d fs.DirEntry, dst FSPath) error {
+ stat, err := d.Info()
+ if err != nil {
+ return err
+ }
+ hasWritePerm := stat.Mode()&0300 == 0300
+ if err := c.openWithRetry(dst, func() error {
+ if hasWritePerm {
+ return wfs.MkdirMode(dst.FS, dst.Path, stat.Mode().Perm())
+ } else {
+ return dst.FS.Mkdir(dst.Path)
}
- if err := os.Symlink(target, dst); err != nil {
- if !force || !fileExists(dst) {
- return err
- }
- if err := os.RemoveAll(dst); err != nil {
- return err
- }
- if err := os.Symlink(target, dst); err != nil {
- return err
+ }); err != nil {
+ return err
+ }
+ entries, err := fs.ReadDir(src.FS, src.Path)
+ wg := new(sync.WaitGroup)
+ <-c.sem
+ for _, entry := range entries {
+ wg.Add(1)
+ c.g(func() {
+ defer wg.Done()
+ src := FSPath{src.FS, path.Join(src.Path, entry.Name())}
+ if err := c.copyFile(src, entry, FSPath{dst.FS, path.Join(dst.Path, entry.Name())}); err != nil {
+ c.p.FileDone(src.String(), err)
}
- }
- progress.Progress(1)
- case fs.ModeDir:
- stat, err := d.Info()
- if err != nil {
+ })
+ }
+ wg.Wait()
+ c.sem <- struct{}{}
+ if err != nil {
+ return err
+ }
+ if !hasWritePerm {
+ if err := dst.FS.Chmod(dst.Path, stat.Mode().Perm()); err != nil {
return err
}
- if err := os.Mkdir(dst, stat.Mode().Perm()); err != nil {
- if !force || !fileExists(dst) {
- return err
- }
- if err := os.RemoveAll(dst); err != nil {
- return err
- }
- if err := os.Mkdir(dst, stat.Mode().Perm()); err != nil {
- return err
- }
- }
- progress.Progress(1)
+ }
+ c.p.Progress(1)
+ return nil
+}
+
+func (c *copier) copyFile(src FSPath, d fs.DirEntry, dst FSPath) error {
+ switch d.Type() {
+ case 0: // regular file
+ return c.copyRegularFile(src, dst)
+ case fs.ModeDir:
+ return c.copyDir(src, d, dst)
+ case fs.ModeSymlink:
+ return c.copySymlink(src, d, dst)
default:
return fmt.Errorf("%s: unknown file type %s", src, d.Type())
}
- return nil
}
-func Copy(srcs []string, dstRoot string, force bool, progress Progress) {
+func Copy(progress Progress, srcs []FSPath, dstRoot FSPath, force bool) {
go func() {
progress.Max(size(srcs))
}()
+
+ dstIsDir := true
+ if len(srcs) == 1 {
+ stat, err := fs.Stat(dstRoot.FS, dstRoot.Path)
+ dstIsDir = err == nil && stat.IsDir()
+ }
+
const maxConcurrency = 10
// sem acts as a semaphore to limit the number of concurrent file copies
sem := make(chan struct{}, maxConcurrency)
+ c := &copier{
+ p: progress,
+ sem: sem,
+ force: force,
+ }
+ wg := new(sync.WaitGroup)
for _, srcRoot := range srcs {
- filepath.WalkDir(srcRoot, func(src string, d fs.DirEntry, err error) error {
- if err != nil {
- progress.FileDone(src, err)
- return nil
+ wg.Add(1)
+ c.g(func() {
+ defer wg.Done()
+ dstRoot := dstRoot
+ if dstIsDir {
+ dstRoot.Path = path.Join(dstRoot.Path, path.Base(srcRoot.Path))
}
- relPath, err := filepath.Rel(srcRoot, src)
+ stat, err := fs.Stat(srcRoot.FS, srcRoot.Path)
if err != nil {
- progress.FileDone(src, err)
- return nil
+ progress.FileDone(srcRoot.String(), err)
+ return
}
- dst := filepath.Join(dstRoot, relPath)
- if d.Type().IsRegular() {
- sem <- struct{}{}
- go func() {
- defer func() { <-sem }()
- err := copyRegularFile(src, dst, force, progress)
- progress.FileDone(src, err)
- }()
- } else {
- if err := copySpecialFile(src, d, dst, force, progress); err != nil {
- progress.FileDone(src, err)
- return nil
- }
+ if err := c.copyFile(srcRoot, fs.FileInfoToDirEntry(stat), dstRoot); err != nil {
+ progress.FileDone(srcRoot.String(), err)
+ return
}
- return nil
})
}
- for range maxConcurrency {
- sem <- struct{}{}
- }
+ wg.Wait()
}
diff --git a/internal/wfs/osfs/osfs.go b/internal/wfs/osfs/osfs.go
new file mode 100644
index 0000000..bf6951c
--- /dev/null
+++ b/internal/wfs/osfs/osfs.go
@@ -0,0 +1,58 @@
+package osfs
+
+import (
+ "io"
+ "io/fs"
+ "os"
+
+ "gitlab.com/rhogenson/ccp/internal/wfs"
+)
+
+var (
+ _ wfs.FS = FS{}
+ _ wfs.MkdirModeFS = FS{}
+ _ wfs.ReadLinkFS = FS{}
+ _ fs.StatFS = FS{}
+)
+
+type FS struct{}
+
+func (FS) Open(name string) (fs.File, error) {
+ return os.Open(name)
+}
+
+func (FS) Stat(name string) (fs.FileInfo, error) {
+ return os.Stat(name)
+}
+
+func (FS) Lstat(name string) (fs.FileInfo, error) {
+ return os.Lstat(name)
+}
+
+func (FS) ReadLink(name string) (string, error) {
+ return os.Readlink(name)
+}
+
+func (FS) Create(name string, perm fs.FileMode) (io.WriteCloser, error) {
+ return os.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
+}
+
+func (FS) Remove(name string) error {
+ return os.Remove(name)
+}
+
+func (FS) Mkdir(name string) error {
+ return os.Mkdir(name, 0700)
+}
+
+func (FS) MkdirMode(name string, mode fs.FileMode) error {
+ return os.Mkdir(name, mode)
+}
+
+func (FS) Symlink(oldname, newname string) error {
+ return os.Symlink(oldname, newname)
+}
+
+func (FS) Chmod(name string, mode fs.FileMode) error {
+ return os.Chmod(name, mode)
+}
diff --git a/internal/wfs/sftpfs/sftpfs.go b/internal/wfs/sftpfs/sftpfs.go
new file mode 100644
index 0000000..1457475
--- /dev/null
+++ b/internal/wfs/sftpfs/sftpfs.go
@@ -0,0 +1,234 @@
+package sftpfs
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "net"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+ "sync"
+
+ "github.com/pkg/sftp"
+ "gitlab.com/rhogenson/ccp/internal/wfs"
+ "golang.org/x/crypto/ssh"
+ "golang.org/x/crypto/ssh/agent"
+ "golang.org/x/crypto/ssh/knownhosts"
+ "golang.org/x/term"
+)
+
+var (
+ _ wfs.FS = (*FS)(nil)
+ _ wfs.ReadLinkFS = (*FS)(nil)
+ _ fs.StatFS = (*FS)(nil)
+ _ fs.StatFS = (*FS)(nil)
+ _ fs.ReadDirFS = (*FS)(nil)
+)
+
+type FS struct {
+ User, Host string
+ conn *sftp.Client
+ sshConn *ssh.Client
+}
+
+var sshAgent = sync.OnceValue(func() agent.ExtendedAgent {
+ socket := os.Getenv("SSH_AUTH_SOCK")
+ if socket == "" {
+ return nil
+ }
+ conn, err := net.Dial("unix", socket)
+ if err != nil {
+ return nil
+ }
+ return agent.NewClient(conn)
+})
+
+func sshKeys() ([]ssh.Signer, error) {
+ sshAgent := sshAgent()
+ if sshAgent != nil {
+ if signers, err := sshAgent.Signers(); err == nil && len(signers) > 0 {
+ return signers, nil
+ }
+ }
+ sshDir := filepath.Join(os.Getenv("HOME"), ".ssh")
+ sshFiles, err := os.ReadDir(sshDir)
+ if len(sshFiles) == 0 {
+ return nil, err
+ }
+ var keys []ssh.Signer
+ var passwordProtectedKey []byte
+ var passwordProtectedKeyFile string
+ for _, f := range sshFiles {
+ if f.Name() == "known_hosts" || strings.HasSuffix(f.Name(), ".pub") {
+ continue
+ }
+ fileName := filepath.Join(sshDir, f.Name())
+ keyBytes, err := os.ReadFile(fileName)
+ if err != nil {
+ continue
+ }
+ key, err := ssh.ParsePrivateKey(keyBytes)
+ if err != nil {
+ if passwordProtectedKey == nil && errors.As(err, new(*ssh.PassphraseMissingError)) {
+ passwordProtectedKey = keyBytes
+ passwordProtectedKeyFile = fileName
+ }
+ continue
+ }
+ keys = append(keys, key)
+ }
+ if len(keys) == 0 && passwordProtectedKey != nil {
+ fmt.Fprintf(os.Stderr, "Enter password for %s: ", passwordProtectedKeyFile)
+ for i := range 3 {
+ if i > 0 {
+ fmt.Fprintf(os.Stderr, "Incorrect password, try again: ")
+ }
+ password, err := term.ReadPassword(int(os.Stdin.Fd()))
+ fmt.Fprintln(os.Stderr)
+ if err != nil {
+ return nil, err
+ }
+ key, err := ssh.ParseRawPrivateKeyWithPassphrase(passwordProtectedKey, password)
+ if err != nil {
+ continue
+ }
+ if sshAgent != nil {
+ sshAgent.Add(agent.AddedKey{PrivateKey: key})
+ }
+ signer, err := ssh.NewSignerFromKey(key)
+ if err != nil {
+ return nil, err
+ }
+ return []ssh.Signer{signer}, nil
+ }
+ return nil, errors.New("user couldn't remember her password")
+ }
+ return keys, nil
+}
+
+func appendToKnownHosts(hostname string, key ssh.PublicKey) error {
+ f, err := os.OpenFile(path.Join(os.Getenv("HOME"), ".ssh/known_hosts"), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ if _, err := f.WriteString(knownhosts.Line([]string{hostname}, key) + "\n"); err != nil {
+ return err
+ }
+ return f.Close()
+}
+
+func Dial(target string) (*FS, error) {
+ knownHostChecker, err := knownhosts.New(path.Join(os.Getenv("HOME"), ".ssh/known_hosts"))
+ if err != nil {
+ knownHostChecker = func(string, net.Addr, ssh.PublicKey) error { return &knownhosts.KeyError{} }
+ }
+ var user string
+ if i := strings.Index(target, "@"); i >= 0 {
+ user, target = target[:i], target[i+1:]
+ } else {
+ user = os.Getenv("USER")
+ }
+ sshConn, err := ssh.Dial("tcp", target+":22", &ssh.ClientConfig{
+ User: user,
+ Auth: []ssh.AuthMethod{
+ ssh.PublicKeysCallback(sshKeys),
+ ssh.RetryableAuthMethod(ssh.PasswordCallback(func() (string, error) {
+ fmt.Fprintf(os.Stderr, "Enter password for %s@%s: ", user, target)
+ password, err := term.ReadPassword(int(os.Stdin.Fd()))
+ fmt.Fprintln(os.Stderr)
+ return string(password), err
+ }), 3),
+ },
+ HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
+ err := knownHostChecker(hostname, remote, key)
+ if err == nil {
+ return nil
+ }
+ var keyErr *knownhosts.KeyError
+ if !errors.As(err, &keyErr) || len(keyErr.Want) > 0 {
+ return err
+ }
+ appendToKnownHosts(hostname, key)
+ return nil
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+ sftpConn, err := sftp.NewClient(sshConn)
+ if err != nil {
+ sshConn.Close()
+ return nil, err
+ }
+ return &FS{
+ User: user,
+ Host: target,
+ conn: sftpConn,
+ sshConn: sshConn,
+ }, nil
+}
+
+func (f *FS) Close() error {
+ sftpErr := f.conn.Close()
+ if err := f.sshConn.Close(); err != nil {
+ return err
+ }
+ return sftpErr
+}
+
+func (f *FS) Open(name string) (fs.File, error) {
+ return f.conn.Open(name)
+}
+
+func (f *FS) ReadDir(name string) ([]fs.DirEntry, error) {
+ entriesFileInfo, err := f.conn.ReadDir(name)
+ entries := make([]fs.DirEntry, len(entriesFileInfo))
+ for i, entry := range entriesFileInfo {
+ entries[i] = fs.FileInfoToDirEntry(entry)
+ }
+ return entries, err
+}
+
+func (f *FS) Stat(name string) (fs.FileInfo, error) {
+ return f.conn.Stat(name)
+}
+
+func (f *FS) Lstat(name string) (fs.FileInfo, error) {
+ return f.conn.Lstat(name)
+}
+
+func (f *FS) ReadLink(name string) (string, error) {
+ return f.conn.ReadLink(name)
+}
+
+func (f *FS) Create(name string, perm fs.FileMode) (io.WriteCloser, error) {
+ file, err := f.conn.Create(name)
+ if err != nil {
+ return nil, err
+ }
+ if err := file.Chmod(perm); err != nil {
+ file.Close()
+ return nil, err
+ }
+ return file, nil
+}
+
+func (f *FS) Remove(name string) error {
+ return f.conn.Remove(name)
+}
+
+func (f *FS) Mkdir(name string) error {
+ return f.conn.Mkdir(name)
+}
+
+func (f *FS) Symlink(oldname, newname string) error {
+ return f.conn.Symlink(oldname, newname)
+}
+
+func (f *FS) Chmod(name string, mode fs.FileMode) error {
+ return f.conn.Chmod(name, mode)
+}
diff --git a/internal/wfs/wfs.go b/internal/wfs/wfs.go
new file mode 100644
index 0000000..bcdca00
--- /dev/null
+++ b/internal/wfs/wfs.go
@@ -0,0 +1,86 @@
+package wfs
+
+import (
+ "io"
+ "io/fs"
+ "path"
+)
+
+type ReadLinkFS interface {
+ fs.FS
+
+ ReadLink(string) (string, error)
+ Lstat(string) (fs.FileInfo, error)
+}
+
+func ReadLink(fsys fs.FS, name string) (string, error) {
+ sym, ok := fsys.(ReadLinkFS)
+ if !ok {
+ return "", &fs.PathError{Op: "readlink", Path: name, Err: fs.ErrInvalid}
+ }
+ return sym.ReadLink(name)
+}
+
+func Lstat(fsys fs.FS, name string) (fs.FileInfo, error) {
+ sym, ok := fsys.(ReadLinkFS)
+ if !ok {
+ return fs.Stat(fsys, name)
+ }
+ return sym.Lstat(name)
+}
+
+type FS interface {
+ fs.FS
+
+ Create(string, fs.FileMode) (io.WriteCloser, error)
+ Remove(string) error
+ Mkdir(string) error
+ Symlink(string, string) error
+ Chmod(string, fs.FileMode) error
+}
+
+type MkdirModeFS interface {
+ FS
+
+ MkdirMode(string, fs.FileMode) error
+}
+
+func MkdirMode(fsys FS, name string, mode fs.FileMode) error {
+ if fsys, ok := fsys.(MkdirModeFS); ok {
+ return fsys.MkdirMode(name, mode)
+ }
+ if err := fsys.Mkdir(name); err != nil {
+ return err
+ }
+ return fsys.Chmod(name, mode)
+}
+
+func removeDir(fsys FS, dir string) error {
+ entries, err := fs.ReadDir(fsys, dir)
+ if err != nil {
+ return err
+ }
+ for _, f := range entries {
+ name := path.Join(dir, f.Name())
+ if f.IsDir() {
+ err = removeDir(fsys, name)
+ } else {
+ err = fsys.Remove(name)
+ }
+ if err != nil {
+ return err
+ }
+ }
+ return fsys.Remove(dir)
+}
+
+func RemoveAll(fsys FS, path string) error {
+ stat, err := Lstat(fsys, path)
+ if err != nil {
+ return err
+ }
+ if stat.IsDir() {
+ return removeDir(fsys, path)
+ }
+ return fsys.Remove(path)
+}