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")
+ }
+}