gssh-agent 1.0.4 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - '**'
7
+ pull_request:
8
+ branches:
9
+ - main
10
+
11
+ jobs:
12
+ test:
13
+ runs-on: ubuntu-latest
14
+
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - name: Set up Go
19
+ uses: actions/setup-go@v5
20
+ with:
21
+ go-version: '1.21'
22
+
23
+ - name: Build
24
+ run: go build ./...
25
+
26
+ - name: Test
27
+ run: go test ./...
@@ -0,0 +1,104 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ jobs:
9
+ # npm 发布
10
+ publish-npm:
11
+ if: "contains(github.event.head_commit.message, 'publish')"
12
+ runs-on: ubuntu-latest
13
+ permissions:
14
+ contents: read
15
+ id-token: write
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - name: Set up Go
20
+ uses: actions/setup-go@v5
21
+ with:
22
+ go-version: '1.21'
23
+
24
+ - name: Get version
25
+ id: version
26
+ run: |
27
+ VERSION=$(echo "${{ github.event.head_commit.message }}" | sed -n 's/.*publish[: ]*\(.*\)/\1/p')
28
+ echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
29
+ echo "Publishing version: $VERSION"
30
+
31
+ - name: Build
32
+ run: |
33
+ go build -ldflags="-s -w" -o bin/gssh ./cmd/gssh
34
+
35
+ - name: Update package.json version
36
+ run: |
37
+ VERSION="${{ steps.version.outputs.VERSION }}"
38
+ npm version $VERSION --no-git-tag-version
39
+
40
+ - name: Build npm package
41
+ run: npm pack
42
+
43
+ - name: Setup Node
44
+ uses: actions/setup-node@v4
45
+ with:
46
+ node-version: '20'
47
+
48
+ - name: Configure npm
49
+ run: |
50
+ echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > .npmrc
51
+ cat .npmrc
52
+
53
+ - name: Publish to npm
54
+ run: npm publish --access public
55
+
56
+ # GitHub Releases 发布
57
+ publish-github:
58
+ if: "startsWith(github.ref, 'refs/tags/v')"
59
+ runs-on: ubuntu-latest
60
+ permissions:
61
+ contents: write
62
+
63
+ strategy:
64
+ matrix:
65
+ include:
66
+ - goos: linux
67
+ goarch: amd64
68
+ - goos: linux
69
+ goarch: arm64
70
+ - goos: darwin
71
+ goarch: amd64
72
+ - goos: darwin
73
+ goarch: arm64
74
+ - goos: windows
75
+ goarch: amd64
76
+
77
+ steps:
78
+ - uses: actions/checkout@v4
79
+
80
+ - name: Set up Go
81
+ uses: actions/setup-go@v5
82
+ with:
83
+ go-version: '1.21'
84
+
85
+ - name: Build
86
+ env:
87
+ GOOS: ${{ matrix.goos }}
88
+ GOARCH: ${{ matrix.goarch }}
89
+ run: |
90
+ EXT=""
91
+ if [ "$GOOS" = "windows" ]; then
92
+ EXT=".exe"
93
+ fi
94
+ FILENAME="gssh-${GOOS}-${GOARCH}${EXT}"
95
+ go build -ldflags="-s -w" -o "$FILENAME" ./cmd/gssh
96
+ echo "$FILENAME" >> artifacts.txt
97
+
98
+ - name: Upload Release Asset
99
+ uses: softprops/action-gh-release@v2
100
+ with:
101
+ tag_name: ${{ github.ref }}
102
+ files: ${{ matrix.goos }}-${{ matrix.goarch }}*
103
+ env:
104
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
package/README.md CHANGED
@@ -14,22 +14,13 @@ gssh 是一个供 Agent 使用的 SSH Session 管理工具。通过 Go 语言实
14
14
 
15
15
  ## 安装
16
16
 
17
- ### 方式一:Homebrew(推荐)
17
+ ### 使用 npm(推荐)
18
18
 
19
19
  ```bash
20
- # 添加 tap(未来会支持)
21
- # brew tap forechoandlook/gssh
22
- # brew install gssh
23
-
24
- # 暂时使用手动安装方式
25
- git clone https://github.com/forechoandlook/gssh.git
26
- cd gssh
27
- go build -o bin/daemon cmd/daemon/main.go
28
- go build -o bin/gssh cmd/gssh/main.go
29
- ./homebrew/install.sh
20
+ npm install -g gssh-agent
30
21
  ```
31
22
 
32
- ### 方式二:直接安装
23
+ ### 手动安装
33
24
 
34
25
  ```bash
35
26
  # 克隆项目
@@ -45,53 +36,11 @@ cp bin/daemon /usr/local/bin/gssh-daemon
45
36
  cp bin/gssh /usr/local/bin/gssh
46
37
  ```
47
38
 
48
- ## 服务管理(macOS)
49
-
50
- ### 使用 launchctl
39
+ ## 启动服务
51
40
 
52
41
  ```bash
53
- # 启动服务
54
- launchctl start com.gssh.daemon
55
-
56
- # 停止服务
57
- launchctl stop com.gssh.daemon
58
-
59
- # 查看状态
60
- launchctl list | grep gssh
61
-
62
- # 卸载服务
63
- launchctl unload ~/Library/LaunchAgents/gssh.plist
64
- rm ~/Library/LaunchAgents/gssh.plist
65
- ```
66
-
67
- ### 使用 Homebrew services
68
-
69
- ```bash
70
- # 启动(首次安装后自动启动)
71
- brew services start gssh
72
-
73
- # 停止
74
- brew services stop gssh
75
-
76
- # 查看状态
77
- brew services list
78
-
79
- # 重启
80
- brew services restart gssh
81
- ```
82
-
83
- ### 使用 PM2(跨平台)
84
-
85
- ```bash
86
- # 安装 PM2
87
- npm install -g pm2
88
-
89
- # 启动 daemon
90
- pm2 start gssh-daemon.sh --name gssh
91
-
92
- # 保存并设置开机自启
93
- pm2 save
94
- pm2 startup
42
+ # 启动 gssh daemon
43
+ gssh-daemon
95
44
  ```
96
45
 
97
46
  ## 使用方法
@@ -99,7 +48,7 @@ pm2 startup
99
48
  ### 连接 SSH(密码认证)
100
49
 
101
50
  ```bash
102
- gssh connect -u admin1 -h 139.196.175.163 -p 7080 -P 1234
51
+ gssh connect -u admin1 -h xxxx -p xxxx -P xxxxx
103
52
  ```
104
53
 
105
54
  ### 连接 SSH(密钥认证)
@@ -116,6 +65,12 @@ gssh exec "ls -la"
116
65
 
117
66
  # 指定 session
118
67
  gssh exec -s <session_id> "pwd"
68
+
69
+ # 带超时命令
70
+ gssh exec -t 10 "ls -la"
71
+
72
+ # sudo 命令
73
+ gssh exec -S password "sudo systemctl restart nginx"
119
74
  ```
120
75
 
121
76
  ### 端口转发
@@ -123,16 +78,27 @@ gssh exec -s <session_id> "pwd"
123
78
  ```bash
124
79
  # 本地端口转发:本地 8080 -> 远程 80
125
80
  gssh forward -l 8080 -r 80
81
+
82
+ # 远程端口转发:远程 9000 -> 本地 3000
83
+ gssh forward -R -l 9000 -r 3000
84
+
85
+ # 列出所有端口转发
86
+ gssh forwards
87
+
88
+ # 关闭端口转发
89
+ gssh forward-close <forward_id>
126
90
  ```
127
91
 
128
- ### 文件传输(SFTP/SCP)
92
+ ### 文件传输与同步(SFTP/SCP/SYNC
129
93
 
130
94
  ```bash
131
- # 上传文件(本地 -> 远程)
95
+ # 上传文件或文件夹(本地 -> 远程)
132
96
  gssh scp -put /path/to/local/file.txt /path/to/remote/file.txt
97
+ gssh sync -put /path/to/local/dir /path/to/remote/dir
133
98
 
134
- # 下载文件(远程 -> 本地)
99
+ # 下载文件或文件夹(远程 -> 本地)
135
100
  gssh scp -get /path/to/remote/file.txt /path/to/local/file.txt
101
+ gssh sync -get /path/to/remote/dir /path/to/local/dir
136
102
 
137
103
  # 列出远程目录
138
104
  gssh sftp -c ls -p /path/to/remote/dir
@@ -178,6 +144,10 @@ gssh reconnect -s <session_id>
178
144
  | `-get` | 下载模式(远程 -> 本地) |
179
145
  | `-c command` | SFTP 命令(ls/mkdir/rm) |
180
146
  | `-p path` | SFTP 路径 |
147
+ | `-t timeout` | 命令超时时间(秒) |
148
+ | `-S password` | sudo 密码 |
149
+ | `--ask-pass` | 交互输入 SSH 密码 |
150
+ | `--ask-sudo-pass` | 交互输入 sudo 密码 |
181
151
 
182
152
  ## 开发
183
153
 
package/bin/gssh CHANGED
Binary file
package/cmd/gssh/main.go CHANGED
@@ -73,7 +73,7 @@ func main() {
73
73
  err = handleForwards(socketPath)
74
74
  case "forward-close":
75
75
  err = handleForwardClose(subArgs, socketPath)
76
- case "scp":
76
+ case "scp", "sync":
77
77
  err = handleSCP(subArgs, socketPath)
78
78
  case "sftp":
79
79
  err = handleSFTP(subArgs, socketPath)
@@ -309,6 +309,7 @@ func handleExec(args []string, socketPath string) error {
309
309
  fs.SetOutput(os.Stderr)
310
310
 
311
311
  sessionID := fs.String("s", "", "Session ID")
312
+ timeout := fs.Int("t", 0, "Timeout in seconds (0 = no timeout)")
312
313
  sudoPassword := fs.String("S", "", "sudo password")
313
314
  askSudoPassword := fs.Bool("ask-sudo-pass", false, "Interactively ask for sudo password")
314
315
 
@@ -334,6 +335,7 @@ func handleExec(args []string, socketPath string) error {
334
335
  params := protocol.ExecParams{
335
336
  SessionID: *sessionID,
336
337
  Command: command,
338
+ Timeout: *timeout,
337
339
  }
338
340
 
339
341
  result, err := sendRequest(socketPath, "exec", params)
@@ -623,6 +625,8 @@ Usage:
623
625
  gssh forward-close <forward_id>
624
626
  gssh scp [-s session_id] -put <local> <remote>
625
627
  gssh scp [-s session_id] -get <remote> <local>
628
+ gssh sync [-s session_id] -put <local> <remote>
629
+ gssh sync [-s session_id] -get <remote> <local>
626
630
  gssh sftp [-s session_id] -c <ls|mkdir|rm> -p <path>
627
631
  gssh -v, --version
628
632
 
@@ -652,6 +656,7 @@ Examples:
652
656
  gssh reconnect 549b6eff-f62c-4dae-a7e9-298815233cf4
653
657
  gssh forward -l 8080 -r 80
654
658
  gssh scp -put local.txt /home/user/remote.txt
659
+ gssh sync -put local_dir /home/user/remote_dir
655
660
  `, version)
656
661
  }
657
662
 
@@ -0,0 +1,79 @@
1
+ --- internal/session/manager.go
2
+ +++ internal/session/manager.go
3
+ @@ -100,16 +100,24 @@
4
+ // Check if session already exists
5
+ for _, s := range m.sessions {
6
+ if s.Host == host && s.User == user && s.Port == port {
7
+ - if s.Status == "connected" {
8
+ + s.mu.RLock()
9
+ + status := s.Status
10
+ + s.mu.RUnlock()
11
+ +
12
+ + if status == "connected" {
13
+ return toProtocolSession(s), fmt.Errorf("session already exists")
14
+ }
15
+ // Try to reconnect
16
+ sshClient, err := client.Connect(user, host, port, password, keyPath)
17
+ if err != nil {
18
+ return nil, err
19
+ }
20
+ +
21
+ + s.mu.Lock()
22
+ s.SSHClient = sshClient
23
+ s.Status = "connected"
24
+ + s.mu.Unlock()
25
+ +
26
+ return toProtocolSession(s), nil
27
+ }
28
+ }
29
+ @@ -159,10 +167,12 @@
30
+ return fmt.Errorf("session not found")
31
+ }
32
+
33
+ + ms.mu.Lock()
34
+ if ms.SSHClient != nil {
35
+ ms.SSHClient.Close()
36
+ }
37
+ -
38
+ ms.Status = "disconnected"
39
+ + ms.mu.Unlock()
40
+
41
+ // Clear default ID when disconnecting
42
+ if m.defaultID == sessionID {
43
+ @@ -188,10 +198,14 @@
44
+ return nil, fmt.Errorf("session not found")
45
+ }
46
+
47
+ + ms.mu.RLock()
48
+ + existingClient := ms.SSHClient
49
+ + ms.mu.RUnlock()
50
+ +
51
+ // Close existing connection
52
+ - if ms.SSHClient != nil {
53
+ - ms.SSHClient.Close()
54
+ + if existingClient != nil {
55
+ + existingClient.Close()
56
+ }
57
+
58
+ // Create new connection
59
+ sshClient, err := client.Connect(ms.User, ms.Host, ms.Port, ms.Password, ms.KeyPath)
60
+ @@ -239,15 +253,16 @@
61
+ }
62
+
63
+ ms.mu.RLock()
64
+ - if ms.SSHClient == nil {
65
+ + sshClient := ms.SSHClient
66
+ + ms.mu.RUnlock()
67
+ +
68
+ + if sshClient == nil {
69
+ - ms.mu.RUnlock()
70
+ return nil, fmt.Errorf("session not connected")
71
+ }
72
+ - ms.mu.RUnlock()
73
+
74
+ // 复用 SSH 连接,创建新的 session 执行命令
75
+ - session, err := ms.SSHClient.Client.NewSession()
76
+ + session, err := sshClient.Client.NewSession()
77
+ if err != nil {
78
+ return nil, fmt.Errorf("failed to create session: %w", err)
79
+ }
@@ -11,6 +11,7 @@ import (
11
11
 
12
12
  "github.com/pkg/sftp"
13
13
  "golang.org/x/crypto/ssh"
14
+ "golang.org/x/crypto/ssh/knownhosts"
14
15
  )
15
16
 
16
17
  // SSHClient encapsulates SSH connection logic
@@ -32,12 +33,84 @@ func (k *KeyboardInteractiveHandler) Challenge(name, instruction string, questio
32
33
  return answers, nil
33
34
  }
34
35
 
36
+ // getHostKeyCallback returns a host key callback that verifies the host key against the user's known_hosts file.
37
+ // If the host is unknown, it implements Trust-On-First-Use (TOFU) by adding the new key to the known_hosts file.
38
+ func getHostKeyCallback() (ssh.HostKeyCallback, error) {
39
+ homeDir, err := os.UserHomeDir()
40
+ if err != nil {
41
+ return nil, fmt.Errorf("could not get user home dir: %w", err)
42
+ }
43
+
44
+ knownHostsPath := filepath.Join(homeDir, ".ssh", "known_hosts")
45
+
46
+ // Ensure the .ssh directory exists
47
+ if err := os.MkdirAll(filepath.Dir(knownHostsPath), 0700); err != nil {
48
+ return nil, fmt.Errorf("failed to create .ssh directory: %w", err)
49
+ }
50
+
51
+ // Create the known_hosts file if it doesn't exist
52
+ if _, err := os.Stat(knownHostsPath); os.IsNotExist(err) {
53
+ f, err := os.OpenFile(knownHostsPath, os.O_CREATE|os.O_RDWR, 0600)
54
+ if err != nil {
55
+ return nil, fmt.Errorf("failed to create known_hosts file: %w", err)
56
+ }
57
+ f.Close()
58
+ }
59
+
60
+ cb, err := knownhosts.New(knownHostsPath)
61
+ if err != nil {
62
+ return nil, fmt.Errorf("failed to create knownhosts callback: %w", err)
63
+ }
64
+
65
+ return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
66
+ err := cb(hostname, remote, key)
67
+ if err == nil {
68
+ return nil
69
+ }
70
+
71
+ keyErr, ok := err.(*knownhosts.KeyError)
72
+ if !ok {
73
+ return err
74
+ }
75
+
76
+ // If len(keyErr.Want) is 0, it means the key is completely unknown (not a mismatch).
77
+ // We implement Trust-On-First-Use (TOFU) by adding the new key to known_hosts.
78
+ if len(keyErr.Want) == 0 {
79
+ f, fErr := os.OpenFile(knownHostsPath, os.O_APPEND|os.O_WRONLY, 0600)
80
+ if fErr != nil {
81
+ return fmt.Errorf("failed to open known_hosts for appending: %w", fErr)
82
+ }
83
+ defer f.Close()
84
+
85
+ addresses := []string{knownhosts.Normalize(hostname)}
86
+ if remoteString := remote.String(); remoteString != hostname {
87
+ addresses = append(addresses, knownhosts.Normalize(remoteString))
88
+ }
89
+
90
+ line := knownhosts.Line(addresses, key)
91
+ if _, wErr := f.WriteString(line + "\n"); wErr != nil {
92
+ return fmt.Errorf("failed to append key to known_hosts: %w", wErr)
93
+ }
94
+ return nil
95
+ }
96
+
97
+ // It's a key mismatch (security risk: MITM or host key changed). Reject the connection.
98
+ return err
99
+ }, nil
100
+ }
101
+
35
102
  // NewSSHClient creates a new SSH client
36
103
  func NewSSHClient(user, host string, port int, authMethods ...ssh.AuthMethod) (*SSHClient, error) {
104
+ hostKeyCallback, err := getHostKeyCallback()
105
+ if err != nil {
106
+ return nil, fmt.Errorf("failed to get host key callback: %w", err)
107
+ }
108
+
37
109
  config := &ssh.ClientConfig{
38
110
  User: user,
39
111
  Auth: authMethods,
40
- HostKeyCallback: ssh.InsecureIgnoreHostKey(),
112
+ HostKeyCallback: hostKeyCallback,
113
+ Timeout: 10 * time.Second,
41
114
  }
42
115
 
43
116
  addr := fmt.Sprintf("%s:%d", host, port)
@@ -169,8 +242,56 @@ func (c *SSHClient) NewSFTPClient() (*SFTPClient, error) {
169
242
  return &SFTPClient{Client: sftpClient}, nil
170
243
  }
171
244
 
172
- // Upload uploads a local file to remote
245
+ // Upload uploads a local file or directory to remote
173
246
  func (s *SFTPClient) Upload(localPath, remotePath string) (int64, error) {
247
+ localInfo, err := os.Stat(localPath)
248
+ if err != nil {
249
+ return 0, fmt.Errorf("failed to stat local path: %w", err)
250
+ }
251
+
252
+ if !localInfo.IsDir() {
253
+ return s.uploadFile(localPath, remotePath)
254
+ }
255
+
256
+ var totalWritten int64
257
+ err = filepath.Walk(localPath, func(path string, info os.FileInfo, err error) error {
258
+ if err != nil {
259
+ return err
260
+ }
261
+
262
+ relPath, err := filepath.Rel(localPath, path)
263
+ if err != nil {
264
+ return err
265
+ }
266
+
267
+ targetPath := filepath.ToSlash(filepath.Join(remotePath, relPath))
268
+
269
+ if info.IsDir() {
270
+ s.Client.MkdirAll(targetPath)
271
+ return nil
272
+ }
273
+
274
+ // Sync logic
275
+ remoteInfo, err := s.Client.Stat(targetPath)
276
+ if err == nil && remoteInfo.Size() == info.Size() && remoteInfo.ModTime().Unix() == info.ModTime().Unix() {
277
+ return nil
278
+ }
279
+
280
+ written, err := s.uploadFile(path, targetPath)
281
+ if err != nil {
282
+ return err
283
+ }
284
+
285
+ totalWritten += written
286
+ s.Client.Chtimes(targetPath, info.ModTime(), info.ModTime())
287
+
288
+ return nil
289
+ })
290
+
291
+ return totalWritten, err
292
+ }
293
+
294
+ func (s *SFTPClient) uploadFile(localPath, remotePath string) (int64, error) {
174
295
  localFile, err := os.Open(localPath)
175
296
  if err != nil {
176
297
  return 0, fmt.Errorf("failed to open local file: %w", err)
@@ -199,8 +320,62 @@ func (s *SFTPClient) Upload(localPath, remotePath string) (int64, error) {
199
320
  return written, nil
200
321
  }
201
322
 
202
- // Download downloads a remote file to local
323
+ // Download downloads a remote file or directory to local
203
324
  func (s *SFTPClient) Download(remotePath, localPath string) (int64, error) {
325
+ remoteInfo, err := s.Client.Stat(remotePath)
326
+ if err != nil {
327
+ return 0, fmt.Errorf("failed to stat remote path: %w", err)
328
+ }
329
+
330
+ if !remoteInfo.IsDir() {
331
+ return s.downloadFile(remotePath, localPath)
332
+ }
333
+
334
+ var totalWritten int64
335
+ walker := s.Client.Walk(remotePath)
336
+ for walker.Step() {
337
+ if walker.Err() != nil {
338
+ return totalWritten, walker.Err()
339
+ }
340
+
341
+ path := walker.Path()
342
+ info := walker.Stat()
343
+
344
+ relPath, err := filepath.Rel(remotePath, path)
345
+ if err != nil {
346
+ return totalWritten, err
347
+ }
348
+
349
+ if err := checkPathTraversal(relPath); err != nil {
350
+ return totalWritten, err
351
+ }
352
+
353
+ targetPath := filepath.Join(localPath, relPath)
354
+
355
+ if info.IsDir() {
356
+ os.MkdirAll(targetPath, info.Mode())
357
+ continue
358
+ }
359
+
360
+ // Sync logic
361
+ localInfo, err := os.Stat(targetPath)
362
+ if err == nil && localInfo.Size() == info.Size() && localInfo.ModTime().Unix() == info.ModTime().Unix() {
363
+ continue
364
+ }
365
+
366
+ written, err := s.downloadFile(path, targetPath)
367
+ if err != nil {
368
+ return totalWritten, err
369
+ }
370
+
371
+ totalWritten += written
372
+ os.Chtimes(targetPath, info.ModTime(), info.ModTime())
373
+ }
374
+
375
+ return totalWritten, nil
376
+ }
377
+
378
+ func (s *SFTPClient) downloadFile(remotePath, localPath string) (int64, error) {
204
379
  remoteFile, err := s.Client.Open(remotePath)
205
380
  if err != nil {
206
381
  return 0, fmt.Errorf("failed to open remote file: %w", err)
@@ -380,6 +555,14 @@ func (pw *progressWriter) Write(p []byte) (n int, err error) {
380
555
  return
381
556
  }
382
557
 
558
+ // checkPathTraversal ensures that relPath does not escape the destination directory
559
+ func checkPathTraversal(relPath string) error {
560
+ if !filepath.IsLocal(relPath) {
561
+ return fmt.Errorf("path traversal detected: %s escapes destination", relPath)
562
+ }
563
+ return nil
564
+ }
565
+
383
566
  // expandPath expands ~ to home directory
384
567
  func expandPath(path string) string {
385
568
  if len(path) > 0 && path[0] == '~' {
@@ -31,3 +31,46 @@ func TestNewAuthMethodsFromKeyPath(t *testing.T) {
31
31
  t.Error("expected error for non-existent key")
32
32
  }
33
33
  }
34
+
35
+ func TestCheckPathTraversal(t *testing.T) {
36
+ tests := []struct {
37
+ name string
38
+ relPath string
39
+ wantErr bool
40
+ }{
41
+ {
42
+ name: "safe path within directory",
43
+ relPath: "safe_file.txt",
44
+ wantErr: false,
45
+ },
46
+ {
47
+ name: "safe path same as directory",
48
+ relPath: ".",
49
+ wantErr: false,
50
+ },
51
+ {
52
+ name: "path traversal attempt with ../",
53
+ relPath: "../etc/passwd",
54
+ wantErr: true,
55
+ },
56
+ {
57
+ name: "absolute path traversal",
58
+ relPath: "/etc/passwd",
59
+ wantErr: true,
60
+ },
61
+ {
62
+ name: "deep nested safe path",
63
+ relPath: "a/b/c/d.txt",
64
+ wantErr: false,
65
+ },
66
+ }
67
+
68
+ for _, tt := range tests {
69
+ t.Run(tt.name, func(t *testing.T) {
70
+ err := checkPathTraversal(tt.relPath)
71
+ if (err != nil) != tt.wantErr {
72
+ t.Errorf("checkPathTraversal() error = %v, wantErr %v", err, tt.wantErr)
73
+ }
74
+ })
75
+ }
76
+ }