diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index da10fda..309999d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,12 +10,21 @@ on: jobs: test: - name: test - runs-on: ubuntu-latest + name: test (${{ matrix.os }}) + strategy: + matrix: + os: + - ubuntu-latest + - windows-latest + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 - uses: actions/setup-go@v7 with: go-version: "1.26.x" - - name: Run test with coverage + - name: Run test with race detector and coverage + if: runner.os != 'Windows' run: go test -race -coverprofile=coverage.txt -covermode=atomic ./... + - name: Run test with coverage + if: runner.os == 'Windows' + run: go test "-coverprofile=coverage.txt" -covermode=atomic ./... diff --git a/lib/config_test.go b/lib/config_test.go index 1fd005a..1218cbb 100644 --- a/lib/config_test.go +++ b/lib/config_test.go @@ -60,12 +60,19 @@ func TestConfigDefaults(t *testing.T) { func TestConfigCascade(t *testing.T) { t.Parallel() + // Directories are resolved to absolute paths, which differ by platform + // (for example "/" becomes the current drive root on Windows). + rootDirectory, err := filepath.Abs("/") + require.NoError(t, err) + basicDirectory, err := filepath.Abs("/basic") + require.NoError(t, err) + check := func(t *testing.T, cfg *Config) { require.True(t, cfg.Permissions.Read) require.True(t, cfg.Permissions.Create) require.False(t, cfg.Permissions.Delete) require.False(t, cfg.Permissions.Update) - require.Equal(t, "/", cfg.Directory) + require.Equal(t, rootDirectory, cfg.Directory) require.Len(t, cfg.Rules, 1) require.Len(t, cfg.Users, 2) @@ -73,14 +80,14 @@ func TestConfigCascade(t *testing.T) { require.True(t, cfg.Users[0].Permissions.Create) require.False(t, cfg.Users[0].Permissions.Delete) require.False(t, cfg.Users[0].Permissions.Update) - require.Equal(t, "/", cfg.Users[0].Directory) + require.Equal(t, rootDirectory, cfg.Users[0].Directory) require.Len(t, cfg.Users[0].Rules, 1) require.True(t, cfg.Users[1].Permissions.Read) require.False(t, cfg.Users[1].Permissions.Create) require.False(t, cfg.Users[1].Permissions.Delete) require.False(t, cfg.Users[1].Permissions.Update) - require.Equal(t, "/basic", cfg.Users[1].Directory) + require.Equal(t, basicDirectory, cfg.Users[1].Directory) require.Len(t, cfg.Users[1].Rules, 0) } @@ -485,8 +492,11 @@ func TestConfigEnv(t *testing.T) { cfg, err := ParseConfig("", nil) require.NoError(t, err) + expectedDirectory, err := filepath.Abs("/test") + require.NoError(t, err) + assert.Equal(t, 1234, cfg.Port) - assert.Equal(t, "/test", cfg.Directory) + assert.Equal(t, expectedDirectory, cfg.Directory) assert.Equal(t, true, cfg.Debug) require.True(t, cfg.Permissions.Read) require.True(t, cfg.Permissions.Create) diff --git a/lib/handler_test.go b/lib/handler_test.go index 9076efd..166c814 100644 --- a/lib/handler_test.go +++ b/lib/handler_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "sort" "strings" "testing" @@ -486,13 +487,21 @@ func TestServerPartialUpdateHonorsLocks(t *testing.T) { func TestServerListingCharacters(t *testing.T) { t.Parallel() - dir := makeTestDirectory(t, map[string][]byte{ + contents := map[string][]byte{ "富/foo.txt": []byte("foo"), "你好.txt": []byte("bar"), - "z*.txt": []byte("zbar"), "foo.txt": []byte("foo"), "🌹.txt": []byte("foo"), - }) + } + expectedNames := []string{"foo.txt", "你好.txt", "富", "🌹.txt"} + if runtime.GOOS != "windows" { + // Asterisks are invalid in Windows filenames. + contents["z*.txt"] = []byte("zbar") + expectedNames = append(expectedNames, "z*.txt") + } + sort.Strings(expectedNames) + + dir := makeTestDirectory(t, contents) srv := makeTestServer(t, "directory: "+dir) client := gowebdav.NewClient(srv.URL, "", "") @@ -500,28 +509,21 @@ func TestServerListingCharacters(t *testing.T) { // By default, reading permissions. files, err := client.ReadDir("/") require.NoError(t, err) - require.Len(t, files, 5) + require.Len(t, files, len(expectedNames)) - names := []string{ - files[0].Name(), - files[1].Name(), - files[2].Name(), - files[3].Name(), - files[4].Name(), + names := make([]string, len(files)) + for i, file := range files { + names[i] = file.Name() } sort.Strings(names) - require.Equal(t, []string{ - "foo.txt", - "z*.txt", - "你好.txt", - "富", - "🌹.txt", - }, names) + require.Equal(t, expectedNames, names) - data, err := client.Read("/z*.txt") - require.NoError(t, err) - require.EqualValues(t, []byte("zbar"), data) + if runtime.GOOS != "windows" { + data, err := client.Read("/z*.txt") + require.NoError(t, err) + require.EqualValues(t, []byte("zbar"), data) + } } func TestServerAuthentication(t *testing.T) { diff --git a/lib/locksystem.go b/lib/locksystem.go index 56b3f51..5f68d34 100644 --- a/lib/locksystem.go +++ b/lib/locksystem.go @@ -1,6 +1,7 @@ package lib import ( + "path" "path/filepath" "time" @@ -24,7 +25,10 @@ func newLockSystem(ls webdav.LockSystem, directory string) *lockSystem { return &lockSystem{ LockSystem: ls, resolve: func(name string) (string, error) { - return filepath.Join(directory, name), nil + // Lock names share a slash-separated namespace across users, even + // on Windows where filepath.Join would emit backslashes and break + // descendant-lock matching in the underlying LockSystem. + return path.Join(filepath.ToSlash(directory), name), nil }, } } @@ -44,7 +48,10 @@ func newMultiDirLockSystem(ls webdav.LockSystem, mounts DirectoryMounts) *lockSy return "", err } - return mount.filePath(rest), nil + // filePath returns an OS-native path for real file operations; the + // lock namespace must stay slash-separated so descendant locks match + // on Windows. + return filepath.ToSlash(mount.filePath(rest)), nil }, } } diff --git a/lib/locksystem_test.go b/lib/locksystem_test.go new file mode 100644 index 0000000..58df676 --- /dev/null +++ b/lib/locksystem_test.go @@ -0,0 +1,79 @@ +package lib + +import ( + "path" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + "golang.org/x/net/webdav" +) + +func TestLockSystemRootLockProtectsDescendants(t *testing.T) { + t.Parallel() + + locks := newLockSystem(webdav.NewMemLS(), filepath.Join(t.TempDir(), "nested")) + now := time.Now() + + token, err := locks.Create(now, webdav.LockDetails{ + Root: "/", + Duration: time.Minute, + }) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, locks.Unlock(time.Now(), token)) + }) + + _, err = locks.Create(now, webdav.LockDetails{ + Root: "/child.txt", + Duration: time.Minute, + ZeroDepth: true, + }) + require.ErrorIs(t, err, webdav.ErrLocked) +} + +func TestLockSystemSharesLocksAcrossNestedUserDirectories(t *testing.T) { + t.Parallel() + + shared := webdav.NewMemLS() + parentDirectory := t.TempDir() + childDirectory := filepath.Join(parentDirectory, "child") + parent := newLockSystem(shared, parentDirectory) + child := newLockSystem(shared, childDirectory) + now := time.Now() + + token, err := parent.Create(now, webdav.LockDetails{ + Root: "/", + Duration: time.Minute, + }) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, parent.Unlock(time.Now(), token)) + }) + + _, err = child.Create(now, webdav.LockDetails{ + Root: "/file.txt", + Duration: time.Minute, + ZeroDepth: true, + }) + require.ErrorIs(t, err, webdav.ErrLocked) + + // The lock key is slash-separated on every platform, so the child's file + // nests under the parent's root lock rather than diverging on Windows. + key, err := child.resolve("/file.txt") + require.NoError(t, err) + require.Equal(t, path.Join(filepath.ToSlash(childDirectory), "file.txt"), key) +} + +func TestMultiDirLockSystemUsesSlashSeparatedKeys(t *testing.T) { + t.Parallel() + + mounts := DirectoryMounts{{Name: "docs", Path: filepath.Join(t.TempDir(), "docs")}} + locks := newMultiDirLockSystem(webdav.NewMemLS(), mounts) + + key, err := locks.resolve("/docs/report.txt") + require.NoError(t, err) + require.Equal(t, path.Join(filepath.ToSlash(mounts[0].Path), "report.txt"), key) + require.NotContains(t, key, "\\") +}