diff --git a/.github/workflows/mount-windows-conformance.yml b/.github/workflows/mount-windows-conformance.yml new file mode 100644 index 000000000..95b0ea4b9 --- /dev/null +++ b/.github/workflows/mount-windows-conformance.yml @@ -0,0 +1,118 @@ +name: "mount: windows conformance" + +on: + push: + branches: [ master ] + paths: + - 'weed/mount/**' + - 'weed/command/mount*.go' + - 'test/winfsp-conformance/**' + - '.github/workflows/mount-windows-conformance.yml' + # No base branch filter: this is the only thing that runs the Windows mount, + # so it should cover a pull request stacked on another one too. + pull_request: + paths: + - 'weed/mount/**' + - 'weed/command/mount*.go' + - 'test/winfsp-conformance/**' + - '.github/workflows/mount-windows-conformance.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + conformance: + name: WinFsp conformance + runs-on: windows-latest + timeout-minutes: 60 + env: + # The runner ships MinGW, so cgo is on by default and cgofuse picks its + # cgo variant, which wants WinFsp's headers. The nocgo variant loads + # winfsp-x64.dll at run time instead, which is how weed.exe is released. + CGO_ENABLED: 0 + + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: actions/setup-go@v7 + with: + go-version-file: 'go.mod' + + # cgofuse loads winfsp-x64.dll at run time, so WinFsp is needed here but + # not to build. + - name: Install WinFsp + run: choco install winfsp -y --no-progress + + - name: Build weed.exe + run: go build -o weed.exe ./weed + + # The runner tears down a step's process tree when its shell exits, so a + # cluster started in one step is gone by the next. Everything that needs + # the cluster and the mount alive has to share a step. + - name: Mount and run winfsp-tests + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + + function Test-Port($port) { + # A plain connect, because Test-NetConnection has reported success + # here for a port nothing was listening on. + $client = New-Object System.Net.Sockets.TcpClient + try { $client.Connect('127.0.0.1', $port); return $client.Connected } + catch { return $false } + finally { $client.Dispose() } + } + + function Start-Mount($log) { + Start-Process -FilePath .\weed.exe ` + -ArgumentList '-logtostderr','mount','-filer=127.0.0.1:8888','-dir=S:' ` + -RedirectStandardOutput "C:\$log.log" -RedirectStandardError "C:\$log.err.log" + $deadline = (Get-Date).AddMinutes(2) + while ((Get-Date) -lt $deadline) { + if (Test-Path S:\) { Write-Host "S: is mounted"; return } + Start-Sleep -Seconds 2 + } + Get-Content "C:\$log.log", "C:\$log.err.log" -ErrorAction SilentlyContinue + throw "S: never appeared" + } + + New-Item -ItemType Directory -Force -Path C:\seaweed-data | Out-Null + # -ip pins the cluster to loopback; it otherwise advertises and binds + # the runner's LAN address, which 127.0.0.1 cannot reach. + Start-Process -FilePath .\weed.exe ` + -ArgumentList '-logtostderr','mini','-dir=C:\seaweed-data','-ip=127.0.0.1' ` + -RedirectStandardOutput C:\seaweed-mini.log -RedirectStandardError C:\seaweed-mini.err.log + + $deadline = (Get-Date).AddMinutes(3) + while ((Get-Date) -lt $deadline) { + # The mount dials grpc, not http, so both ports have to answer. + if ((Test-Port 8888) -and (Test-Port 18888)) { break } + Start-Sleep -Seconds 3 + } + if (-not ((Test-Port 8888) -and (Test-Port 18888))) { + Get-Content C:\seaweed-mini.log, C:\seaweed-mini.err.log -ErrorAction SilentlyContinue + throw "filer never came up" + } + Write-Host "filer is up on http 8888 and grpc 18888" + + Start-Mount 'seaweed-mount' + + Write-Host "::group::winfsp-tests" + & pwsh -File test/winfsp-conformance/run.ps1 -MountPoint S:\ + $code = $LASTEXITCODE + Write-Host "::endgroup::" + if ($code -ne 0) { throw "winfsp-tests failed with exit $code" } + + - name: Logs + if: always() + shell: pwsh + run: | + foreach ($f in 'C:\seaweed-mount.log','C:\seaweed-mount.err.log','C:\seaweed-mini.log','C:\seaweed-mini.err.log') { + if (Test-Path $f) { Write-Host "===== $f"; Get-Content $f -Tail 200 } + } diff --git a/test/winfsp-conformance/known_failures.txt b/test/winfsp-conformance/known_failures.txt new file mode 100644 index 000000000..2b179ba7d --- /dev/null +++ b/test/winfsp-conformance/known_failures.txt @@ -0,0 +1,92 @@ +# Known winfsp-tests failures for the SeaweedFS Windows mount. +# +# One test name per line, as winfsp-tests reports it. A trailing * matches a +# prefix, so "stream_*" covers a whole group. Listed tests are excluded from +# the run; a failure in anything NOT listed fails CI, which is what catches a +# regression. +# +# Populated from the first real run: 20 of 50 passed. Extended attributes are +# forwarded now, so that group runs rather than being excluded. Every entry below is a +# gap in the mount rather than a quirk of the suite, and the list is meant to +# shrink. Keep a reason on each group — an entry with no reason cannot be told +# apart from one nobody has looked at. + +# Cannot pass by construction +# --------------------------- +# Alternate data streams: the filer stores one byte range per entry, and the +# mount exposes no second stream. +stream_* +# Reparse points: Symlink is refused, so there is nothing to resolve. +reparse_* +# Byte-range locks live in the WinFsp driver, so they never reach the mount. +lock_* + +# Security descriptors +# -------------------- +# The mount carries unix mode bits and no ACL, and Chown accepts and discards, +# so a descriptor written here does not read back. +create_sd_test +getsecurity_test + +# Cached and overlapped IO +# ------------------------ +# The whole rdwr group fails together, so this is one defect rather than nine: +# the mount does not yet satisfy what Windows expects of cached, write-through +# and overlapped IO. The first thing worth fixing. +rdwr_cached_test +rdwr_cached_append_test +rdwr_cached_overlapped_test +rdwr_noncached_test +rdwr_noncached_overlapped_test +rdwr_writethru_test +rdwr_writethru_append_test +rdwr_writethru_overlapped_test +rdwr_mixed_test +flush_test + +# Delete semantics +# ---------------- +# WinFsp advertises POSIX unlink, so a file can be deleted while handles are +# still open; the mount does not implement the delete-pending state. +delete_access_test +delete_ex_test +delete_mmap_test +delete_pending_test + +# Rename semantics +# ---------------- +# Rename over an open file, and rename of a directory holding open handles, +# which WinFsp is stricter about than POSIX. +rename_ex_test +rename_mmap_test +rename_open_test +exec_rename_dir_test + +# File information +# ---------------- +# Windows attributes (hidden, system, readonly) and creation time are not +# round-tripped: attrToStat leaves Flags unset and reports ctime as birthtime. +create_fileattr_test +create_readonlydir_test +getfileinfo_test +setfileinfo_test + +# Directory enumeration +# --------------------- +# Marker-based resumption and the buffer-overflow path, neither of which the +# hand-written suite reaches. +querydir_test +querydir_buffer_overflow_test +querydir_namelen_test + +# Name length +# ----------- +# checkName caps at 255 bytes while WinFsp allows 255 characters, so a CJK +# name that is legal to Windows is rejected here. +create_namelen_test + +# Sharing modes +# ------------- +# Windows share-access checking on create, which the mount does not enforce. +create_share_test +create_test diff --git a/test/winfsp-conformance/run.ps1 b/test/winfsp-conformance/run.ps1 new file mode 100644 index 000000000..78e967f1b --- /dev/null +++ b/test/winfsp-conformance/run.ps1 @@ -0,0 +1,91 @@ +# Runs WinFsp's own conformance suite against a mounted SeaweedFS drive. +# +# winfsp-tests is what WinFsp uses to check a filesystem behaves like NTFS, +# and --fuse-external points it at somebody else's filesystem instead of the +# bundled memfs. It is the Windows counterpart of the pjdfstest run the FUSE +# mount already goes through, and it is scored the same way: anything failing +# that is not in known_failures.txt is a regression. +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$MountPoint, + [string]$KnownFailures = "$PSScriptRoot\known_failures.txt", + [string]$WinFspTestsVersion = '2.1.25156' +) + +$ErrorActionPreference = 'Stop' + +# "S:" and "S:\" mean different things to Join-Path: without the separator the +# result is relative to the drive's current directory, not its root. +if ($MountPoint -notmatch '[\\/]$') { $MountPoint = $MountPoint + '\' } + +# winfsp-tests links against winfsp-x64.dll, which the MSI puts somewhere the +# loader does not look by default. +foreach ($candidate in @("${env:ProgramFiles(x86)}\WinFsp\bin", "$env:ProgramFiles\WinFsp\bin")) { + if (Test-Path $candidate) { + $env:PATH = "$candidate;$env:PATH" + Write-Host "using WinFsp binaries from $candidate" + } +} + +$toolDir = Join-Path $env:TEMP 'winfsp-tests' +$exe = Join-Path $toolDir 'winfsp-tests-x64.exe' +if (-not (Test-Path $exe)) { + # Shipped as its own archive rather than in the MSI. + $url = "https://github.com/winfsp/winfsp/releases/download/v2.1/winfsp-tests-$WinFspTestsVersion.zip" + $zip = Join-Path $env:TEMP 'winfsp-tests.zip' + Write-Host "downloading $url" + Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing + Expand-Archive -LiteralPath $zip -DestinationPath $toolDir -Force +} +if (-not (Test-Path $exe)) { + throw "winfsp-tests-x64.exe not found under $toolDir" +} + +# A missing or unreadable list would otherwise run with nothing excluded and +# report that as normal, which reads like a pass with no known failures. +if (-not (Test-Path $KnownFailures)) { + throw "known failures list not found at $KnownFailures" +} +$excluded = @(Get-Content $KnownFailures | + ForEach-Object { $_.Trim() } | + Where-Object { $_ -and -not $_.StartsWith('#') }) +Write-Host "read $($excluded.Count) exclusions from $KnownFailures" +if ($excluded.Count -eq 0) { + throw "known failures list at $KnownFailures parsed to nothing" +} + +# The suite refuses to run anywhere but a drive, and works in the current +# directory, so it has to be driven from inside the mount. +$workDir = Join-Path $MountPoint 'winfsp-conformance' +New-Item -ItemType Directory -Force -Path $workDir | Out-Null +Push-Location $workDir +try { + # --fuse-external: a third-party FUSE filesystem, not the bundled memfs. + # --resilient: tolerate operations this filesystem does not implement. + # --no-abort: report every failure instead of stopping at the first. + $arguments = @('--fuse-external', '--resilient', '--no-abort') + foreach ($name in $excluded) { + $arguments += "-$name" + } + Write-Host "running winfsp-tests with $($excluded.Count) excluded entries" + # --no-abort keeps going past a failure, and the exit code stops reflecting + # them, so the report itself is what has to be read. + $output = & $exe @arguments 2>&1 + $output | ForEach-Object { Write-Host $_ } + $failed = @($output | + ForEach-Object { if ($_ -match '^([a-z_0-9]+)\.+\s+KO') { $Matches[1] } }) + $code = if ($failed.Count -gt 0) { 1 } else { $LASTEXITCODE } +} finally { + Pop-Location + Remove-Item $workDir -Recurse -Force -ErrorAction SilentlyContinue +} + +if ($failed.Count -gt 0) { + Write-Host "::error::winfsp-tests failures outside known_failures.txt: $($failed -join ', ')" + exit 1 +} +if ($code -ne 0) { + Write-Host "::error::winfsp-tests exited $code with no failure reported" + exit $code +} +Write-Host 'winfsp-tests passed'