diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..5278408 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,48 @@ +name: Bug report +description: Tell us what went wrong with VX Bridge. +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for helping improve VX Bridge. Check the [FAQ](https://github.com/devigned/vx-bridge/blob/main/docs/FAQ.md) and [existing issues](https://github.com/devigned/vx-bridge/issues) first. + + A diagnostic ZIP is helpful, but **optional**. Use **Save diagnostic report...** in the bridge menu, or **VX Bridge Diagnostics** in Start if the bridge will not open. Nothing is sent automatically. Review the ZIP before attaching it below. + + Do not attach raw vendor/MSI logs, captures, shot/player/device/license/account details, credentials, or unredacted screenshots. You can submit this form even if export fails. See the [reporting guide](https://github.com/devigned/vx-bridge/blob/main/docs/REPORTING.md). + - type: textarea + id: problem + attributes: + label: What went wrong? + description: Describe the problem, visible error, and how it affects you. + placeholder: "For example: the selected game stays unavailable after..." + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Steps, expected result, and observed result + description: What did you do? What should have happened? What happened instead? If it happens only sometimes, say so. + validations: + required: true + - type: textarea + id: versions + attributes: + label: Versions and selected game + description: Bridge release, Windows version/build, ProTee Labs version, game/OpenAPI version, and selected game (Infinite Tees, GSPro OpenAPI, or Standby). Write "unknown" for anything you cannot find. Do not include account or device details. + placeholder: | + Bridge: + Windows: + Labs: + Game / OpenAPI: + Selected game: + - type: textarea + id: context + attributes: + label: When and how often? + description: Approximate time and time zone; fresh install, upgrade, startup, normal play, switching, or Quit; frequency; last known working bridge version; and whether it prevents use, loses shots, or is a minor inconvenience. + - type: textarea + id: evidence + attributes: + label: Optional diagnostic ZIP or redacted screenshots + description: Drag a reviewed ZIP or redacted screenshot here. If export failed, describe the save error instead. Do not attach raw vendor logs or captures. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..78d8532 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Reporting and privacy guide + url: https://github.com/devigned/vx-bridge/blob/main/docs/REPORTING.md + about: What to include, how to save a report, and what to keep private. diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..2636b90 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,32 @@ +name: Feature request +description: Describe a task VX Bridge could make easier. +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Check [existing requests](https://github.com/devigned/vx-bridge/issues) first. Tell us the task and desired outcome; you do not need to design the implementation. + - type: textarea + id: task + attributes: + label: What are you trying to do? + description: Describe the user task or problem, and who it affects. Keep private account, device, player, and license details out. + validations: + required: true + - type: textarea + id: outcome + attributes: + label: What outcome would help? + description: Explain what success would look like and why it matters. + validations: + required: true + - type: textarea + id: workaround + attributes: + label: Current workaround + description: How do you handle this today? Write "none" if there is no workaround. + - type: textarea + id: approach + attributes: + label: Optional proposed approach + description: Suggestions, alternatives, or examples are welcome. A particular implementation is not required. diff --git a/README.md b/README.md index ef7b923..e0f33f6 100644 --- a/README.md +++ b/README.md @@ -85,8 +85,13 @@ replayed. Use the [installation guide](docs/INSTALL.md), [everyday guide](docs/USAGE.md) or [FAQ](docs/FAQ.md) for more detail. The [technical manual](docs/technical/README.md) -covers implementation and testing. Read [Contributing](contributors.md) before -reporting a bug or submitting a change. +covers implementation and testing. Use the [reporting guide](docs/REPORTING.md) to +[report a bug](https://github.com/devigned/vx-bridge/issues/new?template=bug.yml) +or [request a feature](https://github.com/devigned/vx-bridge/issues/new?template=feature.yml). +An optional local diagnostic ZIP can be saved from the bridge menu or +**VX Bridge Diagnostics** in Start, even when the host will not start. +Nothing is uploaded automatically. Read [Contributing](contributors.md) +before submitting a code change. Original work uses the [MIT license](LICENSE). [Third-party notices](THIRD_PARTY_NOTICES.txt) cover dependencies and reference diff --git a/cmd/vxbridge/main.go b/cmd/vxbridge/main.go index 50f9cd0..6c18d51 100644 --- a/cmd/vxbridge/main.go +++ b/cmd/vxbridge/main.go @@ -58,19 +58,43 @@ func buildIdentity() string { return identity } +func diagnosticBuild() diagnostics.Build { + return diagnostics.Build{Version: version, Release: releaseVersion, Revision: buildRevision, + Go: runtime.Version(), OS: runtime.GOOS, Architecture: runtime.GOARCH} +} + func run(args []string, out, stderr io.Writer) (exit int) { flags := flag.NewFlagSet("vxbridge", flag.ContinueOnError) flags.SetOutput(stderr) status := flags.Bool("status", false, "same-user local host status (run as vxbridge.exe)") quit := flags.Bool("quit", false, "request guarded Quit; never forces attached Labs closed") showVersion := flags.Bool("version", false, "show build identity without starting a host") + reportPath := flags.String("diagnostics", "", "save a local diagnostic ZIP to a new absolute file; no host start or upload") + reportDialog := flags.Bool("diagnostics-dialog", false, "native Save diagnostic report dialog; no host start or upload") desktopMode := flags.Bool("desktop", defaultDesktop == "true", "native tray and local logs; no console required") port := flags.Int("target-port", 999, "Infinite Tees IPv4 loopback port (host mode only)") if err := flags.Parse(args); err != nil || flags.NArg() != 0 { return 2 } - if *port < 1 || *port > 65535 || *port == 12321 || (*status && *quit) || - (*showVersion && (*status || *quit)) || ((*status || *quit) && *port != 999) { + modes := 0 + for _, selected := range []bool{*status, *quit, *showVersion, *reportPath != "", *reportDialog} { + if selected { + modes++ + } + } + explicitHostOption := false + emptyDiagnosticMode := false + flags.Visit(func(f *flag.Flag) { + if f.Name == "desktop" || f.Name == "target-port" { + explicitHostOption = true + } + if (f.Name == "diagnostics" && *reportPath == "") || (f.Name == "diagnostics-dialog" && !*reportDialog) { + emptyDiagnosticMode = true + } + }) + if *port < 1 || *port > 65535 || *port == 12321 || modes > 1 || emptyDiagnosticMode || + (((*reportPath != "" || *reportDialog) && explicitHostOption) || + ((*status || *quit) && *port != 999)) { fmt.Fprintln(stderr, "vxbridge: invalid or conflicting options; target port must be 1..65535 excluding 12321.") return 2 } @@ -90,6 +114,35 @@ func run(args []string, out, stderr io.Writer) (exit int) { return 1 } compatibilityImage := strings.EqualFold(filepath.Base(image), "GSPconnect.exe") + if *reportPath != "" || *reportDialog { + if compatibilityImage { + fmt.Fprintln(stderr, "vxbridge: use distinct vxbridge.exe for diagnostics; no second GSPconnect identity.") + return 2 + } + if *reportDialog { + _, err := desktop.ReportDialog(diagnosticBuild()) + if err != nil { + fmt.Fprintln(stderr, "vxbridge: diagnostic save failed; nothing was sent. Report the bug without a ZIP if needed.") + return 1 + } + return 0 + } + ctx, cancel := context.WithTimeout(context.Background(), diagnostics.CollectionTimeout+2*time.Second) + defer cancel() + report, err := diagnostics.NewCollector(diagnosticBuild()).Collect(ctx) + if err == nil { + err = diagnostics.Save(ctx, *reportPath, report) + } + if err != nil { + fmt.Fprintln(stderr, "vxbridge: diagnostic export failed; choose a new absolute local file. Nothing was sent; an incomplete file may remain.") + return 1 + } + if _, err := fmt.Fprintf(out, "Diagnostic report saved locally (partial=%t). Nothing was sent; review the ZIP before attaching it.\n", report.Manifest.Partial); err != nil { + fmt.Fprintln(stderr, "vxbridge: report saved, but the completion message could not be printed.") + return 1 + } + return 0 + } if *status || *quit { if compatibilityImage { fmt.Fprintln(stderr, "vxbridge: use distinct vxbridge.exe for control; no second GSPconnect identity.") @@ -258,7 +311,7 @@ func host(listener net.Listener, pipe net.Listener, target string, interrupts <- } else { directory = "" } - logs = diagnostics.New(directory, diagnostics.Build{Version: version, Revision: buildRevision, Go: runtime.Version()}) + logs = diagnostics.New(directory, diagnosticBuild()) var manager *startup.Manager backend, startupError := startup.CurrentUser() if startupError == nil { @@ -268,6 +321,7 @@ func host(listener net.Listener, pipe net.Listener, target string, interrupts <- logs.WriteEvent(bridge.Event{At: time.Now().UTC(), Reason: "startup_unavailable_use_status"}) } app = desktop.New(controller, logs, manager, directory, preferences) + app.Reporting(diagnosticBuild()) tray = app.Tray() defer func() { flush, stop := context.WithTimeout(context.Background(), 500*time.Millisecond) @@ -297,10 +351,19 @@ func host(listener net.Listener, pipe net.Listener, target string, interrupts <- } return 1 } - var decorate func(*bridge.Snapshot) + decorate := func(state *bridge.Snapshot) { + state.Release, state.Revision = releaseVersion, buildRevision + state.GoVersion, state.Architecture = runtime.Version(), runtime.GOARCH + state.TargetEndpoint = state.Destination.Endpoint(target) + if state.Destination == destination.Standby { + state.TargetEndpoint = "none" + } + if app != nil { + app.Decorate(state) + } + } if app != nil { app.Start() - decorate = app.Decorate } controlDone := make(chan error, 1) go func() { diff --git a/cmd/vxbridge/main_test.go b/cmd/vxbridge/main_test.go index 32dfd5a..eb44d00 100644 --- a/cmd/vxbridge/main_test.go +++ b/cmd/vxbridge/main_test.go @@ -109,6 +109,11 @@ func TestVersionAndInvalidArgumentsDoNotStartHost(t *testing.T) { for _, args := range [][]string{ {"--target-port=12321"}, {"--target-port=0"}, {"--target-port=65536"}, {"--status", "--quit"}, {"--unknown"}, {"unexpected"}, {"--version", "--quit"}, + {"--diagnostics=report.zip", "--status"}, {"--diagnostics=report.zip", "--version"}, + {"--diagnostics=report.zip", "--quit"}, {"--diagnostics=report.zip", "--diagnostics-dialog"}, + {"--diagnostics-dialog", "--target-port=999"}, {"--diagnostics-dialog", "--desktop"}, + {"--diagnostics=report.zip", "--desktop=false"}, + {"--diagnostics="}, {"--diagnostics-dialog=false"}, } { if code := run(args, io.Discard, io.Discard); code != 2 { t.Fatalf("unsafe arguments accepted: %v", args) @@ -127,6 +132,10 @@ func TestReleaseAndDevelopmentVersionIdentity(t *testing.T) { for _, release := range []string{"development", "v1.0.1"} { for _, desktop := range []string{"false", "true"} { releaseVersion, buildRevision, defaultDesktop = release, strings.Repeat("a", 40), desktop + build := diagnosticBuild() + if build.Release != release || build.Revision != buildRevision || build.Version != version { + t.Fatal("structured diagnostics lost release/source identity", build) + } var out bytes.Buffer if code := run([]string{"--version"}, &out, io.Discard); code != 0 { t.Fatal("version query failed") diff --git a/contributors.md b/contributors.md index da3faac..3f163f0 100644 --- a/contributors.md +++ b/contributors.md @@ -10,6 +10,13 @@ For a confusing setup step, suggest the wording you wish you'd seen. For a bug, describe what you did, what you expected, what happened, and the bridge/Labs/game versions involved. Include the visible tray error and selected game when relevant. +Use the [bug form](https://github.com/devigned/vx-bridge/issues/new?template=bug.yml) +and [reporting guide](docs/REPORTING.md). A reviewed diagnostic ZIP is optional; +do not block a report on a working exporter. For a capability request, use the +[feature form](https://github.com/devigned/vx-bridge/issues/new?template=feature.yml) +and describe the task, workaround and desired outcome. Maintainers should follow +the [triage procedure](docs/technical/DIAGNOSTICS.md#maintainer-triage). + Check the [FAQ](docs/FAQ.md) and [existing issues](https://github.com/devigned/vx-bridge/issues) before opening a report. Include the release version and the visible error. @@ -45,6 +52,8 @@ Start with the runtime, not installer qualification or historical probes: [`internal/managed`](internal/managed/) checks installed-copy admission, [`internal/startup`](internal/startup/) owns sign-in registration, and [`internal/diagnostics`](internal/diagnostics/) owns bounded sanitized logs. + Its shared read-only exporter packages allowlisted diagnostic records for + the tray and host-independent control-client entry points. These responsibilities do not authorize forced source teardown or automatic repair. | Area | Where to look | diff --git a/docs/FAQ.md b/docs/FAQ.md index cea620a..c5d878c 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -175,6 +175,23 @@ Logging failure doesn't deliberately close Labs. Use the available menu or local control status to report the build version and sanitized error. Don't substitute full vendor logs or a raw capture: those can contain private data. +## How do I report a bug if the bridge will not start? + +Open **VX Bridge Diagnostics** from Start to save a local ZIP. It does not +start or repair the host. Missing status or logs are marked unavailable, not +healthy. Review the ZIP, then attach it to the +[bug form](https://github.com/devigned/vx-bridge/issues/new?template=bug.yml) +if you choose. Nothing is sent automatically. + +If export fails or an older build lacks that shortcut, submit without an +attachment. Include the visible error, selected game, versions you know, +approximate time/time zone, and what you expected and observed. Do not attach +raw vendor/MSI logs or private data. See [Reporting](REPORTING.md). + +For a new capability rather than a failure, use the +[feature form](https://github.com/devigned/vx-bridge/issues/new?template=feature.yml). +Describe your task, workaround and desired outcome; an implementation is optional. + ## Auto-launch on Windows Startup is checked, but the bridge doesn't start diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 77c37c4..d29d5ab 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -153,6 +153,10 @@ instructions. ## Uninstall +Before removing logs needed for a bug report, save an optional report with +**VX Bridge Diagnostics** in Start. This does not start the host or call MSI. +See [Reporting](REPORTING.md); independently saved reports are not uninstalled. + Disconnect in Labs and Quit normally, then use **Windows Settings > Apps > Installed apps > VX Bridge > Uninstall** (called **Apps & features** on some Windows versions). Normal uninstall does not need a working bridge or working diff --git a/docs/README.md b/docs/README.md index 0d53b46..3b3f506 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,7 +10,8 @@ The bridge remembers the choice; only one game receives shots. | Choose a game, check status, find logs or quit safely | [Everyday use](USAGE.md) | | Fix a connection problem, missed shot or failed installation | [FAQ](FAQ.md) | | Read initial-release features, files and contributor credits | [v0.1.0 release notes](releases/v0.1.0.md) | -| Report a bug or contribute | [Contributing](../contributors.md) | +| Report a bug, save a local report or request a feature | [Reporting](REPORTING.md) | +| Contribute a code or documentation change | [Contributing](../contributors.md) | [v0.1.0 is available](https://github.com/devigned/vx-bridge/releases/tag/v0.1.0). It passed automated build and file checks, not interactive installation, diff --git a/docs/REPORTING.md b/docs/REPORTING.md new file mode 100644 index 0000000..c449a49 --- /dev/null +++ b/docs/REPORTING.md @@ -0,0 +1,75 @@ +# Report a bug or request a feature + +[User guides](README.md) / Reporting + +## Submit a bug + +Check the [FAQ](FAQ.md) and +[existing issues](https://github.com/devigned/vx-bridge/issues) first. Then +choose **Report a bug** in the bridge menu, or open the +[bug form](https://github.com/devigned/vx-bridge/issues/new?template=bug.yml). +A GitHub account is needed to submit it. + +Describe the problem, steps, expected result and observed result. Include the +visible error and selected game. If you know them, add: + +- Approximate problem time and time zone. +- Bridge release, Windows version/build, ProTee Labs version, and game/OpenAPI version. +- Whether this followed a fresh install, upgrade, startup, game change or Quit. +- How often it happens, the last working bridge version, and its effect on play. + +Write **unknown** for versions you cannot find. Find Labs and game versions +in their own About screens; the bridge does not search their files. +Do not hit extra shots, force-close an attached receiver or change vendor +settings just to reproduce a fault. + +## Save an optional diagnostic report + +1. Choose **Save diagnostic report...** from the bridge menu. If the bridge + will not start, its icon is missing or it reports **Repair required**, + open **VX Bridge Diagnostics** from Windows Start instead. No terminal + command is needed. +2. Choose a **new filename in a local folder**. Existing files are not + overwritten. Cancel closes the save dialog without collecting or saving. +3. Wait for the saved, partial, or failure notice. **Nothing was sent.** +4. Open the ZIP and read `summary.txt`. Review its contents before dragging + it into the bug form. You choose whether to attach it. + +The report contains bridge versions, numeric Windows build facts, selected +game/local endpoints, bounded status and sanitized events, log-loss counters, +an installer-result summary and installed-file checks. `manifest.json` +identifies sources that were included, unavailable, skipped or truncated. +`report.json` and `events.jsonl` provide structured details. + +A partial report is useful. An unreachable host is marked unavailable; export +does not start or repair it, disconnect Labs, change settings, or call Windows +Installer. Missing or corrupt sources do not prevent other sources from being +saved. Reports are best-effort observations, not proof of game readiness, +complete shot history, or installation health. + +If saving fails, try a new filename in a writable local folder. An incomplete +output file may remain; do not attach it. Submit the bug **without a ZIP** and +describe the save error. If your build has no reporting menu or shortcut, it +predates this workflow; use the web form without a ZIP. Do not repair, upgrade +or restart solely to make a diagnostic report. + +## Keep private information out + +Issues and attachments are public. Do not attach raw vendor or verbose MSI +logs, packet captures, crash dumps, shot measurements, player data, device or +hardware identifiers, license/account details, usernames, private paths or +credentials. Crop or redact screenshots before attaching them. + +The exporter reads only fixed bridge-owned sources and allowlists exported +fields. It excludes vendor data, raw payloads and private-path/error dumps. +It does not upload, clear, rotate or delete the original logs. Independently +saved reports remain yours; bridge Uninstall does not remove them. See the +[privacy contract](technical/DIAGNOSTICS.md) for precise limits. + +## Request a feature + +Use the +[feature form](https://github.com/devigned/vx-bridge/issues/new?template=feature.yml). +Describe the task or problem, your current workaround and the outcome that +would help. An implementation suggestion is optional. No diagnostic report +is needed for an ordinary feature request. diff --git a/docs/USAGE.md b/docs/USAGE.md index a9f51d9..895c93a 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -95,6 +95,18 @@ Selecting GSPro here means **OpenAPI**, not the native VX connector. To return to the vendor's native integration instead, disconnect normally in Labs and Quit VX Bridge before opening that receiver. +## Report a problem + +Choose **Save diagnostic report...** to save one local ZIP, then **Report a bug** +to open the bug form. Review the ZIP before attaching it; nothing is sent by +the bridge. A partial report is useful and an attachment is optional. + +If the icon is missing or the bridge will not start, open **VX Bridge Diagnostics** +from Start. It uses the same exporter without starting or repairing the host. +If these entries are absent in an older build or saving fails, submit the +[web form](https://github.com/devigned/vx-bridge/issues/new?template=bug.yml) +without a ZIP. See [Reporting](REPORTING.md) for the steps and privacy rules. + ## Open local logs Choose **Open Logs** in the tray menu. The folder is: diff --git a/docs/technical/ARCHITECTURE.md b/docs/technical/ARCHITECTURE.md index f1a1aeb..f3794eb 100644 --- a/docs/technical/ARCHITECTURE.md +++ b/docs/technical/ARCHITECTURE.md @@ -96,7 +96,8 @@ Connected transport still leaves game readiness unknown. `internal/identity` owns the exclusive bind and native window. `internal/control` owns bounded same-user status and Quit. `internal/desktop` owns the tray model, owner-thread dispatch, and action workers. -`internal/diagnostics` owns bounded redacted logs; `internal/startup` owns +`internal/diagnostics` owns bounded redacted logs and the shared read-only local +ZIP exporter; `internal/startup` owns exact-copy, opt-in sign-in registration. `internal/managed` verifies the fixed managed-copy receipt, local Pending @@ -104,6 +105,9 @@ denial, and six payload files, retaining a process-lifetime exclusion handle. It does not call MSI during ordinary startup. Explicit native Setup owns maintenance; it is not a permanent broker, service, or game selector. Control remains available even when managed host admission is refused. +Diagnostic modes also run before admission, without starting a host or touching +its lease. The tray and installed support shortcut share that exporter; no +native duplicate collector or extra payload executable is installed. The shipped Go graph uses the standard library, pinned `golang.org/x/sys`, and `github.com/Microsoft/go-winio`. It is built with CGO disabled and requires diff --git a/docs/technical/COMMANDS.md b/docs/technical/COMMANDS.md index a261318..667b817 100644 --- a/docs/technical/COMMANDS.md +++ b/docs/technical/COMMANDS.md @@ -18,6 +18,8 @@ vxbridge - inspect build identity or control the current user's VX Bridge host vxbridge.exe --version vxbridge.exe --status vxbridge.exe --quit +vxbridge.exe --diagnostics +vxbridge.exe --diagnostics-dialog GSPconnect.exe [--desktop[=true|false]] [--target-port N] ``` @@ -51,6 +53,16 @@ Unreachable control is an error, not cached status. **`--quit`** requests the same guarded Quit as the tray and prints its snapshot. It never forces an attached or possibly attached source closed. +**`--diagnostics PATH`** collects a local diagnostic ZIP into a new absolute file +on a local drive. It never overwrites a file or creates parent directories. +Source failures produce an explicitly partial report; output failures exit 1. +It does not start a host, require healthy managed admission or call MSI. + +**`--diagnostics-dialog`** opens the native Save diagnostic report dialog. +The installed **VX Bridge Diagnostics** shortcut uses it. Cancel exits 0 without +collecting/saving; saved, partial and failure notices state that nothing was +sent. Both export modes use the same [bounded privacy contract](DIAGNOSTICS.md). + **`--desktop`** enables the native tray and bounded local logs in host mode. `--desktop=false` selects console diagnostics; the GUI distribution normally needs no explicit option. @@ -63,6 +75,8 @@ Status and Quit are mutually exclusive; version cannot combine with either. A nondefault target port is invalid with status/Quit. Positional arguments are rejected. Control through the `GSPconnect.exe` basename and host startup through the `vxbridge.exe` basename are rejected to preserve one identity. +Export modes also require the distinct `vxbridge.exe`; they cannot combine with +each other, version/status/Quit, or explicit host-only options. The Go flag parser also supplies `-h`/`--help`; current usage handling exits 2. ### FILES @@ -86,6 +100,8 @@ or Pending values to bypass admission. **0** means the requested version/control operation completed, or the host finished its guarded lifecycle successfully. A Quit response can return 0 while the host still waits for Labs departure. +Diagnostic export also returns 0 for an explicitly partial saved report, or +ordinary save-dialog cancellation; no source is silently assumed healthy. **1** means runtime, admission, control, output, persistence, or cleanup failure. The operation must not be assumed complete from partial output. @@ -101,6 +117,7 @@ From the reviewed build's own directory in PowerShell: .\vxbridge.exe --version .\vxbridge.exe --status .\vxbridge.exe --quit +.\vxbridge.exe --diagnostics "$env:USERPROFILE\Documents\vx-bridge-report.zip" ``` For an explicitly approved desktop host using the default Infinite Tees port: diff --git a/docs/technical/DESKTOP.md b/docs/technical/DESKTOP.md index 1765d34..6fe197b 100644 --- a/docs/technical/DESKTOP.md +++ b/docs/technical/DESKTOP.md @@ -48,8 +48,15 @@ unqualified live behavior. No menu action discovers the running game. **Open Logs** invokes native ShellExecute only on the fixed application log directory. It accepts no arbitrary path or command. Failure is visible through -the menu and `DesktopAction`; there is no placeholder Settings or Export -action. +the menu and `DesktopAction`. + +**Save diagnostic report...** runs the shared exporter behind a native save +dialog on a separate serialized support worker. **Report a bug** opens the +empty GitHub form, with no report data in its URL. Cancel saves nothing; +completion distinguishes saved, partial and failed results. Native Windows +controls provide keyboard/screen-reader labels; nothing is uploaded. Support +work cannot block the engine, native owner, ordinary desktop action worker or +status/Quit poller. See [Diagnostic reports](DIAGNOSTICS.md). ## Remembered destination @@ -98,11 +105,17 @@ by the [managed installer](INSTALLER.md#managed-copy-admission). ## Control and failure reporting -The separate `vxbridge.exe` supplies `--version`, `--status`, and `--quit`; +The separate `vxbridge.exe` supplies `--version`, `--status`, `--quit`, +`--diagnostics` and `--diagnostics-dialog`; see [Command reference](COMMANDS.md#vxbridge1). Status and Quit use bounded same-user control, independently of managed host admission. They neither start a host nor bypass source-first shutdown. +The **VX Bridge Diagnostics** Start shortcut uses `--diagnostics-dialog`, +independently of normal host admission. A missing/refused/unstarted host yields +an honestly partial report; it never triggers repair. The shortcut is a +nonadvertised owned installer resource, not another runtime or service. + `Desktop`, `LocalLogs`, `LocalLogLost`, `Startup`, and `DesktopAction` decorate engine snapshots with health and registration state, not executable paths or raw registry contents. An unreachable pipe is an explicit failure, diff --git a/docs/technical/DIAGNOSTICS.md b/docs/technical/DIAGNOSTICS.md new file mode 100644 index 0000000..d3062ab --- /dev/null +++ b/docs/technical/DIAGNOSTICS.md @@ -0,0 +1,112 @@ +# Diagnostic reports + +[Technical manual](README.md) / Diagnostics + +The [reporting guide](../REPORTING.md) owns submission instructions. +`internal/diagnostics.Collector` is shared by the tray and the distinct +`vxbridge.exe --diagnostics` / `--diagnostics-dialog` modes. The installed +**VX Bridge Diagnostics** shortcut selects the latter. These modes precede +managed host admission and never acquire its lifetime lease or open a source +listener. Collection does not invoke MSI, Setup, repair, Quit, game selection, +startup changes, vendor discovery or network upload. + +## Format and limits + +Schema 1 ZIPs contain exactly four entries: + +| Entry | Contents | +|---|---| +| `summary.txt` | Readable identity, host/source availability and privacy/interpretation notes | +| `manifest.json` | Schema, reporter build, UTC collection time, partial flag and per-source results | +| `report.json` | Typed Windows, host, settings, receipt/payload and installer-result observations | +| `events.jsonl` | Reserialized, sanitized records from existing bridge log slots | + +Reporter build, running-host build and installed receipt identity are separate. +The structured release is distinct from the retained prototype/protocol label. +Older hosts/logs without a structured release report it as unavailable; the +reporter must not supply its own release as their identity. + +Collection waits at most eight seconds, with a one-second status-only query. +An earlier caller cancellation is an export failure, not a successful partial +save. Deadline-limited source availability produces an explicitly partial +report. There is one source worker per process; an OS call that outlives the +deadline keeps later collections busy until it returns. A context cannot +interrupt all kernel filesystem operations. Collection and native save I/O +never run on the engine or tray owner thread. A separate support worker keeps +ordinary desktop actions and status/Quit polling independent of modal dialogs. + +Each of the five log slots is read at most 2 MiB; lines are limited to 2 KiB. +At most 2,048 newest timestamped log records survive the global event budget. +The host retains at most 64 recent events. Invalid/incomplete records are +omitted and counted. Concurrent append/rotation can give a mixed point-in-time +view, not an atomic history. UTC timestamps, not slot names, establish order. +The exporter never flushes or rotates the live logger. + +Settings are limited to 1 KiB, the existing result log to 256 KiB and each +fixed payload file to 32 MiB for local hash comparison. The finished ZIP is +limited to 2 MiB. Sources report included/unavailable/skipped states and stable +failure codes; omitted records and truncation remain visible. Missing logs, +settings or a refused control query do not mean healthy or fresh installation. + +## Source and privacy boundary + +Native known folders select only fixed current-user bridge paths. The exporter +reuses managed receipt decoding and exact Windows file/ancestor pin checks. +Reads reject reparse points, hardlinks, redirected final paths and nonregular +files; they permit concurrent log writes but not deletion while pinned. +Unsafe or unavailable inputs become source-level codes, never copied OS error +text. No environment, registry/process dump, username, SID, device ID, raw +shot/player data, private path, license, credential, vendor log or capture is +collected. + +Records are decoded and constructed again from allowlisted fields, not copied +as raw log bytes. Build text has strict known-value/version/revision shapes. +Unknown reasons use the existing redacted event hash. Local shot aliases are +omitted. Only explicit IPv4 loopback endpoint/port text survives. Windows facts +are numeric major/minor/build values from the native version API, without a +machine name or hardware inventory. + +Installer output uses selected operation/boolean/numeric/source-revision fields from +`setup-result.log`; it excludes the verbose MSI log and debug/authentication +text, including hexadecimal UI records. No MSI API is called. A result recorded +as usable describes that operation, not current installation health. +The read-only installed receipt observation includes Pending presence and +six fixed file hash outcomes. Matches are against the local receipt, not +publisher authentication, current MSI catalog consistency or a passed admission. +The diagnostic tool does not acquire or create `host.lock`. + +The native save dialog and automation mode require a new absolute file on a +local fixed/removable drive with existing, verified ancestors. They reject +UNC/network, reparse and existing-file outputs. Files are created exclusively; +there is no silent replacement or fallback directory. Write/sync/close errors +are failures. An incomplete file may remain for explicit user cleanup rather +than deleting a possibly changed pathname. Nothing is sent; review/attachment +is manual. Reports are independent exports, outside Uninstall authority. + +## Maintainer triage + +Read the problem and impact first. Identify the area: installation/admission, +desktop/startup, Labs source, selected target/switching, delivery or diagnostics. +Use existing `bug`, `enhancement`, `documentation`, `question` or `accessibility` +labels where appropriate; no special triage label is required. + +Check for duplicates and confirm the exact release, selected game, timing and +expected/observed result. Request only missing facts that could change the next +step. A ZIP is optional, including when export itself fails. Never request raw +vendor/MSI logs or captures in a public issue. + +Compare reporter/host/receipt identities before drawing conclusions. Read source +availability and log-loss/truncation before interpreting absent events. +Correlate the approximate user time and zone with UTC events. Distinguish target +acceptance, local response writes and actual game behavior. Reproduce with +synthetic fixtures first; an issue or successful source check does not authorize +real simulator, vendor, account or installer qualification work. + +## Validation boundary + +Portable tests cover report shape, size, source failures, privacy allowlists, +malformed records, rotation, cancellation, busy workers and output refusal. +Windows fixtures check native ABI and owned-file pin semantics. Static installer +checks cover the support shortcut and uninstall inventory. None qualifies the +interactive save dialog, accessibility/DPI, physical Labs/game use or real +installer servicing; those require separate approval and Windows observation. diff --git a/docs/technical/INSTALLER.md b/docs/technical/INSTALLER.md index 03e22be..293b2b6 100644 --- a/docs/technical/INSTALLER.md +++ b/docs/technical/INSTALLER.md @@ -204,13 +204,21 @@ hardlinks, and foreign Run/cache ownership refuse rather than truncate cleanup. Complete removal includes the six application files, passive Setup and MSI cache, maintenance logs/state, destination/settings and recognized partial -preference files, the five bridge log slots, both owned shortcuts, owned Run +preference files, the five bridge log slots, all three owned shortcuts, owned Run registration, receipt/Pending, and known product/component clients. Validated empty application directories and registry keys are removed too. The shared `LocalAppData\Programs` parent is not owned or removed. Original user downloads, independent exports/development archives, vendor/ game files, and foreign state remain untouched. +The nonadvertised shortcuts are **VX Bridge**, **VX Bridge Setup**, and +**VX Bridge Diagnostics**. The support shortcut targets the existing +`vxbridge.exe --diagnostics-dialog`; it adds no seventh payload file. Its +exact target/arguments are part of shortcut ownership and uninstall inventory. +Install/Repair/compatible-upgrade authoring and static MSI inspection require +it, and full removal checks its absence. Reporting does not require successful +host admission or invoke Windows Installer. + Removal checks use callback-scoped handles. Setup retains relevant ancestor, source, owner, and host protections but hands off the app-root handle for standard engine work, then revalidates identity. This is not a continuous diff --git a/docs/technical/README.md b/docs/technical/README.md index cd2625d..fa95228 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -32,6 +32,8 @@ admission, the self-contained native EXE, conventional MSI servicing, and the full-Uninstall contract. [Security and data ownership](SECURITY.md) defines local storage, trust, and license boundaries. +[Diagnostic reports](DIAGNOSTICS.md) defines local ZIP collection, privacy, +partial-source semantics and maintainer triage. Commands are collected in [Command reference](COMMANDS.md), not interleaved with the design explanation. [Build and release automation](AUTOMATION.md) diff --git a/docs/technical/SECURITY.md b/docs/technical/SECURITY.md index 701440c..c09f866 100644 --- a/docs/technical/SECURITY.md +++ b/docs/technical/SECURITY.md @@ -59,8 +59,16 @@ identity. Unknown reasons become stable fault hashes. Records exclude measurements, DeviceID, real shot IDs, player values, raw objects, window handles, and private-path dumps. Metadata comes from the -application build, not arbitrary payload fields. There is no raw capture or -export menu. +application build, not arbitrary payload fields. The separate structured +`release` field is the actual release version, not the prototype/protocol label. + +The explicit diagnostic exporter saves one local ZIP with allowlisted, +reserialized records. It never uploads or exports raw captures/vendor/MSI logs. +Untrusted metadata is validated again; private strings and local shot aliases +are not copied. Reporter, running host and installed receipt identities remain +separate. Missing/corrupt sources produce explicit partial results. The +[diagnostic contract](DIAGNOSTICS.md) defines bounds, exact owned-input checks, +exclusive local output and the manual review/attachment step. Unexpected file types, oversized slots, permission/lock errors, short writes, disk-full, rotation, and close failures stop persistence explicitly. diff --git a/docs/technical/TESTING.md b/docs/technical/TESTING.md index f0b5a9c..29a50ca 100644 --- a/docs/technical/TESTING.md +++ b/docs/technical/TESTING.md @@ -106,6 +106,14 @@ the guarded disposable-account harness. ## Timing fixtures and negative evidence +Diagnostic fixtures use only invented records and temporary owned files. Run +`go test ./internal/diagnostics ./internal/managed ./internal/desktop ./cmd/vxbridge` +for ZIP shape/size/privacy, running/no-host and corrupt/missing sources, +rotation, bounded collection, cancellation and output refusal. A blocked save +must leave status and guarded Quit responsive. Static installer tests require +the exact support shortcut in authoring, inspection, ownership and removal. +Do not export a report from a real simulator as a routine source check. + A deadline test must reach its intended phase before triggering the cause. Report setup or window creation can consume a short deadline before an interrupt case starts; expiry must then remain expiry, not be relabeled as diff --git a/installer/build-setup.ps1 b/installer/build-setup.ps1 index 0c080c1..f4bf51a 100644 --- a/installer/build-setup.ps1 +++ b/installer/build-setup.ps1 @@ -381,17 +381,17 @@ try { [void]$view.Execute(); $shortcuts=@{} while ($null -ne ($row=$view.Fetch())) { $id=Field $row 'StringData' 1 - $component=if ($id -ceq 'BridgeShortcut') { 'Shortcut' } else { 'Maintenance' } - $target=if ($id -ceq 'BridgeShortcut') { '[INSTALLDIR]GSPconnect.exe' } else { '[VX_MAINTENANCE_EXE]' } - $arguments=if ($id -ceq 'BridgeShortcut') { '' } else { '/maintain' } - if ($id -cnotin @('BridgeShortcut','SetupShortcut') -or $shortcuts.ContainsKey($id) -or + $component=if ($id -ceq 'SetupShortcut') { 'Maintenance' } else { 'Shortcut' } + $target=if ($id -ceq 'BridgeShortcut') { '[INSTALLDIR]GSPconnect.exe' } elseif ($id -ceq 'DiagnosticsShortcut') { '[INSTALLDIR]vxbridge.exe' } else { '[VX_MAINTENANCE_EXE]' } + $arguments=if ($id -ceq 'BridgeShortcut') { '' } elseif ($id -ceq 'DiagnosticsShortcut') { '--diagnostics-dialog' } else { '/maintain' } + if ($id -cnotin @('BridgeShortcut','SetupShortcut','DiagnosticsShortcut') -or $shortcuts.ContainsKey($id) -or (Field $row 'StringData' 2) -cne 'ProgramMenuFolder' -or (Field $row 'StringData' 3) -cne $component -or (Field $row 'StringData' 4) -cne $target -or (Field $row 'StringData' 5) -cne $arguments) { throw 'Unexpected advertised or foreign shortcut.' } $shortcuts[$id]=$target [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($row); $row=$null } - if ($shortcuts.Count -ne 2) { throw 'Missing actual nonadvertised shortcut.' } + if ($shortcuts.Count -ne 3) { throw 'Missing actual nonadvertised shortcut.' } [void]$view.Close(); [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($view); $view=$null $view=$database.OpenView('SELECT `FileKey`,`Component_`,`FileName`,`DirProperty`,`InstallMode` FROM `RemoveFile`'); [void]$view.Execute() $row=$view.Fetch() diff --git a/installer/setup/actions.c b/installer/setup/actions.c index d56f35f..5f7a169 100644 --- a/installer/setup/actions.c +++ b/installer/setup/actions.c @@ -89,6 +89,7 @@ __declspec(dllexport) UINT __stdcall VxSetupAuthorize(MSIHANDLE h) UINT code=closed?setup_remove_authorize(h):ERROR_INSTALL_FAILURE; setup_diag_session_end(h,span,"MSI",code); return code; } + if(!setup_support_shortcut(&c,FALSE)) goto done; if(!setup_property(h,L"VX_SETUP_PIPE",pipe_name,ARRAYSIZE(pipe_name)) || wcsncmp(pipe_name,VX_SETUP_PIPE_PREFIX,wcslen(VX_SETUP_PIPE_PREFIX))) goto done; pipe=CreateFileW(pipe_name,GENERIC_READ|GENERIC_WRITE,0,NULL,OPEN_EXISTING,0,NULL); diff --git a/installer/setup/common.c b/installer/setup/common.c index 54fbe8a..9b56fc7 100644 --- a/installer/setup/common.c +++ b/installer/setup/common.c @@ -1,11 +1,66 @@ /* SPDX-License-Identifier: MIT */ +#define COBJMACROS #include "protocol.h" +#include #include #include #include #include static LONG diagnostic_sequence; + +BOOL setup_shortcut_owned(VX_CONTEXT *c,const WCHAR *name,const WCHAR *path) +{ + IShellLinkW *link=NULL; IPersistFile *persist=NULL; + HRESULT initialized=CoInitializeEx(NULL,COINIT_APARTMENTTHREADED); + BOOL own=SUCCEEDED(initialized),ok=own || initialized==RPC_E_CHANGED_MODE; + WCHAR expected[MAX_PATH],actual[MAX_PATH],arguments[MAX_PATH]; + BOOL setup=!wcscmp(name,L"VX Bridge Setup.lnk"); + BOOL diagnostics=!wcscmp(name,L"VX Bridge Diagnostics.lnk"); + ok=ok && vx_path(expected,setup?c->state:c->root,setup?L"setup.exe":diagnostics?L"vxbridge.exe":L"GSPconnect.exe") && + CoCreateInstance(&CLSID_ShellLink,NULL,CLSCTX_INPROC_SERVER,&IID_IShellLinkW,(void **)&link)==S_OK && + IShellLinkW_QueryInterface(link,&IID_IPersistFile,(void **)&persist)==S_OK && + IPersistFile_Load(persist,path,STGM_READ|STGM_SHARE_DENY_NONE)==S_OK && + IShellLinkW_GetPath(link,actual,MAX_PATH,NULL,SLGP_RAWPATH)==S_OK && !_wcsicmp(actual,expected) && + IShellLinkW_GetArguments(link,arguments,MAX_PATH)==S_OK && + !wcscmp(arguments,setup?L"/maintain":diagnostics?L"--diagnostics-dialog":L""); + if(persist) IPersistFile_Release(persist); + if(link) IShellLinkW_Release(link); + if(own) CoUninitialize(); + return ok; +} + +BOOL setup_support_shortcut(VX_CONTEXT *c,BOOL required) +{ + WCHAR *programs=NULL,path[MAX_PATH],ancestor[MAX_PATH]; + if(SHGetKnownFolderPath(&FOLDERID_Programs,KF_FLAG_DONT_VERIFY,c->user_token,&programs)!=S_OK) return FALSE; + BOOL ok=wcslen(programs)count; + ok=setup_pin_directory(c,ancestor); + if(ok) { + HANDLE file=vx_file(path,FALSE,GENERIC_READ,FILE_SHARE_READ,OPEN_EXISTING); + if(file==INVALID_HANDLE_VALUE) { + DWORD error=GetLastError(); + ok=!required && (error==ERROR_FILE_NOT_FOUND || error==ERROR_PATH_NOT_FOUND); + } else { + ok=setup_shortcut_owned(c,L"VX Bridge Diagnostics.lnk",path); + if(!CloseHandle(file)) ok=FALSE; + } + } + while(c->count>first) if(!CloseHandle(c->pins[--c->count])) ok=FALSE; + return ok; +} + BOOL setup_diagnostics_enabled(void) { DWORD error=GetLastError(); WCHAR value[2]={0}; diff --git a/installer/setup/launcher.c b/installer/setup/launcher.c index e777bc6..fa94727 100644 --- a/installer/setup/launcher.c +++ b/installer/setup/launcher.c @@ -285,15 +285,15 @@ static BOOL root_fresh(void) if(!FindClose(find)) empty=FALSE; return empty; } -static BOOL result_log(UINT install,BOOL usable,int result) +static BOOL result_log(UINT install,BOOL usable,int result,DWORD operation) { WCHAR path[MAX_PATH]; if(!vx_path(path,context.state,L"setup-result.log")) return FALSE; HANDLE file=vx_file(path,FALSE,FILE_APPEND_DATA,FILE_SHARE_READ,OPEN_ALWAYS); if(file==INVALID_HANDLE_VALUE) return FALSE; LARGE_INTEGER size; char line[2048]; DWORD wrote=0; - int n=snprintf(line,sizeof(line),"contract=recovery-first-v2 install_called=%u install=%u engine_namespace=conventional usable=%u result=%d source=%s ui_info=%ld ui_three=%ld ui_format=%ld ui_target=%ld ui_admitted=%ld auth_read=%ld auth_impersonated=%ld auth_context=%ld auth_sid=%ld auth_match=%ld auth_accepted=%ld auth_error=%lu ui_record_fields=%lu ui_field0_code=%lu ui_field0_length=%lu ui_field1_code=%lu ui_field2_code=%lu ui_field3_code=%lu ui_field0_hex=%s auth_token_error=%lu auth_token_level=%lu auth_confirmed=%ld\r\n", - install!=(UINT)-1,install,usable?1U:0U,result,target.revision, + int n=snprintf(line,sizeof(line),"contract=recovery-first-v2 operation=%lu install_called=%u install=%u engine_namespace=conventional usable=%u result=%d source=%s ui_info=%ld ui_three=%ld ui_format=%ld ui_target=%ld ui_admitted=%ld auth_read=%ld auth_impersonated=%ld auth_context=%ld auth_sid=%ld auth_match=%ld auth_accepted=%ld auth_error=%lu ui_record_fields=%lu ui_field0_code=%lu ui_field0_length=%lu ui_field1_code=%lu ui_field2_code=%lu ui_field3_code=%lu ui_field0_hex=%s auth_token_error=%lu auth_token_level=%lu auth_confirmed=%ld\r\n", + (unsigned long)operation,install!=(UINT)-1,install,usable?1U:0U,result,target.revision, (long)ui_info,(long)ui_three,(long)ui_format,(long)ui_target,(long)ui_admitted, (long)auth_read,(long)auth_impersonated,(long)auth_context,(long)auth_sid,(long)auth_match,(long)auth_accepted,(unsigned long)auth_error, (unsigned long)ui_record_fields,(unsigned long)ui_field0_code,(unsigned long)ui_field0_length, @@ -346,6 +346,7 @@ static int execute(const WCHAR *input,BOOL quiet) if(source_pin==INVALID_HANDLE_VALUE || !source(&db,&target,path)) goto done; if(!setup_pin_root(&context,&root_created,&root_slot)) goto done; if(!setup_root_snapshot(&context,root_slot,&root_before)) goto done; + if(!setup_support_shortcut(&context,FALSE)) goto done; setup_diag_handle("owned_root","initial-pin",context.pins[root_slot],FILE_READ_ATTRIBUTES,FILE_SHARE_READ,OPEN_EXISTING); stage="pending_and_original_receipt"; VX_PENDING stale; if(!vx_binary(&context,L"Pending",&stale,sizeof(stale),&pending_exists) || @@ -476,7 +477,8 @@ static int execute(const WCHAR *input,BOOL quiet) if(install!=ERROR_SUCCESS) goto done; stage="terminal_product_and_payload"; if(!setup_repin_root(&context,&root_slot,NULL) || - !registered(&target,FALSE) || !setup_single_product(&target) || !setup_payload(&context,&target,FALSE)) goto done; + !registered(&target,FALSE) || !setup_single_product(&target) || !setup_payload(&context,&target,FALSE) || + !setup_support_shortcut(&context,TRUE)) goto done; if(!vx_enter(&context)) goto done; held=TRUE; stage="receipt_publication"; engine_span=setup_diag_start("receipt_publication"); @@ -515,7 +517,7 @@ static int execute(const WCHAR *input,BOOL quiet) if(!CloseHandle(owner_lock)) result=1; } if(!vx_close(&context)) result=1; - if(context.state[0] && !result_log(install,ready && result==0,result)) { + if(context.state[0] && !result_log(install,ready && result==0,result,p.operation)) { result=1; if(!quiet) failure(L"Setup result could not be recorded. Maintenance completion is unverified; run Setup and choose Repair / Reinstall. No bridge was launched."); } diff --git a/installer/setup/protocol.h b/installer/setup/protocol.h index 13fb8ba..1c780b8 100644 --- a/installer/setup/protocol.h +++ b/installer/setup/protocol.h @@ -34,6 +34,8 @@ BOOL setup_payload(VX_CONTEXT *c, const VX_RECEIPT *r, BOOL allow_missing); BOOL setup_pin_root(VX_CONTEXT *c, BOOL *created, unsigned int *slot); BOOL setup_root_pinned(VX_CONTEXT *c); BOOL setup_pin_directory(VX_CONTEXT *c, const WCHAR *path); +BOOL setup_shortcut_owned(VX_CONTEXT *c, const WCHAR *name, const WCHAR *path); +BOOL setup_support_shortcut(VX_CONTEXT *c, BOOL required); BOOL setup_remove_root(VX_CONTEXT *c, unsigned int *slot); BOOL setup_root_snapshot(VX_CONTEXT *c, unsigned int slot, BY_HANDLE_FILE_INFORMATION *identity); BOOL setup_release_root(VX_CONTEXT *c, unsigned int *slot, const BY_HANDLE_FILE_INFORMATION *identity); diff --git a/installer/setup/remove.c b/installer/setup/remove.c index 4a71e2c..c728fc3 100644 --- a/installer/setup/remove.c +++ b/installer/setup/remove.c @@ -90,7 +90,8 @@ static BOOL known_file(DWORD directory,const WCHAR *name) return wcslen(name)==14 && !wcsncmp(name,L"bridge.",7) && name[7]>=L'0' && name[7]<=L'4' && !wcscmp(name+8,L".jsonl"); } else if(directory==REMOVE_CONFIG) return !wcscmp(name,L"destination.json") || destination_temp(name); - else if(directory==REMOVE_SHORTCUTS) return !wcscmp(name,L"VX Bridge.lnk") || !wcscmp(name,L"VX Bridge Setup.lnk"); + else if(directory==REMOVE_SHORTCUTS) return !wcscmp(name,L"VX Bridge.lnk") || !wcscmp(name,L"VX Bridge Setup.lnk") || + !wcscmp(name,L"VX Bridge Diagnostics.lnk"); return FALSE; } static BOOL add_file(REMOVE_PLAN *p,DWORD directory,const WCHAR *name) @@ -119,7 +120,8 @@ static BOOL fixed_files(REMOVE_PLAN *p) if(swprintf(name,32,L"bridge.%u.jsonl",i)<=0 || !add_file(p,REMOVE_LOGS,name)) return FALSE; } return add_file(p,REMOVE_CONFIG,L"destination.json") && - add_file(p,REMOVE_SHORTCUTS,L"VX Bridge.lnk") && add_file(p,REMOVE_SHORTCUTS,L"VX Bridge Setup.lnk"); + add_file(p,REMOVE_SHORTCUTS,L"VX Bridge.lnk") && add_file(p,REMOVE_SHORTCUTS,L"VX Bridge Setup.lnk") && + add_file(p,REMOVE_SHORTCUTS,L"VX Bridge Diagnostics.lnk"); } /* Only callback-local handles. Missing descendants never authorize traversal * through an unvalidated existing ancestor or creation of a cleanup directory. @@ -151,24 +153,6 @@ static BOOL open_state(VX_CONTEXT *c) if(RegCloseKey(user)!=ERROR_SUCCESS) return FALSE; return e==ERROR_SUCCESS || e==ERROR_FILE_NOT_FOUND; } -static BOOL shortcut_owned(VX_CONTEXT *c,const WCHAR *name,const WCHAR *path) -{ - IShellLinkW *link=NULL; IPersistFile *persist=NULL; - HRESULT initialized=CoInitializeEx(NULL,COINIT_APARTMENTTHREADED); - BOOL own=SUCCEEDED(initialized),ok=own || initialized==RPC_E_CHANGED_MODE; - WCHAR expected[MAX_PATH],actual[MAX_PATH],arguments[MAX_PATH]; - BOOL setup=!wcscmp(name,L"VX Bridge Setup.lnk"); - ok=ok && vx_path(expected,setup?c->state:c->root,setup?L"setup.exe":L"GSPconnect.exe") && - CoCreateInstance(&CLSID_ShellLink,NULL,CLSCTX_INPROC_SERVER,&IID_IShellLinkW,(void **)&link)==S_OK && - IShellLinkW_QueryInterface(link,&IID_IPersistFile,(void **)&persist)==S_OK && - IPersistFile_Load(persist,path,STGM_READ|STGM_SHARE_DENY_NONE)==S_OK && - IShellLinkW_GetPath(link,actual,MAX_PATH,NULL,SLGP_RAWPATH)==S_OK && !_wcsicmp(actual,expected) && - IShellLinkW_GetArguments(link,arguments,MAX_PATH)==S_OK && !wcscmp(arguments,setup?L"/maintain":L""); - if(persist) IPersistFile_Release(persist); - if(link) IShellLinkW_Release(link); - if(own) CoUninitialize(); - return ok; -} static BOOL file_owned(VX_CONTEXT *c,const REMOVE_PLAN *p,DWORD directory,const WCHAR *name, const WCHAR *path,BOOL exclusive) { @@ -192,7 +176,7 @@ static BOOL file_owned(VX_CONTEXT *c,const REMOVE_PLAN *p,DWORD directory,const vx_binary(c,key,want,sizeof(want),&exists) && exists && vx_hex(want,64); ok=ok && vx_hash(file,actual) && !strcmp(actual,want); } else if(directory==REMOVE_SHORTCUTS) { - ok=shortcut_owned(c,name,path); + ok=setup_shortcut_owned(c,name,path); if(recheck_shortcut) { BY_HANDLE_FILE_INFORMATION before,after; ok=ok && GetFileInformationByHandle(file,&before) && vx_hash(file,actual); @@ -222,7 +206,7 @@ static BOOL inventory(MSIHANDLE h,VX_CONTEXT *c,REMOVE_PLAN *p,BOOL collect,BOOL unsigned int first=c->count; BOOL exists=FALSE,ok=pin_existing(c,paths[d],&exists); HANDLE find=INVALID_HANDLE_VALUE; WIN32_FIND_DATAW entry; if(ok && exists && d==REMOVE_SHORTCUTS) { - const WCHAR *names[]={L"VX Bridge.lnk",L"VX Bridge Setup.lnk"}; + const WCHAR *names[]={L"VX Bridge.lnk",L"VX Bridge Setup.lnk",L"VX Bridge Diagnostics.lnk"}; for(unsigned int i=0;ok && icount; BOOL exists=FALSE; BOOL ok=pin_existing(c,paths[i],&exists); if(ok && i==REMOVE_SHORTCUTS && exists) { - const WCHAR *names[]={L"VX Bridge.lnk",L"VX Bridge Setup.lnk"}; WCHAR path[MAX_PATH]; + const WCHAR *names[]={L"VX Bridge.lnk",L"VX Bridge Setup.lnk",L"VX Bridge Diagnostics.lnk"}; WCHAR path[MAX_PATH]; for(unsigned int j=0;ok && j ReportEvents { + r.Manifest.Partial = true + r.Manifest.Sources = append(r.Manifest.Sources, Source{Name: "event_budget", State: "included", + Omitted: len(r.Events) - ReportEvents, Truncated: true, Code: "newest_events_retained"}) + r.Events = r.Events[len(r.Events)-ReportEvents:] + } + return r, nil + } + for { + select { + case observation, ok := <-results: + if !ok { + return finish() + } + seen[observation.source.Name] = true + r.Manifest.Sources = append(r.Manifest.Sources, observation.source) + if observation.apply != nil { + observation.apply(&r) + } + case <-bounded.Done(): + return finish() + } + } +} + +func (c *Collector) observe(ctx context.Context, name string) observation { + o := observation{source: Source{Name: name, State: "included"}} + unavailable := func(code string) observation { + o.source.State, o.source.Code = "unavailable", code + o.source.Truncated = code == "source_size_limit" + return o + } + switch name { + case "windows": + facts, err := c.windows() + if err != nil { + return unavailable("windows_version_unavailable") + } + o.apply = func(r *Report) { r.Windows = facts } + case "host_status": + query, cancel := context.WithTimeout(ctx, time.Second) + state, err := c.query(query, false) + cancel() + if err != nil { + return unavailable("host_unavailable_no_start_attempted") + } + status := safeStatus(state) + o.apply = func(r *Report) { r.Host = &status } + case "settings": + if c.config == "" { + return unavailable("known_folder_unavailable") + } + data, err := c.read(ctx, filepath.Join(c.config, "VxInfiniteTeesBridge", "destination.json"), 1024) + if err != nil { + return unavailable(sourceError(err)) + } + var saved struct { + Destination destination.ID `json:"destination"` + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if !protocol.StrictJSON(data) || decoder.Decode(&saved) != nil || !saved.Destination.Valid() { + return unavailable("invalid_settings") + } + o.apply = func(r *Report) { + r.Settings = &Settings{Destination: string(saved.Destination), Source: "127.0.0.1:12321"} + } + case "installation": + if c.local == "" { + return unavailable("known_folder_unavailable") + } + receipt, state, err := c.receipt() + installed := Installation{State: state} + if err != nil { + o = unavailable("installed_receipt_unavailable_or_invalid") + o.apply = func(r *Report) { r.Installation = &installed } + return o + } + installed.Release, installed.Revision = "v"+receipt.Version, receipt.Revision + names := []string{"GSPconnect.exe", "vxbridge.exe", "LICENSE", "THIRD_PARTY_NOTICES.txt", "build-manifest.json", "SHA256SUMS"} + for i, filename := range names { + data, err := c.read(ctx, filepath.Join(c.local, "Programs", "VxInfiniteTeesBridge", filename), 32<<20) + check := FileCheck{Name: filename, State: "matches_local_receipt"} + if err != nil { + check.State = sourceError(err) + } else if hash := sha256.Sum256(data); hex.EncodeToString(hash[:]) != receipt.Hashes[i] { + check.State = "hash_mismatch" + } + if check.State != "matches_local_receipt" { + o.source.Code = "payload_not_fully_verified" + o.source.Omitted++ + } + installed.Files = append(installed.Files, check) + } + o.apply = func(r *Report) { r.Installation = &installed } + case "installer_result": + if c.local == "" { + return unavailable("known_folder_unavailable") + } + data, err := c.read(ctx, filepath.Join(c.local, "VxInfiniteTeesBridge", "installer", "setup-result.log"), 256<<10) + if err != nil { + return unavailable(sourceError(err)) + } + result, err := parseInstallerResult(data) + if err != nil { + return unavailable("invalid_installer_result") + } + o.apply = func(r *Report) { r.Installer = &result } + default: + if c.local == "" { + return unavailable("known_folder_unavailable") + } + data, err := c.read(ctx, filepath.Join(c.local, "VxInfiniteTeesBridge", "logs", name), FileBytes) + if err != nil { + return unavailable(sourceError(err)) + } + events, invalid, truncated := parseEvents(data) + o.source.Records, o.source.Omitted = len(events), invalid + o.source.Truncated = truncated + if invalid > 0 { + o.source.Code = "invalid_incomplete_or_budgeted_records_omitted" + } + o.apply = func(r *Report) { r.Events = append(r.Events, events...) } + } + return o +} + +func sourceError(err error) string { + if errors.Is(err, managed.ErrDiagnosticSize) { + return "source_size_limit" + } + if errors.Is(err, os.ErrNotExist) { + return "missing" + } + if errors.Is(err, os.ErrPermission) { + return "access_denied" + } + if errors.Is(err, managed.ErrDenied) { + return "unsafe_source_refused" + } + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return "collection_deadline" + } + return "read_refused_or_failed" +} + +var ( + releaseText = regexp.MustCompile(`^v[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,5}$`) + revisionText = regexp.MustCompile(`^[0-9a-f]{40}$`) + goText = regexp.MustCompile(`^go1\.[0-9]{1,3}(\.[0-9]{1,3})?$`) +) + +func safeBuild(b Build) Build { + allow := func(value string, pattern *regexp.Regexp) string { + if value == "development" || pattern.MatchString(value) { + return value + } + return "unavailable" + } + b.Version = choice(b.Version, "vxbridge/prototype-multigame-v1") + b.Release = allow(b.Release, releaseText) + b.Revision = allow(b.Revision, revisionText) + if !goText.MatchString(b.Go) { + b.Go = "unavailable" + } + b.OS = choice(b.OS, "windows", "darwin", "linux") + b.Architecture = choice(b.Architecture, "amd64", "arm64", "386") + return b +} + +func choice(value string, allowed ...string) string { + for _, v := range allowed { + if value == v { + return v + } + } + return "unavailable" +} + +func safeCode(code string) string { + if code == "" { + return "" + } + return sanitizeEvent(bridge.Event{Reason: code}).Reason +} + +func exportedEvent(event bridge.Event) bridge.Event { + event = sanitizeEvent(event) + event.At = event.At.UTC() + event.Alias = 0 // Correlation aliases are not needed in a public report. + return event +} + +func safeStatus(s bridge.Snapshot) Status { + protocolVersion, _, _ := strings.Cut(s.Version, " ") + endpoint := "unavailable" + if strings.HasPrefix(s.TargetEndpoint, "127.0.0.1:") { + port := strings.TrimPrefix(s.TargetEndpoint, "127.0.0.1:") + if n, err := strconv.ParseUint(port, 10, 16); err == nil && n > 0 && strconv.FormatUint(n, 10) == port { + endpoint = "127.0.0.1:" + port + } + } else if s.TargetEndpoint == "none" { + endpoint = "none" + } + status := Status{ + Build: safeBuild(Build{Version: protocolVersion, Release: s.Release, Revision: s.Revision, + Go: s.GoVersion, OS: "windows", Architecture: s.Architecture}), + Source: choice(s.Source, "listening", "attached", "quarantined_may_still_be_attached_disconnect_Labs"), + Target: choice(s.Target, "absent_source", "connecting", "retrying", "standby", "connected_ready_unknown", "target_unavailable", + "target_write_partial_or_uncertain", "target_response_profile_invalid", "target_reported_failure_no_success_ACK", "target_receipt_deadline_delivery_uncertain"), + Ready: "unknown", Quit: choice(s.Quit, "not_requested", "pending_source_departure", "pending_source_admission_barrier", "complete"), + Identity: choice(s.Identity, "verified", "fault"), + Diagnostics: choice(s.Diagnostics, "local_logs", "local_logs_failed_use_status", "local_logs_overload_use_status", + "stdout", "stdout_failed_use_status", "stdout_overload_use_status", "control_listener_failed", "control_request_failed"), + Desktop: safeCode(s.Desktop), LocalLogs: choice(s.LocalLogs, "local_logs", "local_logs_opening", "local_logs_slow_use_status", + "local_logs_open_failed_use_status", "local_logs_write_or_rotation_failed_use_status", "local_logs_close_failed_use_status", + "local_logs_closed_use_status", "local_logs_queue_full_use_status", "local_logs_flush_deadline_use_status"), + LogLost: s.LocalLogLost, Startup: choice(s.Startup, "startup_off", "startup_registered_policy_applies", "startup_conflict_not_changed", "startup_unavailable"), + Action: safeCode(s.DesktopAction), Destination: choice(string(s.Destination), "infinite_tees", "gspro", "standby"), + Requested: choice(string(s.RequestedDestination), "infinite_tees", "gspro", "standby"), Endpoint: endpoint, + Switch: choice(s.Switch, "idle", "waiting_current_delivery", "blocked_source_fault_disconnect_Labs"), + Preference: safeCode(s.DestinationPreference), SourceEpoch: s.SourceEpoch, TargetGen: s.TargetGeneration, Counts: s.Counts, + LastError: safeCode(s.LastError), LastSource: safeCode(s.LastSourceError), Departure: safeCode(s.LastDeparture), LastEvent: safeCode(s.LastEvent), + } + recent := s.Recent + if len(recent) > 64 { + recent = recent[len(recent)-64:] + } + for _, event := range recent { + status.Recent = append(status.Recent, exportedEvent(event)) + } + return status +} + +func parseEvents(data []byte) ([]record, int, bool) { + var events []record + invalid := 0 + truncated := false + scanner := bufio.NewScanner(bytes.NewReader(data)) + scanner.Buffer(make([]byte, 2048), 2048) + for scanner.Scan() { + line := scanner.Bytes() + var entry record + if !protocol.StrictJSON(line) || json.Unmarshal(line, &entry) != nil || entry.At.IsZero() { + invalid++ + continue + } + entry.Event, entry.Build = exportedEvent(entry.Event), safeBuild(entry.Build) + events = append(events, entry) + } + if scanner.Err() != nil { + invalid++ + truncated = true + } + sort.SliceStable(events, func(i, j int) bool { return events[i].At.Before(events[j].At) }) + if len(events) > ReportEvents { + invalid += len(events) - ReportEvents + truncated = true + events = events[len(events)-ReportEvents:] + } + return events, invalid, truncated +} + +func parseInstallerResult(data []byte) (InstallerResult, error) { + lines := bytes.Split(bytes.TrimSpace(data), []byte("\n")) + if len(lines) == 0 || len(lines[len(lines)-1]) > 2048 { + return InstallerResult{}, errors.New("invalid_installer_result") + } + fields := make(map[string]string) + for _, field := range strings.Fields(string(lines[len(lines)-1])) { + key, value, ok := strings.Cut(field, "=") + if _, exists := fields[key]; !ok || exists { + return InstallerResult{}, errors.New("invalid_installer_result") + } + fields[key] = value + } + if fields["contract"] != "recovery-first-v2" || fields["engine_namespace"] != "conventional" || + !revisionText.MatchString(fields["source"]) || + (fields["install_called"] != "0" && fields["install_called"] != "1") || + (fields["usable"] != "0" && fields["usable"] != "1") { + return InstallerResult{}, errors.New("invalid_installer_result") + } + install, err := strconv.ParseUint(fields["install"], 10, 32) + result, resultErr := strconv.ParseInt(fields["result"], 10, 32) + if err != nil || resultErr != nil { + return InstallerResult{}, errors.New("invalid_installer_result") + } + operation := "unavailable" + switch fields["operation"] { + case "1": + operation = "install" + case "2": + operation = "uninstall" + case "3": + operation = "repair" + case "", "0": + default: + return InstallerResult{}, errors.New("invalid_installer_result") + } + return InstallerResult{Operation: operation, InstallCalled: fields["install_called"] == "1", InstallCode: uint32(install), + UsableThen: fields["usable"] == "1", Result: int32(result), Revision: fields["source"]}, nil +} + +// ZIP serializes only allowlisted records. The four fixed entries have no paths +// to source files, hostname, user, raw vendor/MSI text, or automatic upload. +func (r Report) ZIP() ([]byte, error) { + var out bytes.Buffer + writer := zip.NewWriter(&out) + add := func(name string, data []byte) error { + entry, err := writer.Create(name) + if err != nil { + return err + } + _, err = entry.Write(data) + return err + } + manifest, err := json.MarshalIndent(r.Manifest, "", " ") + if err != nil { + return nil, err + } + report, err := json.MarshalIndent(r, "", " ") + if err != nil { + return nil, err + } + var events bytes.Buffer + encoder := json.NewEncoder(&events) + for _, event := range r.Events { + if err := encoder.Encode(event); err != nil { + return nil, err + } + } + for _, entry := range []struct { + name string + data []byte + }{ + {"manifest.json", manifest}, {"report.json", report}, {"summary.txt", []byte(r.summary())}, {"events.jsonl", events.Bytes()}, + } { + if err := add(entry.name, entry.data); err != nil { + return nil, err + } + } + if err := writer.Close(); err != nil { + return nil, err + } + if out.Len() > ReportBytes { + return nil, errors.New("diagnostic_zip_size_limit") + } + return out.Bytes(), nil +} + +func (r Report) summary() string { + var out strings.Builder + fmt.Fprintf(&out, "VX Bridge diagnostic report\nCollected (UTC): %s\nSchema: %d\nReporter release: %s\nReporter revision: %s\nPartial: %t\n\n", + r.Manifest.CollectedAt.Format(time.RFC3339), r.Manifest.Schema, r.Manifest.Producer.Release, r.Manifest.Producer.Revision, r.Manifest.Partial) + fmt.Fprintf(&out, "Reporter platform: %s/%s; %s\n", r.Manifest.Producer.OS, r.Manifest.Producer.Architecture, r.Manifest.Producer.Go) + if r.Windows != nil { + fmt.Fprintf(&out, "Windows: %d.%d build %d\n", r.Windows.Major, r.Windows.Minor, r.Windows.Build) + } + if r.Host != nil { + fmt.Fprintf(&out, "Running host release: %s\nRunning host revision: %s\nSelected game: %s\nLabs: %s\nTarget: %s (%s)\nLocal logs: %s; rejected records: %d\n", + r.Host.Build.Release, r.Host.Build.Revision, r.Host.Destination, r.Host.Source, r.Host.Target, r.Host.Endpoint, r.Host.LocalLogs, r.Host.LogLost) + fmt.Fprintf(&out, "Target acceptances: %d; missed/discarded: %d; uncertain subset: %d\nLast error: %s\n", + r.Host.Counts.TargetAccept, r.Host.Counts.Dropped, r.Host.Counts.Uncertain, r.Host.LastError) + } else { + out.WriteString("Running host: unavailable. No host was started or repaired.\n") + } + if r.Installation != nil { + fmt.Fprintf(&out, "Installed receipt: %s; release: %s; revision: %s\n", r.Installation.State, r.Installation.Release, r.Installation.Revision) + for _, file := range r.Installation.Files { + fmt.Fprintf(&out, " %s: %s\n", file.Name, file.State) + } + } + if r.Installer != nil { + fmt.Fprintf(&out, "Last installer result: operation=%s result=%d install_code=%d usable_at_recording=%t (not current health)\n", + r.Installer.Operation, r.Installer.Result, r.Installer.InstallCode, r.Installer.UsableThen) + } + out.WriteString("\nSources (missing/unavailable does not mean healthy):\n") + for _, source := range r.Manifest.Sources { + fmt.Fprintf(&out, "%s: %s %s; records=%d omitted=%d truncated=%t\n", + source.Name, source.State, source.Code, source.Records, source.Omitted, source.Truncated) + } + out.WriteString("\nSaved locally. Nothing was sent. Review this ZIP before attaching it to a public issue.\n" + + "Add the approximate problem time and time zone, Windows/Labs/game versions, steps, expected and observed behavior.\n" + + "Vendor versions are not scanned. Raw MSI/vendor logs, shots, player/device/license/account details, credentials and private paths are excluded.\n" + + "Best-effort observations only: not proof of game readiness, exactly-once delivery, install health or audit completeness.\n") + return out.String() +} + +// Save writes a complete ZIP to an exclusively created local file. Existing +// files are never replaced. A failed write is reported as failure, not success; +// its incomplete file is left for the user rather than unlinking a changed path. +func Save(ctx context.Context, path string, report Report) error { + data, err := report.ZIP() + if err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + file, pins, err := managed.CreateDiagnosticFile(path) + if err != nil { + return errors.New("diagnostic_output_create_refused_or_failed") + } + if err := writeReport(file, pins, data); err != nil || ctx.Err() != nil { + return errors.New("diagnostic_output_incomplete") + } + return nil +} + +type reportFile interface { + io.WriteCloser + Sync() error +} + +func writeReport(file reportFile, pins io.Closer, data []byte) error { + n, writeErr := file.Write(data) + if writeErr == nil && n != len(data) { + writeErr = io.ErrShortWrite + } + var syncErr error + if writeErr == nil { + syncErr = file.Sync() + } + return errors.Join(writeErr, syncErr, file.Close(), pins.Close()) +} diff --git a/internal/diagnostics/report_other.go b/internal/diagnostics/report_other.go new file mode 100644 index 0000000..9234587 --- /dev/null +++ b/internal/diagnostics/report_other.go @@ -0,0 +1,8 @@ +//go:build !windows + +package diagnostics + +import "errors" + +func reportFolders() (string, string) { return "", "" } +func windowsFacts() (*Windows, error) { return nil, errors.New("Windows_required") } diff --git a/internal/diagnostics/report_test.go b/internal/diagnostics/report_test.go new file mode 100644 index 0000000..e78d3aa --- /dev/null +++ b/internal/diagnostics/report_test.go @@ -0,0 +1,444 @@ +package diagnostics + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/devigned/vx-bridge/internal/bridge" + "github.com/devigned/vx-bridge/internal/destination" + "github.com/devigned/vx-bridge/internal/managed" +) + +func testBuild() Build { + return Build{Version: "vxbridge/prototype-multigame-v1", Release: "v0.2.0", Revision: strings.Repeat("a", 40), + Go: "go1.27.2", OS: "windows", Architecture: "amd64"} +} + +func fixtureCollector() *Collector { + return &Collector{build: testBuild(), local: "synthetic-local", config: "synthetic-config", + query: func(ctx context.Context, quit bool) (bridge.Snapshot, error) { + if quit { + panic("diagnostics must not request Quit") + } + if _, ok := ctx.Deadline(); !ok { + panic("diagnostics must bound control") + } + return bridge.Snapshot{Version: "PRIVATE_COMPOSITE", Release: "v0.1.0", Revision: strings.Repeat("b", 40), + Architecture: "amd64", GoVersion: "go1.27.2", Destination: destination.GSPro, TargetEndpoint: "127.0.0.1:921", + Source: "attached", Target: "connected_ready_unknown", LocalLogs: "local_logs", LocalLogLost: 3, + Counts: bridge.Counts{TargetAccept: 4}}, nil + }, + read: func(context.Context, string, int64) ([]byte, error) { return nil, os.ErrNotExist }, + receipt: func() (managed.Receipt, string, error) { return managed.Receipt{}, "receipt_missing", os.ErrNotExist }, + windows: func() (*Windows, error) { return &Windows{Major: 10, Build: 26100}, nil }, + } +} + +func unzipReport(t *testing.T, data []byte) map[string][]byte { + t.Helper() + reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + t.Fatal(err) + } + if len(reader.File) != 4 || len(data) > ReportBytes { + t.Fatal("wrong report shape or size", len(reader.File), len(data)) + } + result := make(map[string][]byte) + for _, entry := range reader.File { + if !map[string]bool{"manifest.json": true, "report.json": true, "summary.txt": true, "events.jsonl": true}[entry.Name] { + t.Fatal("unexpected ZIP entry", entry.Name) + } + file, err := entry.Open() + if err != nil { + t.Fatal(err) + } + result[entry.Name], err = io.ReadAll(io.LimitReader(file, ReportBytes+1)) + if err := errors.Join(err, file.Close()); err != nil { + t.Fatal(err) + } + } + return result +} + +func TestReportRunningAndUnavailableHostsAreHonestAndDistinctFromReporter(t *testing.T) { + for _, running := range []bool{true, false} { + t.Run(fmt.Sprint(running), func(t *testing.T) { + c := fixtureCollector() + if !running { + c.query = func(context.Context, bool) (bridge.Snapshot, error) { + return bridge.Snapshot{Source: "PRIVATE_partial_decode"}, errors.New("PRIVATE_path_or_pipe") + } + } + r, err := c.Collect(context.Background()) + if err != nil || !r.Manifest.Partial || (r.Host != nil) != running { + t.Fatal("host failure hidden or partial data used", r.Host, err) + } + if running && (r.Manifest.Producer.Release != "v0.2.0" || r.Host.Build.Release != "v0.1.0" || + r.Host.Counts.TargetAccept != 4 || r.Host.LogLost != 3 || r.Host.Endpoint != "127.0.0.1:921") { + t.Fatal("reporter identity substituted for reported host") + } + data, err := r.ZIP() + if err != nil { + t.Fatal(err) + } + for name, content := range unzipReport(t, data) { + if bytes.Contains(content, []byte("PRIVATE")) { + t.Fatal("private raw source escaped", name) + } + } + }) + } +} + +func TestReportAllowlistRejectsPrivateStringsInEveryExportedStringSurface(t *testing.T) { + private := `PRIVATE_PLAYER_C:\Users\private\license=secret@example.test` + c := fixtureCollector() + c.build = safeBuild(Build{Version: private, Release: private, Revision: private, Go: private, OS: private, Architecture: private}) + c.query = func(context.Context, bool) (bridge.Snapshot, error) { + return bridge.Snapshot{ + Version: private, Release: private, Revision: private, GoVersion: private, Architecture: private, + Source: private, Target: private, Ready: private, Quit: private, Identity: private, Diagnostics: private, + Desktop: private, LocalLogs: private, Startup: private, DesktopAction: private, LastError: private, + LastSourceError: private, LastDeparture: private, LastEvent: private, Destination: destination.ID(private), + RequestedDestination: destination.ID(private), Switch: private, DestinationPreference: private, TargetEndpoint: private, + Recent: []bridge.Event{{At: time.Now(), Reason: private, Alias: 123456789}}, + }, nil + } + logRecord, err := json.Marshal(record{Event: bridge.Event{At: time.Now(), Reason: private, Alias: 123456789}, + Build: Build{Version: private, Release: private, Revision: private, Go: private, OS: private, Architecture: private}}) + if err != nil { + t.Fatal(err) + } + c.read = func(_ context.Context, path string, _ int64) ([]byte, error) { + switch filepath.Base(path) { + case "bridge.0.jsonl": + return append(logRecord, '\n'), nil + case "destination.json": + return []byte(`{"destination":"gspro","private":"` + private + `"}`), nil + case "setup-result.log": + return []byte("contract=recovery-first-v2 engine_namespace=conventional install_called=1 install=0 usable=1 result=0 source=" + + strings.Repeat("a", 40) + " ui_field0_hex=" + private), nil + } + return nil, errors.New(private) + } + r, err := c.Collect(context.Background()) + if err != nil || r.Installer == nil || len(r.Events) != 1 { + t.Fatal("safe observations lost", err) + } + data, err := r.ZIP() + if err != nil { + t.Fatal(err) + } + for name, content := range unzipReport(t, data) { + for _, forbidden := range []string{"PRIVATE", "secret@example", "123456789", "ui_field0_hex", "shot_alias", `C:\\Users`} { + if bytes.Contains(content, []byte(forbidden)) { + t.Fatal("private input exported", name, forbidden) + } + } + } +} + +func TestReportBoundsMalformedLogsAndNewestRecords(t *testing.T) { + var data bytes.Buffer + for i := 0; i < ReportEvents+50; i++ { + entry := record{Event: bridge.Event{At: time.Unix(int64(i+1), 0).UTC(), Reason: "source_attached"}, Build: testBuild()} + if err := json.NewEncoder(&data).Encode(entry); err != nil { + t.Fatal(err) + } + } + data.WriteString("{incomplete\n") + events, omitted, truncated := parseEvents(data.Bytes()) + if len(events) != ReportEvents || omitted != 51 || !truncated || events[0].At.Unix() != 51 { + t.Fatal("event budget/chronology wrong", len(events), omitted) + } + events, omitted, truncated = parseEvents([]byte(strings.Repeat("X", 4096) + "\n")) + if len(events) != 0 || omitted == 0 || !truncated { + t.Fatal("oversized log record not rejected") + } + c := fixtureCollector() + c.read = func(_ context.Context, path string, _ int64) ([]byte, error) { + if strings.HasPrefix(filepath.Base(path), "bridge.") { + return data.Bytes(), nil + } + return nil, os.ErrNotExist + } + r, err := c.Collect(context.Background()) + if err != nil || len(r.Events) != ReportEvents || !r.Manifest.Partial { + t.Fatal("global event budget not applied", err) + } + bundle, err := r.ZIP() + if err != nil { + t.Fatal(err) + } + unzipReport(t, bundle) +} + +func TestReportDeadlineAndCancellationDoNotAccumulateSourceWorkers(t *testing.T) { + c := fixtureCollector() + entered, release := make(chan struct{}), make(chan struct{}) + c.windows = func() (*Windows, error) { close(entered); <-release; return nil, errors.New("synthetic blocked IO") } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { _, err := c.Collect(ctx); done <- err }() + <-entered + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatal("cancel looked like successful report", err) + } + if _, err := c.Collect(context.Background()); err == nil || err.Error() != "diagnostic_collection_busy" { + t.Fatal("blocked source spawned another worker", err) + } + close(release) + until := time.Now().Add(time.Second) + for collecting.Load() { + if time.Now().After(until) { + t.Fatal("source worker did not retire") + } + time.Sleep(time.Millisecond) + } +} + +func TestReportMissingCorruptSettingsAndInstallerRecords(t *testing.T) { + for _, input := range []string{"", "PRIVATE", "source=PRIVATE", "contract=recovery-first-v2 source=" + strings.Repeat("a", 40)} { + if _, err := parseInstallerResult([]byte(input)); err == nil { + t.Fatal("malformed installer record accepted") + } + } + c := fixtureCollector() + c.read = func(_ context.Context, path string, _ int64) ([]byte, error) { + if filepath.Base(path) == "destination.json" { + return []byte(`{"destination":"gspro"}`), nil + } + return []byte("{incomplete"), nil + } + r, err := c.Collect(context.Background()) + if err != nil || r.Settings == nil || r.Settings.Destination != "gspro" || r.Installer != nil || len(r.Events) != 0 { + t.Fatal("corrupt source made the export fail or invented success", err) + } +} + +func TestReportInstalledFileChecksDoNotUseTheReporterIdentity(t *testing.T) { + c := fixtureCollector() + names := []string{"GSPconnect.exe", "vxbridge.exe", "LICENSE", "THIRD_PARTY_NOTICES.txt", "build-manifest.json", "SHA256SUMS"} + receipt := managed.Receipt{Version: "0.1.0", Revision: strings.Repeat("c", 40)} + for i, name := range names { + hash := sha256.Sum256([]byte("invented-" + name)) + receipt.Hashes[i] = hex.EncodeToString(hash[:]) + } + c.receipt = func() (managed.Receipt, string, error) { return receipt, "maintenance_pending", nil } + c.read = func(_ context.Context, path string, limit int64) ([]byte, error) { + name := filepath.Base(path) + for _, candidate := range names { + if name == candidate { + if limit != 32<<20 { + t.Fatal("payload reads are not bounded") + } + if name == "vxbridge.exe" { + return []byte("invented damage"), nil + } + return []byte("invented-" + name), nil + } + } + if name == "bridge.0.jsonl" { + return nil, managed.ErrDiagnosticSize + } + return nil, os.ErrNotExist + } + r, err := c.Collect(context.Background()) + if err != nil || r.Installation == nil || len(r.Installation.Files) != 6 || + r.Installation.Release != "v0.1.0" || r.Installation.State != "maintenance_pending" || + r.Installation.Files[1].State != "hash_mismatch" || r.Installation.Files[0].State != "matches_local_receipt" { + t.Fatal("installed observation was replaced with reporter or admission success", r.Installation, err) + } + for _, source := range r.Manifest.Sources { + if source.Name == "bridge.0.jsonl" && (!source.Truncated || source.Code != "source_size_limit") { + t.Fatal("oversized source not explicitly recorded") + } + } +} + +func TestControlDeadlineProducesPartialReportNotAHostStart(t *testing.T) { + c := fixtureCollector() + c.query = func(ctx context.Context, quit bool) (bridge.Snapshot, error) { + if quit { + t.Fatal("collector requested Quit") + } + <-ctx.Done() + return bridge.Snapshot{Source: "PRIVATE_incomplete"}, ctx.Err() + } + start := time.Now() + r, err := c.Collect(context.Background()) + if err != nil || r.Host != nil || !r.Manifest.Partial || time.Since(start) > 3*time.Second { + t.Fatal("unavailable control did not yield bounded honest partial results", err) + } +} + +func TestInstallerResultOperationIsTypedAndBackwardCompatible(t *testing.T) { + base := "contract=recovery-first-v2 engine_namespace=conventional install_called=1 install=0 usable=1 result=0 source=" + strings.Repeat("a", 40) + for field, expected := range map[string]string{"": "unavailable", " operation=1": "install", " operation=2": "uninstall", " operation=3": "repair"} { + result, err := parseInstallerResult([]byte(base + field)) + if err != nil || result.Operation != expected { + t.Fatal("installer operation summary differs", result, err) + } + } + if _, err := parseInstallerResult([]byte(base + " operation=PRIVATE")); err == nil { + t.Fatal("private operation text accepted") + } +} + +type outputFixture struct { + short, writeFail, syncFail, closeFail bool + closed, synced bool +} + +func (f *outputFixture) Write(data []byte) (int, error) { + if f.writeFail { + return 0, errors.New("synthetic disk full") + } + if f.short { + return len(data) - 1, nil + } + return len(data), nil +} + +func (f *outputFixture) Sync() error { + f.synced = true + if f.syncFail { + return errors.New("synthetic sync failure") + } + return nil +} + +func (f *outputFixture) Close() error { + f.closed = true + if f.closeFail { + return errors.New("synthetic close failure") + } + return nil +} + +func TestReportWriteSyncCloseAndPinFailuresNeverLookSuccessful(t *testing.T) { + for _, file := range []*outputFixture{{short: true}, {writeFail: true}, {syncFail: true}, {closeFail: true}} { + pins := new(outputFixture) + if err := writeReport(file, pins, []byte("invented ZIP bytes")); err == nil || !file.closed || !pins.closed { + t.Fatal("output failure hidden or handles leaked", file, err) + } + } + file, pins := new(outputFixture), &outputFixture{closeFail: true} + if err := writeReport(file, pins, []byte("invented ZIP bytes")); err == nil || !file.closed || !file.synced || !pins.closed { + t.Fatal("pin cleanup failure hidden") + } +} + +func TestReportRejectsZIPOverExactByteBudget(t *testing.T) { + random := rand.New(rand.NewSource(42)) + const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" + text := make([]byte, ReportBytes*2) + for i := range text { + text[i] = alphabet[random.Intn(len(alphabet))] + } + r := Report{Events: []record{{Event: bridge.Event{At: time.Now(), Reason: string(text)}}}} + if _, err := r.ZIP(); err == nil || err.Error() != "diagnostic_zip_size_limit" { + t.Fatal("oversized ZIP accepted", err) + } +} + +func canonicalTemp(t *testing.T) string { + t.Helper() + path, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return path +} + +func TestReportSaveCancellationExistingAndOutputErrors(t *testing.T) { + r, err := fixtureCollector().Collect(context.Background()) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(canonicalTemp(t), "report.zip") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := Save(ctx, path, r); !errors.Is(err, context.Canceled) { + t.Fatal("cancel was ignored", err) + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatal("cancel created an output file") + } + if err := Save(context.Background(), path, r); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + unzipReport(t, before) + if err := Save(context.Background(), path, r); err == nil { + t.Fatal("existing report silently overwritten") + } + after, err := os.ReadFile(path) + if err != nil || !bytes.Equal(before, after) { + t.Fatal("existing report changed", err) + } + for _, output := range []string{"relative.zip", filepath.Dir(path), filepath.Join(path, "missing", "report.zip")} { + if err := Save(context.Background(), output, r); err == nil { + t.Fatal("bad output accepted", output) + } + } +} + +func TestReportReadsConcurrentRotatingSlotsWithoutChangingThem(t *testing.T) { + dir := canonicalTemp(t) + logDirectory := filepath.Join(dir, "VxInfiniteTeesBridge", "logs") + if err := os.MkdirAll(logDirectory, 0700); err != nil { + t.Fatal(err) + } + slots, err := openSlots(logDirectory, 1024) + if err != nil { + t.Fatal(err) + } + var writers sync.WaitGroup + writers.Add(1) + go func() { + defer writers.Done() + for i := 0; i < 100; i++ { + data, _ := json.Marshal(record{Event: bridge.Event{At: time.Now().UTC(), Reason: "source_attached"}, Build: testBuild()}) + if _, err := slots.Write(append(data, '\n')); err != nil { + t.Error(err) + return + } + } + }() + c := fixtureCollector() + c.local, c.read = dir, managed.ReadDiagnosticFile + r, err := c.Collect(context.Background()) + writers.Wait() + if closeErr := slots.Close(); closeErr != nil { + t.Fatal(closeErr) + } + if err != nil || r.Host == nil { + t.Fatal("concurrent logs blocked other sources", err) + } + for i := 0; i < FileCount; i++ { + path := filepath.Join(logDirectory, fmt.Sprintf("bridge.%d.jsonl", i)) + info, err := os.Stat(path) + if err != nil || info.Size() == 0 || info.Size() > 1024 { + t.Fatal("collection cleared or expanded source", err) + } + } +} diff --git a/internal/diagnostics/report_windows.go b/internal/diagnostics/report_windows.go new file mode 100644 index 0000000..ec549b6 --- /dev/null +++ b/internal/diagnostics/report_windows.go @@ -0,0 +1,14 @@ +package diagnostics + +import "golang.org/x/sys/windows" + +func reportFolders() (string, string) { + local, _ := windows.KnownFolderPath(windows.FOLDERID_LocalAppData, windows.KF_FLAG_DONT_VERIFY) + config, _ := windows.KnownFolderPath(windows.FOLDERID_RoamingAppData, windows.KF_FLAG_DONT_VERIFY) + return local, config +} + +func windowsFacts() (*Windows, error) { + version := windows.RtlGetVersion() + return &Windows{Major: version.MajorVersion, Minor: version.MinorVersion, Build: version.BuildNumber}, nil +} diff --git a/internal/managed/admission_windows.go b/internal/managed/admission_windows.go index 58f1a8a..52a4978 100644 --- a/internal/managed/admission_windows.go +++ b/internal/managed/admission_windows.go @@ -94,13 +94,17 @@ func pin(path string, directory bool, access, share, creation uint32) (windows.H } func pinDirectories(path string, a *admission) error { + return pinDirectoryChain(path, a, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE) +} + +func pinDirectoryChain(path string, a *admission, share uint32) error { var chain []string for current := path; current != filepath.Dir(current); current = filepath.Dir(current) { chain = append(chain, current) } for i := len(chain) - 1; i >= 0; i-- { handle, err := pin(chain[i], true, windows.FILE_READ_ATTRIBUTES, - windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, windows.OPEN_EXISTING) + share, windows.OPEN_EXISTING) if err != nil { return err } diff --git a/internal/managed/diagnostics.go b/internal/managed/diagnostics.go new file mode 100644 index 0000000..7137321 --- /dev/null +++ b/internal/managed/diagnostics.go @@ -0,0 +1,44 @@ +package managed + +import ( + "context" + "errors" + "io" + "os" +) + +// ErrDiagnosticSize means the fixed diagnostic input byte budget was exceeded. +var ErrDiagnosticSize = errors.New("diagnostic_source_size_limit") + +// ReadDiagnosticFile reads a bounded regular single-link file under pinned, +// non-reparse ancestors on Windows. It never creates or modifies a source. +// Callers select only fixed application-owned names, not vendor or user input. +func ReadDiagnosticFile(ctx context.Context, path string, limit int64) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + file, pins, err := diagnosticFile(path, false) + if err != nil { + return nil, err + } + data, readErr := io.ReadAll(io.LimitReader(file, limit+1)) + err = errors.Join(readErr, file.Close(), pins.Close(), ctx.Err()) + if len(data) > int(limit) { + err = errors.Join(err, ErrDiagnosticSize) + } + return data, err +} + +// CreateDiagnosticFile exclusively creates a new local report under verified +// ancestors. The caller must close both file and pins. It never overwrites an +// existing file, follows a reparse point, or creates parent directories. +func CreateDiagnosticFile(path string) (*os.File, io.Closer, error) { + return diagnosticFile(path, true) +} + +// DiagnosticReceipt reads only the installed receipt and Pending presence. +// It does not acquire a host lease, admit a host, query MSI, or alter state. +// state is a fixed observation code; a valid receipt is not proof of health. +func DiagnosticReceipt() (receipt Receipt, state string, err error) { + return diagnosticReceipt() +} diff --git a/internal/managed/diagnostics_other.go b/internal/managed/diagnostics_other.go new file mode 100644 index 0000000..16089f6 --- /dev/null +++ b/internal/managed/diagnostics_other.go @@ -0,0 +1,56 @@ +//go:build !windows + +package managed + +import ( + "errors" + "io" + "os" + "path/filepath" + "syscall" +) + +// The portable implementation is for synthetic fixtures, not Windows qualification. +func diagnosticFile(path string, create bool) (*os.File, io.Closer, error) { + if !filepath.IsAbs(path) { + return nil, nil, errors.New("diagnostic_absolute_path_required") + } + for at := filepath.Dir(path); ; at = filepath.Dir(at) { + info, err := os.Lstat(at) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return nil, nil, errors.New("diagnostic_unsafe_ancestor") + } + if at == filepath.Dir(at) { + break + } + } + if create { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + return file, noPins{}, err + } + info, err := os.Lstat(path) + if err != nil { + return nil, nil, err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !info.Mode().IsRegular() || !ok || stat.Nlink != 1 { + return nil, nil, errors.New("diagnostic_regular_file_required") + } + file, err := os.Open(path) + if err != nil { + return nil, nil, err + } + actual, err := file.Stat() + if err != nil || !os.SameFile(info, actual) { + return nil, nil, errors.Join(errors.New("diagnostic_source_changed"), file.Close()) + } + return file, noPins{}, nil +} + +type noPins struct{} + +func (noPins) Close() error { return nil } + +func diagnosticReceipt() (Receipt, string, error) { + return Receipt{}, "windows_required", errors.New("Windows_required") +} diff --git a/internal/managed/diagnostics_test.go b/internal/managed/diagnostics_test.go new file mode 100644 index 0000000..121e55d --- /dev/null +++ b/internal/managed/diagnostics_test.go @@ -0,0 +1,91 @@ +package managed + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +func diagnosticTemp(t *testing.T) string { + t.Helper() + path, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return path +} + +func TestDiagnosticInputsAreBoundedReadOnlyAndRejectLinks(t *testing.T) { + dir := diagnosticTemp(t) + path := filepath.Join(dir, "owned.jsonl") + original := []byte("synthetic original") + if err := os.WriteFile(path, original, 0600); err != nil { + t.Fatal(err) + } + data, err := ReadDiagnosticFile(context.Background(), path, int64(len(original))) + if err != nil || !bytes.Equal(data, original) { + t.Fatal("exact byte limit failed", err) + } + if _, err := ReadDiagnosticFile(context.Background(), path, int64(len(original)-1)); !errors.Is(err, ErrDiagnosticSize) { + t.Fatal("oversized source not explicitly refused", err) + } + if _, err := ReadDiagnosticFile(context.Background(), dir, 1024); err == nil { + t.Fatal("directory accepted as a log") + } + link := filepath.Join(dir, "hardlink") + if err := os.Link(path, link); err != nil { + t.Fatal(err) + } + for _, name := range []string{path, link} { + if _, err := ReadDiagnosticFile(context.Background(), name, 1024); err == nil { + t.Fatal("hardlink accepted") + } + } + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + after, err := os.ReadFile(path) + if err != nil || !bytes.Equal(original, after) { + t.Fatal("collection mutated its source", err) + } + symlink := filepath.Join(dir, "symlink") + if err := os.Symlink(path, symlink); err != nil { + t.Skip("symlink permission unavailable") + } + if _, err := ReadDiagnosticFile(context.Background(), symlink, 1024); err == nil { + t.Fatal("source symlink accepted") + } +} + +func TestDiagnosticOutputsRejectExistingFilesAndRedirectedParents(t *testing.T) { + dir := diagnosticTemp(t) + path := filepath.Join(dir, "report.zip") + file, pins, err := CreateDiagnosticFile(path) + if err != nil { + t.Fatal(err) + } + _, writeErr := file.Write([]byte("synthetic")) + if err := errors.Join(writeErr, file.Close(), pins.Close()); err != nil { + t.Fatal(err) + } + if file, pins, err := CreateDiagnosticFile(path); err == nil { + file.Close() + pins.Close() + t.Fatal("existing output replaced") + } + parent := filepath.Join(dir, "redirect") + if err := os.Symlink(dir, parent); err != nil { + t.Skip("symlink permission unavailable") + } + if file, pins, err := CreateDiagnosticFile(filepath.Join(parent, "escaped.zip")); err == nil { + file.Close() + pins.Close() + t.Fatal("redirected output parent accepted") + } + if _, err := os.Stat(filepath.Join(dir, "escaped.zip")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("refusal created a file through a symlink", err) + } +} diff --git a/internal/managed/diagnostics_windows.go b/internal/managed/diagnostics_windows.go new file mode 100644 index 0000000..20a9a57 --- /dev/null +++ b/internal/managed/diagnostics_windows.go @@ -0,0 +1,68 @@ +package managed + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +func diagnosticFile(path string, create bool) (*os.File, io.Closer, error) { + path = filepath.Clean(path) + if !filepath.IsAbs(path) || strings.HasPrefix(path, `\\`) || strings.Contains(path[2:], ":") { + return nil, nil, errors.New("diagnostic_local_path_required") + } + drive := windows.GetDriveType(windows.StringToUTF16Ptr(filepath.VolumeName(path) + `\`)) + if drive != windows.DRIVE_FIXED && drive != windows.DRIVE_REMOVABLE { + return nil, nil, errors.New("diagnostic_local_drive_required") + } + a := new(admission) + if err := pinDirectoryChain(filepath.Dir(path), a, windows.FILE_SHARE_READ); err != nil { + return nil, nil, errors.Join(err, a.Close()) + } + access, share, creation := uint32(windows.GENERIC_READ), + uint32(windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE), uint32(windows.OPEN_EXISTING) + if create { + access, share, creation = windows.GENERIC_WRITE, 0, windows.CREATE_NEW + } + handle, err := pin(path, false, access, share, creation) + if err != nil { + return nil, nil, errors.Join(err, a.Close()) + } + return os.NewFile(uintptr(handle), path), a, nil +} + +func diagnosticReceipt() (Receipt, string, error) { + key, err := registry.OpenKey(registry.CURRENT_USER, stateKey, registry.QUERY_VALUE) + if errors.Is(err, registry.ErrNotExist) { + return Receipt{}, "receipt_missing", err + } + if err != nil { + return Receipt{}, "receipt_unavailable", err + } + data := make([]byte, ReceiptSize) + size, kind, readErr := key.GetValue("Receipt", data) + _, _, pendingErr := key.GetValue("Pending", nil) + closeErr := key.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return Receipt{}, "receipt_unavailable", err + } + if kind != registry.BINARY || size != ReceiptSize { + return Receipt{}, "receipt_invalid", ErrDenied + } + r, err := DecodeReceipt(data) + if err != nil { + return Receipt{}, "receipt_invalid", err + } + if pendingErr == nil { + return r, "maintenance_pending", nil + } + if !errors.Is(pendingErr, registry.ErrNotExist) { + return r, "pending_unavailable", pendingErr + } + return r, "receipt_present_no_pending", nil +} diff --git a/tools/msi/lifecycle/audit_windows.go b/tools/msi/lifecycle/audit_windows.go index b0e0595..08f38c7 100644 --- a/tools/msi/lifecycle/audit_windows.go +++ b/tools/msi/lifecycle/audit_windows.go @@ -104,7 +104,8 @@ func capture(name, out string, u userContext, media []media) (s snapshot, result s = snapshot{Name: name, UTC: time.Now().UTC().Format(time.RFC3339Nano), Context: u, Files: map[string]fileState{}, Registry: map[string]registryState{}} for _, root := range append([]string{u.Root, u.LocalData, u.Config, - filepath.Join(u.Programs, "VX Bridge.lnk"), filepath.Join(u.Programs, "VX Bridge Setup.lnk")}, foreignFiles(u)...) { + filepath.Join(u.Programs, "VX Bridge.lnk"), filepath.Join(u.Programs, "VX Bridge Setup.lnk"), + filepath.Join(u.Programs, "VX Bridge Diagnostics.lnk")}, foreignFiles(u)...) { files, err := collectFiles(root) if err != nil { result = errors.Join(result, fmt.Errorf("inventory %s: %w", root, err)) @@ -206,12 +207,15 @@ func verifyInstalled(m media, u userContext, all []media) error { return err } for name, target := range map[string]string{ - "VX Bridge.lnk": filepath.Join(u.Root, "GSPconnect.exe"), - "VX Bridge Setup.lnk": filepath.Join(u.State, "setup.exe"), + "VX Bridge.lnk": filepath.Join(u.Root, "GSPconnect.exe"), + "VX Bridge Setup.lnk": filepath.Join(u.State, "setup.exe"), + "VX Bridge Diagnostics.lnk": filepath.Join(u.Root, "vxbridge.exe"), } { arguments := "" if name == "VX Bridge Setup.lnk" { arguments = "/maintain" + } else if name == "VX Bridge Diagnostics.lnk" { + arguments = "--diagnostics-dialog" } if err := verifyShortcut(filepath.Join(u.Programs, name), target, arguments); err != nil { return err @@ -278,7 +282,8 @@ func verifyShortcut(path, target, arguments string) error { func verifyAbsent(u userContext, all []media) error { for _, path := range []string{u.Root, u.LocalData, u.Config, - filepath.Join(u.Programs, "VX Bridge.lnk"), filepath.Join(u.Programs, "VX Bridge Setup.lnk")} { + filepath.Join(u.Programs, "VX Bridge.lnk"), filepath.Join(u.Programs, "VX Bridge Setup.lnk"), + filepath.Join(u.Programs, "VX Bridge Diagnostics.lnk")} { if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("uninstall app-owned path remains or cannot be verified: %s: %v", path, err) } diff --git a/tools/msi/setup/main.go b/tools/msi/setup/main.go index 2086b1e..610f371 100644 --- a/tools/msi/setup/main.go +++ b/tools/msi/setup/main.go @@ -535,6 +535,7 @@ func author(product, version, revision, owner string, payload map[string][]byte) + diff --git a/tools/msi/setup/main_test.go b/tools/msi/setup/main_test.go index 6ecbdfd..703cb91 100644 --- a/tools/msi/setup/main_test.go +++ b/tools/msi/setup/main_test.go @@ -101,7 +101,8 @@ func TestAuthoredScopeAndNoEagerLaunch(t *testing.T) { t.Fatal(err) } for _, required := range []string{`InstallScope="perUser"`, `InstallPrivileges="limited"`, `AllowAdvertise="no"`, - `Name="VX Bridge"`, `Name="VX Bridge Setup"`, + `Name="VX Bridge"`, `Name="VX Bridge Setup"`, `Name="VX Bridge Diagnostics"`, + `Target="[INSTALLDIR]vxbridge.exe" Arguments="--diagnostics-dialog"`, `Target="[INSTALLDIR]GSPconnect.exe"`, `MSIRESTARTMANAGERCONTROL" Value="Disable"`, `REBOOT" Value="ReallySuppress"`, `VxSetupAuthorize" Before="InstallValidate"`, `Execute="rollback"`, `Execute="deferred"`, diff --git a/tools/msi/setup/remove_phase_test.go b/tools/msi/setup/remove_phase_test.go index 0868321..3bd00e4 100644 --- a/tools/msi/setup/remove_phase_test.go +++ b/tools/msi/setup/remove_phase_test.go @@ -35,7 +35,7 @@ func TestShortcutOwnershipReadRetainsExactDeleteHandoffChecks(t *testing.T) { for _, required := range []string{ "BOOL recheck_shortcut=exclusive && directory==REMOVE_SHORTCUTS;", "access=recheck_shortcut?GENERIC_READ:exclusive?GENERIC_READ|DELETE", - "ok=shortcut_owned(c,name,path);", + "ok=setup_shortcut_owned(c,name,path);", "GetFileInformationByHandle(file,&before) && vx_hash(file,actual)", "before.dwVolumeSerialNumber==after.dwVolumeSerialNumber", "before.nFileIndexHigh==after.nFileIndexHigh && before.nFileIndexLow==after.nFileIndexLow", @@ -46,3 +46,31 @@ func TestShortcutOwnershipReadRetainsExactDeleteHandoffChecks(t *testing.T) { } } } + +func TestDiagnosticShortcutHasCompleteOwnershipAndRemovalCoverage(t *testing.T) { + common := setupSource(t, "../../../installer/setup/common.c") + remove := setupSource(t, "../../../installer/setup/remove.c") + launcher := setupSource(t, "../../../installer/setup/launcher.c") + actions := setupSource(t, "../../../installer/setup/actions.c") + inspect := setupSource(t, "../../../installer/build-setup.ps1") + for _, required := range []string{ + `BOOL diagnostics=!wcscmp(name,L"VX Bridge Diagnostics.lnk");`, + `diagnostics?L"vxbridge.exe":L"GSPconnect.exe"`, + `diagnostics?L"--diagnostics-dialog":L""`, + `setup_shortcut_owned(c,L"VX Bridge Diagnostics.lnk",path)`, + `vx_file(path,FALSE,GENERIC_READ,FILE_SHARE_READ,OPEN_EXISTING)`, + } { + if !strings.Contains(common, required) { + t.Fatal("support shortcut has no exact owner proof", required) + } + } + if strings.Count(remove, `L"VX Bridge Diagnostics.lnk"`) != 4 || + !strings.Contains(launcher, "!setup_support_shortcut(&context,FALSE)") || + !strings.Contains(launcher, "!setup_support_shortcut(&context,TRUE)") || + !strings.Contains(actions, "!setup_support_shortcut(&c,FALSE)") || + !strings.Contains(inspect, "'DiagnosticsShortcut'") || + !strings.Contains(inspect, "$shortcuts.Count -ne 3") || + !strings.Contains(inspect, "'--diagnostics-dialog'") { + t.Fatal("support shortcut install/repair/upgrade/removal/inspection incomplete") + } +}