mirror of
https://github.com/Cheviiot/Vintner.git
synced 2026-08-04 00:07:24 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91471397fa | ||
|
|
94a19e6b43 | ||
|
|
6186837fd2 | ||
|
|
c049274626 | ||
|
|
fc20b2fb15 | ||
|
|
26f6df6a9a | ||
|
|
98ee39018a | ||
|
|
4b3aacfdf7 | ||
|
|
0b7b686e3a | ||
|
|
0a4f05c673 | ||
|
|
e0475101b2 | ||
|
|
d11b534fa1 |
@@ -43,3 +43,13 @@ jobs:
|
|||||||
|
|
||||||
- name: go test
|
- name: go test
|
||||||
run: 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
|
||||||
|
|||||||
@@ -1,37 +1,92 @@
|
|||||||
# vintner
|
# vintner
|
||||||
|
|
||||||
Cross compile with MSVC on Linux, using Wine — a single-binary Go tool
|
[](https://github.com/Cheviiot/vintner/actions/workflows/ci.yml)
|
||||||
inspired by [mstorsjo/msvc-wine](https://github.com/mstorsjo/msvc-wine)'s
|
[](https://github.com/Cheviiot/vintner/releases/latest)
|
||||||
approach (download the real MSVC/WinSDK, wrap the compiler under Wine),
|
[](LICENSE.txt)
|
||||||
implemented independently.
|
|
||||||
|
|
||||||
Once installed, you invoke the real Microsoft toolchain exactly like on
|
vintner cross-compiles with the real MSVC toolchain on Linux, using Wine.
|
||||||
Windows: `cl`, `link`, `lib`, `rc`, `midl`, `mc`, `mt`, `dumpbin`, `msbuild`,
|
One Go binary drops in as `cl`, `link`, `lib`, `rc`, `midl`, `mc`, `mt`,
|
||||||
`nmake`, `ml`, `ml64`, `armasm`, `armasm64`, plus trivial `cmd`/`findstr`
|
`dumpbin`, `msbuild`, `nmake`, `ml`, `ml64`, `armasm`, `armasm64`, plus
|
||||||
shims, all just work from your `PATH`.
|
`cmd`/`findstr` shims, so once installed you invoke the real Microsoft
|
||||||
|
tools exactly like on Windows. It handles full MSBuild projects, and with
|
||||||
|
`--with-wdk`, real KMDF/UMDF Windows drivers.
|
||||||
|
|
||||||
|
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)
|
||||||
|
- [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
|
## How it works
|
||||||
|
|
||||||
`vintner` is one Go binary that behaves differently depending on the name
|
vintner is a multi-call binary, like busybox: it behaves differently
|
||||||
it's invoked as (a "multi-call binary", like busybox):
|
depending on the name it's invoked as.
|
||||||
|
|
||||||
- Invoked as `cl`, `link`, `lib`, ... → it loads a small per-architecture
|
- As `cl`, `link`, `lib`, and the rest: it loads a per-architecture
|
||||||
`env.json`, builds the `INCLUDE`/`LIB`/`WINEPATH` environment Wine needs,
|
`env.json`, sets `INCLUDE`/`LIB`/`WINEPATH`, and rewrites absolute Unix
|
||||||
rewrites absolute unix paths in the arguments into Wine's `z:\...` form
|
paths in the arguments to Wine's `z:\...` form (Wine and cl.exe
|
||||||
(working around [a Wine/cl.exe include-path bug](https://bugs.winehq.org/show_bug.cgi?id=55200)),
|
otherwise mishandle relative includes — see
|
||||||
runs the real `.exe` under `wine`/`wine64`, and rewrites the tool's output
|
[winehq bug 55200](https://bugs.winehq.org/show_bug.cgi?id=55200)). It
|
||||||
back from `z:\...` paths to plain unix paths so your build system's error
|
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.
|
parsing keeps working.
|
||||||
- Invoked as `vintner` → it exposes the `download`, `install`, `env` and
|
- As `vintner`: it exposes the `download`, `install`, `env`, `version`
|
||||||
`version` management subcommands described below (each also has a short
|
and `completion` subcommands below (short aliases: `dl`, `i`, `e`, `v`;
|
||||||
alias: `dl`, `i`, `e`, `v`; `help`/`h` prints usage).
|
`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
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Download and unpack MSVC + Windows SDK into ~/.vintner (requires
|
# 1. Download and unpack MSVC + Windows SDK into ~/.vintner (accepts
|
||||||
# accepting Microsoft's Visual Studio Build Tools license, and msitools
|
# Microsoft's Visual Studio Build Tools license). Pass --dest <dir>
|
||||||
# for unpacking .msi payloads). Pass --dest <dir> for a different location.
|
# for a different location.
|
||||||
vintner download --accept-license
|
vintner download --accept-license
|
||||||
|
|
||||||
# 2. Wire up the tool wrappers
|
# 2. Wire up the tool wrappers
|
||||||
@@ -42,19 +97,6 @@ export PATH=~/.vintner/bin/x64:$PATH
|
|||||||
cl /nologo /EHsc hello.cpp
|
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
|
## Commands
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -63,48 +105,72 @@ 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 env (e) --bin <dir>/bin/<arch> print INCLUDE/LIB for native clang-cl/lld-link use
|
||||||
vintner version (v) print the version
|
vintner version (v) print the version
|
||||||
vintner help (h) print usage
|
vintner help (h) print usage
|
||||||
|
vintner completion bash|zsh print a shell completion script
|
||||||
```
|
```
|
||||||
|
|
||||||
`--dest`/`[dir]` both default to `~/.vintner` when omitted.
|
`--dest`/`[dir]` both default to `~/.vintner` when omitted.
|
||||||
|
|
||||||
`download`'s main options: `--msvc-version`, `--sdk-version`,
|
`download`'s main options: `--msvc-version`, `--sdk-version`,
|
||||||
`--architecture`, `--host-arch`, `--only-host`, `--with-wdk` (also fetch the
|
`--architecture` (repeatable: `x86`/`x64`/`arm`/`arm64`/`host`),
|
||||||
Windows Driver Kit, for building KMDF/UMDF drivers), `--ignore`,
|
`--host-arch`, `--only-host`, `--with-wdk` (see below), `--ignore`
|
||||||
`--only-download`, `--only-unpack`, `--keep-unpack`, `--skip-patch`,
|
(repeatable), `--only-download`, `--only-unpack`, `--keep-unpack`,
|
||||||
`--cache`, `--language`, `--include-optional`, `--skip-recommended`,
|
`--skip-patch`, `--cache`, `--language`, `--include-optional`,
|
||||||
`--major`, `--preview`, `--manifest`, `--list-workloads`,
|
`--skip-recommended`, `--major`, `--preview`, `--manifest`,
|
||||||
`--list-components`, `--print-deps-tree`. Run `vintner download -h` for the
|
`--list-workloads`, `--list-components`, `--print-deps-tree`. Run
|
||||||
full list with descriptions.
|
`vintner download -h` for the full list with descriptions.
|
||||||
|
|
||||||
`--list-workloads`/`--list-components` print every workload/component id
|
`--list-workloads`/`--list-components` print every workload/component id
|
||||||
(with its human-readable title) available in the fetched manifest and exit
|
and its human-readable title from the fetched manifest, then exit
|
||||||
without downloading anything - useful for discovering what to pass as a bare
|
without downloading anything. Useful for finding what to pass as a bare
|
||||||
package id or via `--with-*`. `--print-deps-tree` prints the dependency tree
|
package id or through `--with-*`. `--print-deps-tree` prints the
|
||||||
of whatever would actually be selected (honoring every other flag), also
|
dependency tree of whatever would actually be selected — honoring every
|
||||||
without downloading.
|
other flag — without downloading anything.
|
||||||
|
|
||||||
### Building drivers (WDK)
|
## Building drivers (WDK)
|
||||||
|
|
||||||
`vintner download --with-wdk` additionally fetches the Windows Driver Kit
|
`--with-wdk` also fetches the Windows Driver Kit: headers, import libs,
|
||||||
(headers, import libs, and the MSBuild `WindowsKernelModeDriver10.0`/
|
and the MSBuild `WindowsKernelModeDriver10.0`/`WindowsUserModeDriver10.0`
|
||||||
`WindowsUserModeDriver10.0` PlatformToolsets) so `msbuild` can build real
|
PlatformToolsets.
|
||||||
KMDF/UMDF drivers - compiling, linking, INF stamping and the `Inf2Cat`
|
|
||||||
signability check (with `SignMode=off`) all work under Wine. Verified
|
```bash
|
||||||
end-to-end against a real sample driver from
|
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).
|
[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
|
## Language
|
||||||
|
|
||||||
CLI messages (usage text, progress lines, prompts) are in English by
|
CLI text (usage, progress lines, prompts) defaults to English. Set
|
||||||
default. Set `VINTNER_LANG=ru` (or have a `ru`-prefixed `LC_ALL`/
|
`VINTNER_LANG=ru` (or a `ru`-prefixed `LC_ALL`/`LC_MESSAGES`/`LANG`, e.g.
|
||||||
`LC_MESSAGES`/`LANG`, e.g. `ru_RU.UTF-8`) for Russian. Deeper error text
|
`ru_RU.UTF-8`) for Russian:
|
||||||
bubbled up from internal packages stays in English.
|
|
||||||
|
|
||||||
### Using clang-cl/lld-link instead of Wine
|
```bash
|
||||||
|
VINTNER_LANG=ru vintner help
|
||||||
|
```
|
||||||
|
|
||||||
You don't need Wine at all if you drive the (nonredistributable) MSVC/WinSDK
|
Error text from internal packages stays in English regardless.
|
||||||
headers and libraries with Clang/LLD in MSVC-compatible mode:
|
|
||||||
|
## 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
|
```bash
|
||||||
eval "$(vintner env --bin ~/.vintner/bin/x64)"
|
eval "$(vintner env --bin ~/.vintner/bin/x64)"
|
||||||
@@ -112,49 +178,44 @@ clang-cl -c hello.c
|
|||||||
lld-link hello.obj -out:hello.exe
|
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
|
## Building from source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build -o vintner ./cmd/vintner
|
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
|
Go 1.23+ builds it. `wine`/`msitools` are only needed at run time, for
|
||||||
run time (`install`/tool invocation and `download` respectively).
|
`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.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT, see [LICENSE.txt](LICENSE.txt) - covers vintner's own source only. The
|
MIT (see [LICENSE.txt](LICENSE.txt)) for vintner's own source. The MSVC
|
||||||
MSVC Build Tools / Windows SDK / WDK that `download` fetches remain governed
|
Build Tools, Windows SDK, and WDK that `download` fetches stay under
|
||||||
by Microsoft's own license (accepted via `--accept-license`), same as with
|
Microsoft's own license (accepted via `--accept-license`), same as with
|
||||||
any other way of obtaining them.
|
any other way of obtaining them.
|
||||||
|
|||||||
Vendored
+10
-2
@@ -79,8 +79,16 @@ HANDLE MakeKillOnCloseJob() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool IsMtExe(const wchar_t *path) {
|
bool IsMtExe(const wchar_t *path) {
|
||||||
const wchar_t *name = wcsrchr(path, L'\\');
|
// vintner passes toolExePath straight through from Go's filepath.Join,
|
||||||
name = name ? name + 1 : path;
|
// 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;
|
return _wcsicmp(name, L"mt.exe") == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 "$@"
|
||||||
|
`
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,17 @@ func runDownload(args []string) int {
|
|||||||
}
|
}
|
||||||
packages := fs.Args()
|
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{
|
opts := &download.Options{
|
||||||
Package: packages,
|
Package: packages,
|
||||||
Ignore: []string(ignoreFlag),
|
Ignore: []string(ignoreFlag),
|
||||||
@@ -267,6 +278,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 {
|
func detectHostArch() string {
|
||||||
if runtime.GOARCH == "arm64" {
|
if runtime.GOARCH == "arm64" {
|
||||||
return "arm64"
|
return "arm64"
|
||||||
|
|||||||
@@ -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)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -47,8 +47,10 @@ func runCLI(args []string) int {
|
|||||||
return runInstall(args[1:])
|
return runInstall(args[1:])
|
||||||
case "env", "e":
|
case "env", "e":
|
||||||
return runEnv(args[1:])
|
return runEnv(args[1:])
|
||||||
|
case "completion":
|
||||||
|
return runCompletion(args[1:])
|
||||||
case "version", "v", "--version":
|
case "version", "v", "--version":
|
||||||
fmt.Println("vintner " + version)
|
fmt.Println(versionString())
|
||||||
return 0
|
return 0
|
||||||
case "-h", "--help", "help", "h":
|
case "-h", "--help", "help", "h":
|
||||||
printUsage()
|
printUsage()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,6 +84,9 @@ func FetchPayloads(selected []*Package, cacheDir string, allowHashMismatch bool)
|
|||||||
func fetchOnePayloadWithRetries(payload Payload, dest, fileID string, allowHashMismatch bool) (int64, error) {
|
func fetchOnePayloadWithRetries(payload Payload, dest, fileID string, allowHashMismatch bool) (int64, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt < maxDownloadAttempts; attempt++ {
|
for attempt := 0; attempt < maxDownloadAttempts; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
time.Sleep(retryBackoff(attempt))
|
||||||
|
}
|
||||||
n, err := tryDownloadPayload(payload, dest, fileID, allowHashMismatch)
|
n, err := tryDownloadPayload(payload, dest, fileID, allowHashMismatch)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return n, 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)
|
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) {
|
func tryDownloadPayload(payload Payload, dest, fileID string, allowHashMismatch bool) (int64, error) {
|
||||||
if fi, err := os.Stat(dest); err == nil && fi.Mode().IsRegular() {
|
if fi, err := os.Stat(dest); err == nil && fi.Mode().IsRegular() {
|
||||||
if payload.SHA256 != "" {
|
if payload.SHA256 != "" {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -198,6 +198,9 @@ const maxManifestAttempts = 5
|
|||||||
func httpGet(url string) ([]byte, error) {
|
func httpGet(url string) ([]byte, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt < maxManifestAttempts; attempt++ {
|
for attempt := 0; attempt < maxManifestAttempts; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
time.Sleep(retryBackoff(attempt))
|
||||||
|
}
|
||||||
data, err := tryHTTPGet(url)
|
data, err := tryHTTPGet(url)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return data, nil
|
return data, nil
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@ var reSDKVersion = regexp.MustCompile(`^\d+\.\d+\.\d+`)
|
|||||||
type TriState = *bool
|
type TriState = *bool
|
||||||
|
|
||||||
func on() TriState { v := true; return &v }
|
func on() TriState { v := true; return &v }
|
||||||
func off() TriState { v := false; return &v }
|
|
||||||
|
|
||||||
// Options holds every flag that feeds package selection and download.
|
// Options holds every flag that feeds package selection and download.
|
||||||
type Options struct {
|
type Options struct {
|
||||||
@@ -303,6 +302,7 @@ func selectSDK(opts *Options, idx Index) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
|
sort.Strings(versions)
|
||||||
return fmt.Errorf("WinSDK version %s not found (available: %s)", opts.SDKVersion, strings.Join(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
|
included[key] = true
|
||||||
|
|
||||||
ret := []*Package{p}
|
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
|
id := target
|
||||||
if dep.TargetID != "" {
|
if dep.TargetID != "" {
|
||||||
id = dep.TargetID
|
id = dep.TargetID
|
||||||
|
|||||||
@@ -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 {
|
func idsOf(pkgs []*Package) []string {
|
||||||
var ids []string
|
var ids []string
|
||||||
for _, p := range pkgs {
|
for _, p := range pkgs {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,6 +74,7 @@ Usage:
|
|||||||
vintner env (e) --bin <dir/bin/arch> print INCLUDE/LIB for native clang-cl/lld-link use
|
vintner env (e) --bin <dir/bin/arch> print INCLUDE/LIB for native clang-cl/lld-link use
|
||||||
vintner version (v) print the version
|
vintner version (v) print the version
|
||||||
vintner help (h) show this message
|
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
|
Run "vintner <command> --help" for that command's own options - download
|
||||||
has many, including --with-wdk, --list-workloads, --list-components and
|
has many, including --with-wdk, --list-workloads, --list-components and
|
||||||
@@ -81,6 +82,7 @@ has many, including --with-wdk, --list-workloads, --list-components and
|
|||||||
|
|
||||||
--dest/[dir] default to ~/.vintner if omitted.
|
--dest/[dir] default to ~/.vintner if omitted.
|
||||||
Language: set VINTNER_LANG=ru (or LANG=ru_RU...) for Russian output.
|
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:
|
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
|
cl, link, lib, ml, ml64, mc, midl, mt, rc, dumpbin, msbuild, nmake, armasm, armasm64, cmd, findstr
|
||||||
@@ -94,6 +96,7 @@ 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 env (e) --bin <dir/bin/arch> вывести INCLUDE/LIB для clang-cl/lld-link напрямую
|
||||||
vintner version (v) показать версию
|
vintner version (v) показать версию
|
||||||
vintner help (h) показать эту справку
|
vintner help (h) показать эту справку
|
||||||
|
vintner completion bash|zsh вывести скрипт автодополнения для оболочки
|
||||||
|
|
||||||
Запустите «vintner <команда> --help» для параметров конкретной команды —
|
Запустите «vintner <команда> --help» для параметров конкретной команды —
|
||||||
у download их много, включая --with-wdk, --list-workloads, --list-components
|
у download их много, включая --with-wdk, --list-workloads, --list-components
|
||||||
@@ -101,6 +104,7 @@ Once installed, add <dir>/bin/<arch> to PATH and invoke the tools directly:
|
|||||||
|
|
||||||
--dest/[каталог] по умолчанию — ~/.vintner.
|
--dest/[каталог] по умолчанию — ~/.vintner.
|
||||||
Язык: установите VINTNER_LANG=en (или LANG=en_US...) для вывода на английском.
|
Язык: установите VINTNER_LANG=en (или LANG=en_US...) для вывода на английском.
|
||||||
|
Автодополнение: source <(vintner completion bash) # или zsh
|
||||||
|
|
||||||
После установки добавьте <dir>/bin/<arch> в PATH и вызывайте инструменты напрямую:
|
После установки добавьте <dir>/bin/<arch> в PATH и вызывайте инструменты напрямую:
|
||||||
cl, link, lib, ml, ml64, mc, midl, mt, rc, dumpbin, msbuild, nmake, armasm, armasm64, cmd, findstr
|
cl, link, lib, ml, ml64, mc, midl, mt, rc, dumpbin, msbuild, nmake, armasm, armasm64, cmd, findstr
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
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["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)
|
||||||
|
|
||||||
|
for _, v := range []string{"v145", "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", "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 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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,13 @@ func execInherit(args []string) int {
|
|||||||
cmd.Stdin = os.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
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 {
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
return exitErr.ExitCode()
|
return exitErr.ExitCode()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ func Run(tool string, args []string) int {
|
|||||||
}
|
}
|
||||||
cmd.Env = env
|
cmd.Env = env
|
||||||
cmd.Stdin = os.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
|
setNewProcessGroup(cmd)
|
||||||
exitCode = runRawStdout(cmd)
|
exitCode = runRawStdout(cmd)
|
||||||
default:
|
default:
|
||||||
relay := filepath.Join(paths.BaseUnix, "bin", toolRelayName)
|
relay := filepath.Join(paths.BaseUnix, "bin", toolRelayName)
|
||||||
@@ -100,6 +101,7 @@ func Run(tool string, args []string) int {
|
|||||||
cmd := exec.Command(wineBin, append([]string{toolExePath}, rewritten...)...)
|
cmd := exec.Command(wineBin, append([]string{toolExePath}, rewritten...)...)
|
||||||
cmd.Env = buildEnv(paths)
|
cmd.Env = buildEnv(paths)
|
||||||
cmd.Stdin = os.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
|
setNewProcessGroup(cmd)
|
||||||
exitCode = runFiltered(cmd, s.stdoutFilter, s.stderrFilter)
|
exitCode = runFiltered(cmd, s.stdoutFilter, s.stderrFilter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,6 +139,7 @@ func runViaToolRelay(wineBin, relayExe, exePath string, args []string, paths *wi
|
|||||||
cmdArgs := append([]string{relayExe, exePath}, args...)
|
cmdArgs := append([]string{relayExe, exePath}, args...)
|
||||||
cmd := exec.Command(wineBin, cmdArgs...)
|
cmd := exec.Command(wineBin, cmdArgs...)
|
||||||
cmd.Env = append(buildEnv(paths), "MSVCGOWINE_STDOUT="+stdoutFifo, "MSVCGOWINE_STDERR="+stderrFifo)
|
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 {
|
if devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0); err == nil {
|
||||||
defer devNull.Close()
|
defer devNull.Close()
|
||||||
cmd.Stdout = devNull
|
cmd.Stdout = devNull
|
||||||
@@ -147,6 +150,8 @@ func runViaToolRelay(wineBin, relayExe, exePath string, args []string, paths *wi
|
|||||||
fmt.Fprintln(os.Stderr, "vintner:", err)
|
fmt.Fprintln(os.Stderr, "vintner:", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
stopSignals := forwardSignals(cmd.Process)
|
||||||
|
defer stopSignals()
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(2)
|
wg.Add(2)
|
||||||
@@ -204,6 +209,8 @@ func runRawStdout(cmd *exec.Cmd) int {
|
|||||||
fmt.Fprintln(os.Stderr, "vintner:", err)
|
fmt.Fprintln(os.Stderr, "vintner:", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
stopSignals := forwardSignals(cmd.Process)
|
||||||
|
defer stopSignals()
|
||||||
|
|
||||||
doneOut := make(chan struct{})
|
doneOut := make(chan struct{})
|
||||||
doneErr := make(chan struct{})
|
doneErr := make(chan struct{})
|
||||||
@@ -278,6 +285,8 @@ func runFiltered(cmd *exec.Cmd, stdoutF, stderrF lineFilter) int {
|
|||||||
fmt.Fprintln(os.Stderr, "vintner:", err)
|
fmt.Fprintln(os.Stderr, "vintner:", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
stopSignals := forwardSignals(cmd.Process)
|
||||||
|
defer stopSignals()
|
||||||
|
|
||||||
doneOut := make(chan struct{})
|
doneOut := make(chan struct{})
|
||||||
doneErr := make(chan struct{})
|
doneErr := make(chan struct{})
|
||||||
@@ -310,4 +319,11 @@ func pumpLines(r io.Reader, w *os.File, filter lineFilter) {
|
|||||||
}
|
}
|
||||||
fmt.Fprintln(w, line)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user