返回 DeepSeek-Reasonix
sftpfs.go
根目录 / internal / remote / sftpfs / sftpfs.go
1 // Package sftpfs is the SFTP file layer for the remote module: directory
2 // listing, stat, capped reads with text/binary detection, atomic writes, and
3 // the usual mkdir/rename/remove. It quarantines the github.com/pkg/sftp
4 // dependency — no other Reasonix package imports it directly. One *FS is shared
5 // per SSH connection; the underlying pkg/sftp client is safe for concurrent
6 // use.
7 package sftpfs
8
9 import (
10 "bytes"
11 "context"
12 "crypto/rand"
13 "encoding/hex"
14 "io"
15 "io/fs"
16 "os"
17 "path"
18 "strings"
19
20 "github.com/pkg/sftp"
21 "golang.org/x/crypto/ssh"
22 )
23
24 // FS wraps an SFTP client bound to one SSH connection.
25 type FS struct {
26 client *sftp.Client
27 }
28
29 // Entry is one directory entry.
30 type Entry struct {
31 Name string
32 Path string
33 Size int64
34 Mode fs.FileMode
35 ModTime int64 // unix seconds
36 IsDir bool
37 Symlink bool
38 }
39
40 // New opens an SFTP session over an established SSH client.
41 func New(cl *ssh.Client) (*FS, error) {
42 // Pipeline writes so high-RTT links overlap packet acknowledgements while
43 // preserving per-file offsets.
44 c, err := sftp.NewClient(cl, sftp.UseConcurrentWrites(true))
45 if err != nil {
46 return nil, err
47 }
48 return &FS{client: c}, nil
49 }
50
51 // Close tears down the SFTP session (not the SSH connection).
52 func (f *FS) Close() error {
53 if f == nil || f.client == nil {
54 return nil
55 }
56 return f.client.Close()
57 }
58
59 // run executes op in a goroutine and honors ctx cancellation. pkg/sftp has no
60 // context-aware API; on cancellation we abandon (not abort) the in-flight op —
61 // it completes in the background and its result is discarded.
62 func run[T any](ctx context.Context, op func() (T, error)) (T, error) {
63 type result struct {
64 val T
65 err error
66 }
67 ch := make(chan result, 1)
68 go func() {
69 v, err := op()
70 ch <- result{v, err}
71 }()
72 select {
73 case <-ctx.Done():
74 var zero T
75 return zero, ctx.Err()
76 case r := <-ch:
77 return r.val, r.err
78 }
79 }
80
81 // List returns dir entries. "~" and "~/..." resolve from the SFTP session's
82 // canonical starting directory because the protocol does not expand tildes.
83 func (f *FS) List(ctx context.Context, dir string) ([]Entry, error) {
84 return run(ctx, func() ([]Entry, error) {
85 if dir == "~" || strings.HasPrefix(dir, "~/") {
86 home, err := f.client.RealPath(".")
87 if err != nil {
88 return nil, err
89 }
90 dir = path.Join(home, strings.TrimPrefix(strings.TrimPrefix(dir, "~"), "/"))
91 }
92 infos, err := f.client.ReadDir(dir)
93 if err != nil {
94 return nil, err
95 }
96 out := make([]Entry, 0, len(infos))
97 for _, fi := range infos {
98 full := path.Join(dir, fi.Name())
99 e := Entry{
100 Name: fi.Name(),
101 Path: full,
102 Size: fi.Size(),
103 Mode: fi.Mode(),
104 ModTime: fi.ModTime().Unix(),
105 IsDir: fi.IsDir(),
106 Symlink: fi.Mode()&fs.ModeSymlink != 0,
107 }
108 // Resolve symlink dir-ness so the tree can show expanders.
109 if e.Symlink {
110 if st, serr := f.client.Stat(full); serr == nil {
111 e.IsDir = st.IsDir()
112 e.Size = st.Size()
113 }
114 }
115 out = append(out, e)
116 }
117 return out, nil
118 })
119 }
120
121 // Stat returns metadata for a single path (following symlinks).
122 func (f *FS) Stat(ctx context.Context, p string) (Entry, error) {
123 return run(ctx, func() (Entry, error) {
124 fi, err := f.client.Stat(p)
125 if err != nil {
126 return Entry{}, err
127 }
128 return Entry{
129 Name: path.Base(p),
130 Path: p,
131 Size: fi.Size(),
132 Mode: fi.Mode(),
133 ModTime: fi.ModTime().Unix(),
134 IsDir: fi.IsDir(),
135 }, nil
136 })
137 }
138
139 // ReadFile reads up to maxSize bytes (0 => DefaultReadCap). It reports
140 // truncated=true when the file exceeds the cap, and returns the detected Kind.
141 func (f *FS) ReadFile(ctx context.Context, p string, maxSize int64) (data []byte, truncated bool, kind Kind, err error) {
142 if maxSize <= 0 {
143 maxSize = DefaultReadCap
144 }
145 type res struct {
146 data []byte
147 truncated bool
148 kind Kind
149 }
150 r, err := run(ctx, func() (res, error) {
151 fh, oerr := f.client.Open(p)
152 if oerr != nil {
153 return res{}, oerr
154 }
155 defer fh.Close()
156 // Read one extra byte to detect truncation.
157 buf, rerr := io.ReadAll(io.LimitReader(fh, maxSize+1))
158 if rerr != nil {
159 return res{}, rerr
160 }
161 out := res{}
162 if int64(len(buf)) > maxSize {
163 out.truncated = true
164 buf = buf[:maxSize]
165 }
166 out.data = buf
167 out.kind = DetectKind(buf)
168 return out, nil
169 })
170 if err != nil {
171 return nil, false, KindBinary, err
172 }
173 return r.data, r.truncated, r.kind, nil
174 }
175
176 // Download streams the entire remote file p to w with no size cap. Use this for
177 // `fs get`-style whole-file transfers; ReadFile is the capped preview path and
178 // must not be used to download files (it silently truncates at DefaultReadCap).
179 // Returns the number of bytes copied.
180 func (f *FS) Download(ctx context.Context, p string, w io.Writer) (int64, error) {
181 return run(ctx, func() (int64, error) {
182 fh, oerr := f.client.Open(p)
183 if oerr != nil {
184 return 0, oerr
185 }
186 defer fh.Close()
187 return io.Copy(w, fh)
188 })
189 }
190
191 // WriteFileAtomic writes data to p via a temp file in the same directory
192 // followed by a rename, so a concurrent reader never sees a partial file.
193 func (f *FS) WriteFileAtomic(ctx context.Context, p string, data []byte, perm fs.FileMode) error {
194 _, err := f.writeFileAtomic(ctx, p, bytes.NewReader(data), perm)
195 return err
196 }
197
198 // UploadAtomic streams r into a same-directory temporary file and publishes it
199 // with the same atomic-write contract as WriteFileAtomic.
200 func (f *FS) UploadAtomic(ctx context.Context, p string, r io.Reader, perm fs.FileMode) (int64, error) {
201 return f.writeFileAtomic(ctx, p, r, perm)
202 }
203
204 func (f *FS) writeFileAtomic(ctx context.Context, p string, r io.Reader, perm fs.FileMode) (int64, error) {
205 return run(ctx, func() (int64, error) {
206 dir := path.Dir(p)
207 tmp := path.Join(dir, "."+path.Base(p)+".reasonix-tmp-"+randSuffix())
208 fh, oerr := f.client.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
209 if oerr != nil {
210 return 0, oerr
211 }
212 // Explicitly request the client's bounded packet concurrency even when
213 // the reader cannot report its total size.
214 n, werr := fh.ReadFromWithConcurrency(r, 0)
215 if werr != nil {
216 _ = fh.Close()
217 _ = f.client.Remove(tmp)
218 return n, werr
219 }
220 if cerr := fh.Close(); cerr != nil {
221 _ = f.client.Remove(tmp)
222 return n, cerr
223 }
224 if perm != 0 {
225 if cerr := f.client.Chmod(tmp, perm); cerr != nil {
226 _ = f.client.Remove(tmp)
227 return n, cerr
228 }
229 }
230 if rerr := f.rename(tmp, p); rerr != nil {
231 _ = f.client.Remove(tmp)
232 return n, rerr
233 }
234 return n, nil
235 })
236 }
237
238 // rename prefers the POSIX atomic rename extension, falling back to
239 // remove-then-rename when the destination exists on a server without it.
240 func (f *FS) rename(oldPath, newPath string) error {
241 if err := f.client.PosixRename(oldPath, newPath); err == nil {
242 return nil
243 }
244 if err := f.client.Rename(oldPath, newPath); err == nil {
245 return nil
246 }
247 // Destination may already exist on a plain-SFTP server: remove and retry.
248 if _, serr := f.client.Stat(newPath); serr == nil {
249 if rerr := f.client.Remove(newPath); rerr != nil {
250 return rerr
251 }
252 }
253 return f.client.Rename(oldPath, newPath)
254 }
255
256 // MkdirAll creates p and any missing parents.
257 func (f *FS) MkdirAll(ctx context.Context, p string) error {
258 _, err := run(ctx, func() (struct{}, error) {
259 return struct{}{}, f.client.MkdirAll(p)
260 })
261 return err
262 }
263
264 // MkdirExclusive creates exactly p and fails when it already exists. It is the
265 // atomic primitive used by cross-client remote bootstrap locks.
266 func (f *FS) MkdirExclusive(ctx context.Context, p string) error {
267 _, err := run(ctx, func() (struct{}, error) {
268 return struct{}{}, f.client.Mkdir(p)
269 })
270 return err
271 }
272
273 // Rename moves oldPath to newPath.
274 func (f *FS) Rename(ctx context.Context, oldPath, newPath string) error {
275 _, err := run(ctx, func() (struct{}, error) {
276 return struct{}{}, f.rename(oldPath, newPath)
277 })
278 return err
279 }
280
281 // Remove deletes a file or (recursively) a directory.
282 func (f *FS) Remove(ctx context.Context, p string, recursive bool) error {
283 _, err := run(ctx, func() (struct{}, error) {
284 fi, serr := f.client.Stat(p)
285 if serr != nil {
286 return struct{}{}, serr
287 }
288 if fi.IsDir() {
289 if recursive {
290 return struct{}{}, f.client.RemoveAll(p)
291 }
292 return struct{}{}, f.client.RemoveDirectory(p)
293 }
294 return struct{}{}, f.client.Remove(p)
295 })
296 return err
297 }
298
299 // RealPath resolves ~, relative, and symlinked paths to an absolute path on
300 // the remote host.
301 func (f *FS) RealPath(ctx context.Context, p string) (string, error) {
302 return run(ctx, func() (string, error) {
303 if p == "~" || strings.HasPrefix(p, "~/") {
304 home, herr := f.client.Getwd() // sftp opens at the login home
305 if herr == nil {
306 p = path.Join(home, strings.TrimPrefix(strings.TrimPrefix(p, "~"), "/"))
307 }
308 }
309 rp, err := f.client.RealPath(p)
310 if err != nil {
311 return "", err
312 }
313 return rp, nil
314 })
315 }
316
317 func randSuffix() string {
318 var b [8]byte
319 _, _ = rand.Read(b[:])
320 return hex.EncodeToString(b[:])
321 }
322
322 lines GO