14 Commits
Author SHA1 Message Date
Cheviiot 25f751e874 Build old (pre-v145) PlatformToolset-pinned .vcxproj files
vintner only ever downloads one compiler generation, but real-world
.vcxproj files are pinned to whichever PlatformToolset they were last
saved under - v142 (VS2019) for anything not actively maintained is
extremely common. MSBuild checks toolset "installed-ness" (MSB8020) by
testing whether MSBuild/Microsoft/VC/v<schema>/Platforms/<arch>/
PlatformToolsets/<toolset>/ exists on disk - a plain file lookup our
downloaded MSBuild package only satisfies for the exact generation it
shipped. `install` now symlinks every historical numeric PlatformToolset
name (v90 through v143) onto whichever real toolset directory is
actually present, so any of them resolves transparently; Toolset.props/
.targets don't hardcode a version number, so aliasing is correct, not
just a workaround.

Three more MSBuild property/environment issues came with it, all found
building a real years-old project against the one modern toolchain
vintner installs:

- VCInstallDir_<N>/VCToolsInstallDir_<N> needed a third numbering
  source (PlatformToolset short names from Microsoft.VCToolsVersion.
  v<N>.default.props) alongside the existing MSBuild schema-version and
  known-toolset lists, so the env-var-driven half of toolset resolution
  covers the same names the on-disk alias does.
- VCToolsVersion must be a real version string: left unset, it falls
  back to a literal placeholder that then hits an unconditional
  version-string comparison elsewhere in Microsoft.CppBuild.targets
  (MSB4184). Setting it to the real installed version in turn requires
  CheckMSVCComponents=false, since CheckVCToolsetVersion (MSB8052)
  otherwise rejects an aliased PlatformToolset whenever its numeric
  generation doesn't match VCToolsVersion's - exactly the case aliasing
  creates on purpose. Everything else CheckMSVCComponents gates is
  diagnostic-only (MFC/ATL/Spectre presence warnings), so disabling it
  costs nothing else.
- WindowsTargetPlatformVersion needed to become an explicit /p: global
  property on the msbuild command line, not just an env var: legacy
  .vcxproj files commonly hardcode this in a PropertyGroup, and an
  explicit project assignment always wins over an inherited environment
  variable of the same name. A command-line global property is the one
  thing a project file can't override. Only injected when the caller
  hasn't already pinned it themselves.
2026-07-25 15:41:09 +10:00
Cheviiot 4795c7c105 Add DirectX SDK (D3DX9) download support
download --with-dxsdk fetches the DirectX SDK (June 2010) - the last
standalone release of D3DX9/10/11, XInput and XAudio2, dropped from
the Windows SDK entirely once D3DX was deprecated, but still needed
by plenty of legacy code. Like the WDK, it isn't part of the VS
installer manifest, so this is its own self-contained fetch+unpack
path: the installer is a self-extracting CAB, unpacked directly via
cabextract (already a prerequisite for the WinSDK .msi payloads) with
its -F filter restricting extraction to just Include/ and Lib/ -
about 21MB out of the installer's 1.2GB uncompressed payload.

Verified against the real installer: real d3dx9.h and d3dx9.lib
(x86 and x64) extracted correctly and linked into an actual legacy
game client build.
2026-07-25 15:40:41 +10:00
Cheviiot 91471397fa Fix toolrelay.exe's mt.exe detection: it never matched a real path
IsMtExe() looked for the last '\\' in the target executable path to
find the bare filename, but vintner passes toolExePath straight from
Go's filepath.Join (forward slashes) all the way through to
CreateProcessW - so the path toolrelay.exe actually receives has no
backslash in it at all, and IsMtExe() compared the *entire path*
against "mt.exe", which of course never matched. The whole point of
this file - translating mt.exe's CMake-compatibility exit code
(0x41020001 -> 0xbb) before Wine's own exit-code truncation destroys
it - has silently never fired in real usage.

This had gone unnoticed because MSBuild's rawStdout path bypasses
toolrelay.exe entirely, and every real end-to-end test so far
(msbuild-driven builds, including the KMDF driver) went through that
path. Found while getting a CMake+Ninja-generated MSVC build of a
real project (Ogre3D) past its `cmake -E vs_link_exe` manifest step,
which depends on exactly this translation.

Now checks for both '\\' and '/' and uses whichever separator occurs
last in the path. Verified directly: `mt /manifest ... /notify_update`
through the wrapper now exits 187 (0xbb) instead of 1, and the Ogre
build gets past the manifest-embedding step it was failing at.
2026-07-25 11:55:05 +10:00
Cheviiot 94a19e6b43 Show git commit and build time in vintner version
Uses Go's automatic VCS build-info stamping (vcs.revision/vcs.time/
vcs.modified, embedded by `go build` since Go 1.18 - no -ldflags
changes needed, works the same for a CI release build and a plain
local `go build`). Knowing the exact commit a bug report's binary was
built from, not just the X.Y.Z tag, is the point - two builds of the
same tag could still differ.

Split the pure formatting logic (formatVersion) from the
debug.ReadBuildInfo() call so it's actually unit-testable: `go test`
binaries don't get VCS stamping the way `go build` ones do, so there
was no way to exercise the revision-formatting branch through
versionString() itself.
2026-07-25 11:37:02 +10:00
Cheviiot 6186837fd2 docs: note that Nivora installs get shell completion automatically 2026-07-25 11:33:41 +10:00
Cheviiot c049274626 Rewrite README prose: cut repeated self-assurance, tighten sentences
The old version restated "independently implemented"/"original to
this project" in three separate places, which reads as protesting
too much rather than as confidence. Credits the msvc-wine inspiration
once, plainly, and drops the rest. Also split several overlong
comma-chained sentences, fixed the WDK section (a sentence was cut in
half by its own code block), and cleaned up the license paragraph's
phrasing. Content is unchanged - verified the two claims most worth
double-checking (the clang-cl/lld-link path, and that `stplr install
nivora/vintner` actually resolves) rather than just rewording them.
2026-07-25 10:55:01 +10:00
Cheviiot fc20b2fb15 Add test coverage for cmd/vintner and internal/i18n
Both were at 0% coverage. Focused on what's safely testable without
touching the network or filesystem: flag validation (--architecture/
--host-arch, the same guard added in the stability pass), subcommand
dispatch and aliases, help/usage error paths, and - for i18n - full
language-detection table coverage plus a completeness check that
every catalog key has both an EN and RU entry (an English-only or
Russian-only entry would silently degrade rather than fail loudly,
so this is worth locking in). cmd/vintner: 0% -> 28.8%, i18n: 0% ->
94.1%.
2026-07-25 10:41:48 +10:00
Cheviiot 26f6df6a9a Add bash/zsh shell completion
vintner completion bash|zsh prints a completion script meant to be
sourced (source <(vintner completion bash)); completes subcommands
(including short aliases), download's flags, and directory arguments
for install/env --bin. Mentioned in the top-level usage text and
documented in the README.

The Nivora package doesn't auto-install these system-wide yet - it'd
need Stapler's install-completion helper, whose calling convention
isn't documented anywhere in this repo or Nivora's other packages, so
guessing at it risked a broken package build for a nice-to-have.
source <(vintner completion bash) works today regardless of install
method (Nivora, prebuilt binary, or from source).
2026-07-25 10:39:22 +10:00
Cheviiot 98ee39018a Stop orphaning wine subprocesses when vintner is killed by PID
Every wrapped tool invocation (cl/link/msbuild/etc via wine, plus the
native cmd/findstr shims) now starts its child in its own process
group and forwards SIGINT/SIGTERM to that group, escalating to
SIGKILL after a 5s grace period if it doesn't exit.

Previously, interactive Ctrl-C happened to work by accident (the
child inherited the terminal's foreground process group and got the
signal directly), but anything that signals vintner by PID alone - a
CI job's timeout, a supervisor's `kill <pid>` - never reached the
wine/wineserver tree underneath it, which got reparented to init and
kept running: wasted CPU, held file locks, stray FIFOs/temp files.

Verified two ways: a unit test (signals_test.go) that starts a
detached `sleep 30`, signals the test process itself, and checks the
child actually dies; and a real end-to-end run - killed an in-flight
`msbuild` driver build by PID mid-compile and confirmed no orphaned
msbuild/cl/link/vintner process was left behind (wineserver and its
persistent service processes are expected to survive, by design - see
pipeDrainGrace's doc comment).
2026-07-25 10:34:45 +10:00
Cheviiot 4b3aacfdf7 fix(ci): let staticcheck install its own newer Go instead of reusing 1.23 2026-07-25 04:21:15 +10:00
Cheviiot 0b7b686e3a Add staticcheck to CI
Caught a real dead-code finding (an unused off() helper) during this
session's stability pass; running it on every push/PR catches this
class of issue automatically instead of relying on someone happening
to run it locally.
2026-07-25 04:19:34 +10:00
Cheviiot 0a4f05c673 Add regression test for deterministic dependency resolution order
Loops ExpandSelection 50 times and checks the result order never
changes, guarding against the map-iteration-order bug fixed in the
previous commit ever coming back unnoticed. Verified this actually
catches the regression by temporarily reverting the fix locally.
2026-07-25 04:17:57 +10:00
Cheviiot e0475101b2 Rewrite README with real install paths, TOC, and current command surface
Adds the two installation options that now actually exist (Nivora
package, prebuilt GitHub Release binary) alongside building from
source, a table of contents, CI/release/license badges, and a
Language section for VINTNER_LANG. Moves the more implementation-
focused toolrelay.exe/compatibility-patches explanations into
collapsible sections so the top of the page stays focused on using
the tool rather than how it's built.
2026-07-25 04:16:33 +10:00
Cheviiot d11b534fa1 Stability pass: deterministic dependency order, retry backoff, input validation
Found via manual audit plus a staticcheck run:

- collectDependencyClosure iterated a package's dependencies map
  directly, so which package "won" a same-key collision (and the
  order things got downloaded/unpacked in) could vary between runs
  of the exact same download command. Sort the dependency targets
  first, matching what --print-deps-tree's tree-printer already did.
  Verified two consecutive --print-deps-tree runs now produce
  byte-identical output.
- HTTP retry loops (manifest fetch, payload download) retried
  immediately with no backoff, which just hammers a server harder
  during exactly the kind of transient failure retries exist for.
  Added a capped exponential backoff (1s/2s/4s/8s/10s).
- --architecture/--host-arch accepted any string silently; a typo'd
  value matched nothing during package selection and surfaced as a
  confusing downstream failure far from the actual mistake. Now
  rejected up front with a clear error.
- pumpLines' bufio.Scanner silently stops (dropping the rest of a
  tool's output) if a single line ever exceeds its buffer - narrow but
  real for pathological cases like heavily templated C++ diagnostics.
  Now at least reports that truncation happened instead of losing
  output with no trace.
- Removed select.go's unused off() helper (staticcheck U1000).

Re-verified end-to-end after these changes: a real KMDF driver build
and a plain cl/link build both still succeed.
2026-07-25 04:14:34 +10:00
38 changed files with 2291 additions and 130 deletions
+10
View File
@@ -43,3 +43,13 @@ jobs:
- name: go test
run: go test ./...
- name: staticcheck
uses: dominikh/staticcheck-action@9716614d4101e79b4340dd97b10e54d68234e431 # v1.4.1
with:
version: latest
# Staticcheck itself needs a newer Go than the 1.23 this repo
# targets (go.mod's floor) - let the action install its own,
# separate from the "Set up Go" step above. min-go-version
# defaults to reading go.mod, so diagnostics still target 1.23.
install-go: true
+179 -99
View File
@@ -1,37 +1,94 @@
# vintner
Cross compile with MSVC on Linux, using Wine — a single-binary Go tool
inspired by [mstorsjo/msvc-wine](https://github.com/mstorsjo/msvc-wine)'s
approach (download the real MSVC/WinSDK, wrap the compiler under Wine),
implemented independently.
[![CI](https://github.com/Cheviiot/vintner/actions/workflows/ci.yml/badge.svg)](https://github.com/Cheviiot/vintner/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/Cheviiot/vintner)](https://github.com/Cheviiot/vintner/releases/latest)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
Once installed, you invoke the real Microsoft toolchain exactly like on
Windows: `cl`, `link`, `lib`, `rc`, `midl`, `mc`, `mt`, `dumpbin`, `msbuild`,
`nmake`, `ml`, `ml64`, `armasm`, `armasm64`, plus trivial `cmd`/`findstr`
shims, all just work from your `PATH`.
vintner cross-compiles with the real MSVC toolchain on Linux, using Wine.
One Go binary drops in as `cl`, `link`, `lib`, `rc`, `midl`, `mc`, `mt`,
`dumpbin`, `msbuild`, `nmake`, `ml`, `ml64`, `armasm`, `armasm64`, plus
`cmd`/`findstr` shims, so once installed you invoke the real Microsoft
tools exactly like on Windows. It handles full MSBuild projects, with
`--with-wdk` real KMDF/UMDF Windows drivers, and with `--with-dxsdk` the
real D3DX9 headers/libs.
Inspired by [mstorsjo/msvc-wine](https://github.com/mstorsjo/msvc-wine)'s
approach: download the real MSVC/WinSDK, wrap the compiler under Wine.
## Contents
- [How it works](#how-it-works)
- [Installation](#installation)
- [Quick start](#quick-start)
- [Commands](#commands)
- [Building drivers (WDK)](#building-drivers-wdk)
- [Building against D3DX9 (DirectX SDK)](#building-against-d3dx9-directx-sdk)
- [Language](#language)
- [Shell completion](#shell-completion)
- [Using clang-cl/lld-link instead of Wine](#using-clang-cllld-link-instead-of-wine)
- [toolrelay.exe](#toolrelayexe)
- [Compatibility patches](#compatibility-patches)
- [Building from source](#building-from-source)
- [License](#license)
## How it works
`vintner` is one Go binary that behaves differently depending on the name
it's invoked as (a "multi-call binary", like busybox):
vintner is a multi-call binary, like busybox: it behaves differently
depending on the name it's invoked as.
- Invoked as `cl`, `link`, `lib`, ... → it loads a small per-architecture
`env.json`, builds the `INCLUDE`/`LIB`/`WINEPATH` environment Wine needs,
rewrites absolute unix paths in the arguments into Wine's `z:\...` form
(working around [a Wine/cl.exe include-path bug](https://bugs.winehq.org/show_bug.cgi?id=55200)),
runs the real `.exe` under `wine`/`wine64`, and rewrites the tool's output
back from `z:\...` paths to plain unix paths so your build system's error
- As `cl`, `link`, `lib`, and the rest: it loads a per-architecture
`env.json`, sets `INCLUDE`/`LIB`/`WINEPATH`, and rewrites absolute Unix
paths in the arguments to Wine's `z:\...` form (Wine and cl.exe
otherwise mishandle relative includes — see
[winehq bug 55200](https://bugs.winehq.org/show_bug.cgi?id=55200)). It
then runs the real `.exe` under `wine`/`wine64`, and rewrites `z:\...`
paths back to Unix paths in the output, so your build system's error
parsing keeps working.
- Invoked as `vintner` it exposes the `download`, `install`, `env` and
`version` management subcommands described below (each also has a short
alias: `dl`, `i`, `e`, `v`; `help`/`h` prints usage).
- As `vintner`: it exposes the `download`, `install`, `env`, `version`
and `completion` subcommands below (short aliases: `dl`, `i`, `e`, `v`;
`help`/`h` prints usage).
## Installation
On ALT Linux, via [Nivora](https://github.com/Cheviiot/Nivora):
```bash
stplr install nivora/vintner
```
Prebuilt binary, from the [latest release](https://github.com/Cheviiot/vintner/releases/latest):
```bash
curl -fLo vintner "https://github.com/Cheviiot/vintner/releases/latest/download/vintner-linux-$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')"
chmod +x vintner
sudo install vintner /usr/local/bin/vintner
```
From source: see [Building from source](#building-from-source).
Either way, `wine`/`wine64`, `msitools` (for `msiextract`) and `git` need
to be on `PATH` at run time. Nivora installs pull these in automatically
as package dependencies.
### Prerequisites
- `wine` (or `wine64`) — runs the real `cl.exe`/`link.exe`/etc.
- `msitools` (`msiextract`) — unpacks the `.msi` payloads MSVC/WinSDK ship as.
- `git` — applies the compatibility patches bundled with `download` (see
[Compatibility patches](#compatibility-patches)).
On ALT Linux:
```bash
pkcon install wine msitools git
```
## Quick start
```bash
# 1. Download and unpack MSVC + Windows SDK into ~/.vintner (requires
# accepting Microsoft's Visual Studio Build Tools license, and msitools
# for unpacking .msi payloads). Pass --dest <dir> for a different location.
# 1. Download and unpack MSVC + Windows SDK into ~/.vintner (accepts
# Microsoft's Visual Studio Build Tools license). Pass --dest <dir>
# for a different location.
vintner download --accept-license
# 2. Wire up the tool wrappers
@@ -42,19 +99,6 @@ export PATH=~/.vintner/bin/x64:$PATH
cl /nologo /EHsc hello.cpp
```
### Prerequisites
- `wine` (or `wine64`) — runs the real `cl.exe`/`link.exe`/etc.
- `msitools` (`msiextract`) — unpacks the `.msi` payloads MSVC/WinSDK ship as.
- `git` — used to apply the small compatibility patches bundled with
`download` (see Compatibility patches below).
On ALT Linux:
```bash
pkcon install wine msitools
```
## Commands
```
@@ -63,48 +107,89 @@ vintner install (i) [dir] wire up wrappe
vintner env (e) --bin <dir>/bin/<arch> print INCLUDE/LIB for native clang-cl/lld-link use
vintner version (v) print the version
vintner help (h) print usage
vintner completion bash|zsh print a shell completion script
```
`--dest`/`[dir]` both default to `~/.vintner` when omitted.
`download`'s main options: `--msvc-version`, `--sdk-version`,
`--architecture`, `--host-arch`, `--only-host`, `--with-wdk` (also fetch the
Windows Driver Kit, for building KMDF/UMDF drivers), `--ignore`,
`--only-download`, `--only-unpack`, `--keep-unpack`, `--skip-patch`,
`--cache`, `--language`, `--include-optional`, `--skip-recommended`,
`--major`, `--preview`, `--manifest`, `--list-workloads`,
`--list-components`, `--print-deps-tree`. Run `vintner download -h` for the
full list with descriptions.
`--architecture` (repeatable: `x86`/`x64`/`arm`/`arm64`/`host`),
`--host-arch`, `--only-host`, `--with-wdk` (see below), `--with-dxsdk`
(see below), `--ignore` (repeatable), `--only-download`, `--only-unpack`,
`--keep-unpack`, `--skip-patch`, `--cache`, `--language`,
`--include-optional`, `--skip-recommended`, `--major`, `--preview`,
`--manifest`, `--list-workloads`, `--list-components`,
`--print-deps-tree`. Run `vintner download -h` for the full list with
descriptions.
`--list-workloads`/`--list-components` print every workload/component id
(with its human-readable title) available in the fetched manifest and exit
without downloading anything - useful for discovering what to pass as a bare
package id or via `--with-*`. `--print-deps-tree` prints the dependency tree
of whatever would actually be selected (honoring every other flag), also
without downloading.
and its human-readable title from the fetched manifest, then exit
without downloading anything. Useful for finding what to pass as a bare
package id or through `--with-*`. `--print-deps-tree` prints the
dependency tree of whatever would actually be selected honoring every
other flag — without downloading anything.
### Building drivers (WDK)
## Building drivers (WDK)
`vintner download --with-wdk` additionally fetches the Windows Driver Kit
(headers, import libs, and the MSBuild `WindowsKernelModeDriver10.0`/
`WindowsUserModeDriver10.0` PlatformToolsets) so `msbuild` can build real
KMDF/UMDF drivers - compiling, linking, INF stamping and the `Inf2Cat`
signability check (with `SignMode=off`) all work under Wine. Verified
end-to-end against a real sample driver from
`--with-wdk` also fetches the Windows Driver Kit: headers, import libs,
and the MSBuild `WindowsKernelModeDriver10.0`/`WindowsUserModeDriver10.0`
PlatformToolsets.
```bash
vintner download --accept-license --with-wdk
```
With it, `msbuild` builds real KMDF/UMDF drivers — compiling, linking,
INF stamping, and the `Inf2Cat` signability check (`SignMode=off`) all
work under Wine. Tested against a real sample driver from
[microsoft/Windows-driver-samples](https://github.com/microsoft/Windows-driver-samples).
Only x64 and arm64 targets have a WDK package upstream (no x86/arm).
Only x64 and arm64 targets have a WDK package upstream; there's no x86 or
arm one.
### Language
## Building against D3DX9 (DirectX SDK)
CLI messages (usage text, progress lines, prompts) are in English by
default. Set `VINTNER_LANG=ru` (or have a `ru`-prefixed `LC_ALL`/
`LC_MESSAGES`/`LANG`, e.g. `ru_RU.UTF-8`) for Russian. Deeper error text
bubbled up from internal packages stays in English.
`--with-dxsdk` fetches the DirectX SDK (June 2010) — the last standalone
release of D3DX9/10/11, XInput and XAudio2, dropped from the Windows SDK
entirely once D3DX was deprecated. It unpacks the real headers and x86/x64
import libs (`d3dx9.h`/`d3dx9.lib` included) to `<dest>/DXSDK`.
### Using clang-cl/lld-link instead of Wine
```bash
vintner download --accept-license --with-dxsdk
```
You don't need Wine at all if you drive the (nonredistributable) MSVC/WinSDK
headers and libraries with Clang/LLD in MSVC-compatible mode:
Point your project's `IncludePath`/`LibraryPath` at
`<dest>/DXSDK/Include` and `<dest>/DXSDK/Lib/x86` or `<dest>/DXSDK/Lib/x64`.
Requires `cabextract` on `PATH` (the installer is a self-extracting CAB
archive).
## Language
CLI text (usage, progress lines, prompts) defaults to English. Set
`VINTNER_LANG=ru` (or a `ru`-prefixed `LC_ALL`/`LC_MESSAGES`/`LANG`, e.g.
`ru_RU.UTF-8`) for Russian:
```bash
VINTNER_LANG=ru vintner help
```
Error text from internal packages stays in English regardless.
## Shell completion
Already set up if you installed via Nivora. Otherwise:
```bash
source <(vintner completion bash) # or add to ~/.bashrc
source <(vintner completion zsh) # or add to ~/.zshrc
```
Completes subcommands, including the short aliases, `download`'s flags,
and directory arguments for `install`/`env --bin`.
## Using clang-cl/lld-link instead of Wine
The MSVC/WinSDK headers and libraries work directly with Clang/LLD in
MSVC-compatible mode. No Wine needed:
```bash
eval "$(vintner env --bin ~/.vintner/bin/x64)"
@@ -112,49 +197,44 @@ clang-cl -c hello.c
lld-link hello.obj -out:hello.exe
```
## toolrelay.exe
`install` compiles `assets/vendor/toolrelay.cpp`, a small native Windows
launcher, with the freshly-installed host-arch `cl.exe`. This is
best-effort: if `wine` isn't available yet, or the compile fails, install
still succeeds, and tool invocations just skip it. When present, every
non-MSBuild tool call is routed through it via two named FIFOs.
That's what lets `mt.exe`'s CMake-compatibility exit code
(`0x41020001``0xbb`) survive Wine's own exit-code truncation: a native
Windows process can read the real 32-bit exit code via
`GetExitCodeProcess()` before Wine collapses it to a single byte on the
way back to Unix.
## Compatibility patches
`download` applies a few small patches (`assets/patches`) to the
downloaded MSVC/WinSDK tree, so `VsDevCmd.bat` and MSBuild's
SDK-detection props work without a Windows Registry, which doesn't exist
under Wine. They look up the SDK directly under the VS install root
instead of querying the registry, skip telemetry, and don't fail devcmd
setup when an optional component (ConnectionManagerExe, bundled
CMake/Ninja) is missing.
## Building from source
```bash
go build -o vintner ./cmd/vintner
go vet ./...
go test ./...
```
Go 1.23+ is all you need to build it; `wine`/`msitools` are only needed at
run time (`install`/tool invocation and `download` respectively).
## toolrelay.exe
`install` compiles `assets/vendor/toolrelay.cpp` (a small native Windows
launcher, original to this project) with the freshly-installed host-arch
`cl.exe` (best-effort: if `wine` isn't present yet, or the compile fails,
install still succeeds and the wrapper runtime just falls back to invoking
tools directly through wine). When present, every non-MSBuild tool
invocation is routed through it via two named FIFOs. This is what lets
`mt.exe`'s CMake-compatibility exit-code translation (`0x41020001``0xbb`)
survive Wine's own exit-code truncation: only a native Windows process
observing the untranslated code via `GetExitCodeProcess()` can catch it
before Wine marshals the process exit back to Unix and drops everything but
the low byte.
## Compatibility patches
`download` applies a handful of small patches (`assets/patches`) to the
downloaded MSVC/WinSDK tree - independently written for this project - that
make `VsDevCmd.bat` and MSBuild's SDK-detection props work without a
Windows Registry (which doesn't exist under Wine): they check the SDK
directly under the VS install root instead of querying the registry, skip
telemetry, and don't hard-fail devcmd setup when an optional component
(ConnectionManagerExe, bundled CMake/Ninja) wasn't downloaded.
## Known gaps
None currently tracked. Download/select/unpack/install, general MSBuild
projects, WDK driver builds, dependency-tree printing, and
workload/component listing are all implemented and verified against real
projects.
Go 1.23+ builds it. `wine`/`msitools` are only needed at run time, for
`install`/tool invocation and `download` respectively.
## License
MIT, see [LICENSE.txt](LICENSE.txt) - covers vintner's own source only. The
MSVC Build Tools / Windows SDK / WDK that `download` fetches remain governed
by Microsoft's own license (accepted via `--accept-license`), same as with
MIT (see [LICENSE.txt](LICENSE.txt)) for vintner's own source. The MSVC
Build Tools, Windows SDK, and WDK that `download` fetches stay under
Microsoft's own license (accepted via `--accept-license`), same as with
any other way of obtaining them.
+10 -2
View File
@@ -79,8 +79,16 @@ HANDLE MakeKillOnCloseJob() {
}
bool IsMtExe(const wchar_t *path) {
const wchar_t *name = wcsrchr(path, L'\\');
name = name ? name + 1 : path;
// vintner passes toolExePath straight through from Go's filepath.Join,
// which uses forward slashes even for a path that's about to be handed
// to a native Windows process - so the separator here isn't reliably
// '\\'. Check both; using whichever comes later in the string covers a
// mixed-separator path too.
const wchar_t *back = wcsrchr(path, L'\\');
const wchar_t *fwd = wcsrchr(path, L'/');
const wchar_t *sep = back;
if (fwd && (!sep || fwd > sep)) sep = fwd;
const wchar_t *name = sep ? sep + 1 : path;
return _wcsicmp(name, L"mt.exe") == 0;
}
+140
View File
@@ -0,0 +1,140 @@
package main
import "fmt"
// runCompletion prints a shell completion script for shell ("bash" or
// "zsh") to stdout, meant to be sourced directly:
//
// source <(vintner completion bash) # or add to ~/.bashrc
// source <(vintner completion zsh) # or add to ~/.zshrc
//
// The flag lists below are hand-maintained alongside download.go/env.go's
// flag.FlagSet definitions rather than generated from them - there's no
// reflection-friendly registry to walk, and the flag set rarely changes.
func runCompletion(args []string) int {
if len(args) != 1 {
fmt.Println("usage: vintner completion bash|zsh")
return 1
}
switch args[0] {
case "bash":
fmt.Print(bashCompletionScript)
return 0
case "zsh":
fmt.Print(zshCompletionScript)
return 0
default:
fmt.Printf("vintner completion: unsupported shell %q (want bash or zsh)\n", args[0])
return 1
}
}
const downloadFlags = "--dest --cache --major --preview --manifest --accept-license " +
"--msvc-version --sdk-version --host-arch --only-host --language " +
"--include-optional --skip-recommended --only-download --only-unpack " +
"--keep-unpack --skip-patch --list-workloads --list-components " +
"--print-deps-tree --with-wdk --architecture --ignore -h --help"
var bashCompletionScript = `# vintner bash completion - eval "$(vintner completion bash)"
_vintner_complete() {
local cur cmd
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
cmd="${COMP_WORDS[1]}"
if [ "$COMP_CWORD" -eq 1 ]; then
COMPREPLY=($(compgen -W "download dl install i env e version v help h completion" -- "$cur"))
return 0
fi
case "$cmd" in
download|dl)
COMPREPLY=($(compgen -W "` + downloadFlags + `" -- "$cur"))
;;
install|i)
COMPREPLY=($(compgen -d -- "$cur"))
;;
env|e)
COMPREPLY=($(compgen -W "--bin -h --help" -- "$cur"))
;;
completion)
COMPREPLY=($(compgen -W "bash zsh" -- "$cur"))
;;
esac
return 0
}
complete -F _vintner_complete vintner
`
var zshCompletionScript = `#compdef vintner
# vintner zsh completion - source <(vintner completion zsh)
_vintner() {
local -a subcommands
subcommands=(
'download:fetch and unpack MSVC/WinSDK/WDK'
'dl:alias for download'
'install:wire up wrappers for a downloaded MSVC'
'i:alias for install'
'env:print INCLUDE/LIB for native clang-cl/lld-link use'
'e:alias for env'
'version:print the version'
'v:alias for version'
'help:print usage'
'h:alias for help'
'completion:print a shell completion script'
)
if (( CURRENT == 2 )); then
_describe 'command' subcommands
return
fi
case "${words[2]}" in
download|dl)
local -a flags
flags=(
'--dest[directory to install into]:directory:_files -/'
'--cache[persistent download cache directory]:directory:_files -/'
'--major[major VS version]:version:'
'--preview[use the preview/insiders channel]'
'--manifest[use a predownloaded installer manifest file]:file:_files'
'--accept-license[do not prompt for accepting the license]'
'--msvc-version[install a specific MSVC toolchain version]:version:'
'--sdk-version[install a specific Windows SDK version]:version:'
'--host-arch[host architecture]:arch:(x86 x64 arm64)'
'--only-host[only download packages matching the host architecture]'
'--language[preferred package language]:language:'
'--include-optional[include all optional dependencies]'
'--skip-recommended[skip recommended dependencies]'
'--only-download[stop after downloading package files]'
'--only-unpack[unpack without pruning to just the CLI tools]'
'--keep-unpack[keep the scratch unpack dir]'
'--skip-patch[do not apply the Wine compatibility patches]'
'--list-workloads[list available workloads and exit]'
'--list-components[list available components and exit]'
'--print-deps-tree[print the dependency tree and exit]'
'--with-wdk[also fetch the Windows Driver Kit]'
'--architecture[target architecture]:arch:(x86 x64 arm arm64 host)'
'--ignore[package id to skip]:package id:'
'-h[show help]'
'--help[show help]'
)
_arguments $flags
;;
install|i)
_files -/
;;
env|e)
_arguments \
'--bin[bin/<arch> directory produced by install]:directory:_files -/' \
'-h[show help]' '--help[show help]'
;;
completion)
_values 'shell' bash zsh
;;
esac
}
_vintner "$@"
`
+44
View File
@@ -0,0 +1,44 @@
package main
import (
"os/exec"
"strings"
"testing"
)
// TestCompletionScriptsAreSyntacticallyValid catches the easy way to break
// these: a typo in the hand-maintained flag lists that produces invalid
// shell syntax. It shells out to bash/zsh -n rather than parsing the script
// itself, so it's testing exactly what a user's shell would see.
func TestCompletionScriptsAreSyntacticallyValid(t *testing.T) {
for _, tc := range []struct {
shell string
script string
}{
{"bash", bashCompletionScript},
{"zsh", zshCompletionScript},
} {
t.Run(tc.shell, func(t *testing.T) {
if _, err := exec.LookPath(tc.shell); err != nil {
t.Skipf("%s not installed", tc.shell)
}
cmd := exec.Command(tc.shell, "-n", "/dev/stdin")
cmd.Stdin = strings.NewReader(tc.script)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%s -n rejected the completion script: %v\n%s", tc.shell, err, out)
}
})
}
}
func TestRunCompletionUnknownShell(t *testing.T) {
if code := runCompletion([]string{"fish"}); code != 1 {
t.Errorf("runCompletion([\"fish\"]) = %d, want 1", code)
}
if code := runCompletion(nil); code != 1 {
t.Errorf("runCompletion(nil) = %d, want 1", code)
}
if code := runCompletion([]string{"bash", "extra"}); code != 1 {
t.Errorf("runCompletion with extra arg = %d, want 1", code)
}
}
+34
View File
@@ -35,6 +35,7 @@ func runDownload(args []string) int {
listComponents := fs.Bool("list-components", false, "list available components from the manifest and exit, without downloading anything")
printDepsTree := fs.Bool("print-deps-tree", false, "print the dependency tree of the selected packages and exit, without downloading anything")
withWDK := fs.Bool("with-wdk", false, "also fetch and install the Windows Driver Kit (headers, libs and MSBuild driver PlatformToolsets, for building KMDF/UMDF drivers)")
withDXSDK := fs.Bool("with-dxsdk", false, "also fetch and install the DirectX SDK (June 2010): real D3DX9/10/11, XInput and XAudio2 headers and import libs, dropped from the modern Windows SDK")
var archsFlag stringList
fs.Var(&archsFlag, "architecture", "target architecture to include (x86, x64, arm, arm64, host); repeatable")
var ignoreFlag stringList
@@ -45,6 +46,17 @@ func runDownload(args []string) int {
}
packages := fs.Args()
for _, a := range archsFlag {
if !validArchitectures[a] {
fmt.Fprintf(os.Stderr, "vintner download: invalid --architecture %q (expected one of x86, x64, arm, arm64, host)\n", a)
return 2
}
}
if *hostArch != "" && !validHostArchs[*hostArch] {
fmt.Fprintf(os.Stderr, "vintner download: invalid --host-arch %q (expected one of x86, x64, arm64)\n", *hostArch)
return 2
}
opts := &download.Options{
Package: packages,
Ignore: []string(ignoreFlag),
@@ -208,6 +220,13 @@ func runDownload(args []string) int {
}
}
if *withDXSDK && !*onlyUnpack {
if err := downloadDXSDK(cache, destAbs); err != nil {
fmt.Fprintln(os.Stderr, "vintner download:", err)
return 1
}
}
fmt.Println(i18n.T("download.done", destAbs))
return 0
}
@@ -244,6 +263,18 @@ func downloadWDK(opts *download.Options, selected []*download.Package, cache, de
return nil
}
// downloadDXSDK fetches and unpacks the DirectX SDK (June 2010) into
// destAbs/DXSDK. See internal/download/dxsdk.go for why this is a separate
// download path from the rest of ExpandSelection/FetchPayloads/Unpack.
func downloadDXSDK(cache, destAbs string) error {
dxsdkDir, err := download.DownloadDXSDK(cache, destAbs)
if err != nil {
return err
}
fmt.Print(i18n.T("download.dxsdk_installed", dxsdkDir))
return nil
}
func contains(list []string, v string) bool {
for _, s := range list {
if s == v {
@@ -267,6 +298,9 @@ func printPackageList(headerKey string, pkgs []*download.Package, language strin
}
}
var validArchitectures = map[string]bool{"x86": true, "x64": true, "arm": true, "arm64": true, "host": true}
var validHostArchs = map[string]bool{"x86": true, "x64": true, "arm64": true}
func detectHostArch() string {
if runtime.GOARCH == "arm64" {
return "arm64"
+40
View File
@@ -0,0 +1,40 @@
package main
import "testing"
// TestRunDownloadRejectsInvalidArchFlags exercises the validation added
// after the flags are parsed, which must reject typos before runDownload
// gets anywhere near the network (FetchChannelManifest) - these tests would
// hang/fail on network access if that ordering ever regressed.
func TestRunDownloadRejectsInvalidArchFlags(t *testing.T) {
for _, tc := range []struct {
name string
args []string
}{
{"bad architecture", []string{"--architecture", "x866"}},
{"bad architecture, valid mixed with invalid", []string{"--architecture", "x64", "--architecture", "sparc"}},
{"bad host-arch", []string{"--host-arch", "sparc"}},
} {
t.Run(tc.name, func(t *testing.T) {
if code := runDownload(tc.args); code != 2 {
t.Errorf("runDownload(%v) = %d, want 2", tc.args, code)
}
})
}
}
func TestValidArchitectureSets(t *testing.T) {
for _, a := range []string{"x86", "x64", "arm", "arm64", "host"} {
if !validArchitectures[a] {
t.Errorf("validArchitectures[%q] = false, want true", a)
}
}
for _, a := range []string{"x86", "x64", "arm64"} {
if !validHostArchs[a] {
t.Errorf("validHostArchs[%q] = false, want true", a)
}
}
if validHostArchs["arm"] {
t.Error(`validHostArchs["arm"] = true, want false (no 32-bit ARM host toolchain exists)`)
}
}
+23
View File
@@ -0,0 +1,23 @@
package main
import "testing"
func TestRunEnvRequiresBin(t *testing.T) {
if code := runEnv(nil); code != 1 {
t.Errorf("runEnv(nil) = %d, want 1", code)
}
}
func TestRunEnvRejectsMissingBinDir(t *testing.T) {
if code := runEnv([]string{"--bin", "/nonexistent/path/for/vintner/tests"}); code != 1 {
t.Errorf("runEnv with a nonexistent --bin = %d, want 1", code)
}
}
func TestToUnixPathList(t *testing.T) {
got := toUnixPathList(`z:\vc\include;z:\kits\10\include`)
want := "/vc/include;/kits/10/include"
if got != want {
t.Errorf("toUnixPathList(...) = %q, want %q", got, want)
}
}
+17
View File
@@ -0,0 +1,17 @@
package main
import "testing"
func TestRunInstallRejectsExtraArgs(t *testing.T) {
if code := runInstall([]string{"one", "two"}); code != 1 {
t.Errorf("runInstall with two args = %d, want 1", code)
}
}
func TestRunInstallHelp(t *testing.T) {
for _, flag := range []string{"-h", "--help"} {
if code := runInstall([]string{flag}); code != 1 {
t.Errorf("runInstall([%q]) = %d, want 1", flag, code)
}
}
}
+3 -1
View File
@@ -47,8 +47,10 @@ func runCLI(args []string) int {
return runInstall(args[1:])
case "env", "e":
return runEnv(args[1:])
case "completion":
return runCompletion(args[1:])
case "version", "v", "--version":
fmt.Println("vintner " + version)
fmt.Println(versionString())
return 0
case "-h", "--help", "help", "h":
printUsage()
+30
View File
@@ -0,0 +1,30 @@
package main
import "testing"
// TestRunCLIDispatch covers the subset of runCLI's switch that has no side
// effects (no filesystem/network touched) - the actual subcommand bodies
// (download/install/env) get their own focused tests.
func TestRunCLIDispatch(t *testing.T) {
for _, tc := range []struct {
name string
args []string
want int
}{
{"no args prints usage", nil, 1},
{"unknown subcommand", []string{"frobnicate"}, 1},
{"help long", []string{"--help"}, 0},
{"help short flag", []string{"-h"}, 0},
{"help word", []string{"help"}, 0},
{"help alias", []string{"h"}, 0},
{"version word", []string{"version"}, 0},
{"version alias", []string{"v"}, 0},
{"version flag", []string{"--version"}, 0},
} {
t.Run(tc.name, func(t *testing.T) {
if got := runCLI(tc.args); got != tc.want {
t.Errorf("runCLI(%v) = %d, want %d", tc.args, got, tc.want)
}
})
}
}
+22
View File
@@ -0,0 +1,22 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestDefaultToolchainDir(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skipf("no home directory available: %v", err)
}
got, err := defaultToolchainDir()
if err != nil {
t.Fatalf("defaultToolchainDir() error: %v", err)
}
want := filepath.Join(home, ".vintner")
if got != want {
t.Errorf("defaultToolchainDir() = %q, want %q", got, want)
}
}
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"fmt"
"runtime/debug"
)
// versionString renders "vintner <version>" plus, when available, the git
// commit and build time Go's toolchain embeds automatically (since Go
// 1.18, `go build` stamps vcs.revision/vcs.time/vcs.modified into the
// binary on its own - no -ldflags needed for this part, so it works the
// same whether the binary came from CI or a plain local `go build`).
// Knowing the exact commit a reported bug was built from, not just the
// X.Y.Z tag, is the point: two builds of the same tag could still differ
// if the tag was ever moved, or if someone built from an uncommitted tree.
//
// Note for anyone testing this: `go build` stamps vcs.* build settings,
// but `go test` binaries don't get them - there's no environment where a
// `go test` run can exercise the revision-formatting branch below, hence
// formatVersion is split out and tested directly instead.
func versionString() string {
revision, buildTime, dirty := "", "", false
if info, ok := debug.ReadBuildInfo(); ok {
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision":
revision = s.Value
case "vcs.time":
buildTime = s.Value
case "vcs.modified":
dirty = s.Value == "true"
}
}
}
return formatVersion(version, revision, buildTime, dirty)
}
// formatVersion is the pure part of versionString: given a revision (full
// git SHA, may be empty), it's shortened to 12 chars and marked "-dirty" if
// the build tree had uncommitted changes.
func formatVersion(ver, revision, buildTime string, dirty bool) string {
v := "vintner " + ver
if revision == "" {
return v
}
if len(revision) > 12 {
revision = revision[:12]
}
if dirty {
revision += "-dirty"
}
if buildTime != "" {
return fmt.Sprintf("%s (%s, %s)", v, revision, buildTime)
}
return fmt.Sprintf("%s (%s)", v, revision)
}
+46
View File
@@ -0,0 +1,46 @@
package main
import "testing"
func TestFormatVersion(t *testing.T) {
for _, tc := range []struct {
name string
ver, revision, buildTime string
dirty bool
want string
}{
{
name: "no VCS info at all",
ver: "dev",
want: "vintner dev",
},
{
name: "clean build with full info",
ver: "0.3.0",
revision: "6186837fd23616335ba8aff830801692a756799c",
buildTime: "2026-07-25T01:33:41Z",
want: "vintner 0.3.0 (6186837fd236, 2026-07-25T01:33:41Z)",
},
{
name: "dirty tree",
ver: "0.3.0",
revision: "6186837fd23616335ba8aff830801692a756799c",
dirty: true,
want: "vintner 0.3.0 (6186837fd236-dirty)",
},
{
name: "short revision left untouched",
ver: "dev",
revision: "abc123",
want: "vintner dev (abc123)",
},
} {
t.Run(tc.name, func(t *testing.T) {
got := formatVersion(tc.ver, tc.revision, tc.buildTime, tc.dirty)
if got != tc.want {
t.Errorf("formatVersion(%q, %q, %q, %v) = %q, want %q",
tc.ver, tc.revision, tc.buildTime, tc.dirty, got, tc.want)
}
})
}
}
+94
View File
@@ -0,0 +1,94 @@
package download
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
// The DirectX SDK (June 2010) is the last standalone release of D3DX9 (and
// D3DX10/11, XInput, XAudio2, ...). Microsoft never carried D3DX forward
// into the Windows 10/11 SDK - it's deprecated in favor of D3DCompiler/
// WICTextureLoader/DirectXTex, but plenty of legacy code (this included)
// still links against the real d3dx9.h/d3dx9.lib. Like the WDK (see
// wdk.go), it isn't part of the VS installer manifest or any package feed
// vsman knows about, so this is a separate, self-contained download path
// outside ExpandSelection/FetchPayloads/UnpackSelectedPackages.
//
// The installer is a self-extracting PE with an appended CAB archive.
// cabextract (already a vintner prerequisite - see runMsiExtract's sibling
// extractWindowsSDKPackage) unpacks it directly, without needing Wine or a
// separate archive tool. Its -F/--filter flag (repeatable) restricts
// extraction to the Include and Lib subtrees actually needed for building -
// about 21MB out of the installer's 1.2GB uncompressed payload.
const dxsdkURL = "https://download.microsoft.com/download/A/E/7/AE743F1F-632B-4809-87A9-AA1BB3458E31/DXSDK_Jun10.exe"
// dxsdkSHA256 pins the exact installer build this code was written against
// (verified by fully extracting it with both cabextract and 7z and cross
// checking the file lists) - the June 2010 DirectX SDK is a frozen legacy
// artifact Microsoft is not going to rebuild. A var, not a const, so tests
// can point it at a small fake payload instead of the real 600MB installer.
var dxsdkSHA256 = "705271dc83bfee54d9b94e028426e288d5f070784b7446d164f48ecfbb2a02cb"
// DownloadDXSDK fetches (or reuses a cached copy of) the DirectX SDK (June
// 2010) installer into cacheDir, then unpacks its Include and Lib trees -
// headers and x86/x64 import libs for D3DX9/10/11, XInput, XAudio2, and the
// rest - into destDir/DXSDK. Returns that directory.
func DownloadDXSDK(cacheDir, destDir string) (string, error) {
if _, err := exec.LookPath("cabextract"); err != nil {
return "", fmt.Errorf("cabextract not found in PATH (install the cabextract package): %w", err)
}
cacheFile := filepath.Join(cacheDir, "DXSDK_Jun10.exe")
if !isFile(cacheFile) {
fmt.Println("Downloading DirectX SDK (June 2010)")
if err := httpDownloadFile(dxsdkURL, cacheFile); err != nil {
return "", fmt.Errorf("downloading DirectX SDK: %w", err)
}
} else {
fmt.Println("Using existing file", filepath.Base(cacheFile))
}
sum, err := sha256File(cacheFile)
if err != nil {
return "", err
}
if !equalFoldHex(sum, dxsdkSHA256) {
return "", fmt.Errorf("incorrect hash for downloaded file %s, aborting", filepath.Base(cacheFile))
}
scratch, err := os.MkdirTemp(destDir, "dxsdk-unpack-")
if err != nil {
return "", err
}
defer os.RemoveAll(scratch)
if err := runCabextract(cacheFile, scratch, "DXSDK/Include/*", "DXSDK/Lib/*"); err != nil {
return "", fmt.Errorf("extracting DirectX SDK: %w", err)
}
dxsdkDir := filepath.Join(destDir, "DXSDK")
if err := combineDirTrees(filepath.Join(scratch, "DXSDK"), dxsdkDir); err != nil {
return "", fmt.Errorf("moving DirectX SDK content into place: %w", err)
}
return dxsdkDir, nil
}
// runCabextract extracts srcFile into destDir, restricted to entries
// matching any of patterns (cabextract's -F, repeatable, glob-matched
// against the full in-archive path).
func runCabextract(srcFile, destDir string, patterns ...string) error {
if err := os.MkdirAll(destDir, 0o755); err != nil {
return err
}
args := []string{"-q", "-d", destDir}
for _, p := range patterns {
args = append(args, "-F", p)
}
args = append(args, srcFile)
cmd := exec.Command("cabextract", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
+111
View File
@@ -0,0 +1,111 @@
package download
import (
"os"
"path/filepath"
"runtime"
"testing"
)
// withFakeCabextract prepends a directory containing a fake "cabextract"
// script to PATH, so tests can exercise runCabextract/DownloadDXSDK without
// the real tool (or a real DXSDK installer) present. The fake script
// records the arguments it was invoked with to argsFile and creates an
// empty DXSDK/Include and DXSDK/Lib under whatever -d directory it was
// given, mimicking a successful (if empty) extraction.
func withFakeCabextract(t *testing.T, argsFile string) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("fake cabextract script requires a POSIX shell")
}
bin := t.TempDir()
script := `#!/bin/sh
echo "$@" > "` + argsFile + `"
dest=""
prev=""
for a in "$@"; do
if [ "$prev" = "-d" ]; then
dest="$a"
fi
prev="$a"
done
mkdir -p "$dest/DXSDK/Include" "$dest/DXSDK/Lib"
`
path := filepath.Join(bin, "cabextract")
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+":"+os.Getenv("PATH"))
}
func TestRunCabextractPassesFilterArgs(t *testing.T) {
dest := t.TempDir()
argsFile := filepath.Join(t.TempDir(), "args")
withFakeCabextract(t, argsFile)
src := filepath.Join(t.TempDir(), "installer.exe")
writeFile(t, src, "fake-installer-bytes")
if err := runCabextract(src, dest, "DXSDK/Include/*", "DXSDK/Lib/*"); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("expected the fake cabextract to have run: %v", err)
}
want := "-q -d " + dest + " -F DXSDK/Include/* -F DXSDK/Lib/* " + src + "\n"
if string(got) != want {
t.Errorf("cabextract args = %q, want %q", got, want)
}
}
func TestDownloadDXSDKReusesCachedFileAndRejectsHashMismatch(t *testing.T) {
argsFile := filepath.Join(t.TempDir(), "args")
withFakeCabextract(t, argsFile)
cacheDir := t.TempDir()
destDir := t.TempDir()
writeFile(t, filepath.Join(cacheDir, "DXSDK_Jun10.exe"), "not the real installer")
_, err := DownloadDXSDK(cacheDir, destDir)
if err == nil {
t.Fatal("expected an error for a cached file that doesn't match dxsdkSHA256")
}
if _, statErr := os.Stat(argsFile); statErr == nil {
t.Error("cabextract should not have run before the hash was verified")
}
}
func TestDownloadDXSDKMovesExtractedContentIntoPlace(t *testing.T) {
argsFile := filepath.Join(t.TempDir(), "args")
withFakeCabextract(t, argsFile)
cacheDir := t.TempDir()
destDir := t.TempDir()
cacheFile := filepath.Join(cacheDir, "DXSDK_Jun10.exe")
writeFile(t, cacheFile, "fake-installer-bytes-for-hash-test")
// Patch the expected hash to match our fake cached file, since we can't
// (and shouldn't) fetch or embed the real 600MB installer in a test.
sum, err := sha256File(cacheFile)
if err != nil {
t.Fatal(err)
}
restore := dxsdkSHA256
dxsdkSHA256 = sum
defer func() { dxsdkSHA256 = restore }()
dxsdkDir, err := DownloadDXSDK(cacheDir, destDir)
if err != nil {
t.Fatal(err)
}
if dxsdkDir != filepath.Join(destDir, "DXSDK") {
t.Errorf("DownloadDXSDK returned %q, want %q", dxsdkDir, filepath.Join(destDir, "DXSDK"))
}
for _, sub := range []string{"Include", "Lib"} {
if !isDir(filepath.Join(dxsdkDir, sub)) {
t.Errorf("expected %s/%s to exist after extraction", dxsdkDir, sub)
}
}
}
+152
View File
@@ -0,0 +1,152 @@
package download
import (
"os"
"path/filepath"
"testing"
)
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestCombineDirTreesNonexistentSrcIsNoop(t *testing.T) {
dest := t.TempDir()
if err := combineDirTrees(filepath.Join(t.TempDir(), "does-not-exist"), dest); err != nil {
t.Fatalf("combineDirTrees with a nonexistent src returned an error: %v", err)
}
}
func TestCombineDirTreesRenamesWholesaleWhenDestMissing(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "src")
dest := filepath.Join(root, "nested", "dest")
writeFile(t, filepath.Join(src, "file.txt"), "hello")
if err := combineDirTrees(src, dest); err != nil {
t.Fatal(err)
}
if _, err := os.ReadFile(filepath.Join(dest, "file.txt")); err != nil {
t.Errorf("expected %s/file.txt to exist after combine: %v", dest, err)
}
if isDir(src) {
t.Error("src should have been moved (renamed), not copied")
}
}
func TestCombineDirTreesMergesNewSubdir(t *testing.T) {
root := t.TempDir()
src, dest := filepath.Join(root, "src"), filepath.Join(root, "dest")
writeFile(t, filepath.Join(src, "NewDir", "a.txt"), "a")
writeFile(t, filepath.Join(dest, "Existing.txt"), "keep me")
if err := combineDirTrees(src, dest); err != nil {
t.Fatal(err)
}
if _, err := os.ReadFile(filepath.Join(dest, "NewDir", "a.txt")); err != nil {
t.Errorf("expected merged NewDir/a.txt: %v", err)
}
if _, err := os.ReadFile(filepath.Join(dest, "Existing.txt")); err != nil {
t.Errorf("pre-existing dest file was lost: %v", err)
}
}
func TestCombineDirTreesMergesCaseInsensitiveCollision(t *testing.T) {
root := t.TempDir()
src, dest := filepath.Join(root, "src"), filepath.Join(root, "dest")
// src has "Include" (capital I), dest already has "include" (lowercase) -
// this is exactly the MSVC/WinSDK casing-inconsistency scenario the
// function's doc comment describes.
writeFile(t, filepath.Join(src, "Include", "new.h"), "new")
writeFile(t, filepath.Join(dest, "include", "old.h"), "old")
if err := combineDirTrees(src, dest); err != nil {
t.Fatal(err)
}
if _, err := os.ReadFile(filepath.Join(dest, "include", "new.h")); err != nil {
t.Errorf("new.h should have merged into the existing lowercase 'include' dir: %v", err)
}
if _, err := os.ReadFile(filepath.Join(dest, "include", "old.h")); err != nil {
t.Errorf("old.h should still be there: %v", err)
}
if isDir(filepath.Join(dest, "Include")) {
t.Error("a separate capital-I 'Include' dir should not have been created")
}
}
func TestCombineDirTreesRecursesIntoExactNameMatch(t *testing.T) {
root := t.TempDir()
src, dest := filepath.Join(root, "src"), filepath.Join(root, "dest")
writeFile(t, filepath.Join(src, "lib", "x64", "new.lib"), "new")
writeFile(t, filepath.Join(dest, "lib", "x64", "old.lib"), "old")
if err := combineDirTrees(src, dest); err != nil {
t.Fatal(err)
}
for _, f := range []string{"new.lib", "old.lib"} {
if _, err := os.ReadFile(filepath.Join(dest, "lib", "x64", f)); err != nil {
t.Errorf("expected lib/x64/%s to survive the merge: %v", f, err)
}
}
}
func TestCopyRedirectedAssembliesNoConfigIsNoop(t *testing.T) {
dir := t.TempDir()
app := filepath.Join(dir, "MSBuild.exe")
if err := CopyRedirectedAssemblies(app); err != nil {
t.Fatalf("with no .config file present, expected no error, got: %v", err)
}
}
func TestCopyRedirectedAssembliesCopiesReferencedDLL(t *testing.T) {
dir := t.TempDir()
app := filepath.Join(dir, "MSBuild.exe")
writeFile(t, app+".config", `<?xml version="1.0"?>
<configuration>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<codeBase version="1.0.0.0" href="amd64\Some.Assembly.dll"/>
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>`)
writeFile(t, filepath.Join(dir, "amd64", "Some.Assembly.dll"), "binary-content")
if err := CopyRedirectedAssemblies(app); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(filepath.Join(dir, "Some.Assembly.dll"))
if err != nil {
t.Fatalf("expected Some.Assembly.dll copied next to MSBuild.exe: %v", err)
}
if string(got) != "binary-content" {
t.Errorf("copied file content = %q, want %q", got, "binary-content")
}
}
func TestCopyRedirectedAssembliesSkipsMissingTarget(t *testing.T) {
dir := t.TempDir()
app := filepath.Join(dir, "MSBuild.exe")
writeFile(t, app+".config", `<?xml version="1.0"?>
<configuration>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<codeBase href="nowhere\Missing.dll"/>
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>`)
if err := CopyRedirectedAssemblies(app); err != nil {
t.Fatalf("a redirect pointing at a nonexistent file should be silently skipped, got: %v", err)
}
}
+14
View File
@@ -84,6 +84,9 @@ func FetchPayloads(selected []*Package, cacheDir string, allowHashMismatch bool)
func fetchOnePayloadWithRetries(payload Payload, dest, fileID string, allowHashMismatch bool) (int64, error) {
var lastErr error
for attempt := 0; attempt < maxDownloadAttempts; attempt++ {
if attempt > 0 {
time.Sleep(retryBackoff(attempt))
}
n, err := tryDownloadPayload(payload, dest, fileID, allowHashMismatch)
if err == nil {
return n, nil
@@ -94,6 +97,17 @@ func fetchOnePayloadWithRetries(payload Payload, dest, fileID string, allowHashM
return 0, fmt.Errorf("giving up on %s after %d attempts: %w", fileID, maxDownloadAttempts, lastErr)
}
// retryBackoff gives a transient failure (network blip, momentary rate
// limiting) a little room to clear before hammering the same URL again:
// 1s, 2s, 4s, 8s, capped at 10s.
func retryBackoff(attempt int) time.Duration {
d := time.Second << uint(attempt-1)
if d > 10*time.Second {
d = 10 * time.Second
}
return d
}
func tryDownloadPayload(payload Payload, dest, fileID string, allowHashMismatch bool) (int64, error) {
if fi, err := os.Stat(dest); err == nil && fi.Mode().IsRegular() {
if payload.SHA256 != "" {
+59
View File
@@ -0,0 +1,59 @@
package download
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestRetryBackoff(t *testing.T) {
for _, tc := range []struct {
attempt int
want time.Duration
}{
{1, 1 * time.Second},
{2, 2 * time.Second},
{3, 4 * time.Second},
{4, 8 * time.Second},
{5, 10 * time.Second}, // capped
{10, 10 * time.Second},
} {
if got := retryBackoff(tc.attempt); got != tc.want {
t.Errorf("retryBackoff(%d) = %v, want %v", tc.attempt, got, tc.want)
}
}
}
func TestSHA256File(t *testing.T) {
path := filepath.Join(t.TempDir(), "f")
if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
got, err := sha256File(path)
if err != nil {
t.Fatal(err)
}
// echo -n hello | sha256sum
want := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
if got != want {
t.Errorf("sha256File(hello) = %q, want %q", got, want)
}
}
func TestEqualFoldHex(t *testing.T) {
for _, tc := range []struct {
a, b string
want bool
}{
{"ABCDEF", "abcdef", true},
{"abc123", "ABC123", true},
{"abc123", "abc124", false},
{"abc", "abcd", false},
{"", "", true},
} {
if got := equalFoldHex(tc.a, tc.b); got != tc.want {
t.Errorf("equalFoldHex(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
}
}
}
+3
View File
@@ -198,6 +198,9 @@ const maxManifestAttempts = 5
func httpGet(url string) ([]byte, error) {
var lastErr error
for attempt := 0; attempt < maxManifestAttempts; attempt++ {
if attempt > 0 {
time.Sleep(retryBackoff(attempt))
}
data, err := tryHTTPGet(url)
if err == nil {
return data, nil
+134
View File
@@ -0,0 +1,134 @@
package download
import (
"encoding/json"
"testing"
)
func TestPayloadName(t *testing.T) {
for _, tc := range []struct {
fileName string
want string
}{
{"payload.msi", "payload.msi"},
{"folder/payload.msi", "payload.msi"},
{`folder\payload.msi`, "payload.msi"},
{`a\b/c\payload.msi`, "payload.msi"},
{"", ""},
} {
p := Payload{FileName: tc.fileName}
if got := p.Name(); got != tc.want {
t.Errorf("Payload{FileName: %q}.Name() = %q, want %q", tc.fileName, got, tc.want)
}
}
}
func TestPackageKey(t *testing.T) {
for _, tc := range []struct {
name string
p Package
want string
}{
{"id only", Package{ID: "Foo"}, "Foo"},
{"id+version", Package{ID: "Foo", Version: "1.0"}, "Foo-1.0"},
{
"id+version+all arches",
Package{ID: "Foo", Version: "1.0", Chip: "x64", MachineArch: "x86", ProductArch: "neutral"},
"Foo-1.0-chip.x64-machineArch.x86-productArch.neutral",
},
} {
t.Run(tc.name, func(t *testing.T) {
if got := tc.p.Key(); got != tc.want {
t.Errorf("Key() = %q, want %q", got, tc.want)
}
})
}
}
func TestPackageLocalized(t *testing.T) {
noResources := Package{}
if got := noResources.Localized("en"); got != nil {
t.Errorf("Localized() on a package with no LocalizedResources = %v, want nil", got)
}
p := Package{LocalizedResources: []LocalizedResource{
{Language: "de-DE", Title: "Deutsch"},
{Language: "en-US", Title: "English"},
{Language: "ru-RU", Title: "Русский"},
}}
for _, tc := range []struct {
lang string
wantTitle string
}{
{"ru", "Русский"},
{"ru-RU", "Русский"},
{"en", "English"},
{"", "English"}, // "" defaults to "en"
{"fr", "English"}, // no fr variant, falls back to the en-* one
} {
t.Run("lang="+tc.lang, func(t *testing.T) {
got := p.Localized(tc.lang)
if got == nil {
t.Fatalf("Localized(%q) = nil", tc.lang)
}
if got.Title != tc.wantTitle {
t.Errorf("Localized(%q).Title = %q, want %q", tc.lang, got.Title, tc.wantTitle)
}
})
}
}
func TestPackageSizes(t *testing.T) {
p := Package{
InstallSizes: map[string]int64{"x86": 100, "x64": 200},
Payloads: []Payload{{Size: 10}, {Size: 20}, {Size: 30}},
}
if got := p.InstalledSize(); got != 300 {
t.Errorf("InstalledSize() = %d, want 300", got)
}
if got := p.DownloadSize(); got != 60 {
t.Errorf("DownloadSize() = %d, want 60", got)
}
}
func TestPackageDependenciesNormalizesBothShapes(t *testing.T) {
p := Package{DependenciesRaw: map[string]json.RawMessage{
"Bare.Version": json.RawMessage(`"1.0"`),
"Full.Object": json.RawMessage(`{"version":"2.0","type":"Optional","id":"Real.Target"}`),
"Recommended.Dep": json.RawMessage(`{"version":"3.0","type":"Recommended"}`),
}}
deps := p.Dependencies()
if d := deps["Bare.Version"]; d.Version != "1.0" || d.TargetID != "" || d.Type != "" {
t.Errorf("Bare.Version = %+v, want Version=1.0 TargetID='' Type=''", d)
}
if d := deps["Full.Object"]; d.Version != "2.0" || d.TargetID != "Real.Target" || d.Type != "Optional" {
t.Errorf("Full.Object = %+v, want Version=2.0 TargetID=Real.Target Type=Optional", d)
}
if d := deps["Recommended.Dep"]; d.Version != "3.0" || d.Type != "Recommended" {
t.Errorf("Recommended.Dep = %+v, want Version=3.0 Type=Recommended", d)
}
// Calling Dependencies() again must return the same cached map, not
// re-parse (and must not panic on the second call).
if d2 := p.Dependencies(); len(d2) != len(deps) {
t.Errorf("second Dependencies() call returned a different map: %v vs %v", d2, deps)
}
}
func TestHumanizeBytes(t *testing.T) {
for _, tc := range []struct {
size int64
want string
}{
{500, "500 bytes"},
{2048, "2.0 KB"},
{5 * 1024 * 1024, "5.0 MB"},
{2 * 1024 * 1024 * 1024, "2.0 GB"},
} {
if got := HumanizeBytes(tc.size); got != tc.want {
t.Errorf("HumanizeBytes(%d) = %q, want %q", tc.size, got, tc.want)
}
}
}
+10 -3
View File
@@ -15,8 +15,7 @@ var reSDKVersion = regexp.MustCompile(`^\d+\.\d+\.\d+`)
// default) explicitly chose to include/exclude the component.
type TriState = *bool
func on() TriState { v := true; return &v }
func off() TriState { v := false; return &v }
func on() TriState { v := true; return &v }
// Options holds every flag that feeds package selection and download.
type Options struct {
@@ -303,6 +302,7 @@ func selectSDK(opts *Options, idx Index) error {
}
}
if !found {
sort.Strings(versions)
return fmt.Errorf("WinSDK version %s not found (available: %s)", opts.SDKVersion, strings.Join(versions, ", "))
}
}
@@ -347,7 +347,14 @@ func collectDependencyClosure(idx Index, included map[string]bool, target string
included[key] = true
ret := []*Package{p}
for target, dep := range p.Dependencies() {
deps := p.Dependencies()
targets := make([]string, 0, len(deps))
for target := range deps {
targets = append(targets, target)
}
sort.Strings(targets)
for _, target := range targets {
dep := deps[target]
id := target
if dep.TargetID != "" {
id = dep.TargetID
+34
View File
@@ -193,6 +193,40 @@ func TestAggregateDependsHostArchMismatchExcludes(t *testing.T) {
}
}
// TestExpandSelectionDeterministic guards against collectDependencyClosure
// iterating a package's dependency map directly (Go map iteration order is
// randomized per range statement, so a regression here wouldn't necessarily
// show up on the first run - looping catches it reliably in practice).
func TestExpandSelectionDeterministic(t *testing.T) {
idx := fixtureIndex(t, "x86")
opts := &Options{
Package: []string{"Microsoft.VisualStudio.Workload.VCTools"},
HostArch: "x86",
OnlyHost: true,
IncludeOptional: true,
}
first, err := ExpandSelection(idx, opts)
if err != nil {
t.Fatal(err)
}
want := idsOf(first)
for i := 0; i < 50; i++ {
got, err := ExpandSelection(idx, opts)
if err != nil {
t.Fatal(err)
}
gotIDs := idsOf(got)
if len(gotIDs) != len(want) {
t.Fatalf("run %d: got %v, want %v", i, gotIDs, want)
}
for j := range want {
if gotIDs[j] != want[j] {
t.Fatalf("run %d: order changed: got %v, want %v", i, gotIDs, want)
}
}
}
}
func idsOf(pkgs []*Package) []string {
var ids []string
for _, p := range pkgs {
+118
View File
@@ -0,0 +1,118 @@
package download
import (
"os"
"path/filepath"
"testing"
)
func TestWDKNuGetID(t *testing.T) {
for _, tc := range []struct{ arch, want string }{
{"x64", "Microsoft.Windows.WDK.x64"},
{"x86", "Microsoft.Windows.WDK.x64"}, // no 32-bit package exists
{"arm64", "Microsoft.Windows.WDK.ARM64"},
} {
if got := WDKNuGetID(tc.arch); got != tc.want {
t.Errorf("WDKNuGetID(%q) = %q, want %q", tc.arch, got, tc.want)
}
}
}
func TestSDKBuildPrefix(t *testing.T) {
for _, tc := range []struct {
name string
selected []*Package
want string
}{
{"no SDK package", []*Package{{ID: "Something.Else"}}, ""},
{"win10sdk", []*Package{{ID: "Win10SDK_10.0.26100", Version: "10.0.26100.1742"}}, "10.0.26100"},
{"win11sdk case-insensitive id", []*Package{{ID: "WIN11SDK_10.0.22621", Version: "10.0.22621.5"}}, "10.0.22621"},
{"short version", []*Package{{ID: "Win10SDK_x", Version: "10.0"}}, ""},
} {
t.Run(tc.name, func(t *testing.T) {
if got := SDKBuildPrefix(tc.selected); got != tc.want {
t.Errorf("SDKBuildPrefix(...) = %q, want %q", got, tc.want)
}
})
}
}
func TestFillMissingHostToolsCopiesWithoutOverwriting(t *testing.T) {
cDir := t.TempDir()
writeFile(t, filepath.Join(cDir, "bin", "10.0.26100.0", "x64", "stampinf.exe"), "x64-stampinf")
writeFile(t, filepath.Join(cDir, "bin", "10.0.26100.0", "x64", "inf2cat.exe"), "x64-inf2cat")
// x86 already ships its own real inf2cat.exe - must not be clobbered.
writeFile(t, filepath.Join(cDir, "bin", "10.0.26100.0", "x86", "inf2cat.exe"), "real-x86-inf2cat")
if err := fillMissingHostTools(cDir); err != nil {
t.Fatal(err)
}
x86Dir := filepath.Join(cDir, "bin", "10.0.26100.0", "x86")
stampinf, err := os.ReadFile(filepath.Join(x86Dir, "stampinf.exe"))
if err != nil {
t.Fatalf("expected stampinf.exe to be copied into x86: %v", err)
}
if string(stampinf) != "x64-stampinf" {
t.Errorf("copied stampinf.exe content = %q, want the x64 copy's content", stampinf)
}
inf2cat, err := os.ReadFile(filepath.Join(x86Dir, "inf2cat.exe"))
if err != nil {
t.Fatal(err)
}
if string(inf2cat) != "real-x86-inf2cat" {
t.Errorf("inf2cat.exe = %q, want the original x86 file preserved (not overwritten by the x64 copy)", inf2cat)
}
}
func TestFillMissingHostToolsNoBinDirIsNoop(t *testing.T) {
if err := fillMissingHostTools(t.TempDir()); err != nil {
t.Fatalf("missing bin/ dir should be a no-op, got: %v", err)
}
}
func TestDuplicateVersionedBuildTaskAssemblies(t *testing.T) {
cDir := t.TempDir()
buildDir := filepath.Join(cDir, "build")
writeFile(t, filepath.Join(buildDir, "Microsoft.DriverKit.Build.Tasks.17.0.dll"), "task-dll-bytes")
writeFile(t, filepath.Join(buildDir, "Microsoft.DriverKit.Build.Tasks.18.0.dll"), "already-there")
writeFile(t, filepath.Join(buildDir, "unrelated.dll"), "unrelated")
if err := duplicateVersionedBuildTaskAssemblies(cDir, "18.0"); err != nil {
t.Fatal(err)
}
// Already had an 18.0 copy - must not have been overwritten.
got, err := os.ReadFile(filepath.Join(buildDir, "Microsoft.DriverKit.Build.Tasks.18.0.dll"))
if err != nil {
t.Fatal(err)
}
if string(got) != "already-there" {
t.Errorf("pre-existing 18.0 dll was overwritten: got %q", got)
}
}
func TestDuplicateVersionedBuildTaskAssembliesCreatesMissingCopy(t *testing.T) {
cDir := t.TempDir()
buildDir := filepath.Join(cDir, "build")
writeFile(t, filepath.Join(buildDir, "sub", "Foo.Bar.17.0.dll"), "bytes")
if err := duplicateVersionedBuildTaskAssemblies(cDir, "18.0"); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(filepath.Join(buildDir, "sub", "Foo.Bar.18.0.dll"))
if err != nil {
t.Fatalf("expected a Foo.Bar.18.0.dll duplicate: %v", err)
}
if string(got) != "bytes" {
t.Errorf("duplicated dll content = %q, want %q", got, "bytes")
}
}
func TestDuplicateVersionedBuildTaskAssembliesNoBuildDirIsNoop(t *testing.T) {
if err := duplicateVersionedBuildTaskAssemblies(t.TempDir(), "18.0"); err != nil {
t.Fatalf("missing build/ dir should be a no-op, got: %v", err)
}
}
+12 -4
View File
@@ -74,13 +74,15 @@ Usage:
vintner env (e) --bin <dir/bin/arch> print INCLUDE/LIB for native clang-cl/lld-link use
vintner version (v) print the version
vintner help (h) show this message
vintner completion bash|zsh print a shell completion script
Run "vintner <command> --help" for that command's own options - download
has many, including --with-wdk, --list-workloads, --list-components and
--print-deps-tree.
has many, including --with-wdk, --with-dxsdk, --list-workloads,
--list-components and --print-deps-tree.
--dest/[dir] default to ~/.vintner if omitted.
Language: set VINTNER_LANG=ru (or LANG=ru_RU...) for Russian output.
Completion: source <(vintner completion bash) # or zsh
Once installed, add <dir>/bin/<arch> to PATH and invoke the tools directly:
cl, link, lib, ml, ml64, mc, midl, mt, rc, dumpbin, msbuild, nmake, armasm, armasm64, cmd, findstr
@@ -94,13 +96,15 @@ Once installed, add <dir>/bin/<arch> to PATH and invoke the tools directly:
vintner env (e) --bin <dir/bin/arch> вывести INCLUDE/LIB для clang-cl/lld-link напрямую
vintner version (v) показать версию
vintner help (h) показать эту справку
vintner completion bash|zsh вывести скрипт автодополнения для оболочки
Запустите «vintner <команда> --help» для параметров конкретной команды —
у download их много, включая --with-wdk, --list-workloads, --list-components
и --print-deps-tree.
у download их много, включая --with-wdk, --with-dxsdk, --list-workloads,
--list-components и --print-deps-tree.
--dest/[каталог] по умолчанию — ~/.vintner.
Язык: установите VINTNER_LANG=en (или LANG=en_US...) для вывода на английском.
Автодополнение: source <(vintner completion bash) # или zsh
После установки добавьте <dir>/bin/<arch> в PATH и вызывайте инструменты напрямую:
cl, link, lib, ml, ml64, mc, midl, mt, rc, dumpbin, msbuild, nmake, armasm, armasm64, cmd, findstr
@@ -157,6 +161,10 @@ Once installed, add <dir>/bin/<arch> to PATH and invoke the tools directly:
EN: "Installed WDK (%s) %s at %s\n",
RU: "WDK (%s) %s установлен в %s\n",
},
"download.dxsdk_installed": {
EN: "Installed DirectX SDK (June 2010) at %s\n",
RU: "DirectX SDK (июнь 2010) установлен в %s\n",
},
"download.workloads_header": {
EN: "Available Workloads (%d):\n",
RU: "Доступные рабочие нагрузки (Workload) (%d):\n",
+93
View File
@@ -0,0 +1,93 @@
package i18n
import (
"strings"
"testing"
)
func TestDetect(t *testing.T) {
envKeys := []string{"VINTNER_LANG", "LC_ALL", "LC_MESSAGES", "LANG"}
clear := func() {
for _, k := range envKeys {
t.Setenv(k, "")
// t.Setenv("", "") leaves the var set-but-empty, which detect()
// already treats as "unset" (its loop skips v == "") - matches
// how a genuinely-unset env var behaves for this function.
}
}
for _, tc := range []struct {
name string
env map[string]string
want Lang
}{
{"nothing set defaults to English", nil, EN},
{"VINTNER_LANG=ru", map[string]string{"VINTNER_LANG": "ru"}, RU},
{"VINTNER_LANG=en", map[string]string{"VINTNER_LANG": "en"}, EN},
{"VINTNER_LANG wins over a Russian LANG", map[string]string{"VINTNER_LANG": "en", "LANG": "ru_RU.UTF-8"}, EN},
{"LC_ALL wins over LANG", map[string]string{"LC_ALL": "ru_RU.UTF-8", "LANG": "en_US.UTF-8"}, RU},
{"LANG=ru_RU.UTF-8 alone", map[string]string{"LANG": "ru_RU.UTF-8"}, RU},
{"LANG=en_US.UTF-8 alone", map[string]string{"LANG": "en_US.UTF-8"}, EN},
{"unrelated locale defaults to English", map[string]string{"LANG": "de_DE.UTF-8"}, EN},
{"case-insensitive RU prefix", map[string]string{"VINTNER_LANG": "RU"}, RU},
} {
t.Run(tc.name, func(t *testing.T) {
clear()
for k, v := range tc.env {
t.Setenv(k, v)
}
if got := detect(); got != tc.want {
t.Errorf("detect() = %q, want %q", got, tc.want)
}
})
}
}
// TestCatalogCompleteness guards against adding an EN string without its RU
// counterpart (or vice versa) - a silent gap here degrades to showing the
// wrong language's text via T()'s EN-fallback rather than failing loudly.
func TestCatalogCompleteness(t *testing.T) {
for key, entry := range catalog {
en, hasEN := entry[EN]
if !hasEN || strings.TrimSpace(en) == "" {
t.Errorf("catalog[%q] has no (non-empty) English translation", key)
}
ru, hasRU := entry[RU]
if !hasRU || strings.TrimSpace(ru) == "" {
t.Errorf("catalog[%q] has no (non-empty) Russian translation", key)
}
}
}
func TestTMissingKeyReturnsKeyItself(t *testing.T) {
got := T("no.such.key")
if got != "no.such.key" {
t.Errorf("T(unknown key) = %q, want the key itself", got)
}
}
func TestTFallsBackToEnglish(t *testing.T) {
const testKey = "test.fallback.only.en"
catalog[testKey] = map[Lang]string{EN: "hello %s"}
defer delete(catalog, testKey)
saved := current
current = RU
defer func() { current = saved }()
if got := T(testKey, "world"); got != "hello world" {
t.Errorf("T(%q) with no RU entry = %q, want %q", testKey, got, "hello world")
}
}
func TestTFormatsArgs(t *testing.T) {
saved := current
current = EN
defer func() { current = saved }()
got := T("download.wdk_installed", "x64", "10.0.26100.1", "/dest")
want := "Installed WDK (x64) 10.0.26100.1 at /dest\n"
if got != want {
t.Errorf("T(download.wdk_installed, ...) = %q, want %q", got, want)
}
}
+4
View File
@@ -62,6 +62,10 @@ func Install(dest, selfBinary string) error {
return err
}
if err := aliasPlatformToolsets(dest); err != nil {
return err
}
includeDir := filepath.Join(msvcDir, "include")
if err := Lowercase(includeDir, LowercaseOptions{Symlink: true}); err != nil {
return fmt.Errorf("lowercasing %s: %w", includeDir, err)
+92
View File
@@ -0,0 +1,92 @@
package install
import (
"os"
"path/filepath"
"regexp"
"github.com/Cheviiot/vintner/internal/wineenv"
)
var rePlatformToolsetDir = regexp.MustCompile(`^v(\d+)$`)
// aliasPlatformToolsets makes every historical PlatformToolset name in
// wineenv.KnownPlatformToolsets resolve to the one compiler `download`
// actually fetched.
//
// MSBuild decides whether a PlatformToolset is "installed" at all - the
// check behind MSB8020 - by testing whether
// MSBuild/Microsoft/VC/v<schema>/Platforms/<arch>/PlatformToolsets/<toolset>/
// exists on disk (Microsoft.Cpp.props, via
// ToolLocationHelper.FindRootFolderWhereAllFilesExist). That's a plain file
// lookup, not influenced by any environment variable - unlike the later,
// env-var-driven VCInstallDir_<N> checks internal/wrapper's msbuildEnv
// covers, this one needs the actual directory to exist under dest.
// Microsoft's own downloaded MSBuild package only ships a PlatformToolsets
// entry for the exact generation matching the fetched compiler, so any
// project pinned to an older PlatformToolset (v142 for a project last saved
// under VS2019, say) fails this check outright even though the one real
// toolchain installed could easily build it.
//
// Toolset.props/Toolset.targets don't hardcode a version number (they just
// import version-agnostic files like Microsoft.Cpp.MSVC.Toolset.<arch>.props),
// so a symlink under any other historical name is a correct, transparent
// alias rather than a divergent copy.
func aliasPlatformToolsets(dest string) error {
schemaDirs, err := filepath.Glob(filepath.Join(dest, "MSBuild", "Microsoft", "VC", "v*"))
if err != nil {
return err
}
for _, schemaDir := range schemaDirs {
archDirs, err := filepath.Glob(filepath.Join(schemaDir, "Platforms", "*", "PlatformToolsets"))
if err != nil {
return err
}
for _, toolsetsDir := range archDirs {
if err := aliasOneDir(toolsetsDir); err != nil {
return err
}
}
}
return nil
}
// aliasOneDir symlinks every name in wineenv.KnownPlatformToolsets that
// doesn't already exist in toolsetsDir onto whichever real v<N> toolset
// subdirectory is actually present there.
func aliasOneDir(toolsetsDir string) error {
entries, err := os.ReadDir(toolsetsDir)
if err != nil {
return err
}
var real string
for _, e := range entries {
if !e.IsDir() {
continue
}
if rePlatformToolsetDir.MatchString(e.Name()) {
real = e.Name()
break
}
}
if real == "" {
// Nothing numeric here (e.g. only the WindowsKernelModeDriver10.0-style
// WDK toolsets) - nothing to alias.
return nil
}
for _, n := range wineenv.KnownPlatformToolsets {
alias := "v" + n
if alias == real {
continue
}
aliasPath := filepath.Join(toolsetsDir, alias)
if exists(aliasPath) {
continue
}
if err := os.Symlink(real, aliasPath); err != nil {
return err
}
}
return nil
}
+106
View File
@@ -0,0 +1,106 @@
package install
import (
"os"
"path/filepath"
"testing"
"github.com/Cheviiot/vintner/internal/wineenv"
)
func TestAliasPlatformToolsetsAliasesRealToolset(t *testing.T) {
dest := t.TempDir()
toolsetsDir := filepath.Join(dest, "MSBuild", "Microsoft", "VC", "v180", "Platforms", "x64", "PlatformToolsets")
realDir := filepath.Join(toolsetsDir, "v145")
if err := os.MkdirAll(realDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(realDir, "Toolset.props"), []byte("<Project/>"), 0o644); err != nil {
t.Fatal(err)
}
// A WDK toolset entry alongside it - must not be mistaken for the real
// numeric toolset or itself get aliased over.
if err := os.MkdirAll(filepath.Join(toolsetsDir, "WindowsKernelModeDriver10.0"), 0o755); err != nil {
t.Fatal(err)
}
if err := aliasPlatformToolsets(dest); err != nil {
t.Fatalf("aliasPlatformToolsets: %v", err)
}
for _, n := range wineenv.KnownPlatformToolsets {
alias := filepath.Join(toolsetsDir, "v"+n)
fi, err := os.Lstat(alias)
if err != nil {
t.Errorf("expected v%s alias to exist: %v", n, err)
continue
}
if fi.Mode()&os.ModeSymlink == 0 {
t.Errorf("v%s should be a symlink, got mode %v", n, fi.Mode())
continue
}
target, err := os.Readlink(alias)
if err != nil {
t.Fatal(err)
}
if target != "v145" {
t.Errorf("v%s symlink target = %q, want \"v145\"", n, target)
}
// Follow the alias and confirm it actually reaches the real content.
if !isFile(filepath.Join(toolsetsDir, "v"+n, "Toolset.props")) {
t.Errorf("v%s/Toolset.props not reachable through the alias", n)
}
}
if exists(filepath.Join(toolsetsDir, "WindowsKernelModeDriver10.0", "v"+wineenv.KnownPlatformToolsets[0])) {
t.Error("WDK toolset directory should not have been touched")
}
}
func TestAliasPlatformToolsetsDoesNotOverwriteExisting(t *testing.T) {
dest := t.TempDir()
toolsetsDir := filepath.Join(dest, "MSBuild", "Microsoft", "VC", "v180", "Platforms", "x64", "PlatformToolsets")
if err := os.MkdirAll(filepath.Join(toolsetsDir, "v145"), 0o755); err != nil {
t.Fatal(err)
}
// v142 already genuinely installed (e.g. a real VS install with several
// side-by-side toolsets) - must be left alone, not replaced with an alias.
real142 := filepath.Join(toolsetsDir, "v142")
if err := os.MkdirAll(real142, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(real142, "Toolset.props"), []byte("<!-- real v142 -->"), 0o644); err != nil {
t.Fatal(err)
}
if err := aliasPlatformToolsets(dest); err != nil {
t.Fatalf("aliasPlatformToolsets: %v", err)
}
fi, err := os.Lstat(real142)
if err != nil {
t.Fatal(err)
}
if fi.Mode()&os.ModeSymlink != 0 {
t.Error("pre-existing v142 directory should not have been replaced with a symlink")
}
}
func TestAliasPlatformToolsetsNoNumericToolset(t *testing.T) {
dest := t.TempDir()
// Only a WDK-style toolset present, nothing numeric to alias from.
toolsetsDir := filepath.Join(dest, "MSBuild", "Microsoft", "VC", "v180", "Platforms", "x64", "PlatformToolsets")
if err := os.MkdirAll(filepath.Join(toolsetsDir, "WindowsUserModeDriver10.0"), 0o755); err != nil {
t.Fatal(err)
}
if err := aliasPlatformToolsets(dest); err != nil {
t.Fatalf("aliasPlatformToolsets: %v", err)
}
for _, n := range wineenv.KnownPlatformToolsets {
if exists(filepath.Join(toolsetsDir, "v"+n)) {
t.Errorf("v%s should not have been created with no real numeric toolset present", n)
}
}
}
+15
View File
@@ -0,0 +1,15 @@
package wineenv
// KnownPlatformToolsets are every numeric PlatformToolset short name
// Microsoft.Cpp.Default.props has ever defined a
// _PlatformToolsetShortNameFor_v<N> entry for (VS2013 through the VS2022
// initial release; excludes the _xp/_wp80/_wp81 variants, which aren't
// purely numeric). vintner only ever installs one compiler generation, but
// real .vcxproj files in the wild are pinned to whichever generation they
// were last edited under - v142 (VS2019) for anything not yet retargeted is
// extremely common. Shared between internal/install (which symlinks these
// names onto the one real MSBuild PlatformToolsets directory) and
// internal/wrapper (which mirrors the same names onto VCInstallDir_<N>/
// VCToolsInstallDir_<N> for the older, environment-variable-driven toolset
// redirect chain) - see doc comments there for why both are needed.
var KnownPlatformToolsets = []string{"90", "100", "110", "120", "140", "141", "142", "143"}
+61
View File
@@ -0,0 +1,61 @@
package wrapper
import (
"os"
"path/filepath"
"testing"
)
func TestClPostProcessRewritesLineDirectivesInPreprocessedOutput(t *testing.T) {
dir := t.TempDir()
fi := filepath.Join(dir, "out.i")
// A #line directive as cl.exe's /P emits it: z:-prefixed, backslash
// path, doubled ("escaped") backslashes, CRLF line ending.
input := "#line 1 \"z:\\\\home\\\\user\\\\src\\\\hello.c\"\r\n" +
"int main(void) { return 0; }\r\n"
if err := os.WriteFile(fi, []byte(input), 0o644); err != nil {
t.Fatal(err)
}
clPostProcess([]string{"/P", "/Fi" + fi, "hello.c"})
got, err := os.ReadFile(fi)
if err != nil {
t.Fatal(err)
}
want := "#line 1 \"/home/user/src/hello.c\"\n" +
"int main(void) { return 0; }\n"
if string(got) != want {
t.Errorf("clPostProcess output = %q, want %q", got, want)
}
}
func TestClPostProcessNoopWithoutP(t *testing.T) {
dir := t.TempDir()
fi := filepath.Join(dir, "out.i")
original := "#line 1 \"z:\\\\foo.c\"\r\n"
if err := os.WriteFile(fi, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
// No "/P" flag - should leave the file untouched even though -Fi is present.
clPostProcess([]string{"/Fi" + fi, "foo.c"})
got, err := os.ReadFile(fi)
if err != nil {
t.Fatal(err)
}
if string(got) != original {
t.Errorf("file was modified without /P: got %q, want unchanged %q", got, original)
}
}
func TestClPostProcessNoopWithoutFi(t *testing.T) {
// Must not panic or error when -Fi wasn't passed - just silently skip.
clPostProcess([]string{"/P", "foo.c"})
}
func TestClPostProcessMissingFileIsSilent(t *testing.T) {
// The referenced -Fi file doesn't exist - clPostProcess must not panic.
clPostProcess([]string{"/P", "/Fi" + filepath.Join(t.TempDir(), "missing.i"), "foo.c"})
}
+142 -18
View File
@@ -29,10 +29,31 @@ func msbuildEnv(cfg *wineenv.Config, paths *wineenv.Paths) map[string]string {
// tools onto the same UTC clock removes the mismatch.
"TZ": "UTC",
"DisableRegistryUse": "true",
"VCToolsVersion": cfg.MSVCVer,
"VsInstallRoot": paths.BaseWin + `\`,
"VSInstallDir": paths.BaseWin + `\`,
// VCToolsVersion must be a real version string, not left unset:
// Microsoft.Cpp.VCTools.props itself falls back to the literal
// placeholder "VCToolsVersion_is_not_defined" whenever it's empty,
// and that placeholder then reaches unconditional (not gated behind
// CheckMSVCComponents) version-string comparisons elsewhere in
// Microsoft.CppBuild.targets, e.g. the SegmentHeap manifest check's
// VersionGreaterThanOrEquals(), which throws MSB4184 on a
// non-version string.
//
// CheckMSVCComponents=false is what actually makes an aliased
// PlatformToolset safe to combine with that real version: without
// it, CheckVCToolsetVersion (Microsoft.CppBuild.targets, MSB8052)
// rejects the combination whenever VCToolsVersion's numeric
// generation doesn't match PlatformToolset's - exactly the legacy-
// project case toolsetSuffixes/aliasPlatformToolsets exist for (a
// v142 project against the one real, newer compiler actually
// installed). Everything else CheckMSVCComponents gates
// (Microsoft.CppBuild.targets ~495-535) is diagnostic-only - MFC/ATL/
// Spectre component presence warnings, none of it feeding into the
// actual compile/link - so disabling it costs nothing here.
"DisableRegistryUse": "true",
"CheckMSVCComponents": "false",
"VCToolsVersion": cfg.MSVCVer,
"VsInstallRoot": paths.BaseWin + `\`,
"VSInstallDir": paths.BaseWin + `\`,
"SDKReferenceDirectoryRoot": paths.BaseWin + `\`,
"SDKExtensionDirectoryRoot": paths.BaseWin + `\`,
@@ -58,20 +79,29 @@ func msbuildEnv(cfg *wineenv.Config, paths *wineenv.Paths) map[string]string {
"Platform": msbuildPlatform(cfg.Arch),
}
// Microsoft.Cpp.props resolves the compiler/toolset location through
// VCInstallDir_<N>/VCToolsInstallDir_<N>, where <N> is whatever numeric
// suffix the installed MSBuild toolset property sheets use (e.g.
// .../MSBuild/Microsoft/VC/v180 -> "180"). Populate every one actually
// present, so a project pinned to any of them resolves to the one real
// toolchain that's installed.
matches, _ := filepath.Glob(filepath.Join(paths.BaseUnix, "MSBuild", "Microsoft", "VC", "v*"))
for _, m := range matches {
sub := reToolsetDir.FindStringSubmatch(filepath.Base(m))
if sub == nil {
continue
}
env["VCInstallDir_"+sub[1]] = paths.MSVCBaseWin + `\`
env["VCToolsInstallDir_"+sub[1]] = paths.MSVCDirWin + `\`
// VCInstallDir_<N>/VCToolsInstallDir_<N> are consulted under two
// completely different numbering schemes, both needing the single real
// toolchain behind every <N> they might ask for:
//
// - Microsoft.Cpp.Default.props keys its early "is this toolset even
// installed" check (the one MSB8020 comes from) off <N> = the
// PlatformToolset suffix a .vcxproj actually declares (v142, v143,
// v145, ...) - the same short name Microsoft stamps on
// VC/Auxiliary/Build/Microsoft.VCToolsVersion.v<N>.default.props for
// the downloaded compiler.
// - Microsoft.CppBuild.targets (MSB8070) instead keys off <N> = the
// MSBuild targets-schema version whose Microsoft.Cpp.props ended up
// imported for this run (MSBuild/Microsoft/VC/v150|v160|v170|v180 -
// fixed, shipped identically with every MSBuild release, unrelated to
// which compiler is installed), to locate the specific toolset
// version subfolder.
//
// Populate every <N> from both sources, all pointing at the one real
// toolchain that's installed, so a project pinned to any PlatformToolset
// resolves at every stage MSBuild checks it.
for _, n := range toolsetSuffixes(paths.BaseUnix) {
env["VCInstallDir_"+n] = paths.MSVCBaseWin + `\`
env["VCToolsInstallDir_"+n] = paths.MSVCDirWin + `\`
}
if strings.HasSuffix(paths.MSBuildBinDir, "amd64") {
@@ -94,6 +124,100 @@ func msbuildEnv(cfg *wineenv.Config, paths *wineenv.Paths) map[string]string {
return env
}
// toolsetSuffixes collects every numeric <N> that either VCInstallDir_<N>
// lookup mechanism (see msbuildEnv) might be asked to resolve for this
// installation: PlatformToolset short names from
// vc/Auxiliary/Build/Microsoft.VCToolsVersion.v<N>.default.props, MSBuild
// targets-schema versions from MSBuild/Microsoft/VC/v<N>, and every
// historical PlatformToolset name (see wineenv.KnownPlatformToolsets) - a
// project pinned to any of them all resolves to the one real toolchain
// installed.
func toolsetSuffixes(baseUnix string) []string {
seen := map[string]bool{}
var suffixes []string
record := func(n string) {
if seen[n] {
return
}
seen[n] = true
suffixes = append(suffixes, n)
}
add := func(dir, prefix, suffix string) {
matches, _ := filepath.Glob(filepath.Join(dir, "*"))
for _, m := range matches {
name := filepath.Base(m)
if prefix != "" {
if !strings.HasPrefix(name, prefix) {
continue
}
name = strings.TrimPrefix(name, prefix)
}
name = strings.TrimSuffix(name, suffix)
sub := reToolsetDir.FindStringSubmatch(name)
if sub == nil {
continue
}
record(sub[1])
}
}
add(filepath.Join(baseUnix, "vc", "Auxiliary", "Build"), "Microsoft.VCToolsVersion.", ".default.props")
add(filepath.Join(baseUnix, "MSBuild", "Microsoft", "VC"), "", "")
for _, n := range wineenv.KnownPlatformToolsets {
record(n)
}
return suffixes
}
// reGlobalProp matches an MSBuild global-property command-line switch
// ("/p:Name=...", "-property:Name=...", case-insensitive on both the
// -p/-property spelling and the property name) so msbuildGlobalArgs can tell
// whether the caller already pinned a given property themselves.
func reGlobalProp(name string) *regexp.Regexp {
return regexp.MustCompile(`(?i)^[-/](p|property):` + regexp.QuoteMeta(name) + `=`)
}
// msbuildGlobalArgs returns /p: switches to prepend to an MSBuild invocation,
// one per forced property not already present in args.
//
// WindowsTargetPlatformVersion is the one property that needs this rather
// than an env var: unlike VCInstallDir_<N> (an input to a props-file
// *lookup*, so any value msbuildEnv sets is visible no matter what a project
// pins its PlatformToolset to), WindowsTargetPlatformVersion is itself the
// value most legacy .vcxproj files hardcode directly in a PropertyGroup -
// and an explicit PropertyGroup assignment always wins over an inherited
// environment variable of the same name. A command-line global property is
// the one thing a project file can't override, which is exactly what's
// needed here: vintner only ever installs one Windows SDK version, so - same
// reasoning as the PlatformToolset fallback above - any project should
// transparently build against that one installed version rather than fail
// outright over an exact version string it happened to be pinned to when
// last saved from a real Windows SDK selector dropdown.
func msbuildGlobalArgs(cfg *wineenv.Config, args []string) []string {
forced := map[string]string{
"WindowsTargetPlatformVersion": cfg.SDKVer,
}
var out []string
for name, value := range forced {
re := reGlobalProp(name)
alreadySet := false
for _, a := range args {
if re.MatchString(a) {
alreadySet = true
break
}
}
if !alreadySet {
out = append(out, "/p:"+name+"="+value)
}
}
return out
}
func msbuildPlatform(arch string) string {
switch arch {
case "x86":
+200
View File
@@ -0,0 +1,200 @@
package wrapper
import (
"os"
"path/filepath"
"testing"
"github.com/Cheviiot/vintner/internal/wineenv"
)
func TestMsbuildPlatform(t *testing.T) {
for _, tc := range []struct{ arch, want string }{
{"x86", "Win32"},
{"x64", "x64"},
{"arm", "ARM"},
{"arm64", "ARM64"},
} {
if got := msbuildPlatform(tc.arch); got != tc.want {
t.Errorf("msbuildPlatform(%q) = %q, want %q", tc.arch, got, tc.want)
}
}
}
func newTestPaths(t *testing.T, cfg *wineenv.Config) (*wineenv.Paths, string) {
t.Helper()
base := t.TempDir()
return wineenv.NewPaths(cfg, base), base
}
func TestMsbuildEnvBasics(t *testing.T) {
cfg := &wineenv.Config{Arch: "x64", Host: "x64", DotnetHost: "amd64", MSVCVer: "14.51.36231", SDKVer: "10.0.26100.0"}
paths, _ := newTestPaths(t, cfg)
env := msbuildEnv(cfg, paths)
if env["TZ"] != "UTC" {
t.Errorf(`env["TZ"] = %q, want "UTC"`, env["TZ"])
}
if env["DisableRegistryUse"] != "true" {
t.Errorf(`env["DisableRegistryUse"] = %q, want "true"`, env["DisableRegistryUse"])
}
if env["CheckMSVCComponents"] != "false" {
t.Errorf(`env["CheckMSVCComponents"] = %q, want "false" (else CheckVCToolsetVersion errors on an aliased PlatformToolset)`, env["CheckMSVCComponents"])
}
// VCToolsVersion must be a real version string (see msbuildEnv's doc
// comment on it: leaving it unset makes Microsoft.Cpp.VCTools.props
// substitute a placeholder that then breaks unconditional version
// comparisons elsewhere). CheckMSVCComponents=false is what keeps this
// safe to combine with an aliased PlatformToolset.
if env["VCToolsVersion"] != cfg.MSVCVer {
t.Errorf(`env["VCToolsVersion"] = %q, want %q`, env["VCToolsVersion"], cfg.MSVCVer)
}
if env["WindowsTargetPlatformVersion"] != cfg.SDKVer {
t.Errorf(`env["WindowsTargetPlatformVersion"] = %q, want %q`, env["WindowsTargetPlatformVersion"], cfg.SDKVer)
}
if env["Platform"] != "x64" {
t.Errorf(`env["Platform"] = %q, want "x64"`, env["Platform"])
}
if env["SignMode"] != "off" {
t.Errorf(`env["SignMode"] = %q, want "off" (driver builds must not attempt real signing)`, env["SignMode"])
}
// No WDK content on disk in this test - must not claim otherwise.
if _, ok := env["WDKContentRoot"]; ok {
t.Error(`env["WDKContentRoot"] set even though no wdk/<arch>/c directory exists`)
}
}
func TestMsbuildEnvDiscoversEveryToolsetVersion(t *testing.T) {
cfg := &wineenv.Config{Arch: "x64", Host: "x64", DotnetHost: "amd64", MSVCVer: "14.51.36231", SDKVer: "10.0.26100.0"}
paths, base := newTestPaths(t, cfg)
// Real layout has two independent sources feeding VCInstallDir_<N>/
// VCToolsInstallDir_<N> (see toolsetSuffixes' doc comment for why both
// are needed): the PlatformToolset short names a downloaded compiler
// ships default-props for, and MSBuild's own fixed schema-version dirs.
buildDir := filepath.Join(base, "vc", "Auxiliary", "Build")
if err := os.MkdirAll(buildDir, 0o755); err != nil {
t.Fatal(err)
}
for _, name := range []string{
"Microsoft.VCToolsVersion.v145.default.props",
"Microsoft.VCToolsVersion.v143.default.props",
"Microsoft.VCToolsVersion.default.props", // no version suffix - must not match
} {
if err := os.WriteFile(filepath.Join(buildDir, name), nil, 0o644); err != nil {
t.Fatal(err)
}
}
for _, v := range []string{"v180", "not-a-version"} {
if err := os.MkdirAll(filepath.Join(base, "MSBuild", "Microsoft", "VC", v), 0o755); err != nil {
t.Fatal(err)
}
}
env := msbuildEnv(cfg, paths)
for _, n := range []string{"145", "143", "180"} {
if _, ok := env["VCInstallDir_"+n]; !ok {
t.Errorf("expected VCInstallDir_%s to be set", n)
}
if _, ok := env["VCToolsInstallDir_"+n]; !ok {
t.Errorf("expected VCToolsInstallDir_%s to be set", n)
}
}
if _, ok := env["VCInstallDir_not-a-version"]; ok {
t.Error("a directory not matching v<digits> should not have produced a VCInstallDir_ entry")
}
}
func TestToolsetSuffixesDedupsOverlap(t *testing.T) {
base := t.TempDir()
buildDir := filepath.Join(base, "vc", "Auxiliary", "Build")
if err := os.MkdirAll(buildDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(buildDir, "Microsoft.VCToolsVersion.v180.default.props"), nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(base, "MSBuild", "Microsoft", "VC", "v180"), 0o755); err != nil {
t.Fatal(err)
}
got := toolsetSuffixes(base)
count := 0
for _, n := range got {
if n == "180" {
count++
}
}
if count != 1 {
t.Errorf("toolsetSuffixes() returned %q with %d entries for \"180\" (from both sources), want exactly 1", got, count)
}
}
func TestMsbuildGlobalArgsForcesWindowsTargetPlatformVersion(t *testing.T) {
cfg := &wineenv.Config{SDKVer: "10.0.26100.0"}
got := msbuildGlobalArgs(cfg, []string{"Foo.sln", "/p:Configuration=Release"})
want := "/p:WindowsTargetPlatformVersion=10.0.26100.0"
found := false
for _, a := range got {
if a == want {
found = true
}
}
if !found {
t.Errorf("msbuildGlobalArgs(...) = %v, want it to contain %q", got, want)
}
}
func TestMsbuildGlobalArgsRespectsExplicitOverride(t *testing.T) {
cfg := &wineenv.Config{SDKVer: "10.0.26100.0"}
for _, explicit := range []string{
"/p:WindowsTargetPlatformVersion=10.0.19041.0",
"-p:WindowsTargetPlatformVersion=10.0.19041.0",
"/property:WindowsTargetPlatformVersion=10.0.19041.0",
} {
got := msbuildGlobalArgs(cfg, []string{"Foo.sln", explicit})
for _, a := range got {
if reGlobalProp("WindowsTargetPlatformVersion").MatchString(a) {
t.Errorf("msbuildGlobalArgs with explicit %q also injected %q - should have left the caller's value alone", explicit, a)
}
}
}
}
func TestMsbuildEnvDetectsWDKContentRoot(t *testing.T) {
cfg := &wineenv.Config{Arch: "x64", Host: "x64", DotnetHost: "amd64", MSVCVer: "14.51.36231", SDKVer: "10.0.26100.0"}
paths, base := newTestPaths(t, cfg)
if err := os.MkdirAll(filepath.Join(base, "wdk", "x64", "c"), 0o755); err != nil {
t.Fatal(err)
}
env := msbuildEnv(cfg, paths)
if env["WDKContentRoot"] == "" {
t.Error("expected WDKContentRoot to be set once wdk/x64/c exists on disk")
}
if env["WDKBuildFolder"] != cfg.SDKVer {
t.Errorf(`env["WDKBuildFolder"] = %q, want %q`, env["WDKBuildFolder"], cfg.SDKVer)
}
}
func TestMsbuildEnvPreferredToolArchitecture(t *testing.T) {
// PreferredToolArchitecture should only be set when the host toolset
// bin dir is the 64-bit ("amd64") .NET host - not for arm64.
cfg64 := &wineenv.Config{Arch: "x64", Host: "x64", DotnetHost: "amd64", MSVCVer: "1", SDKVer: "1"}
paths64, _ := newTestPaths(t, cfg64)
if env := msbuildEnv(cfg64, paths64); env["PreferredToolArchitecture"] != "x64" {
t.Errorf(`with DotnetHost=amd64, PreferredToolArchitecture = %q, want "x64"`, env["PreferredToolArchitecture"])
}
cfgARM := &wineenv.Config{Arch: "arm64", Host: "arm64", DotnetHost: "arm64", MSVCVer: "1", SDKVer: "1"}
pathsARM, _ := newTestPaths(t, cfgARM)
if env := msbuildEnv(cfgARM, pathsARM); env["PreferredToolArchitecture"] != "" {
t.Errorf(`with DotnetHost=arm64, PreferredToolArchitecture = %q, want unset`, env["PreferredToolArchitecture"])
}
}
+7 -1
View File
@@ -29,7 +29,13 @@ func execInherit(args []string) int {
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
setNewProcessGroup(cmd)
if err := cmd.Start(); err != nil {
return 127
}
stopSignals := forwardSignals(cmd.Process)
defer stopSignals()
if err := cmd.Wait(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode()
}
+20 -2
View File
@@ -83,14 +83,17 @@ func Run(tool string, args []string) int {
// MSBuild: skip all filtering/toolrelay (its output is meant to be
// read as-is), and add the extra environment MSBuild's own
// toolset/SDK-detection props need on top of the generic
// INCLUDE/LIB/WINEPATH.
cmd := exec.Command(wineBin, append([]string{toolExePath}, rewritten...)...)
// INCLUDE/LIB/WINEPATH, plus any global properties a project file
// itself could otherwise override (see msbuildGlobalArgs).
msArgs := append(msbuildGlobalArgs(cfg, rewritten), rewritten...)
cmd := exec.Command(wineBin, append([]string{toolExePath}, msArgs...)...)
env := buildEnv(paths)
for k, v := range msbuildEnv(cfg, paths) {
env = append(env, k+"="+v)
}
cmd.Env = env
cmd.Stdin = os.Stdin
setNewProcessGroup(cmd)
exitCode = runRawStdout(cmd)
default:
relay := filepath.Join(paths.BaseUnix, "bin", toolRelayName)
@@ -100,6 +103,7 @@ func Run(tool string, args []string) int {
cmd := exec.Command(wineBin, append([]string{toolExePath}, rewritten...)...)
cmd.Env = buildEnv(paths)
cmd.Stdin = os.Stdin
setNewProcessGroup(cmd)
exitCode = runFiltered(cmd, s.stdoutFilter, s.stderrFilter)
}
}
@@ -137,6 +141,7 @@ func runViaToolRelay(wineBin, relayExe, exePath string, args []string, paths *wi
cmdArgs := append([]string{relayExe, exePath}, args...)
cmd := exec.Command(wineBin, cmdArgs...)
cmd.Env = append(buildEnv(paths), "MSVCGOWINE_STDOUT="+stdoutFifo, "MSVCGOWINE_STDERR="+stderrFifo)
setNewProcessGroup(cmd)
if devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0); err == nil {
defer devNull.Close()
cmd.Stdout = devNull
@@ -147,6 +152,8 @@ func runViaToolRelay(wineBin, relayExe, exePath string, args []string, paths *wi
fmt.Fprintln(os.Stderr, "vintner:", err)
return 1
}
stopSignals := forwardSignals(cmd.Process)
defer stopSignals()
var wg sync.WaitGroup
wg.Add(2)
@@ -204,6 +211,8 @@ func runRawStdout(cmd *exec.Cmd) int {
fmt.Fprintln(os.Stderr, "vintner:", err)
return 1
}
stopSignals := forwardSignals(cmd.Process)
defer stopSignals()
doneOut := make(chan struct{})
doneErr := make(chan struct{})
@@ -278,6 +287,8 @@ func runFiltered(cmd *exec.Cmd, stdoutF, stderrF lineFilter) int {
fmt.Fprintln(os.Stderr, "vintner:", err)
return 1
}
stopSignals := forwardSignals(cmd.Process)
defer stopSignals()
doneOut := make(chan struct{})
doneErr := make(chan struct{})
@@ -310,4 +321,11 @@ func pumpLines(r io.Reader, w *os.File, filter lineFilter) {
}
fmt.Fprintln(w, line)
}
// bufio.Scanner silently stops (dropping the rest of the stream) once a
// single line exceeds its 16MB buffer - surface that rather than letting
// build output vanish without explanation (heavily templated C++ error
// messages are the realistic way to hit this).
if err := scanner.Err(); err != nil {
fmt.Fprintf(w, "vintner: output truncated: %v\n", err)
}
}
+68
View File
@@ -0,0 +1,68 @@
package wrapper
import (
"os"
"os/exec"
"os/signal"
"syscall"
"time"
)
// killGrace bounds how long a forwarded SIGINT/SIGTERM gets to make a
// subprocess tree exit on its own before escalating to SIGKILL - long
// enough for wineserver to tear down a Windows process tree cleanly, short
// enough that an unresponsive one doesn't hang vintner's own shutdown.
const killGrace = 5 * time.Second
// setNewProcessGroup puts cmd's eventual child in its own process group
// (pgid = its own pid) instead of inheriting vintner's. Without this, a
// caller that signals vintner by PID alone (a CI runner enforcing a
// timeout, a supervisor's `kill <pid>`) never reaches the wine/wineserver
// tree underneath it, which is then reparented to init and keeps running -
// wasting CPU, holding file locks, leaving stray FIFOs/temp files behind.
// (Interactive Ctrl-C already reaches every process in the terminal's
// foreground group regardless of this, but forwardSignals below handles
// that case too now that the child has moved to its own group.)
func setNewProcessGroup(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
// forwardSignals relays SIGINT/SIGTERM received by vintner itself to
// proc's entire process group (proc must have been started via a cmd that
// called setNewProcessGroup, making proc.Pid also the group id), escalating
// to SIGKILL after killGrace if the group hasn't exited by then. Callers
// must call the returned stop func once the process has actually exited
// (e.g. right after cmd.Wait() returns), both to stop listening for
// signals and to cancel a pending escalation.
func forwardSignals(proc *os.Process) (stop func()) {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
done := make(chan struct{})
go func() {
pgid := -proc.Pid
for {
select {
case sig := <-sigCh:
s, ok := sig.(syscall.Signal)
if !ok {
continue
}
_ = syscall.Kill(pgid, s)
select {
case <-time.After(killGrace):
_ = syscall.Kill(pgid, syscall.SIGKILL)
case <-done:
return
}
case <-done:
return
}
}
}()
return func() {
signal.Stop(sigCh)
close(done)
}
}
+43
View File
@@ -0,0 +1,43 @@
package wrapper
import (
"os"
"os/exec"
"syscall"
"testing"
"time"
)
// TestForwardSignalsKillsChild verifies the actual mechanism that keeps a
// wine subprocess from being orphaned: a SIGTERM delivered to the current
// process (mimicking `kill <vintner-pid>`, not an interactive Ctrl-C) must
// reach a child started with setNewProcessGroup, even though it's no longer
// in the same process group.
func TestForwardSignalsKillsChild(t *testing.T) {
cmd := exec.Command("sleep", "30")
setNewProcessGroup(cmd)
if err := cmd.Start(); err != nil {
t.Fatalf("starting sleep: %v", err)
}
stop := forwardSignals(cmd.Process)
defer stop()
// signal.Notify (inside forwardSignals) intercepts this rather than
// letting it terminate the test binary itself.
if err := syscall.Kill(os.Getpid(), syscall.SIGTERM); err != nil {
t.Fatalf("signaling self: %v", err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
if err == nil {
t.Fatal("expected the child to be killed by the forwarded signal, but it exited successfully")
}
case <-time.After(3 * time.Second):
cmd.Process.Kill()
t.Fatal("child was still running 3s after the signal should have been forwarded")
}
}
+45
View File
@@ -0,0 +1,45 @@
package wrapper
import (
"testing"
"github.com/Cheviiot/vintner/internal/wineenv"
)
func TestSpecExeDir(t *testing.T) {
paths := &wineenv.Paths{
BinDir: "/bin-dir",
SDKBinDir: "/sdk-bin-dir",
MSBuildBinDir: "/msbuild-bin-dir",
}
for _, tc := range []struct {
name string
dir dirKind
want string
}{
{"dirBin", dirBin, "/bin-dir"},
{"dirSDK", dirSDK, "/sdk-bin-dir"},
{"dirMSBuild", dirMSBuild, "/msbuild-bin-dir"},
} {
s := spec{dir: tc.dir}
if got := s.exeDir(paths); got != tc.want {
t.Errorf("%s: exeDir() = %q, want %q", tc.name, got, tc.want)
}
}
}
func TestToolsAndNativeToolsAreDisjoint(t *testing.T) {
for name := range Tools {
if nativeTools[name] {
t.Errorf("%q is in both Tools and nativeTools", name)
}
}
}
func TestEveryToolHasAnExeName(t *testing.T) {
for name, s := range Tools {
if s.exeName == "" {
t.Errorf("Tools[%q] has no exeName", name)
}
}
}