committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1436 additions and 30 deletions
-
92.github/workflows/sftp-tests.yml
-
39test/sftp/Makefile
-
91test/sftp/README.md
-
606test/sftp/basic_test.go
-
421test/sftp/framework.go
-
17test/sftp/go.mod
-
64test/sftp/go.sum
-
36test/sftp/testdata/userstore.json
-
5weed/sftpd/sftp_file_writer.go
-
62weed/sftpd/sftp_filer.go
-
33weed/sftpd/sftp_server.go
@ -0,0 +1,92 @@ |
|||||
|
name: "SFTP Integration Tests" |
||||
|
|
||||
|
on: |
||||
|
push: |
||||
|
branches: [ master, main ] |
||||
|
paths: |
||||
|
- 'weed/sftpd/**' |
||||
|
- 'weed/command/sftp.go' |
||||
|
- 'test/sftp/**' |
||||
|
- '.github/workflows/sftp-tests.yml' |
||||
|
pull_request: |
||||
|
branches: [ master, main ] |
||||
|
paths: |
||||
|
- 'weed/sftpd/**' |
||||
|
- 'weed/command/sftp.go' |
||||
|
- 'test/sftp/**' |
||||
|
- '.github/workflows/sftp-tests.yml' |
||||
|
|
||||
|
concurrency: |
||||
|
group: ${{ github.head_ref }}/sftp-tests |
||||
|
cancel-in-progress: true |
||||
|
|
||||
|
permissions: |
||||
|
contents: read |
||||
|
|
||||
|
env: |
||||
|
GO_VERSION: '1.24' |
||||
|
TEST_TIMEOUT: '15m' |
||||
|
|
||||
|
jobs: |
||||
|
sftp-integration: |
||||
|
name: SFTP Integration Testing |
||||
|
runs-on: ubuntu-22.04 |
||||
|
timeout-minutes: 20 |
||||
|
|
||||
|
steps: |
||||
|
- name: Checkout code |
||||
|
uses: actions/checkout@v4 |
||||
|
|
||||
|
- name: Set up Go ${{ env.GO_VERSION }} |
||||
|
uses: actions/setup-go@v5 |
||||
|
with: |
||||
|
go-version: ${{ env.GO_VERSION }} |
||||
|
|
||||
|
- name: Install dependencies |
||||
|
run: | |
||||
|
sudo apt-get update |
||||
|
sudo apt-get install -y openssh-client |
||||
|
|
||||
|
- name: Build SeaweedFS |
||||
|
run: | |
||||
|
cd weed |
||||
|
go build -o weed . |
||||
|
chmod +x weed |
||||
|
./weed version |
||||
|
|
||||
|
- name: Run SFTP Integration Tests |
||||
|
run: | |
||||
|
cd test/sftp |
||||
|
|
||||
|
echo "🧪 Running SFTP integration tests..." |
||||
|
echo "============================================" |
||||
|
|
||||
|
# Install test dependencies |
||||
|
go mod download |
||||
|
|
||||
|
# Run all SFTP tests |
||||
|
go test -v -timeout=${{ env.TEST_TIMEOUT }} ./... |
||||
|
|
||||
|
echo "============================================" |
||||
|
echo "✅ SFTP integration tests completed" |
||||
|
|
||||
|
- name: Test Summary |
||||
|
if: always() |
||||
|
run: | |
||||
|
echo "## 🔐 SFTP Integration Test Summary" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "### Test Coverage" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "- ✅ **HomeDir Path Translation**: User home directory mapping (fixes #7470)" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "- ✅ **File Operations**: Upload, download, delete" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "- ✅ **Directory Operations**: Create, list, remove" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "- ✅ **Large File Handling**: 1MB+ file support" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "- ✅ **Path Edge Cases**: Unicode, trailing slashes, .. paths" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "- ✅ **Admin Access**: Root user verification" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "### Test Configuration" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "| User | HomeDir | Permissions |" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "|------|---------|-------------|" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "| admin | / | Full access |" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "| testuser | /sftp/testuser | Home directory only |" >> $GITHUB_STEP_SUMMARY |
||||
|
echo "| readonly | /public | Read-only |" >> $GITHUB_STEP_SUMMARY |
||||
|
|
||||
@ -0,0 +1,39 @@ |
|||||
|
.PHONY: build test test-verbose test-short clean deps |
||||
|
|
||||
|
# Build the weed binary first
|
||||
|
build: |
||||
|
cd ../../weed && go build -o weed . |
||||
|
|
||||
|
# Install test dependencies
|
||||
|
deps: |
||||
|
go mod download |
||||
|
|
||||
|
# Run all tests
|
||||
|
test: build deps |
||||
|
go test -v -timeout 5m ./... |
||||
|
|
||||
|
# Run tests with verbose output
|
||||
|
test-verbose: build deps |
||||
|
go test -v -timeout 5m ./... |
||||
|
|
||||
|
# Run quick tests only (skip integration tests)
|
||||
|
test-short: deps |
||||
|
go test -short -v ./... |
||||
|
|
||||
|
# Run specific test
|
||||
|
test-homedir: build deps |
||||
|
go test -v -timeout 5m -run TestHomeDirPathTranslation ./... |
||||
|
|
||||
|
# Run tests with debug output from SeaweedFS
|
||||
|
test-debug: build deps |
||||
|
go test -v -timeout 5m ./... 2>&1 | tee test.log |
||||
|
|
||||
|
# Clean up test artifacts
|
||||
|
clean: |
||||
|
rm -f test.log |
||||
|
go clean -testcache |
||||
|
|
||||
|
# Update go.sum
|
||||
|
tidy: |
||||
|
go mod tidy |
||||
|
|
||||
@ -0,0 +1,91 @@ |
|||||
|
# SeaweedFS SFTP Integration Tests |
||||
|
|
||||
|
This directory contains integration tests for the SeaweedFS SFTP server. |
||||
|
|
||||
|
## Prerequisites |
||||
|
|
||||
|
1. Build the SeaweedFS binary: |
||||
|
```bash |
||||
|
cd ../../weed |
||||
|
go build -o weed . |
||||
|
``` |
||||
|
|
||||
|
2. Ensure `ssh-keygen` is available (for generating test SSH host keys) |
||||
|
|
||||
|
## Running Tests |
||||
|
|
||||
|
### Run all tests |
||||
|
```bash |
||||
|
make test |
||||
|
``` |
||||
|
|
||||
|
### Run tests with verbose output |
||||
|
```bash |
||||
|
make test-verbose |
||||
|
``` |
||||
|
|
||||
|
### Run a specific test |
||||
|
```bash |
||||
|
go test -v -run TestHomeDirPathTranslation |
||||
|
``` |
||||
|
|
||||
|
### Skip long-running tests |
||||
|
```bash |
||||
|
go test -short ./... |
||||
|
``` |
||||
|
|
||||
|
## Test Structure |
||||
|
|
||||
|
- `framework.go` - Test framework that starts SeaweedFS cluster with SFTP |
||||
|
- `basic_test.go` - Basic SFTP operation tests including: |
||||
|
- HomeDir path translation (fixes issue #7470) |
||||
|
- File upload/download |
||||
|
- Directory operations |
||||
|
- Large file handling |
||||
|
- Edge cases |
||||
|
|
||||
|
## Test Configuration |
||||
|
|
||||
|
Tests use `testdata/userstore.json` which defines test users: |
||||
|
|
||||
|
| Username | Password | HomeDir | Permissions | |
||||
|
|----------|----------|---------|-------------| |
||||
|
| admin | adminpassword | / | Full access | |
||||
|
| testuser | testuserpassword | /sftp/testuser | Full access to home | |
||||
|
| readonly | readonlypassword | /public | Read-only | |
||||
|
|
||||
|
## Key Tests |
||||
|
|
||||
|
### TestHomeDirPathTranslation |
||||
|
|
||||
|
Tests the fix for [issue #7470](https://github.com/seaweedfs/seaweedfs/issues/7470) where |
||||
|
users with a non-root HomeDir (e.g., `/sftp/testuser`) could not upload files to `/` |
||||
|
because the path wasn't being translated to their home directory. |
||||
|
|
||||
|
The test verifies: |
||||
|
- Uploading to `/` correctly maps to the user's HomeDir |
||||
|
- Creating directories at `/` works |
||||
|
- Listing `/` shows the user's home directory contents |
||||
|
- All path operations respect the HomeDir translation |
||||
|
|
||||
|
## Debugging |
||||
|
|
||||
|
To debug test failures: |
||||
|
|
||||
|
1. Enable verbose output: |
||||
|
```bash |
||||
|
go test -v -run TestName |
||||
|
``` |
||||
|
|
||||
|
2. Keep test artifacts (don't cleanup): |
||||
|
```go |
||||
|
config := DefaultTestConfig() |
||||
|
config.SkipCleanup = true |
||||
|
``` |
||||
|
|
||||
|
3. Enable debug logging: |
||||
|
```go |
||||
|
config := DefaultTestConfig() |
||||
|
config.EnableDebug = true |
||||
|
``` |
||||
|
|
||||
@ -0,0 +1,606 @@ |
|||||
|
package sftp |
||||
|
|
||||
|
import ( |
||||
|
"bytes" |
||||
|
"io" |
||||
|
"path" |
||||
|
"testing" |
||||
|
|
||||
|
"github.com/stretchr/testify/require" |
||||
|
) |
||||
|
|
||||
|
// TestHomeDirPathTranslation tests that SFTP operations correctly translate
|
||||
|
// paths relative to the user's HomeDir.
|
||||
|
// This is the fix for https://github.com/seaweedfs/seaweedfs/issues/7470
|
||||
|
func TestHomeDirPathTranslation(t *testing.T) { |
||||
|
if testing.Short() { |
||||
|
t.Skip("skipping integration test in short mode") |
||||
|
} |
||||
|
|
||||
|
config := DefaultTestConfig() |
||||
|
config.EnableDebug = testing.Verbose() |
||||
|
|
||||
|
fw := NewSftpTestFramework(t, config) |
||||
|
err := fw.Setup(config) |
||||
|
require.NoError(t, err, "failed to setup test framework") |
||||
|
defer fw.Cleanup() |
||||
|
|
||||
|
// Test with user "testuser" who has HomeDir="/sftp/testuser"
|
||||
|
// When they upload to "/", it should actually go to "/sftp/testuser"
|
||||
|
sftpClient, sshConn, err := fw.ConnectSFTP("testuser", "testuserpassword") |
||||
|
require.NoError(t, err, "failed to connect as testuser") |
||||
|
defer sshConn.Close() |
||||
|
defer sftpClient.Close() |
||||
|
|
||||
|
// Test 1: Upload file to "/" (should map to /sftp/testuser/)
|
||||
|
t.Run("UploadToRoot", func(t *testing.T) { |
||||
|
testContent := []byte("Hello from SFTP test!") |
||||
|
filename := "test_upload.txt" |
||||
|
|
||||
|
// Create file at "/" from user's perspective
|
||||
|
file, err := sftpClient.Create("/" + filename) |
||||
|
require.NoError(t, err, "should be able to create file at /") |
||||
|
|
||||
|
_, err = file.Write(testContent) |
||||
|
require.NoError(t, err, "should be able to write to file") |
||||
|
err = file.Close() |
||||
|
require.NoError(t, err, "should be able to close file") |
||||
|
|
||||
|
// Verify file exists and has correct content
|
||||
|
readFile, err := sftpClient.Open("/" + filename) |
||||
|
require.NoError(t, err, "should be able to open file") |
||||
|
defer readFile.Close() |
||||
|
|
||||
|
content, err := io.ReadAll(readFile) |
||||
|
require.NoError(t, err, "should be able to read file") |
||||
|
require.Equal(t, testContent, content, "file content should match") |
||||
|
|
||||
|
// Clean up
|
||||
|
err = sftpClient.Remove("/" + filename) |
||||
|
require.NoError(t, err, "should be able to remove file") |
||||
|
}) |
||||
|
|
||||
|
// Test 2: Create directory at "/" (should map to /sftp/testuser/)
|
||||
|
t.Run("CreateDirAtRoot", func(t *testing.T) { |
||||
|
dirname := "test_dir" |
||||
|
|
||||
|
err := sftpClient.Mkdir("/" + dirname) |
||||
|
require.NoError(t, err, "should be able to create directory at /") |
||||
|
|
||||
|
// Verify directory exists
|
||||
|
info, err := sftpClient.Stat("/" + dirname) |
||||
|
require.NoError(t, err, "should be able to stat directory") |
||||
|
require.True(t, info.IsDir(), "should be a directory") |
||||
|
|
||||
|
// Clean up
|
||||
|
err = sftpClient.RemoveDirectory("/" + dirname) |
||||
|
require.NoError(t, err, "should be able to remove directory") |
||||
|
}) |
||||
|
|
||||
|
// Test 3: List directory at "/" (should list /sftp/testuser/)
|
||||
|
t.Run("ListRoot", func(t *testing.T) { |
||||
|
// Create a test file first
|
||||
|
testContent := []byte("list test content") |
||||
|
filename := "list_test.txt" |
||||
|
|
||||
|
file, err := sftpClient.Create("/" + filename) |
||||
|
require.NoError(t, err) |
||||
|
_, err = file.Write(testContent) |
||||
|
require.NoError(t, err) |
||||
|
file.Close() |
||||
|
|
||||
|
// List root directory
|
||||
|
files, err := sftpClient.ReadDir("/") |
||||
|
require.NoError(t, err, "should be able to list root directory") |
||||
|
|
||||
|
// Should find our test file
|
||||
|
found := false |
||||
|
for _, f := range files { |
||||
|
if f.Name() == filename { |
||||
|
found = true |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
require.True(t, found, "should find test file in listing") |
||||
|
|
||||
|
// Clean up
|
||||
|
err = sftpClient.Remove("/" + filename) |
||||
|
require.NoError(t, err) |
||||
|
}) |
||||
|
|
||||
|
// Test 4: Nested directory operations
|
||||
|
t.Run("NestedOperations", func(t *testing.T) { |
||||
|
// Create nested directory structure
|
||||
|
err := sftpClient.MkdirAll("/nested/dir/structure") |
||||
|
require.NoError(t, err, "should be able to create nested directories") |
||||
|
|
||||
|
// Create file in nested directory
|
||||
|
testContent := []byte("nested file content") |
||||
|
file, err := sftpClient.Create("/nested/dir/structure/file.txt") |
||||
|
require.NoError(t, err) |
||||
|
_, err = file.Write(testContent) |
||||
|
require.NoError(t, err) |
||||
|
file.Close() |
||||
|
|
||||
|
// Verify file exists
|
||||
|
readFile, err := sftpClient.Open("/nested/dir/structure/file.txt") |
||||
|
require.NoError(t, err) |
||||
|
content, err := io.ReadAll(readFile) |
||||
|
require.NoError(t, err) |
||||
|
readFile.Close() |
||||
|
require.Equal(t, testContent, content) |
||||
|
|
||||
|
// Clean up
|
||||
|
err = sftpClient.Remove("/nested/dir/structure/file.txt") |
||||
|
require.NoError(t, err) |
||||
|
err = sftpClient.RemoveDirectory("/nested/dir/structure") |
||||
|
require.NoError(t, err) |
||||
|
err = sftpClient.RemoveDirectory("/nested/dir") |
||||
|
require.NoError(t, err) |
||||
|
err = sftpClient.RemoveDirectory("/nested") |
||||
|
require.NoError(t, err) |
||||
|
}) |
||||
|
|
||||
|
// Test 5: Rename operation
|
||||
|
t.Run("RenameFile", func(t *testing.T) { |
||||
|
testContent := []byte("rename test content") |
||||
|
|
||||
|
file, err := sftpClient.Create("/original.txt") |
||||
|
require.NoError(t, err) |
||||
|
_, err = file.Write(testContent) |
||||
|
require.NoError(t, err) |
||||
|
file.Close() |
||||
|
|
||||
|
// Rename file
|
||||
|
err = sftpClient.Rename("/original.txt", "/renamed.txt") |
||||
|
require.NoError(t, err, "should be able to rename file") |
||||
|
|
||||
|
// Verify old file doesn't exist
|
||||
|
_, err = sftpClient.Stat("/original.txt") |
||||
|
require.Error(t, err, "original file should not exist") |
||||
|
|
||||
|
// Verify new file exists with correct content
|
||||
|
readFile, err := sftpClient.Open("/renamed.txt") |
||||
|
require.NoError(t, err, "renamed file should exist") |
||||
|
content, err := io.ReadAll(readFile) |
||||
|
require.NoError(t, err) |
||||
|
readFile.Close() |
||||
|
require.Equal(t, testContent, content) |
||||
|
|
||||
|
// Clean up
|
||||
|
err = sftpClient.Remove("/renamed.txt") |
||||
|
require.NoError(t, err) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// TestAdminRootAccess tests that admin user with HomeDir="/" can access everything
|
||||
|
func TestAdminRootAccess(t *testing.T) { |
||||
|
if testing.Short() { |
||||
|
t.Skip("skipping integration test in short mode") |
||||
|
} |
||||
|
|
||||
|
config := DefaultTestConfig() |
||||
|
config.EnableDebug = testing.Verbose() |
||||
|
|
||||
|
fw := NewSftpTestFramework(t, config) |
||||
|
err := fw.Setup(config) |
||||
|
require.NoError(t, err, "failed to setup test framework") |
||||
|
defer fw.Cleanup() |
||||
|
|
||||
|
// Connect as admin with HomeDir="/"
|
||||
|
sftpClient, sshConn, err := fw.ConnectSFTP("admin", "adminpassword") |
||||
|
require.NoError(t, err, "failed to connect as admin") |
||||
|
defer sshConn.Close() |
||||
|
defer sftpClient.Close() |
||||
|
|
||||
|
// Admin should be able to create directories anywhere
|
||||
|
t.Run("CreateAnyDirectory", func(t *testing.T) { |
||||
|
// Create the user's home directory structure
|
||||
|
err := sftpClient.MkdirAll("/sftp/testuser") |
||||
|
require.NoError(t, err, "admin should be able to create any directory") |
||||
|
|
||||
|
// Create file in that directory
|
||||
|
testContent := []byte("admin created this") |
||||
|
file, err := sftpClient.Create("/sftp/testuser/admin_file.txt") |
||||
|
require.NoError(t, err) |
||||
|
_, err = file.Write(testContent) |
||||
|
require.NoError(t, err) |
||||
|
file.Close() |
||||
|
|
||||
|
// Verify file exists
|
||||
|
info, err := sftpClient.Stat("/sftp/testuser/admin_file.txt") |
||||
|
require.NoError(t, err) |
||||
|
require.False(t, info.IsDir()) |
||||
|
|
||||
|
// Clean up
|
||||
|
err = sftpClient.Remove("/sftp/testuser/admin_file.txt") |
||||
|
require.NoError(t, err) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// TestLargeFileUpload tests uploading larger files through SFTP
|
||||
|
func TestLargeFileUpload(t *testing.T) { |
||||
|
if testing.Short() { |
||||
|
t.Skip("skipping integration test in short mode") |
||||
|
} |
||||
|
|
||||
|
config := DefaultTestConfig() |
||||
|
config.EnableDebug = testing.Verbose() |
||||
|
|
||||
|
fw := NewSftpTestFramework(t, config) |
||||
|
err := fw.Setup(config) |
||||
|
require.NoError(t, err, "failed to setup test framework") |
||||
|
defer fw.Cleanup() |
||||
|
|
||||
|
sftpClient, sshConn, err := fw.ConnectSFTP("testuser", "testuserpassword") |
||||
|
require.NoError(t, err, "failed to connect as testuser") |
||||
|
defer sshConn.Close() |
||||
|
defer sftpClient.Close() |
||||
|
|
||||
|
// Create a 1MB file
|
||||
|
t.Run("Upload1MB", func(t *testing.T) { |
||||
|
size := 1024 * 1024 // 1MB
|
||||
|
testData := bytes.Repeat([]byte("A"), size) |
||||
|
|
||||
|
file, err := sftpClient.Create("/large_file.bin") |
||||
|
require.NoError(t, err) |
||||
|
n, err := file.Write(testData) |
||||
|
require.NoError(t, err) |
||||
|
require.Equal(t, size, n) |
||||
|
file.Close() |
||||
|
|
||||
|
// Verify file size
|
||||
|
info, err := sftpClient.Stat("/large_file.bin") |
||||
|
require.NoError(t, err) |
||||
|
require.Equal(t, int64(size), info.Size()) |
||||
|
|
||||
|
// Verify content
|
||||
|
readFile, err := sftpClient.Open("/large_file.bin") |
||||
|
require.NoError(t, err) |
||||
|
content, err := io.ReadAll(readFile) |
||||
|
require.NoError(t, err) |
||||
|
readFile.Close() |
||||
|
require.Equal(t, testData, content) |
||||
|
|
||||
|
// Clean up
|
||||
|
err = sftpClient.Remove("/large_file.bin") |
||||
|
require.NoError(t, err) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// TestStatOperations tests Stat and Lstat operations
|
||||
|
func TestStatOperations(t *testing.T) { |
||||
|
if testing.Short() { |
||||
|
t.Skip("skipping integration test in short mode") |
||||
|
} |
||||
|
|
||||
|
config := DefaultTestConfig() |
||||
|
config.EnableDebug = testing.Verbose() |
||||
|
|
||||
|
fw := NewSftpTestFramework(t, config) |
||||
|
err := fw.Setup(config) |
||||
|
require.NoError(t, err, "failed to setup test framework") |
||||
|
defer fw.Cleanup() |
||||
|
|
||||
|
sftpClient, sshConn, err := fw.ConnectSFTP("testuser", "testuserpassword") |
||||
|
require.NoError(t, err, "failed to connect as testuser") |
||||
|
defer sshConn.Close() |
||||
|
defer sftpClient.Close() |
||||
|
|
||||
|
// Create a test file
|
||||
|
testContent := []byte("stat test content") |
||||
|
file, err := sftpClient.Create("/stat_test.txt") |
||||
|
require.NoError(t, err) |
||||
|
_, err = file.Write(testContent) |
||||
|
require.NoError(t, err) |
||||
|
file.Close() |
||||
|
|
||||
|
t.Run("StatFile", func(t *testing.T) { |
||||
|
info, err := sftpClient.Stat("/stat_test.txt") |
||||
|
require.NoError(t, err) |
||||
|
require.Equal(t, "stat_test.txt", info.Name()) |
||||
|
require.Equal(t, int64(len(testContent)), info.Size()) |
||||
|
require.False(t, info.IsDir()) |
||||
|
}) |
||||
|
|
||||
|
t.Run("StatDirectory", func(t *testing.T) { |
||||
|
err := sftpClient.Mkdir("/stat_dir") |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
info, err := sftpClient.Stat("/stat_dir") |
||||
|
require.NoError(t, err) |
||||
|
require.Equal(t, "stat_dir", info.Name()) |
||||
|
require.True(t, info.IsDir()) |
||||
|
|
||||
|
// Clean up
|
||||
|
err = sftpClient.RemoveDirectory("/stat_dir") |
||||
|
require.NoError(t, err) |
||||
|
}) |
||||
|
|
||||
|
t.Run("StatRoot", func(t *testing.T) { |
||||
|
// Should be able to stat "/" which maps to user's home directory
|
||||
|
info, err := sftpClient.Stat("/") |
||||
|
require.NoError(t, err, "should be able to stat root (home) directory") |
||||
|
require.True(t, info.IsDir(), "root should be a directory") |
||||
|
}) |
||||
|
|
||||
|
// Clean up
|
||||
|
err = sftpClient.Remove("/stat_test.txt") |
||||
|
require.NoError(t, err) |
||||
|
} |
||||
|
|
||||
|
// TestWalk tests walking directory trees
|
||||
|
func TestWalk(t *testing.T) { |
||||
|
if testing.Short() { |
||||
|
t.Skip("skipping integration test in short mode") |
||||
|
} |
||||
|
|
||||
|
config := DefaultTestConfig() |
||||
|
config.EnableDebug = testing.Verbose() |
||||
|
|
||||
|
fw := NewSftpTestFramework(t, config) |
||||
|
err := fw.Setup(config) |
||||
|
require.NoError(t, err, "failed to setup test framework") |
||||
|
defer fw.Cleanup() |
||||
|
|
||||
|
sftpClient, sshConn, err := fw.ConnectSFTP("testuser", "testuserpassword") |
||||
|
require.NoError(t, err, "failed to connect as testuser") |
||||
|
defer sshConn.Close() |
||||
|
defer sftpClient.Close() |
||||
|
|
||||
|
// Create directory structure
|
||||
|
err = sftpClient.MkdirAll("/walk/a/b") |
||||
|
require.NoError(t, err) |
||||
|
err = sftpClient.MkdirAll("/walk/c") |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// Create files
|
||||
|
for _, p := range []string{"/walk/file1.txt", "/walk/a/file2.txt", "/walk/a/b/file3.txt", "/walk/c/file4.txt"} { |
||||
|
file, err := sftpClient.Create(p) |
||||
|
require.NoError(t, err) |
||||
|
file.Write([]byte("test")) |
||||
|
file.Close() |
||||
|
} |
||||
|
|
||||
|
t.Run("WalkEntireTree", func(t *testing.T) { |
||||
|
var paths []string |
||||
|
walker := sftpClient.Walk("/walk") |
||||
|
for walker.Step() { |
||||
|
if walker.Err() != nil { |
||||
|
continue |
||||
|
} |
||||
|
paths = append(paths, walker.Path()) |
||||
|
} |
||||
|
|
||||
|
// Should find all directories and files
|
||||
|
require.Contains(t, paths, "/walk") |
||||
|
require.Contains(t, paths, "/walk/a") |
||||
|
require.Contains(t, paths, "/walk/a/b") |
||||
|
require.Contains(t, paths, "/walk/c") |
||||
|
}) |
||||
|
|
||||
|
// Clean up
|
||||
|
for _, p := range []string{"/walk/file1.txt", "/walk/a/file2.txt", "/walk/a/b/file3.txt", "/walk/c/file4.txt"} { |
||||
|
sftpClient.Remove(p) |
||||
|
} |
||||
|
for _, p := range []string{"/walk/a/b", "/walk/a", "/walk/c", "/walk"} { |
||||
|
sftpClient.RemoveDirectory(p) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// TestCurrentWorkingDirectory tests that Getwd and Chdir work correctly
|
||||
|
func TestCurrentWorkingDirectory(t *testing.T) { |
||||
|
if testing.Short() { |
||||
|
t.Skip("skipping integration test in short mode") |
||||
|
} |
||||
|
|
||||
|
config := DefaultTestConfig() |
||||
|
config.EnableDebug = testing.Verbose() |
||||
|
|
||||
|
fw := NewSftpTestFramework(t, config) |
||||
|
err := fw.Setup(config) |
||||
|
require.NoError(t, err, "failed to setup test framework") |
||||
|
defer fw.Cleanup() |
||||
|
|
||||
|
sftpClient, sshConn, err := fw.ConnectSFTP("testuser", "testuserpassword") |
||||
|
require.NoError(t, err, "failed to connect as testuser") |
||||
|
defer sshConn.Close() |
||||
|
defer sftpClient.Close() |
||||
|
|
||||
|
// Create test directory
|
||||
|
err = sftpClient.Mkdir("/cwd_test") |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
t.Run("GetCurrentDir", func(t *testing.T) { |
||||
|
cwd, err := sftpClient.Getwd() |
||||
|
require.NoError(t, err) |
||||
|
// The initial working directory should be the user's home directory
|
||||
|
// which from the user's perspective is "/"
|
||||
|
require.NotEmpty(t, cwd) |
||||
|
}) |
||||
|
|
||||
|
t.Run("ChangeAndCreate", func(t *testing.T) { |
||||
|
// Create file in subdirectory using relative path after chdir
|
||||
|
// Note: pkg/sftp doesn't support Chdir, so we test using absolute paths
|
||||
|
file, err := sftpClient.Create("/cwd_test/relative_file.txt") |
||||
|
require.NoError(t, err) |
||||
|
file.Write([]byte("test")) |
||||
|
file.Close() |
||||
|
|
||||
|
// Verify using absolute path
|
||||
|
_, err = sftpClient.Stat("/cwd_test/relative_file.txt") |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// Clean up
|
||||
|
sftpClient.Remove("/cwd_test/relative_file.txt") |
||||
|
}) |
||||
|
|
||||
|
// Clean up
|
||||
|
err = sftpClient.RemoveDirectory("/cwd_test") |
||||
|
require.NoError(t, err) |
||||
|
} |
||||
|
|
||||
|
// TestPathEdgeCases tests various edge cases in path handling
|
||||
|
func TestPathEdgeCases(t *testing.T) { |
||||
|
if testing.Short() { |
||||
|
t.Skip("skipping integration test in short mode") |
||||
|
} |
||||
|
|
||||
|
config := DefaultTestConfig() |
||||
|
config.EnableDebug = testing.Verbose() |
||||
|
|
||||
|
fw := NewSftpTestFramework(t, config) |
||||
|
err := fw.Setup(config) |
||||
|
require.NoError(t, err, "failed to setup test framework") |
||||
|
defer fw.Cleanup() |
||||
|
|
||||
|
sftpClient, sshConn, err := fw.ConnectSFTP("testuser", "testuserpassword") |
||||
|
require.NoError(t, err, "failed to connect as testuser") |
||||
|
defer sshConn.Close() |
||||
|
defer sftpClient.Close() |
||||
|
|
||||
|
t.Run("PathWithDotDot", func(t *testing.T) { |
||||
|
// Create directory structure
|
||||
|
err := sftpClient.MkdirAll("/edge/subdir") |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// Create file using path with ..
|
||||
|
file, err := sftpClient.Create("/edge/subdir/../file.txt") |
||||
|
require.NoError(t, err) |
||||
|
file.Write([]byte("test")) |
||||
|
file.Close() |
||||
|
|
||||
|
// Verify file was created in /edge
|
||||
|
_, err = sftpClient.Stat("/edge/file.txt") |
||||
|
require.NoError(t, err, "file should be created in parent directory") |
||||
|
|
||||
|
// Clean up
|
||||
|
sftpClient.Remove("/edge/file.txt") |
||||
|
sftpClient.RemoveDirectory("/edge/subdir") |
||||
|
sftpClient.RemoveDirectory("/edge") |
||||
|
}) |
||||
|
|
||||
|
t.Run("PathWithTrailingSlash", func(t *testing.T) { |
||||
|
err := sftpClient.Mkdir("/trailing") |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// Stat with trailing slash
|
||||
|
info, err := sftpClient.Stat("/trailing/") |
||||
|
require.NoError(t, err) |
||||
|
require.True(t, info.IsDir()) |
||||
|
|
||||
|
// Clean up
|
||||
|
sftpClient.RemoveDirectory("/trailing") |
||||
|
}) |
||||
|
|
||||
|
t.Run("CreateFileAtRootPath", func(t *testing.T) { |
||||
|
// This is the exact scenario from issue #7470
|
||||
|
// User with HomeDir="/sftp/testuser" uploads to "/"
|
||||
|
file, err := sftpClient.Create("/issue7470.txt") |
||||
|
require.NoError(t, err, "should be able to create file at / (issue #7470)") |
||||
|
file.Write([]byte("This tests the fix for issue #7470")) |
||||
|
file.Close() |
||||
|
|
||||
|
// Verify
|
||||
|
_, err = sftpClient.Stat("/issue7470.txt") |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// Clean up
|
||||
|
sftpClient.Remove("/issue7470.txt") |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// TestFileContent tests reading and writing file content correctly
|
||||
|
func TestFileContent(t *testing.T) { |
||||
|
if testing.Short() { |
||||
|
t.Skip("skipping integration test in short mode") |
||||
|
} |
||||
|
|
||||
|
config := DefaultTestConfig() |
||||
|
config.EnableDebug = testing.Verbose() |
||||
|
|
||||
|
fw := NewSftpTestFramework(t, config) |
||||
|
err := fw.Setup(config) |
||||
|
require.NoError(t, err, "failed to setup test framework") |
||||
|
defer fw.Cleanup() |
||||
|
|
||||
|
sftpClient, sshConn, err := fw.ConnectSFTP("testuser", "testuserpassword") |
||||
|
require.NoError(t, err, "failed to connect as testuser") |
||||
|
defer sshConn.Close() |
||||
|
defer sftpClient.Close() |
||||
|
|
||||
|
t.Run("BinaryContent", func(t *testing.T) { |
||||
|
// Create binary data with all byte values
|
||||
|
data := make([]byte, 256) |
||||
|
for i := 0; i < 256; i++ { |
||||
|
data[i] = byte(i) |
||||
|
} |
||||
|
|
||||
|
file, err := sftpClient.Create("/binary.bin") |
||||
|
require.NoError(t, err) |
||||
|
n, err := file.Write(data) |
||||
|
require.NoError(t, err) |
||||
|
require.Equal(t, 256, n) |
||||
|
file.Close() |
||||
|
|
||||
|
// Read back
|
||||
|
readFile, err := sftpClient.Open("/binary.bin") |
||||
|
require.NoError(t, err) |
||||
|
content, err := io.ReadAll(readFile) |
||||
|
require.NoError(t, err) |
||||
|
readFile.Close() |
||||
|
|
||||
|
require.Equal(t, data, content, "binary content should match") |
||||
|
|
||||
|
// Clean up
|
||||
|
sftpClient.Remove("/binary.bin") |
||||
|
}) |
||||
|
|
||||
|
t.Run("EmptyFile", func(t *testing.T) { |
||||
|
file, err := sftpClient.Create("/empty.txt") |
||||
|
require.NoError(t, err) |
||||
|
file.Close() |
||||
|
|
||||
|
info, err := sftpClient.Stat("/empty.txt") |
||||
|
require.NoError(t, err) |
||||
|
require.Equal(t, int64(0), info.Size()) |
||||
|
|
||||
|
// Clean up
|
||||
|
sftpClient.Remove("/empty.txt") |
||||
|
}) |
||||
|
|
||||
|
t.Run("UnicodeFilename", func(t *testing.T) { |
||||
|
filename := "/文件名.txt" |
||||
|
content := []byte("Unicode content: 你好世界") |
||||
|
|
||||
|
file, err := sftpClient.Create(filename) |
||||
|
require.NoError(t, err) |
||||
|
file.Write(content) |
||||
|
file.Close() |
||||
|
|
||||
|
// Read back
|
||||
|
readFile, err := sftpClient.Open(filename) |
||||
|
require.NoError(t, err) |
||||
|
readContent, err := io.ReadAll(readFile) |
||||
|
require.NoError(t, err) |
||||
|
readFile.Close() |
||||
|
|
||||
|
require.Equal(t, content, readContent) |
||||
|
|
||||
|
// Verify in listing
|
||||
|
files, err := sftpClient.ReadDir("/") |
||||
|
require.NoError(t, err) |
||||
|
found := false |
||||
|
for _, f := range files { |
||||
|
if f.Name() == path.Base(filename) { |
||||
|
found = true |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
require.True(t, found, "should find unicode filename in listing") |
||||
|
|
||||
|
// Clean up
|
||||
|
sftpClient.Remove(filename) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
@ -0,0 +1,421 @@ |
|||||
|
package sftp |
||||
|
|
||||
|
import ( |
||||
|
"fmt" |
||||
|
"net" |
||||
|
"os" |
||||
|
"os/exec" |
||||
|
"path/filepath" |
||||
|
"runtime" |
||||
|
"syscall" |
||||
|
"testing" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/pkg/sftp" |
||||
|
"github.com/stretchr/testify/require" |
||||
|
"golang.org/x/crypto/ssh" |
||||
|
) |
||||
|
|
||||
|
// SftpTestFramework provides utilities for SFTP integration testing
|
||||
|
type SftpTestFramework struct { |
||||
|
t *testing.T |
||||
|
tempDir string |
||||
|
dataDir string |
||||
|
masterProcess *os.Process |
||||
|
volumeProcess *os.Process |
||||
|
filerProcess *os.Process |
||||
|
sftpProcess *os.Process |
||||
|
masterAddr string |
||||
|
volumeAddr string |
||||
|
filerAddr string |
||||
|
sftpAddr string |
||||
|
weedBinary string |
||||
|
userStoreFile string |
||||
|
hostKeyFile string |
||||
|
isSetup bool |
||||
|
} |
||||
|
|
||||
|
// TestConfig holds configuration for SFTP tests
|
||||
|
type TestConfig struct { |
||||
|
NumVolumes int |
||||
|
EnableDebug bool |
||||
|
SkipCleanup bool // for debugging failed tests
|
||||
|
UserStoreFile string |
||||
|
} |
||||
|
|
||||
|
// DefaultTestConfig returns a default configuration for SFTP tests
|
||||
|
func DefaultTestConfig() *TestConfig { |
||||
|
return &TestConfig{ |
||||
|
NumVolumes: 3, |
||||
|
EnableDebug: false, |
||||
|
SkipCleanup: false, |
||||
|
UserStoreFile: "", |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// NewSftpTestFramework creates a new SFTP testing framework
|
||||
|
func NewSftpTestFramework(t *testing.T, config *TestConfig) *SftpTestFramework { |
||||
|
if config == nil { |
||||
|
config = DefaultTestConfig() |
||||
|
} |
||||
|
|
||||
|
tempDir, err := os.MkdirTemp("", "seaweedfs_sftp_test_") |
||||
|
require.NoError(t, err) |
||||
|
|
||||
|
// Generate SSH host key for SFTP server
|
||||
|
hostKeyFile := filepath.Join(tempDir, "ssh_host_key") |
||||
|
cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", hostKeyFile, "-N", "") |
||||
|
err = cmd.Run() |
||||
|
require.NoError(t, err, "failed to generate SSH host key") |
||||
|
|
||||
|
// Use provided userstore or copy the test one
|
||||
|
userStoreFile := config.UserStoreFile |
||||
|
if userStoreFile == "" { |
||||
|
// Copy test userstore to temp dir
|
||||
|
userStoreFile = filepath.Join(tempDir, "userstore.json") |
||||
|
testDataPath := findTestDataPath() |
||||
|
input, err := os.ReadFile(filepath.Join(testDataPath, "userstore.json")) |
||||
|
require.NoError(t, err, "failed to read test userstore.json") |
||||
|
err = os.WriteFile(userStoreFile, input, 0644) |
||||
|
require.NoError(t, err, "failed to write userstore.json") |
||||
|
} |
||||
|
|
||||
|
return &SftpTestFramework{ |
||||
|
t: t, |
||||
|
tempDir: tempDir, |
||||
|
dataDir: filepath.Join(tempDir, "data"), |
||||
|
masterAddr: "127.0.0.1:19333", |
||||
|
volumeAddr: "127.0.0.1:18080", |
||||
|
filerAddr: "127.0.0.1:18888", |
||||
|
sftpAddr: "127.0.0.1:12022", |
||||
|
weedBinary: findWeedBinary(), |
||||
|
userStoreFile: userStoreFile, |
||||
|
hostKeyFile: hostKeyFile, |
||||
|
isSetup: false, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Setup starts SeaweedFS cluster with SFTP server
|
||||
|
func (f *SftpTestFramework) Setup(config *TestConfig) error { |
||||
|
if f.isSetup { |
||||
|
return fmt.Errorf("framework already setup") |
||||
|
} |
||||
|
|
||||
|
// Create all data directories
|
||||
|
dirs := []string{ |
||||
|
f.dataDir, |
||||
|
filepath.Join(f.dataDir, "master"), |
||||
|
filepath.Join(f.dataDir, "volume"), |
||||
|
} |
||||
|
for _, dir := range dirs { |
||||
|
if err := os.MkdirAll(dir, 0755); err != nil { |
||||
|
return fmt.Errorf("failed to create directory %s: %v", dir, err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Start master
|
||||
|
if err := f.startMaster(config); err != nil { |
||||
|
return fmt.Errorf("failed to start master: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Wait for master to be ready
|
||||
|
if err := f.waitForService(f.masterAddr, 30*time.Second); err != nil { |
||||
|
return fmt.Errorf("master not ready: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Start volume server
|
||||
|
if err := f.startVolumeServer(config); err != nil { |
||||
|
return fmt.Errorf("failed to start volume server: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Wait for volume server to be ready
|
||||
|
if err := f.waitForService(f.volumeAddr, 30*time.Second); err != nil { |
||||
|
return fmt.Errorf("volume server not ready: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Start filer
|
||||
|
if err := f.startFiler(config); err != nil { |
||||
|
return fmt.Errorf("failed to start filer: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Wait for filer to be ready
|
||||
|
if err := f.waitForService(f.filerAddr, 30*time.Second); err != nil { |
||||
|
return fmt.Errorf("filer not ready: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Start SFTP server
|
||||
|
if err := f.startSftpServer(config); err != nil { |
||||
|
return fmt.Errorf("failed to start SFTP server: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Wait for SFTP server to be ready
|
||||
|
if err := f.waitForService(f.sftpAddr, 30*time.Second); err != nil { |
||||
|
return fmt.Errorf("SFTP server not ready: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Additional wait for all services to stabilize (gRPC endpoints)
|
||||
|
time.Sleep(500 * time.Millisecond) |
||||
|
|
||||
|
f.isSetup = true |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// Cleanup stops all processes and removes temporary files
|
||||
|
func (f *SftpTestFramework) Cleanup() { |
||||
|
// Stop processes in reverse order
|
||||
|
processes := []*os.Process{f.sftpProcess, f.filerProcess, f.volumeProcess, f.masterProcess} |
||||
|
for _, proc := range processes { |
||||
|
if proc != nil { |
||||
|
proc.Signal(syscall.SIGTERM) |
||||
|
proc.Wait() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Remove temp directory
|
||||
|
if !DefaultTestConfig().SkipCleanup { |
||||
|
os.RemoveAll(f.tempDir) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// GetSftpAddr returns the SFTP server address
|
||||
|
func (f *SftpTestFramework) GetSftpAddr() string { |
||||
|
return f.sftpAddr |
||||
|
} |
||||
|
|
||||
|
// GetFilerAddr returns the filer address
|
||||
|
func (f *SftpTestFramework) GetFilerAddr() string { |
||||
|
return f.filerAddr |
||||
|
} |
||||
|
|
||||
|
// ConnectSFTP creates an SFTP client connection with the given credentials
|
||||
|
func (f *SftpTestFramework) ConnectSFTP(username, password string) (*sftp.Client, *ssh.Client, error) { |
||||
|
// Load the known host public key for verification
|
||||
|
hostKeyCallback, err := f.getHostKeyCallback() |
||||
|
if err != nil { |
||||
|
return nil, nil, fmt.Errorf("failed to get host key callback: %v", err) |
||||
|
} |
||||
|
|
||||
|
config := &ssh.ClientConfig{ |
||||
|
User: username, |
||||
|
Auth: []ssh.AuthMethod{ |
||||
|
ssh.Password(password), |
||||
|
}, |
||||
|
HostKeyCallback: hostKeyCallback, |
||||
|
Timeout: 5 * time.Second, |
||||
|
} |
||||
|
|
||||
|
sshConn, err := ssh.Dial("tcp", f.sftpAddr, config) |
||||
|
if err != nil { |
||||
|
return nil, nil, fmt.Errorf("failed to connect SSH: %v", err) |
||||
|
} |
||||
|
|
||||
|
sftpClient, err := sftp.NewClient(sshConn) |
||||
|
if err != nil { |
||||
|
sshConn.Close() |
||||
|
return nil, nil, fmt.Errorf("failed to create SFTP client: %v", err) |
||||
|
} |
||||
|
|
||||
|
return sftpClient, sshConn, nil |
||||
|
} |
||||
|
|
||||
|
// getHostKeyCallback returns a callback that verifies the server's host key
|
||||
|
// matches the known test server key we generated
|
||||
|
func (f *SftpTestFramework) getHostKeyCallback() (ssh.HostKeyCallback, error) { |
||||
|
// Read the public key file generated alongside the private key
|
||||
|
pubKeyFile := f.hostKeyFile + ".pub" |
||||
|
pubKeyBytes, err := os.ReadFile(pubKeyFile) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("failed to read host public key: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Parse the public key
|
||||
|
pubKey, _, _, _, err := ssh.ParseAuthorizedKey(pubKeyBytes) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("failed to parse host public key: %v", err) |
||||
|
} |
||||
|
|
||||
|
// Return a callback that verifies the server key matches our known key
|
||||
|
return ssh.FixedHostKey(pubKey), nil |
||||
|
} |
||||
|
|
||||
|
// startMaster starts the SeaweedFS master server
|
||||
|
func (f *SftpTestFramework) startMaster(config *TestConfig) error { |
||||
|
args := []string{ |
||||
|
"master", |
||||
|
"-ip=127.0.0.1", |
||||
|
"-port=19333", |
||||
|
"-mdir=" + filepath.Join(f.dataDir, "master"), |
||||
|
"-raftBootstrap", |
||||
|
"-peers=none", |
||||
|
} |
||||
|
|
||||
|
cmd := exec.Command(f.weedBinary, args...) |
||||
|
cmd.Dir = f.tempDir |
||||
|
if config.EnableDebug { |
||||
|
cmd.Stdout = os.Stdout |
||||
|
cmd.Stderr = os.Stderr |
||||
|
} |
||||
|
if err := cmd.Start(); err != nil { |
||||
|
return err |
||||
|
} |
||||
|
f.masterProcess = cmd.Process |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// startVolumeServer starts SeaweedFS volume server
|
||||
|
func (f *SftpTestFramework) startVolumeServer(config *TestConfig) error { |
||||
|
args := []string{ |
||||
|
"volume", |
||||
|
"-mserver=" + f.masterAddr, |
||||
|
"-ip=127.0.0.1", |
||||
|
"-port=18080", |
||||
|
"-dir=" + filepath.Join(f.dataDir, "volume"), |
||||
|
fmt.Sprintf("-max=%d", config.NumVolumes), |
||||
|
} |
||||
|
|
||||
|
cmd := exec.Command(f.weedBinary, args...) |
||||
|
cmd.Dir = f.tempDir |
||||
|
if config.EnableDebug { |
||||
|
cmd.Stdout = os.Stdout |
||||
|
cmd.Stderr = os.Stderr |
||||
|
} |
||||
|
if err := cmd.Start(); err != nil { |
||||
|
return err |
||||
|
} |
||||
|
f.volumeProcess = cmd.Process |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// startFiler starts the SeaweedFS filer server
|
||||
|
func (f *SftpTestFramework) startFiler(config *TestConfig) error { |
||||
|
args := []string{ |
||||
|
"filer", |
||||
|
"-master=" + f.masterAddr, |
||||
|
"-ip=127.0.0.1", |
||||
|
"-port=18888", |
||||
|
} |
||||
|
|
||||
|
cmd := exec.Command(f.weedBinary, args...) |
||||
|
cmd.Dir = f.tempDir |
||||
|
if config.EnableDebug { |
||||
|
cmd.Stdout = os.Stdout |
||||
|
cmd.Stderr = os.Stderr |
||||
|
} |
||||
|
if err := cmd.Start(); err != nil { |
||||
|
return err |
||||
|
} |
||||
|
f.filerProcess = cmd.Process |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// startSftpServer starts the SeaweedFS SFTP server
|
||||
|
func (f *SftpTestFramework) startSftpServer(config *TestConfig) error { |
||||
|
args := []string{ |
||||
|
"sftp", |
||||
|
"-filer=" + f.filerAddr, |
||||
|
"-ip.bind=127.0.0.1", |
||||
|
"-port=12022", |
||||
|
"-sshPrivateKey=" + f.hostKeyFile, |
||||
|
"-userStoreFile=" + f.userStoreFile, |
||||
|
} |
||||
|
|
||||
|
cmd := exec.Command(f.weedBinary, args...) |
||||
|
cmd.Dir = f.tempDir |
||||
|
if config.EnableDebug { |
||||
|
cmd.Stdout = os.Stdout |
||||
|
cmd.Stderr = os.Stderr |
||||
|
} |
||||
|
if err := cmd.Start(); err != nil { |
||||
|
return err |
||||
|
} |
||||
|
f.sftpProcess = cmd.Process |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// waitForService waits for a service to be available
|
||||
|
func (f *SftpTestFramework) waitForService(addr string, timeout time.Duration) error { |
||||
|
deadline := time.Now().Add(timeout) |
||||
|
for time.Now().Before(deadline) { |
||||
|
conn, err := net.DialTimeout("tcp", addr, 1*time.Second) |
||||
|
if err == nil { |
||||
|
conn.Close() |
||||
|
return nil |
||||
|
} |
||||
|
time.Sleep(100 * time.Millisecond) |
||||
|
} |
||||
|
return fmt.Errorf("service at %s not ready within timeout", addr) |
||||
|
} |
||||
|
|
||||
|
// findWeedBinary locates the weed binary
|
||||
|
// Prefers local build over system-installed weed to ensure we test the latest code
|
||||
|
func findWeedBinary() string { |
||||
|
// Get the directory where this source file is located
|
||||
|
// This ensures we find the locally built weed binary first
|
||||
|
_, thisFile, _, ok := runtime.Caller(0) |
||||
|
if ok { |
||||
|
thisDir := filepath.Dir(thisFile) |
||||
|
// From test/sftp/, the weed binary should be at ../../weed/weed
|
||||
|
candidates := []string{ |
||||
|
filepath.Join(thisDir, "../../weed/weed"), |
||||
|
filepath.Join(thisDir, "../weed/weed"), |
||||
|
} |
||||
|
for _, candidate := range candidates { |
||||
|
if _, err := os.Stat(candidate); err == nil { |
||||
|
abs, _ := filepath.Abs(candidate) |
||||
|
return abs |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Try relative paths from current working directory
|
||||
|
cwd, _ := os.Getwd() |
||||
|
candidates := []string{ |
||||
|
filepath.Join(cwd, "../../weed/weed"), |
||||
|
filepath.Join(cwd, "../weed/weed"), |
||||
|
filepath.Join(cwd, "./weed"), |
||||
|
} |
||||
|
for _, candidate := range candidates { |
||||
|
if _, err := os.Stat(candidate); err == nil { |
||||
|
abs, _ := filepath.Abs(candidate) |
||||
|
return abs |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Fallback to PATH only if local build not found
|
||||
|
if path, err := exec.LookPath("weed"); err == nil { |
||||
|
return path |
||||
|
} |
||||
|
|
||||
|
// Default fallback
|
||||
|
return "weed" |
||||
|
} |
||||
|
|
||||
|
// findTestDataPath locates the testdata directory
|
||||
|
func findTestDataPath() string { |
||||
|
// Get the directory where this source file is located
|
||||
|
_, thisFile, _, ok := runtime.Caller(0) |
||||
|
if ok { |
||||
|
thisDir := filepath.Dir(thisFile) |
||||
|
testDataPath := filepath.Join(thisDir, "testdata") |
||||
|
if _, err := os.Stat(testDataPath); err == nil { |
||||
|
return testDataPath |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Try relative paths from current working directory
|
||||
|
cwd, _ := os.Getwd() |
||||
|
candidates := []string{ |
||||
|
filepath.Join(cwd, "testdata"), |
||||
|
filepath.Join(cwd, "../sftp/testdata"), |
||||
|
filepath.Join(cwd, "test/sftp/testdata"), |
||||
|
} |
||||
|
|
||||
|
for _, candidate := range candidates { |
||||
|
if _, err := os.Stat(candidate); err == nil { |
||||
|
return candidate |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return "./testdata" |
||||
|
} |
||||
|
|
||||
@ -0,0 +1,17 @@ |
|||||
|
module seaweedfs-sftp-tests |
||||
|
|
||||
|
go 1.24.0 |
||||
|
|
||||
|
require ( |
||||
|
github.com/pkg/sftp v1.13.7 |
||||
|
github.com/stretchr/testify v1.10.0 |
||||
|
golang.org/x/crypto v0.45.0 |
||||
|
) |
||||
|
|
||||
|
require ( |
||||
|
github.com/davecgh/go-spew v1.1.1 // indirect |
||||
|
github.com/kr/fs v0.1.0 // indirect |
||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect |
||||
|
golang.org/x/sys v0.38.0 // indirect |
||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect |
||||
|
) |
||||
@ -0,0 +1,64 @@ |
|||||
|
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/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= |
||||
|
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= |
||||
|
github.com/pkg/sftp v1.13.7 h1:uv+I3nNJvlKZIQGSr8JVQLNHFU9YhhNpvC14Y6KgmSM= |
||||
|
github.com/pkg/sftp v1.13.7/go.mod h1:KMKI0t3T6hfA+lTR/ssZdunHo+uwq7ghoN09/FSu3DY= |
||||
|
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/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/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= |
||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= |
||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= |
||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= |
||||
|
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.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= |
||||
|
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= |
||||
|
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= |
||||
|
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/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/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/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-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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
||||
|
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= |
||||
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= |
||||
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= |
||||
|
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.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= |
||||
|
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= |
||||
|
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= |
||||
|
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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= |
||||
|
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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= |
||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= |
||||
|
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= |
||||
@ -0,0 +1,36 @@ |
|||||
|
[ |
||||
|
{ |
||||
|
"Username": "admin", |
||||
|
"Password": "adminpassword", |
||||
|
"PublicKeys": [], |
||||
|
"HomeDir": "/", |
||||
|
"Permissions": { |
||||
|
"/": ["*"] |
||||
|
}, |
||||
|
"Uid": 0, |
||||
|
"Gid": 0 |
||||
|
}, |
||||
|
{ |
||||
|
"Username": "testuser", |
||||
|
"Password": "testuserpassword", |
||||
|
"PublicKeys": [], |
||||
|
"HomeDir": "/sftp/testuser", |
||||
|
"Permissions": { |
||||
|
"/sftp/testuser": ["*"] |
||||
|
}, |
||||
|
"Uid": 1001, |
||||
|
"Gid": 1001 |
||||
|
}, |
||||
|
{ |
||||
|
"Username": "readonly", |
||||
|
"Password": "readonlypassword", |
||||
|
"PublicKeys": [], |
||||
|
"HomeDir": "/public", |
||||
|
"Permissions": { |
||||
|
"/public": ["read", "list"] |
||||
|
}, |
||||
|
"Uid": 1002, |
||||
|
"Gid": 1002 |
||||
|
} |
||||
|
] |
||||
|
|
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue