From 7be68720042bd87c020b47da9c35d32a7f6d7a55 Mon Sep 17 00:00:00 2001 From: lovitus Date: Thu, 17 Sep 2026 13:13:24 +0800 Subject: [PATCH 1/5] feat(wingui): add streamed local and remote file hashes with Windows executable tests --- .github/patches/gui-file-hashes.patch | 94 +++++ .github/workflows/apply-gui-patch.yml | 31 ++ .github/workflows/windows-gui.yml | 90 +++++ docs/file-hashes.md | 54 +++ e2e/wingui_hash_windows_test.go | 513 ++++++++++++++++++++++++++ pkg/wingui/hash.go | 194 ++++++++++ pkg/wingui/hash_test.go | 296 +++++++++++++++ pkg/wingui/hash_windows.go | 176 +++++++++ pkg/wingui/hash_windows_test.go | 86 +++++ 9 files changed, 1534 insertions(+) create mode 100644 .github/patches/gui-file-hashes.patch create mode 100644 .github/workflows/apply-gui-patch.yml create mode 100644 .github/workflows/windows-gui.yml create mode 100644 docs/file-hashes.md create mode 100644 e2e/wingui_hash_windows_test.go create mode 100644 pkg/wingui/hash.go create mode 100644 pkg/wingui/hash_test.go create mode 100644 pkg/wingui/hash_windows.go create mode 100644 pkg/wingui/hash_windows_test.go diff --git a/.github/patches/gui-file-hashes.patch b/.github/patches/gui-file-hashes.patch new file mode 100644 index 0000000..cc29df4 --- /dev/null +++ b/.github/patches/gui-file-hashes.patch @@ -0,0 +1,94 @@ +--- a/pkg/wingui/wingui_windows.go ++++ b/pkg/wingui/wingui_windows.go +@@ -5,6 +5,7 @@ + import ( + "bufio" + "bytes" ++ "context" + "encoding/json" + "fmt" + "io" +@@ -237,6 +238,8 @@ + rsyncButton *walk.PushButton + localNewDir *walk.PushButton + remoteNewDir *walk.PushButton ++ localHash *walk.PushButton ++ remoteHash *walk.PushButton + localRename *walk.PushButton + remoteRename *walk.PushButton + localDelete *walk.PushButton +@@ -257,6 +260,7 @@ + busy bool + rsyncAvailable bool + current *childProcess ++ hashCancel context.CancelFunc + shellClients map[shellClientKind]string + shellGateway *activeShellGateway + suppressSelection bool +@@ -319,6 +323,7 @@ + } + }}, + PushButton{AssignTo: &a.localNewDir, Text: "+Dir", Font: buttonFont(), MinSize: Size{Width: 64, Height: buttonHeight}, MaxSize: Size{Width: 64}, OnClicked: func() { a.newDirectory(sideLocal) }}, ++ PushButton{AssignTo: &a.localHash, Text: "Hash", Font: buttonFont(), Enabled: false, ToolTipText: "Calculate checksums for selected files", MinSize: Size{Width: 64, Height: buttonHeight}, MaxSize: Size{Width: 64}, OnClicked: func() { a.hashSelection(sideLocal) }}, + PushButton{AssignTo: &a.localRename, Text: "MV", Font: buttonFont(), MinSize: Size{Width: 48, Height: buttonHeight}, MaxSize: Size{Width: 48}, OnClicked: func() { a.renameSelection(sideLocal) }}, + PushButton{AssignTo: &a.localDelete, Text: "Del", Font: buttonFont(), MinSize: Size{Width: 48, Height: buttonHeight}, MaxSize: Size{Width: 48}, OnClicked: func() { a.deleteSelection(sideLocal) }}, + }}, +@@ -350,6 +355,7 @@ + } + }}, + PushButton{AssignTo: &a.remoteNewDir, Text: "+Dir", Font: buttonFont(), MinSize: Size{Width: 64, Height: buttonHeight}, MaxSize: Size{Width: 64}, OnClicked: func() { a.newDirectory(sideRemote) }}, ++ PushButton{AssignTo: &a.remoteHash, Text: "Hash", Font: buttonFont(), Enabled: false, ToolTipText: "Calculate checksums for selected files", MinSize: Size{Width: 64, Height: buttonHeight}, MaxSize: Size{Width: 64}, OnClicked: func() { a.hashSelection(sideRemote) }}, + PushButton{AssignTo: &a.remoteRename, Text: "MV", Font: buttonFont(), MinSize: Size{Width: 48, Height: buttonHeight}, MaxSize: Size{Width: 48}, OnClicked: func() { a.renameSelection(sideRemote) }}, + PushButton{AssignTo: &a.remoteDelete, Text: "Del", Font: buttonFont(), MinSize: Size{Width: 48, Height: buttonHeight}, MaxSize: Size{Width: 48}, OnClicked: func() { a.deleteSelection(sideRemote) }}, + }}, +@@ -1649,8 +1655,9 @@ + _, _ = io.Copy(io.MultiWriter(newTerminalSourceWriter(os.Stderr, "child stderr"), guiLogWriter{a: a}), stderr) + }() + ++ // Drain both pipes before Wait closes them, including the last hash result. ++ wg.Wait() + err = child.wait() +- wg.Wait() + code := 0 + if err != nil { + code = 1 +@@ -1720,7 +1727,11 @@ + func (a *app) killCurrent() { + a.mu.Lock() + child := a.current +- a.mu.Unlock() ++ cancel := a.hashCancel ++ a.mu.Unlock() ++ if cancel != nil { ++ cancel() ++ } + if child != nil { + child.kill() + } +@@ -1742,6 +1753,8 @@ + remoteNewDirEnabled := !a.busy && a.remoteNav.Current != "" + localDeleteEnabled := !a.busy && a.selection.valid() && a.selection.Side == sideLocal + remoteDeleteEnabled := !a.busy && a.selection.valid() && a.selection.Side == sideRemote && a.remoteNav.Current != "" ++ localHashEnabled := hashSelectionEnabled(a.selection, sideLocal, a.busy, a.localNav.Current) ++ remoteHashEnabled := hashSelectionEnabled(a.selection, sideRemote, a.busy, a.remoteNav.Current) + localRenameEnabled := localDeleteEnabled && selectionSingle(a.selection) + remoteRenameEnabled := remoteDeleteEnabled && selectionSingle(a.selection) + rsyncEnabled := a.rsyncAvailable +@@ -1757,6 +1770,8 @@ + a.rsyncButton.SetEnabled(enabled && rsyncEnabled) + a.localNewDir.SetEnabled(localNewDirEnabled) + a.remoteNewDir.SetEnabled(remoteNewDirEnabled) ++ a.localHash.SetEnabled(localHashEnabled) ++ a.remoteHash.SetEnabled(remoteHashEnabled) + a.localRename.SetEnabled(localRenameEnabled) + a.remoteRename.SetEnabled(remoteRenameEnabled) + a.localDelete.SetEnabled(localDeleteEnabled) +@@ -2018,6 +2033,8 @@ + return "rsync upload" + case arg == "--rsync-download": + return "rsync download" ++ case strings.Contains(arg, "flyssh-hash"): ++ return "remote file hashes" + case strings.Contains(arg, `rm -rf -- "$1"`): + return "remote delete" + case strings.Contains(arg, `mv -- "$1" "$2"`): diff --git a/.github/workflows/apply-gui-patch.yml b/.github/workflows/apply-gui-patch.yml new file mode 100644 index 0000000..ddb1884 --- /dev/null +++ b/.github/workflows/apply-gui-patch.yml @@ -0,0 +1,31 @@ +name: Apply GUI integration patch +on: + push: + branches: [codex/gui-file-hashes] + paths: [.github/patches/gui-file-hashes.patch] +permissions: + contents: write +jobs: + apply: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Apply exact reviewed patch and verify portable tests + shell: bash + run: | + set -euo pipefail + test "$(git hash-object pkg/wingui/wingui_windows.go)" = fd335030260527f7f37d7709279b5fdfa1b733bd + git apply --check .github/patches/gui-file-hashes.patch + git apply .github/patches/gui-file-hashes.patch + test "$(git hash-object pkg/wingui/wingui_windows.go)" = 57dac9a84096ef797c22123470c85f5cf6ad8607 + gofmt -w pkg/wingui/wingui_windows.go pkg/wingui/hash*.go e2e/wingui_hash_windows_test.go + go test ./pkg/wingui -count=1 + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add pkg/wingui/wingui_windows.go pkg/wingui/hash*.go e2e/wingui_hash_windows_test.go + git commit -m 'feat(wingui): wire file hash buttons and preserve complete subprocess output' + git push origin HEAD:codex/gui-file-hashes diff --git a/.github/workflows/windows-gui.yml b/.github/workflows/windows-gui.yml new file mode 100644 index 0000000..0d68e51 --- /dev/null +++ b/.github/workflows/windows-gui.yml @@ -0,0 +1,90 @@ +name: Windows GUI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + packaged-gui: + runs-on: windows-latest + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build embedded relay binaries + shell: bash + run: | + set -euo pipefail + relay_dir="pkg/forwarding/relaybin" + mkdir -p "${relay_dir}" + targets=("linux amd64" "linux arm64" "linux 386" "linux arm 6" "darwin amd64" "darwin arm64" "freebsd amd64") + for t in "${targets[@]}"; do + read -r goos goarch goarm <<<"${t}" + name="relay-${goos}-${goarch}" + GOOS="${goos}" GOARCH="${goarch}" GOARM="${goarm:-6}" CGO_ENABLED=0 \ + go build -trimpath -ldflags "-s -w" -o "${relay_dir}/${name}" ./cmd/relay + gzip -9 -c "${relay_dir}/${name}" > "${relay_dir}/${name}.gz" + rm "${relay_dir}/${name}" + done + + - name: Windows GUI unit tests + run: go test ./pkg/wingui -v -count=1 -timeout 3m + + - name: Build Windows release-mode executables + shell: bash + run: | + set -euo pipefail + go install github.com/akavel/rsrc@latest + mkdir -p dist validation + version="dev-${GITHUB_SHA:0:12}" + for arch in amd64 arm64; do + rsrc -manifest flyssh.manifest -arch "${arch}" -o "rsrc_windows_${arch}.syso" + GOOS=windows GOARCH="${arch}" CGO_ENABLED=0 go build -trimpath \ + -ldflags "-s -w -X main.Version=${version}" -o "dist/flyssh-windows-${arch}.exe" . + done + + - name: Package and extract the binary under test + shell: pwsh + run: | + foreach ($arch in @('amd64', 'arm64')) { + Compress-Archive -Path "dist/flyssh-windows-$arch.exe" -DestinationPath "dist/flyssh-windows-$arch.exe.zip" + } + Expand-Archive -Path dist/flyssh-windows-amd64.exe.zip -DestinationPath validation/extracted + $binary = (Resolve-Path validation/extracted/flyssh-windows-amd64.exe).Path + "FLYSSH_GUI_BINARY=$binary" >> $env:GITHUB_ENV + "FLYSSH_GUI_TEST_ARTIFACTS=$env:GITHUB_WORKSPACE/validation" >> $env:GITHUB_ENV + & $binary --version + if ($LASTEXITCODE -ne 0) { throw 'Packaged executable failed version smoke test' } + Get-FileHash dist/*.zip -Algorithm SHA256 | ForEach-Object { "$($_.Hash.ToLowerInvariant()) $([IO.Path]::GetFileName($_.Path))" } | Set-Content dist/checksums.txt + + - name: Test the packaged executable over real SSH and native Windows controls + shell: pwsh + run: | + go test ./e2e -run '^TestWindowsGUIHashes$' -count=1 -v -timeout 5m 2>&1 | Tee-Object validation/gui-test.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Upload binaries and validation evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: flyssh-windows-${{ github.sha }} + path: | + dist/*.zip + dist/checksums.txt + validation/*.log + validation/*.txt + validation/*.png + if-no-files-found: error + retention-days: 14 diff --git a/docs/file-hashes.md b/docs/file-hashes.md new file mode 100644 index 0000000..296e5c0 --- /dev/null +++ b/docs/file-hashes.md @@ -0,0 +1,54 @@ +# File hashes in the Windows transfer GUI + +Both the Local and Remote toolbars have **+Dir | Hash | MV | Del**. +Select one or more files in one pane, click **Hash**, choose an algorithm, and +click **Calculate**. The default is SHA-256. MD5, SHA-1, SHA-224, SHA-384 and +SHA-512 are also available. Cancel closes the chooser without reading any files. + +Hash is disabled for an empty selection, folders, mixed file/folder selections, +a pane without a current directory, and while another operation is running. +Hashing does not recurse into folders. The selected paths are captured before +the chooser opens, so later selection or navigation changes cannot redirect the +operation. Hashing runs on a worker goroutine, leaving the window responsive. + +Each successful result appears in the **Log** and the terminal, as +`checksum filename`. Filenames containing backslashes, carriage returns or +newlines use the GNU checksum escaped-filename convention (a leading backslash +before the checksum, and escaped characters in the filename). Errors are +reported for each failed file; the remaining selected files are still processed. +A partial or failed read is never presented as a successful checksum. Closing +the window cancels local hashing and terminates the remote child process. + +Local files are streamed through Go's hash implementations with bounded memory; +no external Windows checksum program is required. Remote files are read and +hashed **on the remote host**, over the same FlySSH route and authentication as +other remote operations. File contents are not downloaded. Remote hashing uses +an available `*sum` utility, `shasum` for SHA algorithms, a BSD hash utility, or +OpenSSL. A missing or incompatible utility produces a visible error. Long +selections are batched to stay within the Windows child-command size limit. + +Only regular files (including links to regular files) are hashed. A file that +changes during hashing has no snapshot guarantee. MD5 and SHA-1 are provided for +compatibility with existing checksums, not for authentication. + +## Verification + +Linux CI runs portable tests for all six algorithms, binary streaming, +cancellation, shell quoting, unusual filenames, command batching, utility +fallbacks and per-file failures. Windows GUI CI runs Windows selection and +subprocess tests, builds both Windows architectures in release mode, extracts +the amd64 ZIP and drives the actual executable's native controls against a +loopback SSH fixture. It checks button placement and enablement, chooser default +and cancellation, all six methods in both panes, and partial failures. + +To test a downloaded Windows release with Git Bash installed: + +```powershell +$env:FLYSSH_GUI_BINARY = 'C:\path\to\flyssh.exe' +$env:FLYSSH_GUI_TEST_ARTIFACTS = 'C:\path\to\validation' +go test ./e2e -run '^TestWindowsGUIHashes$' -count=1 -v -timeout 5m +``` + +Tests use temporary directories and fixture-only SSH credentials. They never +access a personal SSH server or personal files. Windows arm64 is cross-built; +runtime GUI verification runs on Windows amd64. diff --git a/e2e/wingui_hash_windows_test.go b/e2e/wingui_hash_windows_test.go new file mode 100644 index 0000000..78f0514 --- /dev/null +++ b/e2e/wingui_hash_windows_test.go @@ -0,0 +1,513 @@ +//go:build windows + +package e2e_test + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/md5" + "crypto/rand" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "syscall" + "testing" + "time" + "unsafe" + + "golang.org/x/crypto/ssh" +) + +// This test drives a separately packaged executable, not the Go test process. +// The loopback SSH fixture executes the real remote commands with Git Bash. +// No personal accounts, credentials, files, or SSH servers are used. +func TestWindowsGUIHashes(t *testing.T) { + binary := os.Getenv("FLYSSH_GUI_BINARY") + if binary == "" { + t.Skip("set FLYSSH_GUI_BINARY to an extracted Windows release executable") + } + binary, err := filepath.Abs(binary) + if err != nil { + t.Fatal(err) + } + bash := filepath.Join(os.Getenv("ProgramFiles"), "Git", "bin", "bash.exe") + if _, err := os.Stat(bash); err != nil { + t.Fatalf("Git Bash is required for the real SSH fixture: %v", err) + } + localDir, remoteDir, home := t.TempDir(), t.TempDir(), t.TempDir() + files := map[string][]byte{ + "a.txt": []byte("abc"), + "b 'quoted' 数据.bin": []byte("binary\x00\xff\r\n"), + "empty.txt": {}, + } + for _, dir := range []string{localDir, remoteDir} { + if err := os.Mkdir(filepath.Join(dir, "folder"), 0700); err != nil { + t.Fatal(err) + } + for name, data := range files { + if err := os.WriteFile(filepath.Join(dir, name), data, 0600); err != nil { + t.Fatal(err) + } + } + } + addr := startHashSSHFixture(t, bash, remoteDir) + var output hashLockedBuffer + command := exec.Command(binary, "gui:fixture-password@"+addr, "--wingui", "--no-reconnect") + command.Dir = localDir + command.Env = hashTestEnv(os.Environ(), map[string]string{"HOME": home, "USERPROFILE": home}) + command.Stdout, command.Stderr = &output, &output + if err := command.Start(); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- command.Wait() }() + ui := newHashUIDriver(t, uint32(command.Process.Pid)) + var main uintptr + t.Cleanup(func() { + if dir := os.Getenv("FLYSSH_GUI_TEST_ARTIFACTS"); dir != "" { + _ = os.MkdirAll(dir, 0755) + _ = os.WriteFile(filepath.Join(dir, "gui-console.log"), []byte(output.String()), 0600) + if main != 0 { + _ = os.WriteFile(filepath.Join(dir, "gui-log.txt"), []byte(ui.logText(main)), 0600) + } + } + if main != 0 { + ui.post(main, 0x0010, 0, 0) // WM_CLOSE + } + select { + case <-done: + case <-time.After(5 * time.Second): + _ = command.Process.Kill() + <-done + } + if t.Failed() { + t.Logf("executable output:\n%s", output.String()) + } + }) + ui.wait("main window", func() bool { main = ui.window("FlySSH Transfer"); return main != 0 }) + var lists, hashes []uintptr + ui.wait("both file panes initialized", func() bool { + lists = ui.controls(main, "ListBox", "") + hashes = ui.controls(main, "Button", "Hash") + return len(lists) == 2 && len(hashes) == 2 && ui.send(lists[0], 0x018B, 0, 0) == 4 && ui.send(lists[1], 0x018B, 0, 0) == 4 + }) + for i, hash := range hashes { + if ui.enabled(hash) { + t.Fatal("Hash must initially be disabled") + } + newDir, mv := ui.controls(main, "Button", "+Dir"), ui.controls(main, "Button", "MV") + left, middle, right := ui.rect(newDir[i]), ui.rect(hash), ui.rect(mv[i]) + if left.right > middle.left || middle.right > right.left { + t.Fatalf("pane %d: Hash is not between +Dir and MV", i) + } + } + + for pane := range lists { + t.Run([]string{"local", "remote"}[pane], func(t *testing.T) { + parentTest := ui.t + ui.t = t + defer func() { ui.t = parentTest }() + ui.selectItems(lists[pane], "folder/") + ui.wait("directory selection disabled", func() bool { return !ui.enabled(hashes[pane]) }) + ui.selectItems(lists[pane], "folder/", "a.txt") + ui.wait("mixed selection disabled", func() bool { return !ui.enabled(hashes[pane]) }) + ui.selectItems(lists[pane], "a.txt") + ui.wait("single file enabled", func() bool { return ui.enabled(hashes[pane]) && !ui.enabled(hashes[1-pane]) }) + ui.click(hashes[pane]) + dialog := ui.waitDialog() + combos := ui.controls(dialog, "ComboBox", "") + if len(combos) != 1 || ui.send(combos[0], 0x0147, 0, 0) != 3 { + t.Fatal("hash chooser must default to SHA-256") + } + ui.click(ui.button(dialog, "Cancel")) + ui.wait("cancel restores button", func() bool { return ui.window("Calculate file hashes") == 0 && ui.enabled(hashes[pane]) }) + + methods := []string{"md5", "sha1", "sha224", "sha256", "sha384", "sha512"} + for index, method := range methods { + t.Run(method, func(t *testing.T) { + parentTest := ui.t + ui.t = t + defer func() { ui.t = parentTest }() + ui.selectItems(lists[pane], "a.txt", "b 'quoted' 数据.bin", "empty.txt") + ui.wait("files selected", func() bool { return ui.enabled(hashes[pane]) }) + ui.click(ui.button(main, "Clear")) + ui.wait("log cleared", func() bool { return ui.logText(main) == "" }) + ui.click(hashes[pane]) + dialog := ui.waitDialog() + combo := ui.controls(dialog, "ComboBox", "")[0] + ui.send(combo, 0x014E, uintptr(index), 0) // CB_SETCURSEL + if pane == 1 && method == "sha256" { + hashScreenshot(t, "hash-chooser.png") + } + ui.click(ui.button(dialog, "Calculate")) + ui.wait("all hashes printed", func() bool { + return strings.Contains(ui.logText(main), "hash complete: 3 file(s), "+method) && ui.enabled(hashes[pane]) + }) + log := ui.logText(main) + for name, data := range files { + want := hashFixtureDigest(method, data) + found := false + for _, line := range strings.Split(log, "\n") { + if strings.Contains(line, want+" ") && strings.HasSuffix(strings.TrimSuffix(line, "\r"), name) { + found = true + } + } + if !found { + t.Fatalf("missing %s checksum for %q: %s\n%s", method, name, want, log) + } + } + if pane == 1 && method == "sha512" { + hashScreenshot(t, "remote-hash-results.png") + } + }) + } + }) + } + + // A file disappears after listing: report its failure, but still hash the + // remaining selected files. This exercises the actual SSH exit status too. + if err := os.Remove(filepath.Join(remoteDir, "a.txt")); err != nil { + t.Fatal(err) + } + ui.selectItems(lists[1], "a.txt", "empty.txt") + ui.wait("remote selection enabled", func() bool { return ui.enabled(hashes[1]) }) + ui.click(ui.button(main, "Clear")) + ui.wait("log cleared", func() bool { return ui.logText(main) == "" }) + ui.click(hashes[1]) + ui.click(ui.button(ui.waitDialog(), "Calculate")) + ui.wait("per-file error and remaining result", func() bool { + log := ui.logText(main) + return strings.Contains(log, "hash complete with errors") && strings.Contains(log, hashFixtureDigest("sha256", nil)+" ") && ui.enabled(hashes[1]) + }) + if !strings.Contains(ui.logText(main), "not a regular file:") { + t.Fatal("missing-file diagnostic was not displayed") + } + t.Log("Packaged Windows executable: layout, selection, chooser/cancel, all six algorithms on both panes, SSH execution, and partial failures passed") +} + +func startHashSSHFixture(t *testing.T, bash, directory string) string { + t.Helper() + _, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + signer, err := ssh.NewSignerFromKey(private) + if err != nil { + t.Fatal(err) + } + config := &ssh.ServerConfig{PasswordCallback: func(c ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { + if c.User() == "gui" && string(password) == "fixture-password" { + return nil, nil + } + return nil, fmt.Errorf("invalid fixture credentials") + }} + config.AddHostKey(signer) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + var mu sync.Mutex + var connections []net.Conn + t.Cleanup(func() { + cancel() + _ = listener.Close() + mu.Lock() + defer mu.Unlock() + for _, c := range connections { + _ = c.Close() + } + }) + gitRoot := filepath.Dir(filepath.Dir(bash)) + remoteHome := filepath.ToSlash(directory) + if len(remoteHome) > 2 && remoteHome[1] == ':' { + remoteHome = "/" + strings.ToLower(remoteHome[:1]) + remoteHome[2:] + } + env := hashTestEnv(os.Environ(), map[string]string{ + "HOME": remoteHome, + "PATH": filepath.Join(gitRoot, "usr", "bin") + ";" + filepath.Dir(bash) + ";" + os.Getenv("PATH"), + }) + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + mu.Lock() + connections = append(connections, conn) + mu.Unlock() + go func() { + defer conn.Close() + server, channels, requests, err := ssh.NewServerConn(conn, config) + if err != nil { + return + } + defer server.Close() + go ssh.DiscardRequests(requests) + for incoming := range channels { + if incoming.ChannelType() != "session" { + _ = incoming.Reject(ssh.UnknownChannelType, "session only") + continue + } + channel, reqs, err := incoming.Accept() + if err != nil { + continue + } + go func() { + defer channel.Close() + for req := range reqs { + if req.Type != "exec" { + _ = req.Reply(req.Type == "env", nil) + continue + } + var payload struct{ Command string } + if ssh.Unmarshal(req.Payload, &payload) != nil { + _ = req.Reply(false, nil) + return + } + _ = req.Reply(true, nil) + runCtx, stop := context.WithTimeout(ctx, 30*time.Second) + cmd := exec.CommandContext(runCtx, bash, "--noprofile", "--norc", "-c", payload.Command) + cmd.Env, cmd.Dir = env, directory + cmd.Stdout, cmd.Stderr = channel, channel.Stderr() + err := cmd.Run() + stop() + status := uint32(0) + if err != nil { + status = 1 + if e, ok := err.(*exec.ExitError); ok { + status = uint32(e.ExitCode()) + } + } + _, _ = channel.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{status})) + return + } + }() + } + }() + } + }() + return listener.Addr().String() +} + +func hashTestEnv(base []string, values map[string]string) []string { + out := make([]string, 0, len(base)+len(values)) + for _, entry := range base { + key, _, _ := strings.Cut(entry, "=") + replaced := false + for name := range values { + if strings.EqualFold(key, name) { + replaced = true + break + } + } + if !replaced { + out = append(out, entry) + } + } + for key, value := range values { + out = append(out, key+"="+value) + } + return out +} + +func hashFixtureDigest(method string, data []byte) string { + switch method { + case "md5": + return fmt.Sprintf("%x", md5.Sum(data)) + case "sha1": + return fmt.Sprintf("%x", sha1.Sum(data)) + case "sha224": + return fmt.Sprintf("%x", sha256.Sum224(data)) + case "sha256": + return fmt.Sprintf("%x", sha256.Sum256(data)) + case "sha384": + return fmt.Sprintf("%x", sha512.Sum384(data)) + case "sha512": + return fmt.Sprintf("%x", sha512.Sum512(data)) + default: + panic("unknown fixture hash") + } +} + +type hashLockedBuffer struct { + mu sync.Mutex + b bytes.Buffer +} + +func (b *hashLockedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.b.Write(p) +} +func (b *hashLockedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.b.String() +} + +var hashUser32 = syscall.NewLazyDLL("user32.dll") + +type hashUIDriver struct { + t *testing.T + pid uint32 + windowCallback, controlCallback uintptr + wantedTitle, wantedClass, wantedText string + foundWindow uintptr + foundControls []uintptr +} + +// Windows callback thunks cannot be freed. Allocate only two per test, rather +// than leaking a new callback on every polling iteration. +func newHashUIDriver(t *testing.T, pid uint32) *hashUIDriver { + u := &hashUIDriver{t: t, pid: pid} + u.windowCallback = syscall.NewCallback(func(hwnd, _ uintptr) uintptr { + var windowPID uint32 + hashUser32.NewProc("GetWindowThreadProcessId").Call(hwnd, uintptr(unsafe.Pointer(&windowPID))) + if windowPID == u.pid && u.text(hwnd) == u.wantedTitle { + u.foundWindow = hwnd + return 0 + } + return 1 + }) + u.controlCallback = syscall.NewCallback(func(hwnd, _ uintptr) uintptr { + if strings.EqualFold(u.class(hwnd), u.wantedClass) && (u.wantedText == "" || u.text(hwnd) == u.wantedText) { + u.foundControls = append(u.foundControls, hwnd) + } + return 1 + }) + return u +} + +type hashRect struct{ left, top, right, bottom int32 } + +func (u *hashUIDriver) wait(description string, predicate func() bool) { + u.t.Helper() + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + if predicate() { + return + } + time.Sleep(50 * time.Millisecond) + } + u.t.Fatalf("timed out waiting for %s", description) +} +func (u *hashUIDriver) send(hwnd, message, wparam, lparam uintptr) uintptr { + u.t.Helper() + var result uintptr + ok, _, err := hashUser32.NewProc("SendMessageTimeoutW").Call(hwnd, message, wparam, lparam, 2, 2000, uintptr(unsafe.Pointer(&result))) + if ok == 0 { + u.t.Fatalf("UI message %#x to %#x failed: %v", message, hwnd, err) + } + return result +} +func (u *hashUIDriver) post(hwnd, message, wparam, lparam uintptr) { + u.t.Helper() + ok, _, err := hashUser32.NewProc("PostMessageW").Call(hwnd, message, wparam, lparam) + if ok == 0 { + u.t.Fatalf("post UI message: %v", err) + } +} +func (u *hashUIDriver) text(hwnd uintptr) string { + n := u.send(hwnd, 0x000E, 0, 0) + buf := make([]uint16, n+1) + u.send(hwnd, 0x000D, uintptr(len(buf)), uintptr(unsafe.Pointer(&buf[0]))) + return syscall.UTF16ToString(buf) +} +func (u *hashUIDriver) class(hwnd uintptr) string { + buf := make([]uint16, 256) + hashUser32.NewProc("GetClassNameW").Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf))) + return syscall.UTF16ToString(buf) +} +func (u *hashUIDriver) rect(hwnd uintptr) hashRect { + var r hashRect + hashUser32.NewProc("GetWindowRect").Call(hwnd, uintptr(unsafe.Pointer(&r))) + return r +} +func (u *hashUIDriver) window(title string) uintptr { + u.wantedTitle, u.foundWindow = title, 0 + hashUser32.NewProc("EnumWindows").Call(u.windowCallback, 0) + return u.foundWindow +} +func (u *hashUIDriver) controls(parent uintptr, class, text string) []uintptr { + u.wantedClass, u.wantedText = class, text + u.foundControls = nil + hashUser32.NewProc("EnumChildWindows").Call(parent, u.controlCallback, 0) + found := u.foundControls + sort.Slice(found, func(i, j int) bool { return u.rect(found[i]).left < u.rect(found[j]).left }) + return found +} +func (u *hashUIDriver) button(parent uintptr, text string) uintptr { + u.t.Helper() + buttons := u.controls(parent, "Button", text) + if len(buttons) != 1 { + u.t.Fatalf("expected one %q button, found %d", text, len(buttons)) + } + return buttons[0] +} +func (u *hashUIDriver) click(hwnd uintptr) { u.post(hwnd, 0x00F5, 0, 0) } +func (u *hashUIDriver) enabled(hwnd uintptr) bool { + r, _, _ := hashUser32.NewProc("IsWindowEnabled").Call(hwnd) + return r != 0 +} +func (u *hashUIDriver) waitDialog() uintptr { + var dialog uintptr + u.wait("hash algorithm chooser", func() bool { dialog = u.window("Calculate file hashes"); return dialog != 0 }) + return dialog +} +func (u *hashUIDriver) logText(main uintptr) string { + for _, edit := range u.controls(main, "Edit", "") { + style, _, _ := hashUser32.NewProc("GetWindowLongW").Call(edit, ^uintptr(15)) // GWL_STYLE + if style&4 != 0 { // ES_MULTILINE + return u.text(edit) + } + } + return "" +} +func (u *hashUIDriver) selectItems(list uintptr, names ...string) { + u.t.Helper() + u.send(list, 0x0185, 0, ^uintptr(0)) // LB_SETSEL: clear all + for _, name := range names { + found := false + count := u.send(list, 0x018B, 0, 0) + for i := uintptr(0); i < count; i++ { + n := u.send(list, 0x018A, i, 0) + buf := make([]uint16, n+1) + u.send(list, 0x0189, i, uintptr(unsafe.Pointer(&buf[0]))) + if strings.HasPrefix(syscall.UTF16ToString(buf), name+" ") { + u.send(list, 0x0185, 1, i) + found = true + break + } + } + if !found { + u.t.Fatalf("file %q not found in list", name) + } + } + parent, _, _ := hashUser32.NewProc("GetParent").Call(list) + id, _, _ := hashUser32.NewProc("GetDlgCtrlID").Call(list) + u.send(parent, 0x0111, (id&0xffff)|(1<<16), list) // WM_COMMAND, LBN_SELCHANGE +} + +func hashScreenshot(t *testing.T, name string) { + dir := os.Getenv("FLYSSH_GUI_TEST_ARTIFACTS") + if dir == "" { + return + } + _ = os.MkdirAll(dir, 0755) + script := `Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; $r=[System.Windows.Forms.SystemInformation]::VirtualScreen; $b=New-Object System.Drawing.Bitmap($r.Width,$r.Height); $g=[System.Drawing.Graphics]::FromImage($b); try { $g.CopyFromScreen($r.Left,$r.Top,0,0,$b.Size); $b.Save($env:FLYSSH_SCREENSHOT) } finally { $g.Dispose(); $b.Dispose() }` + cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script) + cmd.Env = hashTestEnv(os.Environ(), map[string]string{"FLYSSH_SCREENSHOT": filepath.Join(dir, name)}) + if output, err := cmd.CombinedOutput(); err != nil { + t.Logf("optional screenshot unavailable: %v: %s", err, output) + } +} diff --git a/pkg/wingui/hash.go b/pkg/wingui/hash.go new file mode 100644 index 0000000..c3c6f4e --- /dev/null +++ b/pkg/wingui/hash.go @@ -0,0 +1,194 @@ +package wingui + +import ( + "context" + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "fmt" + "hash" + "io" + "os" + "strconv" + "strings" +) + +type hashMethod struct { + name string + label string +} + +// Keep the choice list and the command allowlist together. No user-supplied +// algorithm name is ever interpolated into a remote shell command. +func hashMethods() []hashMethod { + return []hashMethod{ + {"md5", "MD5 (md5sum)"}, + {"sha1", "SHA-1 (sha1sum)"}, + {"sha224", "SHA-224 (sha224sum)"}, + {"sha256", "SHA-256 (sha256sum)"}, + {"sha384", "SHA-384 (sha384sum)"}, + {"sha512", "SHA-512 (sha512sum)"}, + } +} + +func newFileHasher(method string) (hash.Hash, error) { + switch method { + case "md5": + return md5.New(), nil // File checksums, not authentication. + case "sha1": + return sha1.New(), nil // Compatibility with existing checksum files. + case "sha224": + return sha256.New224(), nil + case "sha256": + return sha256.New(), nil + case "sha384": + return sha512.New384(), nil + case "sha512": + return sha512.New(), nil + default: + return nil, fmt.Errorf("unsupported hash method: %q", method) + } +} + +// hashLocalFile streams a regular file with bounded memory. A result is only +// returned after a successful read of the entire file; partial hashes are never +// presented as successes. Symlinks to regular files are followed. +func hashLocalFile(ctx context.Context, method, filename string) (string, error) { + h, err := newFileHasher(method) + if err != nil { + return "", err + } + if err := ctx.Err(); err != nil { + return "", err + } + info, err := os.Stat(filename) + if err != nil { + return "", err + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("not a regular file: %q", filename) + } + f, err := os.Open(filename) + if err != nil { + return "", err + } + defer f.Close() + info, err = f.Stat() + if err != nil { + return "", err + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("not a regular file: %q", filename) + } + if _, err := io.CopyBuffer(h, hashContextReader{ctx, f}, make([]byte, 128*1024)); err != nil { + return "", err + } + if err := ctx.Err(); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +type hashContextReader struct { + ctx context.Context + r io.Reader +} + +func (r hashContextReader) Read(p []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.r.Read(p) +} + +// Match the checksum tools' escaped-filename convention, keeping each result on +// one line even when a remote filename contains newlines or backslashes. +func escapeHashFilename(filename string) string { + return strings.NewReplacer("\\", "\\\\", "\n", "\\n", "\r", "\\r").Replace(filename) +} + +func formatHashResult(digest, filename string) string { + escaped := escapeHashFilename(filename) + prefix := "" + if escaped != filename { + prefix = "\\" + } + return prefix + digest + " " + escaped +} + +// Leave headroom for Windows command-line quoting and the connection arguments. +const maxRemoteHashCommandBytes = 8000 + +func buildRemoteHashCommands(method string, targets []string) ([]string, error) { + h, err := newFileHasher(method) + if err != nil { + return nil, err + } + if len(targets) == 0 { + return nil, fmt.Errorf("no selected hash targets") + } + + // Select a server-side utility once per batch. Input redirection avoids + // interpreting filenames as options and gives all backends the same output. + var script strings.Builder + script.WriteString("LC_ALL=C; export LC_ALL\n") + fmt.Fprintf(&script, "if command -v %ssum >/dev/null 2>&1; then\n flyssh_hash() { %ssum; }\n", method, method) + if method != "md5" { + fmt.Fprintf(&script, "elif command -v shasum >/dev/null 2>&1; then\n flyssh_hash() { shasum -a %s; }\n", strings.TrimPrefix(method, "sha")) + } + fmt.Fprintf(&script, "elif command -v %s >/dev/null 2>&1; then\n flyssh_hash() { %s -q; }\n", method, method) + fmt.Fprintf(&script, "elif command -v openssl >/dev/null 2>&1; then\n flyssh_hash() { openssl dgst -%s -r; }\n", method) + fmt.Fprintf(&script, "else\n printf 'hash failed: no compatible %s utility found on the remote host\\n' >&2\n exit 127\nfi\n", method) + script.WriteString("expected=" + strconv.Itoa(h.Size()*2) + "\n") + script.WriteString(`flyssh_hash_one() { + if [ ! -f "$1" ]; then + printf 'hash failed: not a regular file: %s\n' "$2" >&2 + return 1 + fi + if digest=$(flyssh_hash < "$1"); then + digest=${digest%% *} + case "$digest" in + ''|*[!0-9a-fA-F]*) + printf 'hash failed: invalid digest for %s\n' "$2" >&2 + return 1 ;; + esac + if [ "${#digest}" -ne "$expected" ]; then + printf 'hash failed: invalid digest length for %s\n' "$2" >&2 + return 1 + fi + prefix= + [ "$1" = "$2" ] || prefix='\' + printf '%s%s %s\n' "$prefix" "$digest" "$2" + else + printf 'hash failed: could not read or hash %s\n' "$2" >&2 + return 1 + fi +} +result=0 +while [ "$#" -gt 1 ]; do + flyssh_hash_one "$1" "$2" || result=1 + shift 2 +done +exit "$result" +`) + prefix := "sh -c " + shellQuote(script.String()) + " flyssh-hash" + var commands []string + command := prefix + for _, target := range targets { + if target == "" || strings.ContainsRune(target, '\x00') { + return nil, fmt.Errorf("invalid hash target: %q", target) + } + args := " " + shellQuote(target) + " " + shellQuote(escapeHashFilename(target)) + if len(prefix)+len(args) > maxRemoteHashCommandBytes { + return nil, fmt.Errorf("hash target is too long: %q", target) + } + if len(command)+len(args) > maxRemoteHashCommandBytes { + commands = append(commands, command) + command = prefix + } + command += args + } + return append(commands, command), nil +} diff --git a/pkg/wingui/hash_test.go b/pkg/wingui/hash_test.go new file mode 100644 index 0000000..98a8aff --- /dev/null +++ b/pkg/wingui/hash_test.go @@ -0,0 +1,296 @@ +package wingui + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestHashLocalFileKnownVectors(t *testing.T) { + vectors := []struct{ method, empty, abc string }{ + {"md5", "d41d8cd98f00b204e9800998ecf8427e", "900150983cd24fb0d6963f7d28e17f72"}, + {"sha1", "da39a3ee5e6b4b0d3255bfef95601890afd80709", "a9993e364706816aba3e25717850c26c9cd0d89d"}, + {"sha224", "d14a028c2a3a2bc9476102bb288234c415a2b01f828ea62ac5b3e42f", "23097d223405d8228642a477bda255b32aadbce4bda0b3f7e36c9da7"}, + {"sha256", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"}, + {"sha384", "38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b", "cb00753f45a35e8bb5a03d699ac65007272c32ab0eded1631a8b605a43ff5bed8086072ba1e7cc2358baeca134c825a7"}, + {"sha512", "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e", "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f"}, + } + for _, v := range vectors { + t.Run(v.method, func(t *testing.T) { + for input, want := range map[string]string{"": v.empty, "abc": v.abc} { + file := filepath.Join(t.TempDir(), "file with spaces and 'quote' 数据.txt") + if err := os.WriteFile(file, []byte(input), 0600); err != nil { + t.Fatal(err) + } + got, err := hashLocalFile(context.Background(), v.method, file) + if err != nil || got != want { + t.Fatalf("input %q: got %q, %v; want %s", input, got, err, want) + } + } + }) + } + if len(hashMethods()) != len(vectors) { + t.Fatal("every offered method must have known-vector coverage") + } +} + +func TestHashLocalFileStreamsLargeBinary(t *testing.T) { + data := bytes.Repeat([]byte("0123456789\x00\xff\r\n"), 600000) + file := filepath.Join(t.TempDir(), "large.bin") + if err := os.WriteFile(file, data, 0600); err != nil { + t.Fatal(err) + } + got, err := hashLocalFile(context.Background(), "sha256", file) + want := fmt.Sprintf("%x", sha256.Sum256(data)) + if err != nil || got != want { + t.Fatalf("got %q, %v; want %s", got, err, want) + } +} + +func TestHashLocalFileErrorsAndCancellation(t *testing.T) { + dir := t.TempDir() + for _, file := range []string{dir, filepath.Join(dir, "missing")} { + if got, err := hashLocalFile(context.Background(), "sha256", file); err == nil || got != "" { + t.Fatalf("invalid file %q returned %q, %v", file, got, err) + } + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if got, err := hashLocalFile(ctx, "sha256", filepath.Join(dir, "missing")); !errors.Is(err, context.Canceled) || got != "" { + t.Fatalf("canceled hash returned %q, %v", got, err) + } + ctx, cancel = context.WithCancel(context.Background()) + r := hashContextReader{ctx, &cancelOnRead{cancel: cancel}} + if _, err := io.Copy(io.Discard, r); !errors.Is(err, context.Canceled) { + t.Fatalf("stream cancellation: %v", err) + } + want := errors.New("read failed") + if _, err := io.Copy(io.Discard, hashContextReader{context.Background(), hashFailReader{want}}); !errors.Is(err, want) { + t.Fatalf("read error not propagated: %v", err) + } +} + +type cancelOnRead struct{ cancel context.CancelFunc } + +func (r *cancelOnRead) Read(p []byte) (int, error) { + p[0] = 'x' + r.cancel() + return 1, nil +} + +type hashFailReader struct{ err error } + +func (r hashFailReader) Read([]byte) (int, error) { return 0, r.err } + +func TestHashAllowlistAndInvalidTargets(t *testing.T) { + for _, method := range []string{"", "SHA256", "sha256sum", "sha256; touch injected", "md4"} { + if _, err := newFileHasher(method); err == nil { + t.Fatalf("accepted method %q", method) + } + if _, err := buildRemoteHashCommands(method, []string{"/tmp/a"}); err == nil { + t.Fatalf("accepted remote method %q", method) + } + } + for _, paths := range [][]string{nil, {""}, {"/tmp/ok", "bad\x00name"}, {strings.Repeat("x", maxRemoteHashCommandBytes)}} { + if commands, err := buildRemoteHashCommands("sha256", paths); err == nil || commands != nil { + t.Fatalf("accepted invalid targets: %q, %v", commands, err) + } + } +} + +func TestFormatHashResult(t *testing.T) { + for _, tt := range []struct{ name, want string }{ + {"a b.txt", "abcd a b.txt"}, + {"a\nb\rc\\d", "\\abcd a\\nb\\rc\\\\d"}, + {"C:\\data\\a.bin", "\\abcd C:\\\\data\\\\a.bin"}, + } { + if got := formatHashResult("abcd", tt.name); got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + } +} + +func hashTestShell(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("POSIX utility integration runs on Unix; Windows GUI is tested separately") + } + sh, err := exec.LookPath("sh") + if err != nil { + t.Skip("POSIX shell unavailable") + } + return sh +} + +func runHashTestCommand(t *testing.T, sh, command, dir, pathEnv string) (string, string, error) { + t.Helper() + cmd := exec.Command(sh, "-c", command) + cmd.Dir = dir + if pathEnv != "" { + cmd.Env = []string{"PATH=" + pathEnv, "LC_ALL=C"} + } + var out, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &stderr + err := cmd.Run() + return out.String(), stderr.String(), err +} + +func TestRemoteHashesRealUtilitiesAndQuoting(t *testing.T) { + sh := hashTestShell(t) + dir := t.TempDir() + names := []string{"plain", "empty", "with space ' quote", "-leading-option", "$(touch injected);`touch injected`", "数据.txt", "line\nbreak\rback\\slash"} + var paths []string + for _, name := range names { + file := filepath.Join(dir, name) + data := []byte("abc") + if name == "empty" { + data = nil + } + if err := os.WriteFile(file, data, 0600); err != nil { + t.Fatal(err) + } + paths = append(paths, file) + } + for _, method := range hashMethods() { + t.Run(method.name, func(t *testing.T) { + if _, err := exec.LookPath(method.name + "sum"); err != nil { + t.Skip(err) + } + commands, err := buildRemoteHashCommands(method.name, paths) + if err != nil { + t.Fatal(err) + } + var got, want strings.Builder + for _, command := range commands { + out, stderr, err := runHashTestCommand(t, sh, command, dir, "") + if err != nil { + t.Fatalf("remote command: %v: %s", err, stderr) + } + got.WriteString(out) + } + for _, file := range paths { + digest, err := hashLocalFile(context.Background(), method.name, file) + if err != nil { + t.Fatal(err) + } + want.WriteString(formatHashResult(digest, file) + "\n") + } + if got.String() != want.String() { + t.Fatalf("got %q, want %q", got.String(), want.String()) + } + }) + } + if _, err := os.Stat(filepath.Join(dir, "injected")); !os.IsNotExist(err) { + t.Fatal("a filename was executed as shell syntax") + } +} + +func TestRemoteHashContinuesAfterFileErrors(t *testing.T) { + sh := hashTestShell(t) + dir := t.TempDir() + file := filepath.Join(dir, "good") + if err := os.WriteFile(file, []byte("abc"), 0600); err != nil { + t.Fatal(err) + } + commands, err := buildRemoteHashCommands("sha256", []string{file, filepath.Join(dir, "missing"), dir, file}) + if err != nil { + t.Fatal(err) + } + out, stderr, err := runHashTestCommand(t, sh, commands[0], dir, "") + if err == nil || !strings.Contains(stderr, "not a regular file") || strings.Count(out, " "+file+"\n") != 2 { + t.Fatalf("must report errors and hash later files: out=%q stderr=%q err=%v", out, stderr, err) + } +} + +func TestRemoteHashFallbacksAndMissingUtility(t *testing.T) { + sh := hashTestShell(t) + for _, backend := range []string{"shasum", "openssl", "missing", "bad-output"} { + t.Run(backend, func(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "bin") + if err := os.Mkdir(bin, 0700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(sh, filepath.Join(bin, "sh")); err != nil { + t.Fatal(err) + } + if backend == "bad-output" { + if err := os.WriteFile(filepath.Join(bin, "sha256sum"), []byte("#!/bin/sh\nprintf 'not-a-checksum\\n'\n"), 0700); err != nil { + t.Fatal(err) + } + } else if backend != "missing" { + utility, err := exec.LookPath(backend) + if err != nil { + t.Skip(err) + } + if err := os.Symlink(utility, filepath.Join(bin, backend)); err != nil { + t.Fatal(err) + } + } + file := filepath.Join(dir, "file") + if err := os.WriteFile(file, []byte("abc"), 0600); err != nil { + t.Fatal(err) + } + for _, method := range hashMethods() { + if (backend == "shasum" && method.name == "md5") || (backend == "bad-output" && method.name != "sha256") { + continue + } + commands, err := buildRemoteHashCommands(method.name, []string{file}) + if err != nil { + t.Fatal(err) + } + out, stderr, err := runHashTestCommand(t, sh, commands[0], dir, bin) + if backend == "missing" || backend == "bad-output" { + if err == nil || out != "" || !strings.Contains(stderr, "hash failed:") { + t.Fatalf("invalid backend succeeded: %q, %q, %v", out, stderr, err) + } + continue + } + digest, localErr := hashLocalFile(context.Background(), method.name, file) + if localErr != nil || err != nil || out != formatHashResult(digest, file)+"\n" { + t.Fatalf("%s via %s: out=%q stderr=%q err=%v local=%v", method.name, backend, out, stderr, err, localErr) + } + } + }) + } +} + +func TestRemoteHashLargeSelectionIsBatched(t *testing.T) { + sh := hashTestShell(t) + dir := t.TempDir() + file := filepath.Join(dir, "file with spaces") + if err := os.WriteFile(file, []byte("abc"), 0600); err != nil { + t.Fatal(err) + } + paths := make([]string, 200) + for i := range paths { + paths[i] = file + } + commands, err := buildRemoteHashCommands("sha256", paths) + if err != nil || len(commands) < 2 { + t.Fatalf("not batched: %d, %v", len(commands), err) + } + lines := 0 + for _, command := range commands { + if len(command) > maxRemoteHashCommandBytes { + t.Fatalf("oversized command: %d", len(command)) + } + out, stderr, err := runHashTestCommand(t, sh, command, dir, "") + if err != nil { + t.Fatalf("batch failed: %s, %v", stderr, err) + } + lines += strings.Count(out, "\n") + } + if lines != len(paths) { + t.Fatalf("hashed %d of %d files", lines, len(paths)) + } +} diff --git a/pkg/wingui/hash_windows.go b/pkg/wingui/hash_windows.go new file mode 100644 index 0000000..e251161 --- /dev/null +++ b/pkg/wingui/hash_windows.go @@ -0,0 +1,176 @@ +//go:build windows + +package wingui + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "github.com/lxn/walk" + . "github.com/lxn/walk/declarative" +) + +func hashSelectionEnabled(sel selectionState, requested side, busy bool, directory string) bool { + return !busy && directory != "" && (requested == sideLocal || requested == sideRemote) && + sel.Side == requested && len(sel.Files) > 0 && !sel.hasDir() +} + +func hashSelectionTargets(sel selectionState, requested side, localDir, remoteDir string) ([]string, error) { + directory := localDir + if requested == sideRemote { + directory = remoteDir + } + if !hashSelectionEnabled(sel, requested, false, directory) { + return nil, fmt.Errorf("hash requires one or more selected files (no folders) in the current pane") + } + names, err := sel.names() + if err != nil { + return nil, err + } + targets := make([]string, 0, len(names)) + for _, name := range names { + if strings.ContainsRune(name, '\x00') { + return nil, fmt.Errorf("invalid selected name: %q", name) + } + if requested == sideRemote { + targets = append(targets, remoteJoin(directory, name)) + } else { + targets = append(targets, normalizeLocalTransferPath(filepath.Join(normalizeLocalTransferPath(directory), name))) + } + } + return targets, nil +} + +func (a *app) hashSelection(requested side) { + // Resolve a snapshot now, not after the dialog or inside the worker: later + // navigation/selection changes must not change which files get hashed. + a.mu.Lock() + busy := a.busy + targets, err := hashSelectionTargets(a.selection, requested, a.localNav.Current, a.remoteNav.Current) + a.mu.Unlock() + if busy { + return + } + if err != nil { + a.setStatus(err.Error()) + return + } + // Reserve the operation before opening a modal dialog. Other remote work + // cannot start while the user is choosing an algorithm. + if !a.startOperationWithStatus("choose a hash method") { + return + } + method, err := a.promptHashMethod(requested, len(targets)) + if err != nil || method == "" { + if err != nil { + a.setStatus("hash dialog failed: " + err.Error()) + } else { + a.setStatus("hash cancelled") + } + a.endOperation() + return + } + ctx, cancel := context.WithCancel(context.Background()) + a.mu.Lock() + a.hashCancel = cancel + a.mu.Unlock() + a.setStatus(fmt.Sprintf("hashing %d %s file(s) with %s", len(targets), requested, method)) + go func() { + defer func() { + cancel() + a.mu.Lock() + a.hashCancel = nil + a.mu.Unlock() + a.endOperation() + }() + failed := false + if requested == sideRemote { + commands, err := buildRemoteHashCommands(method, targets) + if err != nil { + a.setStatus("hash failed: " + err.Error()) + return + } + for _, command := range commands { + if ctx.Err() != nil { + break + } + args := buildChildArgs(a.rawArgs, "--no-reconnect", "--", command) + if _, code, err := a.runChild(args, false); err != nil { + failed = true + a.appendLogLine(fmt.Sprintf("hash batch failed (%d): %v", code, err)) + } + } + } else { + for _, target := range targets { + if ctx.Err() != nil { + break + } + digest, err := hashLocalFile(ctx, method, target) + if err != nil { + failed = true + a.appendLogLine(fmt.Sprintf("hash failed for %q: %v", target, err)) + continue + } + a.appendLogLine(formatHashResult(digest, target)) + } + } + switch { + case ctx.Err() != nil: + a.setStatus("hash cancelled") + case failed: + a.setStatus("hash complete with errors; see Log for individual results") + default: + a.setStatus(fmt.Sprintf("hash complete: %d file(s), %s", len(targets), method)) + } + }() +} + +func (a *app) promptHashMethod(selectedSide side, count int) (string, error) { + methods := hashMethods() + labels := make([]string, len(methods)) + defaultIndex := 0 + for i, method := range methods { + labels[i] = method.label + if method.name == "sha256" { + defaultIndex = i + } + } + var dlg *walk.Dialog + var choice *walk.ComboBox + var calculateButton, cancelButton *walk.PushButton + selectedIndex := -1 + err := (Dialog{ + AssignTo: &dlg, + Title: "Calculate file hashes", + MinSize: Size{Width: 460, Height: 210}, + Font: appFont(), + Layout: VBox{Margins: Margins{Left: 10, Top: 10, Right: 10, Bottom: 10}, Spacing: 8}, + DefaultButton: &calculateButton, + CancelButton: &cancelButton, + Children: []Widget{ + Label{Text: fmt.Sprintf("Hash method for %d selected %s file(s):", count, selectedSide)}, + ComboBox{AssignTo: &choice, Model: labels, CurrentIndex: defaultIndex}, + Label{Text: "MD5 / SHA-1 are for compatibility. SHA-256 is recommended."}, + Label{Text: "Each checksum and filename will be printed in the Log and terminal."}, + Composite{Layout: HBox{MarginsZero: true, Spacing: 8}, Children: []Widget{ + HSpacer{}, + PushButton{AssignTo: &calculateButton, Text: "Calculate", Font: buttonFont(), MinSize: Size{Width: 110, Height: buttonHeight}, OnClicked: func() { selectedIndex = choice.CurrentIndex(); dlg.Accept() }}, + PushButton{AssignTo: &cancelButton, Text: "Cancel", Font: buttonFont(), MinSize: Size{Width: 96, Height: buttonHeight}, OnClicked: func() { dlg.Cancel() }}, + }}, + }, + }).Create(a.mw) + if err != nil { + return "", err + } + defer dlg.Dispose() + _ = choice.SetFocus() + if dlg.Run() != walk.DlgCmdOK { + return "", nil + } + if selectedIndex < 0 || selectedIndex >= len(methods) { + return "", fmt.Errorf("select a hash method") + } + return methods[selectedIndex].name, nil +} diff --git a/pkg/wingui/hash_windows_test.go b/pkg/wingui/hash_windows_test.go new file mode 100644 index 0000000..3806d83 --- /dev/null +++ b/pkg/wingui/hash_windows_test.go @@ -0,0 +1,86 @@ +//go:build windows + +package wingui + +import ( + "bytes" + "fmt" + "os" + "reflect" + "testing" +) + +func TestHashSelectionEnabled(t *testing.T) { + file := selectionState{Side: sideLocal, Files: map[string]bool{"a": true}} + multi := selectionState{Side: sideRemote, Files: map[string]bool{"a": true, "b": true}} + mixed := selectionState{Side: sideLocal, Files: map[string]bool{"a": true}, Dirs: map[string]bool{"dir": true}} + folder := selectionState{Side: sideLocal, Dirs: map[string]bool{"dir": true}} + for _, tt := range []struct { + name string + sel selectionState + requested side + busy bool + dir string + want bool + }{ + {"none", newSelectionState(), sideLocal, false, `C:\data`, false}, + {"local file", file, sideLocal, false, `C:\data`, true}, + {"remote files", multi, sideRemote, false, "/data", true}, + {"wrong pane", multi, sideLocal, false, `C:\data`, false}, + {"folder", folder, sideLocal, false, `C:\data`, false}, + {"mixed", mixed, sideLocal, false, `C:\data`, false}, + {"busy", file, sideLocal, true, `C:\data`, false}, + {"not ready", multi, sideRemote, false, "", false}, + {"invalid side", file, sideNone, false, `C:\data`, false}, + } { + t.Run(tt.name, func(t *testing.T) { + if got := hashSelectionEnabled(tt.sel, tt.requested, tt.busy, tt.dir); got != tt.want { + t.Fatalf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestHashSelectionTargetsAreSortedSnapshot(t *testing.T) { + sel := selectionState{Side: sideLocal, Files: map[string]bool{"b 'quote'.bin": true, "a.txt": true}} + local, err := hashSelectionTargets(sel, sideLocal, `C:data`, "/remote") + wantLocal := []string{`C:\data\a.txt`, `C:\data\b 'quote'.bin`} + if err != nil || !reflect.DeepEqual(local, wantLocal) { + t.Fatalf("local targets: %q, %v", local, err) + } + sel.Side = sideRemote + remote, err := hashSelectionTargets(sel, sideRemote, `C:data`, "/remote") + wantRemote := []string{"/remote/a.txt", "/remote/b 'quote'.bin"} + if err != nil || !reflect.DeepEqual(remote, wantRemote) { + t.Fatalf("remote targets: %q, %v", remote, err) + } + delete(sel.Files, "a.txt") + sel.Files["new.txt"] = true + if !reflect.DeepEqual(remote, wantRemote) { + t.Fatal("selection mutation changed an already resolved hash snapshot") + } + for _, name := range []string{"..", "x/y", "bad\x00name"} { + sel.Files = map[string]bool{name: true} + if _, err := hashSelectionTargets(sel, sideRemote, "", "/remote"); err == nil { + t.Fatalf("accepted invalid selection %q", name) + } + } +} + +func TestHashChildOutputHelper(t *testing.T) { + if os.Getenv("FLYSSH_HASH_TEST_CHILD") != "1" { + return + } + _, _ = fmt.Fprint(os.Stdout, string(bytes.Repeat([]byte("checksum output\n"), 100000))) + os.Exit(0) +} + +func TestRunChildDrainsCompleteHashOutput(t *testing.T) { + t.Setenv("FLYSSH_HASH_TEST_CHILD", "1") + a := &app{exe: os.Args[0]} + got, code, err := a.runChild([]string{"-test.run=^TestHashChildOutputHelper$"}, true) + want := bytes.Repeat([]byte("checksum output\n"), 100000) + if err != nil || code != 0 || !bytes.Equal(got, want) { + t.Fatalf("stdout truncated: got %d bytes, want %d; code=%d err=%v", len(got), len(want), code, err) + } +} From 945b5d2956d8a33f869f1b3b155d804afd6056f0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 05:13:51 +0000 Subject: [PATCH 2/5] feat(wingui): wire file hash buttons and preserve complete subprocess output --- e2e/wingui_hash_windows_test.go | 12 ++++++------ pkg/wingui/wingui_windows.go | 19 ++++++++++++++++++- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/e2e/wingui_hash_windows_test.go b/e2e/wingui_hash_windows_test.go index 78f0514..04ec664 100644 --- a/e2e/wingui_hash_windows_test.go +++ b/e2e/wingui_hash_windows_test.go @@ -358,12 +358,12 @@ func (b *hashLockedBuffer) String() string { var hashUser32 = syscall.NewLazyDLL("user32.dll") type hashUIDriver struct { - t *testing.T - pid uint32 - windowCallback, controlCallback uintptr + t *testing.T + pid uint32 + windowCallback, controlCallback uintptr wantedTitle, wantedClass, wantedText string - foundWindow uintptr - foundControls []uintptr + foundWindow uintptr + foundControls []uintptr } // Windows callback thunks cannot be freed. Allocate only two per test, rather @@ -467,7 +467,7 @@ func (u *hashUIDriver) waitDialog() uintptr { func (u *hashUIDriver) logText(main uintptr) string { for _, edit := range u.controls(main, "Edit", "") { style, _, _ := hashUser32.NewProc("GetWindowLongW").Call(edit, ^uintptr(15)) // GWL_STYLE - if style&4 != 0 { // ES_MULTILINE + if style&4 != 0 { // ES_MULTILINE return u.text(edit) } } diff --git a/pkg/wingui/wingui_windows.go b/pkg/wingui/wingui_windows.go index fd33503..57dac9a 100644 --- a/pkg/wingui/wingui_windows.go +++ b/pkg/wingui/wingui_windows.go @@ -5,6 +5,7 @@ package wingui import ( "bufio" "bytes" + "context" "encoding/json" "fmt" "io" @@ -237,6 +238,8 @@ type app struct { rsyncButton *walk.PushButton localNewDir *walk.PushButton remoteNewDir *walk.PushButton + localHash *walk.PushButton + remoteHash *walk.PushButton localRename *walk.PushButton remoteRename *walk.PushButton localDelete *walk.PushButton @@ -257,6 +260,7 @@ type app struct { busy bool rsyncAvailable bool current *childProcess + hashCancel context.CancelFunc shellClients map[shellClientKind]string shellGateway *activeShellGateway suppressSelection bool @@ -319,6 +323,7 @@ func (a *app) run() error { } }}, PushButton{AssignTo: &a.localNewDir, Text: "+Dir", Font: buttonFont(), MinSize: Size{Width: 64, Height: buttonHeight}, MaxSize: Size{Width: 64}, OnClicked: func() { a.newDirectory(sideLocal) }}, + PushButton{AssignTo: &a.localHash, Text: "Hash", Font: buttonFont(), Enabled: false, ToolTipText: "Calculate checksums for selected files", MinSize: Size{Width: 64, Height: buttonHeight}, MaxSize: Size{Width: 64}, OnClicked: func() { a.hashSelection(sideLocal) }}, PushButton{AssignTo: &a.localRename, Text: "MV", Font: buttonFont(), MinSize: Size{Width: 48, Height: buttonHeight}, MaxSize: Size{Width: 48}, OnClicked: func() { a.renameSelection(sideLocal) }}, PushButton{AssignTo: &a.localDelete, Text: "Del", Font: buttonFont(), MinSize: Size{Width: 48, Height: buttonHeight}, MaxSize: Size{Width: 48}, OnClicked: func() { a.deleteSelection(sideLocal) }}, }}, @@ -350,6 +355,7 @@ func (a *app) run() error { } }}, PushButton{AssignTo: &a.remoteNewDir, Text: "+Dir", Font: buttonFont(), MinSize: Size{Width: 64, Height: buttonHeight}, MaxSize: Size{Width: 64}, OnClicked: func() { a.newDirectory(sideRemote) }}, + PushButton{AssignTo: &a.remoteHash, Text: "Hash", Font: buttonFont(), Enabled: false, ToolTipText: "Calculate checksums for selected files", MinSize: Size{Width: 64, Height: buttonHeight}, MaxSize: Size{Width: 64}, OnClicked: func() { a.hashSelection(sideRemote) }}, PushButton{AssignTo: &a.remoteRename, Text: "MV", Font: buttonFont(), MinSize: Size{Width: 48, Height: buttonHeight}, MaxSize: Size{Width: 48}, OnClicked: func() { a.renameSelection(sideRemote) }}, PushButton{AssignTo: &a.remoteDelete, Text: "Del", Font: buttonFont(), MinSize: Size{Width: 48, Height: buttonHeight}, MaxSize: Size{Width: 48}, OnClicked: func() { a.deleteSelection(sideRemote) }}, }}, @@ -1649,8 +1655,9 @@ func (a *app) runChild(args []string, captureStdout bool) ([]byte, int, error) { _, _ = io.Copy(io.MultiWriter(newTerminalSourceWriter(os.Stderr, "child stderr"), guiLogWriter{a: a}), stderr) }() - err = child.wait() + // Drain both pipes before Wait closes them, including the last hash result. wg.Wait() + err = child.wait() code := 0 if err != nil { code = 1 @@ -1720,7 +1727,11 @@ func createKillOnCloseJob() (windows.Handle, error) { func (a *app) killCurrent() { a.mu.Lock() child := a.current + cancel := a.hashCancel a.mu.Unlock() + if cancel != nil { + cancel() + } if child != nil { child.kill() } @@ -1742,6 +1753,8 @@ func (a *app) setButtons() { remoteNewDirEnabled := !a.busy && a.remoteNav.Current != "" localDeleteEnabled := !a.busy && a.selection.valid() && a.selection.Side == sideLocal remoteDeleteEnabled := !a.busy && a.selection.valid() && a.selection.Side == sideRemote && a.remoteNav.Current != "" + localHashEnabled := hashSelectionEnabled(a.selection, sideLocal, a.busy, a.localNav.Current) + remoteHashEnabled := hashSelectionEnabled(a.selection, sideRemote, a.busy, a.remoteNav.Current) localRenameEnabled := localDeleteEnabled && selectionSingle(a.selection) remoteRenameEnabled := remoteDeleteEnabled && selectionSingle(a.selection) rsyncEnabled := a.rsyncAvailable @@ -1757,6 +1770,8 @@ func (a *app) setButtons() { a.rsyncButton.SetEnabled(enabled && rsyncEnabled) a.localNewDir.SetEnabled(localNewDirEnabled) a.remoteNewDir.SetEnabled(remoteNewDirEnabled) + a.localHash.SetEnabled(localHashEnabled) + a.remoteHash.SetEnabled(remoteHashEnabled) a.localRename.SetEnabled(localRenameEnabled) a.remoteRename.SetEnabled(remoteRenameEnabled) a.localDelete.SetEnabled(localDeleteEnabled) @@ -2018,6 +2033,8 @@ func childDescription(args []string) string { return "rsync upload" case arg == "--rsync-download": return "rsync download" + case strings.Contains(arg, "flyssh-hash"): + return "remote file hashes" case strings.Contains(arg, `rm -rf -- "$1"`): return "remote delete" case strings.Contains(arg, `mv -- "$1" "$2"`): From b0db4ae58f3bd2c28b90e9c2286195d308215384 Mon Sep 17 00:00:00 2001 From: lovitus Date: Thu, 17 Sep 2026 13:16:52 +0800 Subject: [PATCH 3/5] ci: gate v2.0.15 on Linux and Windows CI and verify downloaded release binaries --- .github/patches/gui-file-hashes.patch | 114 +++++--------------------- .github/release-request.json | 1 + .github/scripts/request_release.py | 97 ++++++++++++++++++++++ .github/workflows/apply-gui-patch.yml | 17 ++-- .github/workflows/request-release.yml | 108 ++++++++++++++++++++++++ 5 files changed, 233 insertions(+), 104 deletions(-) create mode 100644 .github/release-request.json create mode 100644 .github/scripts/request_release.py create mode 100644 .github/workflows/request-release.yml diff --git a/.github/patches/gui-file-hashes.patch b/.github/patches/gui-file-hashes.patch index cc29df4..1dcb4cf 100644 --- a/.github/patches/gui-file-hashes.patch +++ b/.github/patches/gui-file-hashes.patch @@ -1,94 +1,22 @@ ---- a/pkg/wingui/wingui_windows.go -+++ b/pkg/wingui/wingui_windows.go -@@ -5,6 +5,7 @@ - import ( - "bufio" - "bytes" -+ "context" - "encoding/json" - "fmt" - "io" -@@ -237,6 +238,8 @@ - rsyncButton *walk.PushButton - localNewDir *walk.PushButton - remoteNewDir *walk.PushButton -+ localHash *walk.PushButton -+ remoteHash *walk.PushButton - localRename *walk.PushButton - remoteRename *walk.PushButton - localDelete *walk.PushButton -@@ -257,6 +260,7 @@ - busy bool - rsyncAvailable bool - current *childProcess -+ hashCancel context.CancelFunc - shellClients map[shellClientKind]string - shellGateway *activeShellGateway - suppressSelection bool -@@ -319,6 +323,7 @@ - } - }}, - PushButton{AssignTo: &a.localNewDir, Text: "+Dir", Font: buttonFont(), MinSize: Size{Width: 64, Height: buttonHeight}, MaxSize: Size{Width: 64}, OnClicked: func() { a.newDirectory(sideLocal) }}, -+ PushButton{AssignTo: &a.localHash, Text: "Hash", Font: buttonFont(), Enabled: false, ToolTipText: "Calculate checksums for selected files", MinSize: Size{Width: 64, Height: buttonHeight}, MaxSize: Size{Width: 64}, OnClicked: func() { a.hashSelection(sideLocal) }}, - PushButton{AssignTo: &a.localRename, Text: "MV", Font: buttonFont(), MinSize: Size{Width: 48, Height: buttonHeight}, MaxSize: Size{Width: 48}, OnClicked: func() { a.renameSelection(sideLocal) }}, - PushButton{AssignTo: &a.localDelete, Text: "Del", Font: buttonFont(), MinSize: Size{Width: 48, Height: buttonHeight}, MaxSize: Size{Width: 48}, OnClicked: func() { a.deleteSelection(sideLocal) }}, - }}, -@@ -350,6 +355,7 @@ - } - }}, - PushButton{AssignTo: &a.remoteNewDir, Text: "+Dir", Font: buttonFont(), MinSize: Size{Width: 64, Height: buttonHeight}, MaxSize: Size{Width: 64}, OnClicked: func() { a.newDirectory(sideRemote) }}, -+ PushButton{AssignTo: &a.remoteHash, Text: "Hash", Font: buttonFont(), Enabled: false, ToolTipText: "Calculate checksums for selected files", MinSize: Size{Width: 64, Height: buttonHeight}, MaxSize: Size{Width: 64}, OnClicked: func() { a.hashSelection(sideRemote) }}, - PushButton{AssignTo: &a.remoteRename, Text: "MV", Font: buttonFont(), MinSize: Size{Width: 48, Height: buttonHeight}, MaxSize: Size{Width: 48}, OnClicked: func() { a.renameSelection(sideRemote) }}, - PushButton{AssignTo: &a.remoteDelete, Text: "Del", Font: buttonFont(), MinSize: Size{Width: 48, Height: buttonHeight}, MaxSize: Size{Width: 48}, OnClicked: func() { a.deleteSelection(sideRemote) }}, - }}, -@@ -1649,8 +1655,9 @@ - _, _ = io.Copy(io.MultiWriter(newTerminalSourceWriter(os.Stderr, "child stderr"), guiLogWriter{a: a}), stderr) - }() +--- a/CHANGELOG.md ++++ b/CHANGELOG.md +@@ -1,3 +1,19 @@ + # Changelog / 更新日志 -+ // Drain both pipes before Wait closes them, including the last hash result. -+ wg.Wait() - err = child.wait() -- wg.Wait() - code := 0 - if err != nil { - code = 1 -@@ -1720,7 +1727,11 @@ - func (a *app) killCurrent() { - a.mu.Lock() - child := a.current -- a.mu.Unlock() -+ cancel := a.hashCancel -+ a.mu.Unlock() -+ if cancel != nil { -+ cancel() -+ } - if child != nil { - child.kill() - } -@@ -1742,6 +1753,8 @@ - remoteNewDirEnabled := !a.busy && a.remoteNav.Current != "" - localDeleteEnabled := !a.busy && a.selection.valid() && a.selection.Side == sideLocal - remoteDeleteEnabled := !a.busy && a.selection.valid() && a.selection.Side == sideRemote && a.remoteNav.Current != "" -+ localHashEnabled := hashSelectionEnabled(a.selection, sideLocal, a.busy, a.localNav.Current) -+ remoteHashEnabled := hashSelectionEnabled(a.selection, sideRemote, a.busy, a.remoteNav.Current) - localRenameEnabled := localDeleteEnabled && selectionSingle(a.selection) - remoteRenameEnabled := remoteDeleteEnabled && selectionSingle(a.selection) - rsyncEnabled := a.rsyncAvailable -@@ -1757,6 +1770,8 @@ - a.rsyncButton.SetEnabled(enabled && rsyncEnabled) - a.localNewDir.SetEnabled(localNewDirEnabled) - a.remoteNewDir.SetEnabled(remoteNewDirEnabled) -+ a.localHash.SetEnabled(localHashEnabled) -+ a.remoteHash.SetEnabled(remoteHashEnabled) - a.localRename.SetEnabled(localRenameEnabled) - a.remoteRename.SetEnabled(remoteRenameEnabled) - a.localDelete.SetEnabled(localDeleteEnabled) -@@ -2018,6 +2033,8 @@ - return "rsync upload" - case arg == "--rsync-download": - return "rsync download" -+ case strings.Contains(arg, "flyssh-hash"): -+ return "remote file hashes" - case strings.Contains(arg, `rm -rf -- "$1"`): - return "remote delete" - case strings.Contains(arg, `mv -- "$1" "$2"`): ++## v2.0.15 (2026-09-17) ++ ++### Features / 新功能 ++ ++- Add **Hash** between **+Dir** and **MV** in both Windows transfer panes. Select one or more files and choose MD5, SHA-1, SHA-224, SHA-256 (default), SHA-384 or SHA-512. Every result and filename is printed in the Log and terminal / Windows 文件传输窗口两侧新增 Hash 按钮,支持多文件及六种哈希算法,结果显示在日志和终端中。 ++- Stream local files with bounded memory and hash remote files on the server over the existing SSH route. Report per-file failures without discarding other results; disable hashing for folders and while busy / 本地流式计算,远程在服务器端计算;逐文件报告错误,不影响其余结果。 ++- Drain subprocess output before waiting so the last checksum is not truncated / 修复子进程输出末尾可能被截断的问题。 ++ ++### Verification / 验证 ++ ++- Portable checksum vectors, cancellation, quoting, fallback, failure and batching tests. ++- Native Windows selection and subprocess-output tests, plus packaged-executable GUI tests against a loopback SSH fixture for all six methods in both panes. ++- Release requests wait for Linux and Windows CI, publish through the existing release pipeline, then download, checksum-verify and execute the published Linux and Windows amd64 binaries. ++ ++--- ++ + ## v2.0.14 (2026-09-04) diff --git a/.github/release-request.json b/.github/release-request.json new file mode 100644 index 0000000..77671eb --- /dev/null +++ b/.github/release-request.json @@ -0,0 +1 @@ +{"tag":"v2.0.15"} diff --git a/.github/scripts/request_release.py b/.github/scripts/request_release.py new file mode 100644 index 0000000..9260b27 --- /dev/null +++ b/.github/scripts/request_release.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Publish a requested tag only after CI for this exact main commit succeeds.""" +import json +import os +from pathlib import Path +import re +import time +import urllib.error +import urllib.request + + +def main(): + repo = os.environ["GITHUB_REPOSITORY"] + sha = os.environ["GITHUB_SHA"] + token = os.environ["GH_TOKEN"] + if os.environ["GITHUB_REF"] != "refs/heads/main": + raise RuntimeError("Release requests must run on main") + tag = json.loads(Path(".github/release-request.json").read_text())["tag"] + if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?", tag): + raise RuntimeError("Invalid release tag") + if not any(line == "## " + tag or line.startswith("## " + tag + " ") + for line in Path("CHANGELOG.md").read_text(encoding="utf-8").splitlines()): + raise RuntimeError("Requested tag has no changelog section") + + def api(path, method="GET", data=None, missing_ok=False): + request = urllib.request.Request( + f"https://api.github.com/repos/{repo}/{path}", + data=None if data is None else json.dumps(data).encode(), + method=method, + headers={"Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json"}) + try: + with urllib.request.urlopen(request, timeout=30) as response: + body = response.read() + return json.loads(body) if body else None + except urllib.error.HTTPError as error: + if error.code == 404 and missing_ok: + return None + raise + + required = {".github/workflows/ci.yml", ".github/workflows/windows-gui.yml"} + deadline = time.monotonic() + 30 * 60 + while True: + runs = api(f"actions/runs?head_sha={sha}&event=push&per_page=100")["workflow_runs"] + latest = {} + for run in sorted(runs, key=lambda r: r["id"], reverse=True): + if run["path"] in required: + latest.setdefault(run["path"], run) + for run in latest.values(): + if run["status"] == "completed" and run["conclusion"] != "success": + raise RuntimeError(f"Release blocked by {run['name']}: {run['conclusion']} ({run['html_url']})") + if required == set(latest) and all(r["conclusion"] == "success" for r in latest.values()): + break + if time.monotonic() > deadline: + raise TimeoutError("Timed out waiting for Linux and Windows CI") + print("Waiting for Linux and Windows CI for " + sha, flush=True) + time.sleep(20) + + existing = api("git/ref/tags/" + tag, missing_ok=True) + if existing is not None: + if existing["object"]["type"] != "commit" or existing["object"]["sha"] != sha: + raise RuntimeError("Refusing to move an existing tag") + else: + api("git/refs", "POST", {"ref": "refs/tags/" + tag, "sha": sha}) + + release = api("releases/tags/" + tag, missing_ok=True) + if release is None: + before = {r["id"] for r in api("actions/workflows/release.yml/runs?event=workflow_dispatch&per_page=100")["workflow_runs"]} + # Events made with GITHUB_TOKEN do not recursively trigger a tag-push + # workflow. Explicit dispatch starts the existing Release workflow. + api("actions/workflows/release.yml/dispatches", "POST", {"ref": tag, "inputs": {"tag": tag}}) + deadline = time.monotonic() + 30 * 60 + while True: + runs = api(f"actions/workflows/release.yml/runs?event=workflow_dispatch&head_sha={sha}&per_page=100")["workflow_runs"] + candidates = [r for r in runs if r["id"] not in before and r["head_branch"] == tag] + if candidates: + run = max(candidates, key=lambda r: r["id"]) + if run["status"] == "completed": + if run["conclusion"] != "success": + raise RuntimeError(f"Release failed: {run['html_url']}") + break + if time.monotonic() > deadline: + raise TimeoutError("Timed out waiting for Release workflow") + print("Waiting for existing Release workflow: " + tag, flush=True) + time.sleep(20) + release = api("releases/tags/" + tag) + if release["draft"]: + raise RuntimeError("Release is still a draft") + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"tag={tag}\n") + print("Published release: " + release["html_url"], flush=True) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/apply-gui-patch.yml b/.github/workflows/apply-gui-patch.yml index ddb1884..6349dd0 100644 --- a/.github/workflows/apply-gui-patch.yml +++ b/.github/workflows/apply-gui-patch.yml @@ -8,24 +8,19 @@ permissions: jobs: apply: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 5 steps: - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - name: Apply exact reviewed patch and verify portable tests + - name: Add the release changelog shell: bash run: | set -euo pipefail - test "$(git hash-object pkg/wingui/wingui_windows.go)" = fd335030260527f7f37d7709279b5fdfa1b733bd + test "$(git hash-object CHANGELOG.md)" = f44bf7312d9e5f18df967d2ea33154ffea4cb69c git apply --check .github/patches/gui-file-hashes.patch git apply .github/patches/gui-file-hashes.patch - test "$(git hash-object pkg/wingui/wingui_windows.go)" = 57dac9a84096ef797c22123470c85f5cf6ad8607 - gofmt -w pkg/wingui/wingui_windows.go pkg/wingui/hash*.go e2e/wingui_hash_windows_test.go - go test ./pkg/wingui -count=1 + python3 -m py_compile .github/scripts/request_release.py git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add pkg/wingui/wingui_windows.go pkg/wingui/hash*.go e2e/wingui_hash_windows_test.go - git commit -m 'feat(wingui): wire file hash buttons and preserve complete subprocess output' + git add CHANGELOG.md + git commit -m 'docs: record file hashing and release verification for v2.0.15' git push origin HEAD:codex/gui-file-hashes diff --git a/.github/workflows/request-release.yml b/.github/workflows/request-release.yml new file mode 100644 index 0000000..8cdba21 --- /dev/null +++ b/.github/workflows/request-release.yml @@ -0,0 +1,108 @@ +name: Requested release and binary verification + +on: + push: + branches: [main] + paths: [.github/release-request.json] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: requested-release + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 65 + permissions: + contents: write + actions: write + outputs: + tag: ${{ steps.release.outputs.tag }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Wait for CI, tag the exact commit and run the existing Release workflow + id: release + env: + GH_TOKEN: ${{ github.token }} + run: python3 .github/scripts/request_release.py + - name: Download and smoke-test the published Linux amd64 binary + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + shell: bash + run: | + set -euo pipefail + version="${TAG#v}" + mkdir validation-linux + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir validation-linux \ + --pattern "flyssh-${version}-linux-amd64.tar.gz" --pattern checksums.txt + cd validation-linux + sha256sum --check --ignore-missing checksums.txt + tar -xzf "flyssh-${version}-linux-amd64.tar.gz" + "./flyssh-${version}-linux-amd64" --version | tee version.txt + grep -F "$version" version.txt + - uses: actions/upload-artifact@v4 + if: always() + with: + name: published-linux-smoke-${{ github.sha }} + path: validation-linux/*.txt + if-no-files-found: ignore + + verify-windows: + needs: publish + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Download, verify and extract the published Windows amd64 binary + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.publish.outputs.tag }} + run: | + $version = $env:TAG.Substring(1) + $archive = "flyssh-$version-windows-amd64.exe.zip" + New-Item -ItemType Directory -Path validation/downloaded -Force | Out-Null + gh release download $env:TAG --repo $env:GITHUB_REPOSITORY --dir validation/downloaded --pattern $archive --pattern checksums.txt + if ($LASTEXITCODE -ne 0) { throw 'Release download failed' } + $lines = @(Get-Content validation/downloaded/checksums.txt | Where-Object { $_.EndsWith(" $archive") }) + if ($lines.Count -ne 1) { throw 'Release checksum entry missing or duplicated' } + $expected = $lines[0].Split(' ')[0] + $actual = (Get-FileHash "validation/downloaded/$archive" -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $expected) { throw 'Published Windows ZIP checksum mismatch' } + "$actual $archive" | Set-Content validation/verified-checksum.txt + Expand-Archive "validation/downloaded/$archive" -DestinationPath validation/extracted + $binary = (Resolve-Path "validation/extracted/flyssh-$version-windows-amd64.exe").Path + "FLYSSH_GUI_BINARY=$binary" >> $env:GITHUB_ENV + "FLYSSH_GUI_TEST_ARTIFACTS=$env:GITHUB_WORKSPACE/validation" >> $env:GITHUB_ENV + $versionOutput = & $binary --version + if ($LASTEXITCODE -ne 0 -or -not ($versionOutput -match [regex]::Escape($version))) { throw 'Published binary version mismatch' } + $versionOutput | Set-Content validation/version.txt + - name: Test the downloaded release through native GUI controls and real SSH + shell: pwsh + run: | + go test ./e2e -run '^TestWindowsGUIHashes$' -count=1 -v -timeout 5m 2>&1 | Tee-Object validation/gui-test.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - uses: actions/upload-artifact@v4 + if: always() + with: + name: published-windows-validation-${{ github.sha }} + path: | + validation/*.txt + validation/*.log + validation/*.png + if-no-files-found: error + retention-days: 30 From 1e3260d8fbb31dbe5a613a9fc8bbbf226f2507f4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 05:17:04 +0000 Subject: [PATCH 4/5] docs: record file hashing and release verification for v2.0.15 --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f44bf73..a6a18b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog / 更新日志 +## v2.0.15 (2026-09-17) + +### Features / 新功能 + +- Add **Hash** between **+Dir** and **MV** in both Windows transfer panes. Select one or more files and choose MD5, SHA-1, SHA-224, SHA-256 (default), SHA-384 or SHA-512. Every result and filename is printed in the Log and terminal / Windows 文件传输窗口两侧新增 Hash 按钮,支持多文件及六种哈希算法,结果显示在日志和终端中。 +- Stream local files with bounded memory and hash remote files on the server over the existing SSH route. Report per-file failures without discarding other results; disable hashing for folders and while busy / 本地流式计算,远程在服务器端计算;逐文件报告错误,不影响其余结果。 +- Drain subprocess output before waiting so the last checksum is not truncated / 修复子进程输出末尾可能被截断的问题。 + +### Verification / 验证 + +- Portable checksum vectors, cancellation, quoting, fallback, failure and batching tests. +- Native Windows selection and subprocess-output tests, plus packaged-executable GUI tests against a loopback SSH fixture for all six methods in both panes. +- Release requests wait for Linux and Windows CI, publish through the existing release pipeline, then download, checksum-verify and execute the published Linux and Windows amd64 binaries. + +--- + ## v2.0.14 (2026-09-04) ### Fixes / 修复 From 867696c50fb2e84d1eac3721db50d2bcb34ed2f8 Mon Sep 17 00:00:00 2001 From: lovitus Date: Thu, 17 Sep 2026 13:17:36 +0800 Subject: [PATCH 5/5] chore: remove temporary patch-application scaffolding before review --- .github/patches/gui-file-hashes.patch | 22 ---------------------- .github/workflows/apply-gui-patch.yml | 26 -------------------------- 2 files changed, 48 deletions(-) delete mode 100644 .github/patches/gui-file-hashes.patch delete mode 100644 .github/workflows/apply-gui-patch.yml diff --git a/.github/patches/gui-file-hashes.patch b/.github/patches/gui-file-hashes.patch deleted file mode 100644 index 1dcb4cf..0000000 --- a/.github/patches/gui-file-hashes.patch +++ /dev/null @@ -1,22 +0,0 @@ ---- a/CHANGELOG.md -+++ b/CHANGELOG.md -@@ -1,3 +1,19 @@ - # Changelog / 更新日志 - -+## v2.0.15 (2026-09-17) -+ -+### Features / 新功能 -+ -+- Add **Hash** between **+Dir** and **MV** in both Windows transfer panes. Select one or more files and choose MD5, SHA-1, SHA-224, SHA-256 (default), SHA-384 or SHA-512. Every result and filename is printed in the Log and terminal / Windows 文件传输窗口两侧新增 Hash 按钮,支持多文件及六种哈希算法,结果显示在日志和终端中。 -+- Stream local files with bounded memory and hash remote files on the server over the existing SSH route. Report per-file failures without discarding other results; disable hashing for folders and while busy / 本地流式计算,远程在服务器端计算;逐文件报告错误,不影响其余结果。 -+- Drain subprocess output before waiting so the last checksum is not truncated / 修复子进程输出末尾可能被截断的问题。 -+ -+### Verification / 验证 -+ -+- Portable checksum vectors, cancellation, quoting, fallback, failure and batching tests. -+- Native Windows selection and subprocess-output tests, plus packaged-executable GUI tests against a loopback SSH fixture for all six methods in both panes. -+- Release requests wait for Linux and Windows CI, publish through the existing release pipeline, then download, checksum-verify and execute the published Linux and Windows amd64 binaries. -+ -+--- -+ - ## v2.0.14 (2026-09-04) diff --git a/.github/workflows/apply-gui-patch.yml b/.github/workflows/apply-gui-patch.yml deleted file mode 100644 index 6349dd0..0000000 --- a/.github/workflows/apply-gui-patch.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Apply GUI integration patch -on: - push: - branches: [codex/gui-file-hashes] - paths: [.github/patches/gui-file-hashes.patch] -permissions: - contents: write -jobs: - apply: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@v4 - - name: Add the release changelog - shell: bash - run: | - set -euo pipefail - test "$(git hash-object CHANGELOG.md)" = f44bf7312d9e5f18df967d2ea33154ffea4cb69c - git apply --check .github/patches/gui-file-hashes.patch - git apply .github/patches/gui-file-hashes.patch - python3 -m py_compile .github/scripts/request_release.py - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add CHANGELOG.md - git commit -m 'docs: record file hashing and release verification for v2.0.15' - git push origin HEAD:codex/gui-file-hashes