返回 DeepSeek-Reasonix
sftpfs_test.go
根目录 / internal / remote / sftpfs / sftpfs_test.go
1 package sftpfs
2
3 import (
4 "bytes"
5 "context"
6 "os"
7 "path/filepath"
8 "runtime"
9 "strings"
10 "testing"
11 "time"
12
13 "golang.org/x/crypto/ssh"
14
15 "reasonix/internal/remote/sshtest"
16 )
17
18 func dialFS(t *testing.T, root string) *FS {
19 t.Helper()
20 // The remote module targets POSIX (Linux/macOS) remotes: SFTP paths are
21 // always forward-slash and rooted on the remote host. This harness runs the
22 // SFTP server against the LOCAL filesystem, so on Windows it serves Windows
23 // drive paths and the POSIX/Windows path translation breaks — a property of
24 // the harness, not the product. Linux/macOS CI covers the round-trips.
25 if runtime.GOOS == "windows" {
26 t.Skip("SFTP-server harness serves the local FS; POSIX-remote paths are only exercised on Linux/macOS")
27 }
28 srv := sshtest.Start(t, sshtest.Options{SFTPRoot: root})
29 cfg := &ssh.ClientConfig{
30 User: "test",
31 HostKeyCallback: ssh.InsecureIgnoreHostKey(),
32 Timeout: 5 * time.Second,
33 }
34 cl, err := ssh.Dial("tcp", srv.Addr, cfg)
35 if err != nil {
36 t.Fatalf("ssh dial: %v", err)
37 }
38 t.Cleanup(func() { cl.Close() })
39 fsys, err := New(cl)
40 if err != nil {
41 t.Fatalf("sftp new: %v", err)
42 }
43 t.Cleanup(func() { fsys.Close() })
44 return fsys
45 }
46
47 func TestSFTPListStatRead(t *testing.T) {
48 root := t.TempDir()
49 if err := os.WriteFile(filepath.Join(root, "hello.txt"), []byte("hi there"), 0o644); err != nil {
50 t.Fatal(err)
51 }
52 if err := os.Mkdir(filepath.Join(root, "sub"), 0o755); err != nil {
53 t.Fatal(err)
54 }
55 fsys := dialFS(t, root)
56 ctx := context.Background()
57
58 entries, err := fsys.List(ctx, root)
59 if err != nil {
60 t.Fatalf("List: %v", err)
61 }
62 names := map[string]bool{}
63 for _, e := range entries {
64 names[e.Name] = e.IsDir
65 }
66 if _, ok := names["hello.txt"]; !ok {
67 t.Fatalf("hello.txt missing from listing: %+v", entries)
68 }
69 if !names["sub"] {
70 t.Fatal("sub not reported as dir")
71 }
72
73 st, err := fsys.Stat(ctx, filepath.Join(root, "hello.txt"))
74 if err != nil {
75 t.Fatalf("Stat: %v", err)
76 }
77 if st.Size != 8 {
78 t.Fatalf("size = %d, want 8", st.Size)
79 }
80
81 data, truncated, kind, err := fsys.ReadFile(ctx, filepath.Join(root, "hello.txt"), 0)
82 if err != nil {
83 t.Fatalf("ReadFile: %v", err)
84 }
85 if string(data) != "hi there" || truncated || kind != KindText {
86 t.Fatalf("read = %q truncated=%v kind=%v", data, truncated, kind)
87 }
88 }
89
90 // TestSFTPDownloadStreamsFullFile pins the fs-get fix: Download must return the
91 // whole file, not the 4 MiB preview cap ReadFile enforces.
92 func TestSFTPDownloadStreamsFullFile(t *testing.T) {
93 root := t.TempDir()
94 big := strings.Repeat("x", (DefaultReadCap)+5000) // > preview cap
95 if err := os.WriteFile(filepath.Join(root, "big.bin"), []byte(big), 0o644); err != nil {
96 t.Fatal(err)
97 }
98 fsys := dialFS(t, root)
99
100 // ReadFile truncates at the cap...
101 _, truncated, _, err := fsys.ReadFile(context.Background(), filepath.Join(root, "big.bin"), 0)
102 if err != nil || !truncated {
103 t.Fatalf("expected ReadFile to report truncation (err=%v truncated=%v)", err, truncated)
104 }
105 // ...but Download returns every byte.
106 var buf bytes.Buffer
107 n, err := fsys.Download(context.Background(), filepath.Join(root, "big.bin"), &buf)
108 if err != nil {
109 t.Fatalf("Download: %v", err)
110 }
111 if n != int64(len(big)) || buf.Len() != len(big) {
112 t.Fatalf("Download got %d bytes, want %d (must not truncate)", n, len(big))
113 }
114 }
115
116 func TestSFTPReadCapTruncates(t *testing.T) {
117 root := t.TempDir()
118 big := strings.Repeat("a", 100)
119 if err := os.WriteFile(filepath.Join(root, "big.txt"), []byte(big), 0o644); err != nil {
120 t.Fatal(err)
121 }
122 fsys := dialFS(t, root)
123 data, truncated, _, err := fsys.ReadFile(context.Background(), filepath.Join(root, "big.txt"), 10)
124 if err != nil {
125 t.Fatalf("ReadFile: %v", err)
126 }
127 if !truncated || len(data) != 10 {
128 t.Fatalf("cap not honored: len=%d truncated=%v", len(data), truncated)
129 }
130 }
131
132 func TestSFTPWriteAtomicAndMkdirRenameRemove(t *testing.T) {
133 root := t.TempDir()
134 fsys := dialFS(t, root)
135 ctx := context.Background()
136
137 target := filepath.Join(root, "out.txt")
138 if err := fsys.WriteFileAtomic(ctx, target, []byte("content"), 0o644); err != nil {
139 t.Fatalf("WriteFileAtomic: %v", err)
140 }
141 // No temp file left behind.
142 entries, _ := os.ReadDir(root)
143 for _, e := range entries {
144 if strings.Contains(e.Name(), "reasonix-tmp") {
145 t.Fatalf("temp file left behind: %s", e.Name())
146 }
147 }
148 got, err := os.ReadFile(target)
149 if err != nil || string(got) != "content" {
150 t.Fatalf("written content = %q err=%v", got, err)
151 }
152 uploaded := filepath.Join(root, "uploaded.txt")
153 n, err := fsys.UploadAtomic(ctx, uploaded, strings.NewReader("streamed"), 0o600)
154 if err != nil || n != 8 {
155 t.Fatalf("UploadAtomic = %d, %v", n, err)
156 }
157 if got, err := os.ReadFile(uploaded); err != nil || string(got) != "streamed" {
158 t.Fatalf("uploaded content = %q err=%v", got, err)
159 }
160 info, err := os.Stat(uploaded)
161 if err != nil {
162 t.Fatal(err)
163 }
164 if info.Mode().Perm() != 0o600 {
165 t.Fatalf("uploaded mode = %v", info.Mode().Perm())
166 }
167
168 // Overwrite existing (exercises rename-over-existing path).
169 if err := fsys.WriteFileAtomic(ctx, target, []byte("v2"), 0o644); err != nil {
170 t.Fatalf("overwrite: %v", err)
171 }
172 got, _ = os.ReadFile(target)
173 if string(got) != "v2" {
174 t.Fatalf("overwrite content = %q", got)
175 }
176
177 dir := filepath.Join(root, "a", "b")
178 if err := fsys.MkdirAll(ctx, dir); err != nil {
179 t.Fatalf("MkdirAll: %v", err)
180 }
181 if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
182 t.Fatalf("mkdir -p failed: %v", err)
183 }
184
185 renamed := filepath.Join(root, "renamed.txt")
186 if err := fsys.Rename(ctx, target, renamed); err != nil {
187 t.Fatalf("Rename: %v", err)
188 }
189 if _, err := os.Stat(renamed); err != nil {
190 t.Fatalf("rename target missing: %v", err)
191 }
192
193 if err := fsys.Remove(ctx, renamed, false); err != nil {
194 t.Fatalf("Remove file: %v", err)
195 }
196 if _, err := os.Stat(renamed); !os.IsNotExist(err) {
197 t.Fatal("file not removed")
198 }
199 if err := fsys.Remove(ctx, filepath.Join(root, "a"), true); err != nil {
200 t.Fatalf("Remove dir recursive: %v", err)
201 }
202 if _, err := os.Stat(filepath.Join(root, "a")); !os.IsNotExist(err) {
203 t.Fatal("dir not removed")
204 }
205 }
206
206 lines GO