diff options
| author | Rose Hogenson <rosehogenson@posteo.net> | 2025-04-10 07:50:41 -0700 |
|---|---|---|
| committer | Rose Hogenson <rosehogenson@posteo.net> | 2025-04-10 10:51:03 -0700 |
| commit | 89b48b3c0d4ed4eceebd76d4fd3d981b642d3824 (patch) | |
| tree | bec39fb4e71c87ccd6c9b2b9d8aff3670056f3cd | |
| parent | e2235c24231f8c33fa0b727b4b25fcd62729ccee (diff) | |
| download | ccp-89b48b3c0d4ed4eceebd76d4fd3d981b642d3824.tar.zst | |
Add support for SFTP remote file copies
| -rw-r--r-- | ccp.go | 121 | ||||
| -rw-r--r-- | go.mod | 19 | ||||
| -rw-r--r-- | go.sum | 118 | ||||
| -rw-r--r-- | internal/cp/cp.go | 237 | ||||
| -rw-r--r-- | internal/wfs/osfs/osfs.go | 58 | ||||
| -rw-r--r-- | internal/wfs/sftpfs/sftpfs.go | 234 | ||||
| -rw-r--r-- | internal/wfs/wfs.go | 86 |
7 files changed, 730 insertions, 143 deletions
@@ -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) } } @@ -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 ) @@ -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) +} |
