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