10 Commits
Author SHA1 Message Date
АлександрandGitHub 6220d26072 Update README.md 2026-07-31 19:38:18 +10:00
Cheviiot de49e80cb7 Resume interrupted downloads instead of restarting from scratch
MSVC/WinSDK/WDK/DXSDK payloads run into the hundreds of MB to several
GB, so a dropped connection or a retry after a transient error used to
mean throwing away everything already fetched and starting over from
byte 0. Track progress in a dest+".part" file and resume it via an
HTTP Range request, falling back to a full restart when the server
doesn't honor Range (200 instead of 206) or the local part is stale
(416).
2026-07-25 18:17:59 +10:00
Cheviiot 98bac75767 Make the wine-not-found error actionable
FindWine's error used to just say wine64/wine weren't on PATH, with no
next step - surface the exact install fix ("install the wine
package") instead of leaving the reader to figure that out
themselves.
2026-07-25 18:17:59 +10:00
Cheviiot 5cf52c9f9f Add vintner doctor for diagnosing a broken wine/toolchain setup
A misconfigured environment (wine missing, msitools not installed, a
partially-built toolchain) otherwise only surfaces as a wine-specific
error buried deep inside a build. `vintner doctor` checks wine itself
(found and actually runs), the optional extraction tools download
needs, and every installed <dest>/bin/<arch> toolchain's on-disk
layout, printing a pass/fail checklist and exiting non-zero if
anything's broken.
2026-07-25 18:14:47 +10:00
Cheviiot b366dfa9ac Add a concurrency lock for download/install against the same destination
combineDirTrees' merge logic assumes it's the only thing moving files
into a given target at a time; two `vintner download`/`install` runs
racing against the same --dest could otherwise interleave os.Rename
calls and corrupt the tree instead of erroring cleanly. Take an
exclusive, non-blocking flock(2) on the destination for the duration
of each run, so a second invocation fails immediately with a clear
message instead of silently colliding with the first.
2026-07-25 18:10:56 +10:00
Cheviiot 738234186d Run wrapped tools directly as vintner <tool> ..., no PATH needed
`vintner cl ...`, `vintner msbuild ...`, etc. now work without adding
<dest>/bin/<arch> to PATH or relying on the same-directory symlinks
`install` sets up there. wrapper.Run gained an explicit binDir
parameter (empty string preserves the existing os.Executable()-based
self-location for the ordinary multi-call/symlink case) so
cmd/vintner's new runTool can point it at a resolved toolchain
directory instead.

Resolution order: VINTNER_BIN if set (same meaning as `env --bin` -
point it at a <dest>/bin/<arch> directory directly, for a non-default
--dest or a specific architecture), else <defaultToolchainDir>/bin/
<hostArch> - the layout a plain `vintner download && vintner install`
with no --dest override produces. A missing toolchain gets a clear
error pointing at both fixes, rather than bubbling up whatever
wineenv.Load's env.json error looks like.

Also fixed shell completion falling out of sync with the actual tool
list: the bash/zsh scripts previously hand-copied tool/flag names
(and had already gone stale once - --with-dxsdk was missing from the
download flag completions since it was added). The tool name list is
now generated from wrapper.ToolNames() instead of hand-maintained,
and both scripts now complete tool names too, so `vintner <TAB>`
suggests `cl`, `link`, `msbuild`, etc. alongside the management
subcommands.

Verified end-to-end with a minimal PATH (/usr/bin:/bin only, no
toolchain dir on it at all): `vintner cl /nologo hello.c` compiled
successfully, and `vintner msbuild -t:Rebuild ...` rebuilt the same
real KMDF driver verified earlier this session - both via the default
~/.vintner/bin/<hostArch> resolution, no VINTNER_BIN override needed.
2026-07-25 16:23:46 +10:00
Cheviiot f6b9a0811a Prevent and recover from wedged Wine-hosted processes
Prompted by a real incident: an MSBuild node-reuse worker (its own
/nodeReuse:true default) survived a build getting interrupted, came
back deadlocked, and got reused by the next `msbuild` invocation -
which then failed with a confusing, unrelated-looking
`System.TypeLoadException` on Microsoft.VisualStudio.Telemetry on
every call for hours, until the stale process was killed by hand.
That's exactly the "unrelated blocker" noted in this repo's own
earlier session notes (CLAUDE.md) while debugging a real project's
build - it wasn't a missing dependency, it was a corrupted reused
process.

Two changes:

- vintner now forces /nodeReuse:false on every msbuild invocation
  (unless the caller already passed their own /nodeReuse or /nr
  switch), so a wedged worker can never poison a later, unrelated
  build in the first place. Costs each invocation the couple-hundred-
  ms/node startup time node reuse exists to save.

- VINTNER_TIMEOUT (a duration string, e.g. "30m") bounds how long any
  single tool invocation is allowed to run, for the case something
  wedges that isn't MSBuild-specific. Every exec.Command site in
  internal/wrapper now goes through a shared newToolCommand
  constructor that, when the timeout is set, kills the *whole*
  process group (not just the immediate `wine` process - a wedged
  child surviving under it is exactly the scenario this needs to
  reach) via a context deadline, and reports a clear "timed out after
  Xm" message (exit 124, matching the timeout(1) convention) instead
  of a bare "signal: killed". Unset by default - every real build
  observed stays unbounded, matching Windows' own behavior.

Verified end-to-end, not just at the unit level: a real `sleep 30`
through the `cmd` native wrapper with VINTNER_TIMEOUT=1s was killed
within the deadline and reported the timeout clearly (exit 124); a
real `cl` invocation with the same 1s timeout finished normally
(0.26s) without being mistaken for a hang.
2026-07-25 15:58:22 +10:00
Cheviiot bdea270d71 docs: list cabextract as a prerequisite for --with-dxsdk 2026-07-25 15:44:36 +10:00
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
30 changed files with 2042 additions and 119 deletions
+93 -13
View File
@@ -1,4 +1,4 @@
# vintner # Vintner
[![CI](https://github.com/Cheviiot/vintner/actions/workflows/ci.yml/badge.svg)](https://github.com/Cheviiot/vintner/actions/workflows/ci.yml) [![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) [![Release](https://img.shields.io/github/v/release/Cheviiot/vintner)](https://github.com/Cheviiot/vintner/releases/latest)
@@ -8,8 +8,9 @@ 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`, One Go binary drops in as `cl`, `link`, `lib`, `rc`, `midl`, `mc`, `mt`,
`dumpbin`, `msbuild`, `nmake`, `ml`, `ml64`, `armasm`, `armasm64`, plus `dumpbin`, `msbuild`, `nmake`, `ml`, `ml64`, `armasm`, `armasm64`, plus
`cmd`/`findstr` shims, so once installed you invoke the real Microsoft `cmd`/`findstr` shims, so once installed you invoke the real Microsoft
tools exactly like on Windows. It handles full MSBuild projects, and with tools exactly like on Windows. It handles full MSBuild projects, with
`--with-wdk`, real KMDF/UMDF Windows drivers. `--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 Inspired by [mstorsjo/msvc-wine](https://github.com/mstorsjo/msvc-wine)'s
approach: download the real MSVC/WinSDK, wrap the compiler under Wine. approach: download the real MSVC/WinSDK, wrap the compiler under Wine.
@@ -20,7 +21,11 @@ approach: download the real MSVC/WinSDK, wrap the compiler under Wine.
- [Installation](#installation) - [Installation](#installation)
- [Quick start](#quick-start) - [Quick start](#quick-start)
- [Commands](#commands) - [Commands](#commands)
- [Invoking tools without PATH](#invoking-tools-without-path)
- [Diagnosing problems (vintner doctor)](#diagnosing-problems-vintner-doctor)
- [Building drivers (WDK)](#building-drivers-wdk) - [Building drivers (WDK)](#building-drivers-wdk)
- [Building against D3DX9 (DirectX SDK)](#building-against-d3dx9-directx-sdk)
- [Automated/scripted builds](#automatedscripted-builds)
- [Language](#language) - [Language](#language)
- [Shell completion](#shell-completion) - [Shell completion](#shell-completion)
- [Using clang-cl/lld-link instead of Wine](#using-clang-cllld-link-instead-of-wine) - [Using clang-cl/lld-link instead of Wine](#using-clang-cllld-link-instead-of-wine)
@@ -42,9 +47,9 @@ depending on the name it's invoked as.
then runs the real `.exe` under `wine`/`wine64`, and rewrites `z:\...` 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 paths back to Unix paths in the output, so your build system's error
parsing keeps working. parsing keeps working.
- As `vintner`: it exposes the `download`, `install`, `env`, `version` - As `vintner`: it exposes the `download`, `install`, `env`, `version`,
and `completion` subcommands below (short aliases: `dl`, `i`, `e`, `v`; `doctor` and `completion` subcommands below (short aliases: `dl`, `i`,
`help`/`h` prints usage). `e`, `v`; `help`/`h` prints usage).
## Installation ## Installation
@@ -74,11 +79,13 @@ as package dependencies.
- `msitools` (`msiextract`) — unpacks the `.msi` payloads MSVC/WinSDK ship as. - `msitools` (`msiextract`) — unpacks the `.msi` payloads MSVC/WinSDK ship as.
- `git` — applies the compatibility patches bundled with `download` (see - `git` — applies the compatibility patches bundled with `download` (see
[Compatibility patches](#compatibility-patches)). [Compatibility patches](#compatibility-patches)).
- `cabextract` — only needed for `download --with-dxsdk` (see
[Building against D3DX9](#building-against-d3dx9-directx-sdk)).
On ALT Linux: On ALT Linux:
```bash ```bash
pkcon install wine msitools git pkcon install wine msitools git cabextract
``` ```
## Quick start ## Quick start
@@ -97,6 +104,9 @@ export PATH=~/.vintner/bin/x64:$PATH
cl /nologo /EHsc hello.cpp cl /nologo /EHsc hello.cpp
``` ```
Don't want to touch PATH? Skip step 3 and run tools through vintner
directly instead - see [Invoking tools without PATH](#invoking-tools-without-path).
## Commands ## Commands
``` ```
@@ -104,6 +114,7 @@ vintner download (dl) --accept-license [--dest <dir>] [options] fetch and unpa
vintner install (i) [dir] wire up wrappers for a downloaded MSVC vintner install (i) [dir] wire up wrappers for a downloaded MSVC
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 doctor check wine/toolchain setup
vintner help (h) print usage vintner help (h) print usage
vintner completion bash|zsh print a shell completion script vintner completion bash|zsh print a shell completion script
``` ```
@@ -112,12 +123,13 @@ vintner completion bash|zsh print a shell
`download`'s main options: `--msvc-version`, `--sdk-version`, `download`'s main options: `--msvc-version`, `--sdk-version`,
`--architecture` (repeatable: `x86`/`x64`/`arm`/`arm64`/`host`), `--architecture` (repeatable: `x86`/`x64`/`arm`/`arm64`/`host`),
`--host-arch`, `--only-host`, `--with-wdk` (see below), `--ignore` `--host-arch`, `--only-host`, `--with-wdk` (see below), `--with-dxsdk`
(repeatable), `--only-download`, `--only-unpack`, `--keep-unpack`, (see below), `--ignore` (repeatable), `--only-download`, `--only-unpack`,
`--skip-patch`, `--cache`, `--language`, `--include-optional`, `--keep-unpack`, `--skip-patch`, `--cache`, `--language`,
`--skip-recommended`, `--major`, `--preview`, `--manifest`, `--include-optional`, `--skip-recommended`, `--major`, `--preview`,
`--list-workloads`, `--list-components`, `--print-deps-tree`. Run `--manifest`, `--list-workloads`, `--list-components`,
`vintner download -h` for the full list with descriptions. `--print-deps-tree`. Run `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
and its human-readable title from the fetched manifest, then exit and its human-readable title from the fetched manifest, then exit
@@ -126,6 +138,38 @@ package id or through `--with-*`. `--print-deps-tree` prints the
dependency tree of whatever would actually be selected — honoring every dependency tree of whatever would actually be selected — honoring every
other flag — without downloading anything. other flag — without downloading anything.
## Invoking tools without PATH
`cl`, `link`, `msbuild`, and the rest also work as `vintner <tool>
[args...]`, with no need to add `<dest>/bin/<arch>` to `PATH` or rely on
the symlinks `install` sets up there:
```bash
vintner cl /nologo /EHsc hello.cpp
vintner msbuild MyProject.sln
```
Resolves the toolchain to use from `VINTNER_BIN` if set (same meaning as
`env --bin`: point it at a `<dest>/bin/<arch>` directory directly — useful
for a non-default `--dest`, or to pick a specific architecture when more
than one is installed), otherwise defaults to
`~/.vintner/bin/<host-arch>`, the layout a plain `vintner download &&
vintner install` with no `--dest` override produces.
## Diagnosing problems (vintner doctor)
```bash
vintner doctor
```
Checks the things vintner actually needs at runtime — that `wine`/`wine64`
is on `PATH` and actually runs, that `msitools`/`cabextract` are present,
and that every installed `<dest>/bin/<arch>` toolchain (or the one
`VINTNER_BIN` points at) has its MSVC/SDK/MSBuild directories in place —
and prints a pass/fail checklist. Exits non-zero if anything failed.
Useful before filing a bug, or after a `download`/`install` that seemed to
finish but left tools failing in confusing ways.
## Building drivers (WDK) ## Building drivers (WDK)
`--with-wdk` also fetches the Windows Driver Kit: headers, import libs, `--with-wdk` also fetches the Windows Driver Kit: headers, import libs,
@@ -143,6 +187,42 @@ work under Wine. Tested against a real sample driver from
Only x64 and arm64 targets have a WDK package upstream; there's no x86 or Only x64 and arm64 targets have a WDK package upstream; there's no x86 or
arm one. arm one.
## Building against D3DX9 (DirectX SDK)
`--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`.
```bash
vintner download --accept-license --with-dxsdk
```
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).
## Automated/scripted builds
Every tool invocation runs unbounded by default, same as the real thing on
Windows. Set `VINTNER_TIMEOUT` (a `time.ParseDuration` string, e.g. `30m`,
`2h`) to have vintner kill and fail a build that runs longer than that
instead of hanging forever - meant for CI and other unattended callers, not
interactive use. This guards against one confirmed failure mode: an
MSBuild node-reuse worker (`/nodeReuse:true` is MSBuild's own default) can
survive its parent process under Wine and, if a prior build was
interrupted mid-compile, come back wedged - reused by the next `msbuild`
call and failing every subsequent build with a confusing, unrelated-looking
error, indefinitely, until it's killed by hand. vintner already forces
`/nodeReuse:false` on every `msbuild` invocation to prevent this in the
first place; `VINTNER_TIMEOUT` is the backstop for whatever else might
wedge under Wine that isn't MSBuild-specific.
```bash
VINTNER_TIMEOUT=30m msbuild MyProject.sln
```
## Language ## Language
CLI text (usage, progress lines, prompts) defaults to English. Set CLI text (usage, progress lines, prompts) defaults to English. Set
+35 -8
View File
@@ -1,6 +1,11 @@
package main package main
import "fmt" import (
"fmt"
"strings"
"github.com/Cheviiot/vintner/internal/wrapper"
)
// runCompletion prints a shell completion script for shell ("bash" or // runCompletion prints a shell completion script for shell ("bash" or
// "zsh") to stdout, meant to be sourced directly: // "zsh") to stdout, meant to be sourced directly:
@@ -11,6 +16,11 @@ import "fmt"
// The flag lists below are hand-maintained alongside download.go/env.go's // The flag lists below are hand-maintained alongside download.go/env.go's
// flag.FlagSet definitions rather than generated from them - there's no // flag.FlagSet definitions rather than generated from them - there's no
// reflection-friendly registry to walk, and the flag set rarely changes. // reflection-friendly registry to walk, and the flag set rarely changes.
// The tool name list isn't hand-maintained, though (see wrapper.ToolNames):
// a hand-copied one already went stale once already for a flag
// (--with-dxsdk missing from here after being added to download.go), and a
// list of every wrapped tool has more entries and changes for the same
// reasons the flag lists do, so it's worth generating for real.
func runCompletion(args []string) int { func runCompletion(args []string) int {
if len(args) != 1 { if len(args) != 1 {
fmt.Println("usage: vintner completion bash|zsh") fmt.Println("usage: vintner completion bash|zsh")
@@ -18,10 +28,10 @@ func runCompletion(args []string) int {
} }
switch args[0] { switch args[0] {
case "bash": case "bash":
fmt.Print(bashCompletionScript) fmt.Print(bashCompletionScript())
return 0 return 0
case "zsh": case "zsh":
fmt.Print(zshCompletionScript) fmt.Print(zshCompletionScript())
return 0 return 0
default: default:
fmt.Printf("vintner completion: unsupported shell %q (want bash or zsh)\n", args[0]) fmt.Printf("vintner completion: unsupported shell %q (want bash or zsh)\n", args[0])
@@ -33,9 +43,16 @@ const downloadFlags = "--dest --cache --major --preview --manifest --accept-lice
"--msvc-version --sdk-version --host-arch --only-host --language " + "--msvc-version --sdk-version --host-arch --only-host --language " +
"--include-optional --skip-recommended --only-download --only-unpack " + "--include-optional --skip-recommended --only-download --only-unpack " +
"--keep-unpack --skip-patch --list-workloads --list-components " + "--keep-unpack --skip-patch --list-workloads --list-components " +
"--print-deps-tree --with-wdk --architecture --ignore -h --help" "--print-deps-tree --with-wdk --with-dxsdk --architecture --ignore -h --help"
var bashCompletionScript = `# vintner bash completion - eval "$(vintner completion bash)" // subcommandNames lists vintner's own management subcommands (long form
// plus every short alias) - unlike the wrapped-tool names, these really are
// fixed enough to hand-maintain: adding one is rare and always touches
// main.go's dispatch switch right next to this file anyway.
const subcommandNames = "download dl install i env e version v doctor help h completion"
func bashCompletionScript() string {
return `# vintner bash completion - eval "$(vintner completion bash)"
_vintner_complete() { _vintner_complete() {
local cur cmd local cur cmd
COMPREPLY=() COMPREPLY=()
@@ -43,7 +60,7 @@ _vintner_complete() {
cmd="${COMP_WORDS[1]}" cmd="${COMP_WORDS[1]}"
if [ "$COMP_CWORD" -eq 1 ]; then if [ "$COMP_CWORD" -eq 1 ]; then
COMPREPLY=($(compgen -W "download dl install i env e version v help h completion" -- "$cur")) COMPREPLY=($(compgen -W "` + subcommandNames + ` ` + strings.Join(wrapper.ToolNames(), " ") + `" -- "$cur"))
return 0 return 0
fi fi
@@ -65,8 +82,15 @@ _vintner_complete() {
} }
complete -F _vintner_complete vintner complete -F _vintner_complete vintner
` `
}
var zshCompletionScript = `#compdef vintner func zshCompletionScript() string {
var toolEntries strings.Builder
for _, name := range wrapper.ToolNames() {
fmt.Fprintf(&toolEntries, " %q\n", name+":run this tool directly, e.g. \"vintner "+name+" ...\"")
}
return `#compdef vintner
# vintner zsh completion - source <(vintner completion zsh) # vintner zsh completion - source <(vintner completion zsh)
_vintner() { _vintner() {
@@ -80,10 +104,11 @@ _vintner() {
'e:alias for env' 'e:alias for env'
'version:print the version' 'version:print the version'
'v:alias for version' 'v:alias for version'
'doctor:check wine/toolchain setup'
'help:print usage' 'help:print usage'
'h:alias for help' 'h:alias for help'
'completion:print a shell completion script' 'completion:print a shell completion script'
) ` + toolEntries.String() + ` )
if (( CURRENT == 2 )); then if (( CURRENT == 2 )); then
_describe 'command' subcommands _describe 'command' subcommands
@@ -115,6 +140,7 @@ _vintner() {
'--list-components[list available components and exit]' '--list-components[list available components and exit]'
'--print-deps-tree[print the dependency tree and exit]' '--print-deps-tree[print the dependency tree and exit]'
'--with-wdk[also fetch the Windows Driver Kit]' '--with-wdk[also fetch the Windows Driver Kit]'
'--with-dxsdk[also fetch the DirectX SDK]'
'--architecture[target architecture]:arch:(x86 x64 arm arm64 host)' '--architecture[target architecture]:arch:(x86 x64 arm arm64 host)'
'--ignore[package id to skip]:package id:' '--ignore[package id to skip]:package id:'
'-h[show help]' '-h[show help]'
@@ -138,3 +164,4 @@ _vintner() {
_vintner "$@" _vintner "$@"
` `
}
+22 -2
View File
@@ -4,6 +4,8 @@ import (
"os/exec" "os/exec"
"strings" "strings"
"testing" "testing"
"github.com/Cheviiot/vintner/internal/wrapper"
) )
// TestCompletionScriptsAreSyntacticallyValid catches the easy way to break // TestCompletionScriptsAreSyntacticallyValid catches the easy way to break
@@ -15,8 +17,8 @@ func TestCompletionScriptsAreSyntacticallyValid(t *testing.T) {
shell string shell string
script string script string
}{ }{
{"bash", bashCompletionScript}, {"bash", bashCompletionScript()},
{"zsh", zshCompletionScript}, {"zsh", zshCompletionScript()},
} { } {
t.Run(tc.shell, func(t *testing.T) { t.Run(tc.shell, func(t *testing.T) {
if _, err := exec.LookPath(tc.shell); err != nil { if _, err := exec.LookPath(tc.shell); err != nil {
@@ -31,6 +33,24 @@ func TestCompletionScriptsAreSyntacticallyValid(t *testing.T) {
} }
} }
// TestCompletionScriptsListEveryTool guards against the exact staleness bug
// found and fixed alongside this test: a hand-copied tool/flag list here
// drifting from the real set in internal/wrapper (or download.go's flags)
// as tools/flags get added. Every current tool name must appear in both
// generated scripts.
func TestCompletionScriptsListEveryTool(t *testing.T) {
bash := bashCompletionScript()
zsh := zshCompletionScript()
for _, name := range wrapper.ToolNames() {
if !strings.Contains(bash, name) {
t.Errorf("bash completion script doesn't mention tool %q", name)
}
if !strings.Contains(zsh, name+":") {
t.Errorf("zsh completion script doesn't mention tool %q", name)
}
}
}
func TestRunCompletionUnknownShell(t *testing.T) { func TestRunCompletionUnknownShell(t *testing.T) {
if code := runCompletion([]string{"fish"}); code != 1 { if code := runCompletion([]string{"fish"}); code != 1 {
t.Errorf("runCompletion([\"fish\"]) = %d, want 1", code) t.Errorf("runCompletion([\"fish\"]) = %d, want 1", code)
+176
View File
@@ -0,0 +1,176 @@
package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/Cheviiot/vintner/internal/i18n"
"github.com/Cheviiot/vintner/internal/wineenv"
)
// runDoctor checks the pieces vintner actually needs at runtime - wine
// itself, the optional extraction tools download needs, and every
// installed <dest>/bin/<arch> toolchain's on-disk layout - and prints a
// pass/fail checklist. The point is surfacing a broken setup as a short,
// readable report instead of a wine-specific error buried deep inside a
// build (see internal/wineenv.FindWine's own error, which this reuses).
func runDoctor(args []string) int {
if len(args) > 0 && (args[0] == "-h" || args[0] == "--help") {
fmt.Fprintln(os.Stderr, i18n.T("doctor.usage"))
return 1
}
d := &doctorReport{}
d.checkWine()
d.checkExtractionTools()
d.checkToolchains()
if d.failed {
fmt.Println(i18n.T("doctor.summary_fail"))
return 1
}
fmt.Println(i18n.T("doctor.summary_ok"))
return 0
}
type doctorReport struct {
failed bool
}
func (d *doctorReport) ok(format string, args ...any) {
fmt.Printf(" [ok] "+format+"\n", args...)
}
func (d *doctorReport) warn(format string, args ...any) {
fmt.Printf(" [warn] "+format+"\n", args...)
}
func (d *doctorReport) fail(format string, args ...any) {
fmt.Printf(" [FAIL] "+format+"\n", args...)
d.failed = true
}
func (d *doctorReport) checkWine() {
fmt.Println(i18n.T("doctor.section_wine"))
wineBin, err := wineenv.FindWine()
if err != nil {
d.fail("%s", err)
return
}
d.ok("found: %s", wineBin)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, wineBin, "--version").Output()
if err != nil {
d.fail("%s --version failed: %v", wineBin, err)
return
}
d.ok("runs: %s", trimNewline(string(out)))
}
func (d *doctorReport) checkExtractionTools() {
fmt.Println(i18n.T("doctor.section_extract"))
if _, err := exec.LookPath("msiextract"); err != nil {
d.warn(i18n.T("doctor.msitools_missing"))
} else {
d.ok("msitools: found (needed by `vintner download`)")
}
if _, err := exec.LookPath("cabextract"); err != nil {
d.warn(i18n.T("doctor.cabextract_missing"))
} else {
d.ok("cabextract: found (needed by --with-wdk/--with-dxsdk)")
}
}
func (d *doctorReport) checkToolchains() {
fmt.Println(i18n.T("doctor.section_toolchain"))
if binDir := os.Getenv("VINTNER_BIN"); binDir != "" {
d.checkToolchainAt(binDir, "VINTNER_BIN="+binDir)
return
}
def, err := defaultToolchainDir()
if err != nil {
d.fail("%s", err)
return
}
destBin := filepath.Join(def, "bin")
archDirs := installedArchDirs(destBin)
if len(archDirs) == 0 {
d.fail(i18n.T("doctor.no_toolchain", destBin))
return
}
for _, arch := range archDirs {
d.checkToolchainAt(filepath.Join(destBin, arch), arch)
}
}
// installedArchDirs returns the subdirectories of destBin that carry their
// own env.json, i.e. every architecture `vintner install` actually set up
// (there can be more than one - e.g. x86 and x64 side by side).
func installedArchDirs(destBin string) []string {
entries, err := os.ReadDir(destBin)
if err != nil {
return nil
}
var dirs []string
for _, e := range entries {
if !e.IsDir() {
continue
}
if _, err := os.Stat(filepath.Join(destBin, e.Name(), wineenv.ConfigFileName)); err == nil {
dirs = append(dirs, e.Name())
}
}
return dirs
}
func (d *doctorReport) checkToolchainAt(binDir, label string) {
cfg, err := wineenv.Load(binDir)
if err != nil {
d.fail("%s: %s", label, err)
return
}
baseUnix, err := wineenv.FindBaseUnix(binDir)
if err != nil {
d.fail("%s: %s", label, err)
return
}
d.ok("%s: MSVC %s, SDK %s, root %s", label, cfg.MSVCVer, cfg.SDKVer, baseUnix)
paths := wineenv.NewPaths(cfg, baseUnix)
for _, dir := range []struct{ name, path string }{
{"MSVC bin", paths.BinDir},
{"SDK bin", paths.SDKBinDir},
{"MSBuild bin", paths.MSBuildBinDir},
} {
if fi, err := os.Stat(dir.path); err != nil || !fi.IsDir() {
d.fail("%s: %s missing: %s", label, dir.name, dir.path)
} else {
d.ok("%s: %s: %s", label, dir.name, dir.path)
}
}
relay := filepath.Join(baseUnix, "bin", "toolrelay.exe")
if fi, err := os.Stat(relay); err != nil || fi.IsDir() {
d.warn("%s: toolrelay.exe not built (mt.exe's CMake exit-code translation won't apply; re-run `vintner install` to retry)", label)
} else {
d.ok("%s: toolrelay.exe: %s", label, relay)
}
}
func trimNewline(s string) string {
for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
s = s[:len(s)-1]
}
return s
}
+83
View File
@@ -0,0 +1,83 @@
package main
import (
"os"
"path/filepath"
"testing"
"github.com/Cheviiot/vintner/internal/wineenv"
)
func TestRunDoctorUsage(t *testing.T) {
for _, flag := range []string{"-h", "--help"} {
if code := runDoctor([]string{flag}); code != 1 {
t.Errorf("runDoctor([%q]) = %d, want 1", flag, code)
}
}
}
func TestDoctorReportOkWarnFail(t *testing.T) {
d := &doctorReport{}
d.ok("fine")
d.warn("meh")
if d.failed {
t.Fatal("ok/warn must not mark the report as failed")
}
d.fail("broken")
if !d.failed {
t.Fatal("fail must mark the report as failed")
}
}
func TestInstalledArchDirsFindsOnlyDirsWithEnvJSON(t *testing.T) {
destBin := t.TempDir()
for _, dir := range []string{"x64", "x86", "not-a-toolchain"} {
if err := os.MkdirAll(filepath.Join(destBin, dir), 0o755); err != nil {
t.Fatal(err)
}
}
for _, dir := range []string{"x64", "x86"} {
if err := os.WriteFile(filepath.Join(destBin, dir, wineenv.ConfigFileName), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
}
got := installedArchDirs(destBin)
want := map[string]bool{"x64": true, "x86": true}
if len(got) != len(want) {
t.Fatalf("installedArchDirs = %v, want exactly %v", got, want)
}
for _, arch := range got {
if !want[arch] {
t.Errorf("installedArchDirs returned unexpected entry %q", arch)
}
}
}
func TestInstalledArchDirsMissingDir(t *testing.T) {
if got := installedArchDirs(filepath.Join(t.TempDir(), "does-not-exist")); got != nil {
t.Errorf("installedArchDirs on a missing dir = %v, want nil", got)
}
}
func TestCheckToolchainAtMissingEnvJSON(t *testing.T) {
d := &doctorReport{}
d.checkToolchainAt(t.TempDir(), "test")
if !d.failed {
t.Error("checkToolchainAt against a dir with no env.json should fail the report")
}
}
func TestTrimNewline(t *testing.T) {
cases := map[string]string{
"wine-9.0\n": "wine-9.0",
"wine-9.0\r\n": "wine-9.0",
"wine-9.0": "wine-9.0",
"": "",
}
for in, want := range cases {
if got := trimNewline(in); got != want {
t.Errorf("trimNewline(%q) = %q, want %q", in, got, want)
}
}
}
+27
View File
@@ -10,6 +10,7 @@ import (
"github.com/Cheviiot/vintner/internal/download" "github.com/Cheviiot/vintner/internal/download"
"github.com/Cheviiot/vintner/internal/i18n" "github.com/Cheviiot/vintner/internal/i18n"
"github.com/Cheviiot/vintner/internal/lock"
) )
func runDownload(args []string) int { func runDownload(args []string) int {
@@ -35,6 +36,7 @@ func runDownload(args []string) int {
listComponents := fs.Bool("list-components", false, "list available components from the manifest and exit, without downloading anything") 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") 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)") 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 var archsFlag stringList
fs.Var(&archsFlag, "architecture", "target architecture to include (x86, x64, arm, arm64, host); repeatable") fs.Var(&archsFlag, "architecture", "target architecture to include (x86, x64, arm, arm64, host); repeatable")
var ignoreFlag stringList var ignoreFlag stringList
@@ -176,6 +178,12 @@ func runDownload(args []string) int {
fmt.Fprintln(os.Stderr, "vintner download:", err) fmt.Fprintln(os.Stderr, "vintner download:", err)
return 1 return 1
} }
unlock, err := lock.Acquire(destAbs)
if err != nil {
fmt.Fprintln(os.Stderr, "vintner download:", err)
return 1
}
defer unlock()
unpack := destAbs unpack := destAbs
if !*onlyUnpack { if !*onlyUnpack {
@@ -219,6 +227,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)) fmt.Println(i18n.T("download.done", destAbs))
return 0 return 0
} }
@@ -255,6 +270,18 @@ func downloadWDK(opts *download.Options, selected []*download.Package, cache, de
return nil 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 { func contains(list []string, v string) bool {
for _, s := range list { for _, s := range list {
if s == v { if s == v {
+11 -8
View File
@@ -1,9 +1,9 @@
// Command vintner cross compiles with the real MSVC toolchain on Linux // Command vintner cross compiles with the real MSVC toolchain on Linux
// via Wine. It's a multi-call binary that behaves as `cl`, `link`, `lib`, // via Wine. It's a multi-call binary that behaves as `cl`, `link`, `lib`,
// `rc`, `midl`, `mt`, `dumpbin`, `msbuild`, etc. when invoked under one of // `rc`, `midl`, `mt`, `dumpbin`, `msbuild`, etc. when invoked under one of
// those names (via symlinks set up by `vintner install`), and // those names (via symlinks set up by `vintner install`) or as
// otherwise exposes the `download`/`install`/`env`/`version` management // `vintner <tool> ...` directly (see runTool), and otherwise exposes the
// subcommands. // `download`/`install`/`env`/`version` management subcommands.
package main package main
import ( import (
@@ -24,11 +24,8 @@ func main() {
base := filepath.Base(os.Args[0]) base := filepath.Base(os.Args[0])
name := strings.TrimSuffix(strings.ToLower(base), ".exe") name := strings.TrimSuffix(strings.ToLower(base), ".exe")
if _, ok := wrapper.Tools[name]; ok { if wrapper.IsTool(name) {
os.Exit(wrapper.Run(name, os.Args[1:])) os.Exit(wrapper.Run(name, os.Args[1:], ""))
}
if name == "cmd" || name == "findstr" {
os.Exit(wrapper.Run(name, os.Args[1:]))
} }
os.Exit(runCLI(os.Args[1:])) os.Exit(runCLI(os.Args[1:]))
@@ -40,6 +37,10 @@ func runCLI(args []string) int {
return 1 return 1
} }
if wrapper.IsTool(args[0]) {
return runTool(args[0], args[1:])
}
switch args[0] { switch args[0] {
case "download", "dl": case "download", "dl":
return runDownload(args[1:]) return runDownload(args[1:])
@@ -49,6 +50,8 @@ func runCLI(args []string) int {
return runEnv(args[1:]) return runEnv(args[1:])
case "completion": case "completion":
return runCompletion(args[1:]) return runCompletion(args[1:])
case "doctor":
return runDoctor(args[1:])
case "version", "v", "--version": case "version", "v", "--version":
fmt.Println(versionString()) fmt.Println(versionString())
return 0 return 0
+40
View File
@@ -0,0 +1,40 @@
package main
import (
"fmt"
"os"
"path/filepath"
"github.com/Cheviiot/vintner/internal/wrapper"
)
// runTool dispatches `vintner <tool> [args...]` (cl, link, msbuild, ...)
// directly, without needing <dest>/bin/<arch> on PATH or a same-directory
// symlink pointing back at this binary.
//
// Resolves which toolchain bin dir to use from VINTNER_BIN if set (same
// meaning as `env --bin`: point it directly at a <dest>/bin/<arch>
// directory - useful for a non-default --dest, or to pick a specific
// architecture when more than one is installed), else defaults to
// <defaultToolchainDir>/bin/<hostArch>, the layout a plain `vintner
// download && vintner install` with no --dest override produces.
func runTool(tool string, args []string) int {
binDir := os.Getenv("VINTNER_BIN")
if binDir == "" {
def, err := defaultToolchainDir()
if err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err)
return 1
}
binDir = filepath.Join(def, "bin", detectHostArch())
}
if fi, err := os.Stat(binDir); err != nil || !fi.IsDir() {
fmt.Fprintf(os.Stderr,
"vintner: no installed toolchain found at %s\n"+
"Run `vintner download --accept-license && vintner install` first, "+
"or set VINTNER_BIN to an existing <dest>/bin/<arch> directory.\n",
binDir)
return 1
}
return wrapper.Run(tool, args, binDir)
}
+24
View File
@@ -0,0 +1,24 @@
package main
import (
"path/filepath"
"testing"
)
func TestRunToolReportsMissingToolchain(t *testing.T) {
t.Setenv("VINTNER_BIN", filepath.Join(t.TempDir(), "does-not-exist"))
if code := runTool("cl", nil); code != 1 {
t.Errorf("runTool with a nonexistent VINTNER_BIN = %d, want 1", code)
}
}
func TestRunToolUsesVINTNERBinOverDefault(t *testing.T) {
// A directory that exists but has no env.json - past the "toolchain
// found at all" check, into wrapper.Run's own (already-tested)
// env.json-loading error path. Confirms VINTNER_BIN is actually being
// read and passed through, without needing a full fake toolchain.
t.Setenv("VINTNER_BIN", t.TempDir())
if code := runTool("cl", nil); code != 1 {
t.Errorf("runTool with an empty VINTNER_BIN dir = %d, want 1 (from the missing env.json)", code)
}
}
+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)
}
}
}
+42 -6
View File
@@ -149,27 +149,63 @@ func tryDownloadPayload(payload Payload, dest, fileID string, allowHashMismatch
var downloadHTTPClient = &http.Client{Timeout: 30 * time.Minute} var downloadHTTPClient = &http.Client{Timeout: 30 * time.Minute}
// httpDownloadFile downloads url to dest via a dest+".part" temp file,
// resuming from wherever a previous attempt left off if one exists - MSVC/
// WinSDK/WDK/DXSDK payloads run into the hundreds of MB to multiple GB, so
// restarting an interrupted download from byte 0 (a dropped connection, a
// retry after this same function returned an error) wastes real time and
// bandwidth on a flaky connection. Requests a byte Range starting at the
// existing .part file's size, if any; a server that doesn't honor Range
// (responds 200 instead of 206) gets treated as sending the whole file
// again from byte 0, so the .part is truncated and started over rather
// than getting byte-0 content appended onto existing bytes.
func httpDownloadFile(url, dest string) error { func httpDownloadFile(url, dest string) error {
resp, err := downloadHTTPClient.Get(url) tmp := dest + ".part"
var offset int64
if fi, err := os.Stat(tmp); err == nil {
offset = fi.Size()
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
if offset > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset))
}
resp, err := downloadHTTPClient.Do(req)
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
var out *os.File
switch resp.StatusCode {
case http.StatusOK:
// No partial-content support (or nothing to resume from): the body
// is the whole file from byte 0.
out, err = os.Create(tmp)
case http.StatusPartialContent:
out, err = os.OpenFile(tmp, os.O_WRONLY|os.O_APPEND, 0o644)
case http.StatusRequestedRangeNotSatisfiable:
// Our .part is already >= the real file size - stale or corrupt.
// Discard it; the next retry starts clean with no Range header.
os.Remove(tmp)
return fmt.Errorf("GET %s: range not satisfiable, discarding partial download and retrying from scratch", url)
default:
return fmt.Errorf("GET %s: %s", url, resp.Status) return fmt.Errorf("GET %s: %s", url, resp.Status)
} }
tmp := dest + ".part"
out, err := os.Create(tmp)
if err != nil { if err != nil {
return err return err
} }
if _, err := io.Copy(out, resp.Body); err != nil { if _, err := io.Copy(out, resp.Body); err != nil {
out.Close() out.Close()
os.Remove(tmp) // Deliberately not removing tmp here: whatever bytes made it to
// disk are exactly what the next attempt should resume from.
return err return err
} }
if err := out.Close(); err != nil { if err := out.Close(); err != nil {
os.Remove(tmp)
return err return err
} }
return os.Rename(tmp, dest) return os.Rename(tmp, dest)
+173
View File
@@ -0,0 +1,173 @@
package download
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
func TestHTTPDownloadFileFullDownload(t *testing.T) {
const body = "the quick brown fox jumps over the lazy dog"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, body)
}))
defer srv.Close()
dest := filepath.Join(t.TempDir(), "out")
if err := httpDownloadFile(srv.URL, dest); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatal(err)
}
if string(got) != body {
t.Errorf("downloaded content = %q, want %q", got, body)
}
}
// rangeServer serves a fixed body and honors byte-range requests, exactly
// like a real payload host (GitHub Releases, nuget.org, etc.) would.
func rangeServer(body string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rng := r.Header.Get("Range")
if rng == "" {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, body)
return
}
var start int
if _, err := fmt.Sscanf(rng, "bytes=%d-", &start); err != nil || start < 0 || start > len(body) {
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
return
}
w.Header().Set("Content-Range", "bytes "+strconv.Itoa(start)+"-"+strconv.Itoa(len(body)-1)+"/"+strconv.Itoa(len(body)))
w.WriteHeader(http.StatusPartialContent)
fmt.Fprint(w, body[start:])
}))
}
func TestHTTPDownloadFileResumesFromExistingPart(t *testing.T) {
const body = "the quick brown fox jumps over the lazy dog"
srv := rangeServer(body)
defer srv.Close()
dest := filepath.Join(t.TempDir(), "out")
partial := body[:10]
if err := os.WriteFile(dest+".part", []byte(partial), 0o644); err != nil {
t.Fatal(err)
}
if err := httpDownloadFile(srv.URL, dest); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatal(err)
}
if string(got) != body {
t.Errorf("resumed download content = %q, want %q (partial %q should have been continued, not duplicated or lost)", got, body, partial)
}
}
func TestHTTPDownloadFileRestartsWhenServerIgnoresRange(t *testing.T) {
const body = "the quick brown fox jumps over the lazy dog"
// Always answers 200 with the full body, regardless of Range - some
// servers/CDNs genuinely don't support partial content.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, body)
}))
defer srv.Close()
dest := filepath.Join(t.TempDir(), "out")
// A stale/bogus .part that must NOT end up prepended to the real
// content - if httpDownloadFile appended instead of truncating here,
// the result would start with this garbage.
if err := os.WriteFile(dest+".part", []byte("GARBAGE-FROM-A-STALE-ATTEMPT"), 0o644); err != nil {
t.Fatal(err)
}
if err := httpDownloadFile(srv.URL, dest); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatal(err)
}
if string(got) != body {
t.Errorf("content = %q, want exactly %q (no leftover garbage prepended)", got, body)
}
}
func TestHTTPDownloadFileKeepsPartOnMidTransferFailure(t *testing.T) {
const fullBody = "0123456789"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, fullBody[:5])
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
// Simulate a dropped connection partway through by closing the
// underlying connection abruptly instead of finishing the body.
hj, ok := w.(http.Hijacker)
if !ok {
return
}
conn, _, err := hj.Hijack()
if err == nil {
conn.Close()
}
}))
defer srv.Close()
dest := filepath.Join(t.TempDir(), "out")
err := httpDownloadFile(srv.URL, dest)
if err == nil {
t.Fatal("expected an error from the truncated connection")
}
partial, err := os.ReadFile(dest + ".part")
if err != nil {
t.Fatalf("expected the .part file with the bytes received so far to survive a failed download: %v", err)
}
if !strings.HasPrefix(fullBody, string(partial)) || len(partial) == 0 {
t.Errorf(".part content = %q, want a non-empty prefix of %q", partial, fullBody)
}
}
func TestHTTPDownloadFileRangeNotSatisfiableDiscardsPart(t *testing.T) {
const body = "short"
srv := rangeServer(body)
defer srv.Close()
dest := filepath.Join(t.TempDir(), "out")
// .part is already longer than the real file - triggers 416 from
// rangeServer's own bounds check.
if err := os.WriteFile(dest+".part", []byte("this partial file is way too long"), 0o644); err != nil {
t.Fatal(err)
}
if err := httpDownloadFile(srv.URL, dest); err == nil {
t.Fatal("expected an error on the first (416) attempt")
}
if _, err := os.Stat(dest + ".part"); !os.IsNotExist(err) {
t.Error("expected the stale .part to be discarded after a 416 response")
}
// The retry (a fresh caller, no Range header since .part is gone) should
// now succeed cleanly.
if err := httpDownloadFile(srv.URL, dest); err != nil {
t.Fatalf("retry after discarding the stale .part failed: %v", err)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatal(err)
}
if string(got) != body {
t.Errorf("content = %q, want %q", got, body)
}
}
+58 -8
View File
@@ -73,18 +73,22 @@ Usage:
vintner install (i) [dir] wire up wrappers for a downloaded MSVC vintner install (i) [dir] wire up wrappers for a downloaded MSVC
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 doctor check wine/toolchain setup
vintner help (h) show this message vintner help (h) show this message
vintner completion bash|zsh print a shell completion script 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, --with-dxsdk, --list-workloads,
--print-deps-tree. --list-components and --print-deps-tree.
--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 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,
or skip PATH and run them through vintner itself - "vintner cl ...",
"vintner msbuild ...", etc. (set VINTNER_BIN to a <dir>/bin/<arch> if it's
not the default ~/.vintner/bin/<host-arch>):
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
`, `,
RU: `vintner — кросс-компиляция настоящим MSVC на Linux через Wine RU: `vintner — кросс-компиляция настоящим MSVC на Linux через Wine
@@ -95,18 +99,23 @@ Once installed, add <dir>/bin/<arch> to PATH and invoke the tools directly:
vintner install (i) [каталог] настроить обёртки для скачанного MSVC vintner install (i) [каталог] настроить обёртки для скачанного MSVC
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 doctor проверить настройку wine/toolchain
vintner help (h) показать эту справку vintner help (h) показать эту справку
vintner completion bash|zsh вывести скрипт автодополнения для оболочки vintner completion bash|zsh вывести скрипт автодополнения для оболочки
Запустите «vintner <команда> --help» для параметров конкретной команды — Запустите «vintner <команда> --help» для параметров конкретной команды —
у download их много, включая --with-wdk, --list-workloads, --list-components у download их много, включая --with-wdk, --with-dxsdk, --list-workloads,
и --print-deps-tree. --list-components и --print-deps-tree.
--dest/[каталог] по умолчанию — ~/.vintner. --dest/[каталог] по умолчанию — ~/.vintner.
Язык: установите VINTNER_LANG=en (или LANG=en_US...) для вывода на английском. Язык: установите VINTNER_LANG=en (или LANG=en_US...) для вывода на английском.
Можно не трогать PATH: "vintner cl ...", "vintner msbuild ..." и т.д. работают
напрямую (VINTNER_BIN — если каталог не стандартный ~/.vintner/bin/<hostarch>).
Автодополнение: source <(vintner completion bash) # или zsh Автодополнение: source <(vintner completion bash) # или zsh
После установки добавьте <dir>/bin/<arch> в PATH и вызывайте инструменты напрямую: После установки добавьте <dir>/bin/<arch> в PATH и вызывайте инструменты
напрямую, либо не трогая PATH — «vintner cl ...», «vintner msbuild ...»
и т.д.:
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
`, `,
}, },
@@ -124,8 +133,8 @@ Once installed, add <dir>/bin/<arch> to PATH and invoke the tools directly:
RU: "Каталог не указан, используется значение по умолчанию: %s", RU: "Каталог не указан, используется значение по умолчанию: %s",
}, },
"install.done": { "install.done": {
EN: "Done. Add %s to PATH to use cl, link, lib, ...", EN: "Done. Add %s to PATH to use cl, link, lib, ... directly, or run them as \"vintner cl\", \"vintner link\", etc. without touching PATH.",
RU: "Готово. Добавьте %s в PATH, чтобы использовать cl, link, lib и т.д.", RU: "Готово. Добавьте %s в PATH, чтобы использовать cl, link, lib и т.д. напрямую, либо запускайте их как «vintner cl», «vintner link» и т.д., не трогая PATH.",
}, },
"env.usage": { "env.usage": {
@@ -161,6 +170,10 @@ Once installed, add <dir>/bin/<arch> to PATH and invoke the tools directly:
EN: "Installed WDK (%s) %s at %s\n", EN: "Installed WDK (%s) %s at %s\n",
RU: "WDK (%s) %s установлен в %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": { "download.workloads_header": {
EN: "Available Workloads (%d):\n", EN: "Available Workloads (%d):\n",
RU: "Доступные рабочие нагрузки (Workload) (%d):\n", RU: "Доступные рабочие нагрузки (Workload) (%d):\n",
@@ -177,4 +190,41 @@ Once installed, add <dir>/bin/<arch> to PATH and invoke the tools directly:
EN: "Do you accept the license? Answer \"yes\" or \"no\": ", EN: "Do you accept the license? Answer \"yes\" or \"no\": ",
RU: "Вы принимаете лицензию? Ответьте «yes» или «no»: ", RU: "Вы принимаете лицензию? Ответьте «yes» или «no»: ",
}, },
"doctor.usage": {
EN: "usage: vintner doctor",
RU: "использование: vintner doctor",
},
"doctor.section_wine": {
EN: "Wine:",
RU: "Wine:",
},
"doctor.section_extract": {
EN: "Extraction tools:",
RU: "Инструменты распаковки:",
},
"doctor.section_toolchain": {
EN: "Installed toolchain:",
RU: "Установленный набор инструментов:",
},
"doctor.msitools_missing": {
EN: "msitools: not found (install the msitools package - needed by `vintner download`)",
RU: "msitools: не найден (установите пакет msitools — нужен для `vintner download`)",
},
"doctor.cabextract_missing": {
EN: "cabextract: not found (install the cabextract package - only needed for --with-wdk/--with-dxsdk)",
RU: "cabextract: не найден (установите пакет cabextract — нужен только для --with-wdk/--with-dxsdk)",
},
"doctor.no_toolchain": {
EN: "no installed toolchain found under %s (run `vintner download --accept-license && vintner install` first, or set VINTNER_BIN)",
RU: "установленный набор инструментов не найден в %s (сначала выполните `vintner download --accept-license && vintner install`, либо задайте VINTNER_BIN)",
},
"doctor.summary_ok": {
EN: "\nAll checks passed.",
RU: "\nВсе проверки пройдены.",
},
"doctor.summary_fail": {
EN: "\nSome checks failed - see [FAIL] lines above.",
RU: "\nНекоторые проверки не пройдены — см. строки [FAIL] выше.",
},
} }
+11
View File
@@ -15,6 +15,7 @@ import (
"strings" "strings"
"github.com/Cheviiot/vintner/assets" "github.com/Cheviiot/vintner/assets"
"github.com/Cheviiot/vintner/internal/lock"
"github.com/Cheviiot/vintner/internal/wineenv" "github.com/Cheviiot/vintner/internal/wineenv"
) )
@@ -34,6 +35,12 @@ func Install(dest, selfBinary string) error {
return fmt.Errorf("destination %q is not a directory", dest) return fmt.Errorf("destination %q is not a directory", dest)
} }
unlock, err := lock.Acquire(dest)
if err != nil {
return err
}
defer unlock()
// Targets are relative so the whole installed tree stays relocatable - // Targets are relative so the whole installed tree stays relocatable -
// moving or renaming dest doesn't break these symlinks the way an // moving or renaming dest doesn't break these symlinks the way an
// absolute target baked in at install time would. // absolute target baked in at install time would.
@@ -62,6 +69,10 @@ func Install(dest, selfBinary string) error {
return err return err
} }
if err := aliasPlatformToolsets(dest); err != nil {
return err
}
includeDir := filepath.Join(msvcDir, "include") includeDir := filepath.Join(msvcDir, "include")
if err := Lowercase(includeDir, LowercaseOptions{Symlink: true}); err != nil { if err := Lowercase(includeDir, LowercaseOptions{Symlink: true}); err != nil {
return fmt.Errorf("lowercasing %s: %w", includeDir, err) 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)
}
}
}
+49
View File
@@ -0,0 +1,49 @@
// Package lock guards a destination directory against two `vintner
// download`/`install` runs mutating it at the same time.
package lock
import (
"fmt"
"os"
"path/filepath"
"syscall"
)
// FileName is the advisory lock file `download` and `install` both take
// out against their (usually shared) destination directory before
// touching anything in it - concurrent download+install, or two
// downloads, against the same dest could otherwise interleave badly:
// combineDirTrees' merge logic assumes it's the only thing moving files
// into a given target at a time, and two `os.Rename` calls racing for the
// same destination path is exactly the kind of thing that corrupts a tree
// instead of erroring cleanly.
const FileName = ".vintner.lock"
// Acquire takes an exclusive, non-blocking lock on dest (creating dest if
// it doesn't exist yet) and returns a func to release it, which the caller
// must defer. If another vintner process already holds the lock, returns
// an error immediately instead of blocking - there's no reason a second
// invocation should silently queue up and wait for the first to finish
// touching the same directory; the caller should simply not have started
// it yet. Uses flock(2), so a crashed holder's lock is released
// automatically by the kernel when its file descriptor closes - never
// needs manual cleanup, unlike a plain "does a file exist" lock
// convention would.
func Acquire(dest string) (unlock func(), err error) {
if err := os.MkdirAll(dest, 0o755); err != nil {
return nil, err
}
path := filepath.Join(dest, FileName)
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
f.Close()
return nil, fmt.Errorf("another vintner download/install is already running against %s", dest)
}
return func() {
syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
f.Close()
}, nil
}
+124
View File
@@ -0,0 +1,124 @@
package lock
import (
"io"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)
func TestAcquireAndRelease(t *testing.T) {
dest := t.TempDir()
unlock, err := Acquire(dest)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dest, FileName)); err != nil {
t.Errorf("expected the lock file to exist while held: %v", err)
}
unlock()
// Released - a second Acquire against the same dest must now succeed.
unlock2, err := Acquire(dest)
if err != nil {
t.Fatalf("Acquire after release failed: %v", err)
}
unlock2()
}
func TestAcquireCreatesDestIfMissing(t *testing.T) {
dest := filepath.Join(t.TempDir(), "does", "not", "exist", "yet")
unlock, err := Acquire(dest)
if err != nil {
t.Fatal(err)
}
defer unlock()
if fi, err := os.Stat(dest); err != nil || !fi.IsDir() {
t.Errorf("expected Acquire to create %s, stat err: %v", dest, err)
}
}
func TestAcquireFailsWhileAlreadyHeld(t *testing.T) {
dest := t.TempDir()
unlock, err := Acquire(dest)
if err != nil {
t.Fatal(err)
}
defer unlock()
if _, err := Acquire(dest); err == nil {
t.Fatal("expected a second Acquire against the same dest, while the first is still held, to fail")
}
}
func TestAcquireSucceedsAfterHolderReleases(t *testing.T) {
dest := t.TempDir()
unlock1, err := Acquire(dest)
if err != nil {
t.Fatal(err)
}
unlock1()
unlock2, err := Acquire(dest)
if err != nil {
t.Fatalf("Acquire should succeed once the first holder released: %v", err)
}
unlock2()
}
// TestAcquireFailsAcrossRealProcesses is the real end-to-end check: flock
// is per-open-file-description, not per-process or per-thread, so a lock
// held by *this* test process via one fd could in principle still be
// re-acquirable by another fd in the same process depending on the
// platform's exact semantics. Spawning this test binary as a genuinely
// separate child process (via the standard TestMain re-exec trick) and
// having it hold the lock while the parent tries to acquire it is what
// actually proves two independent `vintner download`/`install` processes
// contend correctly, not just two Go-level calls in one process.
func TestAcquireFailsAcrossRealProcesses(t *testing.T) {
if os.Getenv("VINTNER_LOCK_TEST_HOLD") != "" {
unlock, err := Acquire(os.Getenv("VINTNER_LOCK_TEST_HOLD"))
if err != nil {
os.Exit(2)
}
defer unlock()
// Signal readiness, then wait to be killed by the parent. A plain
// `select {}` here would have zero other goroutines able to ever
// wake it, which Go's runtime provably detects as a deadlock and
// crashes on ("fatal error: all goroutines are asleep") - a real
// timer avoids that.
os.Stdout.WriteString("locked\n")
time.Sleep(time.Minute)
}
dest := t.TempDir()
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
cmd := exec.Command(exe, "-test.run=TestAcquireFailsAcrossRealProcesses")
cmd.Env = append(os.Environ(), "VINTNER_LOCK_TEST_HOLD="+dest)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
defer cmd.Process.Kill()
buf := make([]byte, len("locked\n"))
if _, err := io.ReadFull(stdout, buf); err != nil || string(buf) != "locked\n" {
t.Fatalf("child process didn't report holding the lock: %v (%q)", err, buf)
}
if _, err := Acquire(dest); err == nil {
t.Fatal("expected Acquire to fail while a separate process holds the lock")
}
}
+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"}
+1 -1
View File
@@ -14,5 +14,5 @@ func FindWine() (string, error) {
if p, err := exec.LookPath("wine"); err == nil { if p, err := exec.LookPath("wine"); err == nil {
return p, nil return p, nil
} }
return "", fmt.Errorf("neither wine64 nor wine found in PATH") return "", fmt.Errorf("neither wine64 nor wine found in PATH (install the wine package)")
} }
+60
View File
@@ -0,0 +1,60 @@
package wineenv
import (
"os"
"path/filepath"
"strings"
"testing"
)
func fakeBinary(t *testing.T, name string) {
t.Helper()
bin := t.TempDir()
path := filepath.Join(bin, name)
if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin)
}
func TestFindWinePrefersWine64(t *testing.T) {
bin := t.TempDir()
for _, name := range []string{"wine64", "wine"} {
if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
}
t.Setenv("PATH", bin)
got, err := FindWine()
if err != nil {
t.Fatal(err)
}
if filepath.Base(got) != "wine64" {
t.Errorf("FindWine() = %q, want wine64 to be preferred over wine", got)
}
}
func TestFindWineFallsBackToWine(t *testing.T) {
fakeBinary(t, "wine")
got, err := FindWine()
if err != nil {
t.Fatal(err)
}
if filepath.Base(got) != "wine" {
t.Errorf("FindWine() = %q, want wine", got)
}
}
func TestFindWineErrorIsActionable(t *testing.T) {
t.Setenv("PATH", t.TempDir()) // empty dir, neither binary present
_, err := FindWine()
if err == nil {
t.Fatal("expected an error when neither wine64 nor wine is on PATH")
}
if !strings.Contains(err.Error(), "install") {
t.Errorf("FindWine() error = %q, want it to say what to install (matching msiextract/cabextract's error style)", err)
}
}
+175 -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. // tools onto the same UTC clock removes the mismatch.
"TZ": "UTC", "TZ": "UTC",
"DisableRegistryUse": "true", // VCToolsVersion must be a real version string, not left unset:
"VCToolsVersion": cfg.MSVCVer, // Microsoft.Cpp.VCTools.props itself falls back to the literal
"VsInstallRoot": paths.BaseWin + `\`, // placeholder "VCToolsVersion_is_not_defined" whenever it's empty,
"VSInstallDir": paths.BaseWin + `\`, // 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 + `\`, "SDKReferenceDirectoryRoot": paths.BaseWin + `\`,
"SDKExtensionDirectoryRoot": paths.BaseWin + `\`, "SDKExtensionDirectoryRoot": paths.BaseWin + `\`,
@@ -58,20 +79,29 @@ func msbuildEnv(cfg *wineenv.Config, paths *wineenv.Paths) map[string]string {
"Platform": msbuildPlatform(cfg.Arch), "Platform": msbuildPlatform(cfg.Arch),
} }
// Microsoft.Cpp.props resolves the compiler/toolset location through // VCInstallDir_<N>/VCToolsInstallDir_<N> are consulted under two
// VCInstallDir_<N>/VCToolsInstallDir_<N>, where <N> is whatever numeric // completely different numbering schemes, both needing the single real
// suffix the installed MSBuild toolset property sheets use (e.g. // toolchain behind every <N> they might ask for:
// .../MSBuild/Microsoft/VC/v180 -> "180"). Populate every one actually //
// present, so a project pinned to any of them resolves to the one real // - Microsoft.Cpp.Default.props keys its early "is this toolset even
// toolchain that's installed. // installed" check (the one MSB8020 comes from) off <N> = the
matches, _ := filepath.Glob(filepath.Join(paths.BaseUnix, "MSBuild", "Microsoft", "VC", "v*")) // PlatformToolset suffix a .vcxproj actually declares (v142, v143,
for _, m := range matches { // v145, ...) - the same short name Microsoft stamps on
sub := reToolsetDir.FindStringSubmatch(filepath.Base(m)) // VC/Auxiliary/Build/Microsoft.VCToolsVersion.v<N>.default.props for
if sub == nil { // the downloaded compiler.
continue // - Microsoft.CppBuild.targets (MSB8070) instead keys off <N> = the
} // MSBuild targets-schema version whose Microsoft.Cpp.props ended up
env["VCInstallDir_"+sub[1]] = paths.MSVCBaseWin + `\` // imported for this run (MSBuild/Microsoft/VC/v150|v160|v170|v180 -
env["VCToolsInstallDir_"+sub[1]] = paths.MSVCDirWin + `\` // 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") { if strings.HasSuffix(paths.MSBuildBinDir, "amd64") {
@@ -94,6 +124,133 @@ func msbuildEnv(cfg *wineenv.Config, paths *wineenv.Paths) map[string]string {
return env 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
}
var reNodeReuse = regexp.MustCompile(`(?i)^[-/](nodereuse|nr):`)
// msbuildNodeReuseArgs returns ["/nodeReuse:false"] unless args already pins
// node reuse one way or the other.
//
// MSBuild's node-reuse worker processes (its own /nodeReuse:true default)
// don't behave like a normal child process here: they're meant to outlive
// the parent msbuild.exe invocation that spawned them, waiting around under
// Wine for the *next* msbuild call to reuse them - so nothing about
// vintner's own process-lifetime handling (see signals.go) touches them,
// and there's no parent process left to notice if one wedges. If a build is
// interrupted (Ctrl-C, a killed session, a crashed Wine transport) mid-
// compile, the worker can be left holding a half-open pipe/mutex,
// permanently deadlocked rather than exited - confirmed in practice: a
// stale reused node kept throwing an unrelated-looking
// `System.TypeLoadException` on Microsoft.VisualStudio.Telemetry on every
// subsequent build, for hours, until it was killed by hand and the next
// build got a fresh node. Forcing node reuse off means every invocation
// gets a clean process, so a wedged one can never poison a later,
// unrelated build - at the cost of the couple-hundred-ms/node startup time
// node reuse exists to save. Callers who deliberately want reuse (e.g.
// running many builds back to back and are prepared to clean up wedged
// nodes themselves) can still pass their own /nodeReuse or /nr switch to
// override this.
func msbuildNodeReuseArgs(args []string) []string {
for _, a := range args {
if reNodeReuse.MatchString(a) {
return nil
}
}
return []string{"/nodeReuse:false"}
}
func msbuildPlatform(arch string) string { func msbuildPlatform(arch string) string {
switch arch { switch arch {
case "x86": case "x86":
+107 -2
View File
@@ -39,6 +39,14 @@ func TestMsbuildEnvBasics(t *testing.T) {
if env["DisableRegistryUse"] != "true" { if env["DisableRegistryUse"] != "true" {
t.Errorf(`env["DisableRegistryUse"] = %q, want "true"`, env["DisableRegistryUse"]) 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 { if env["VCToolsVersion"] != cfg.MSVCVer {
t.Errorf(`env["VCToolsVersion"] = %q, want %q`, env["VCToolsVersion"], cfg.MSVCVer) t.Errorf(`env["VCToolsVersion"] = %q, want %q`, env["VCToolsVersion"], cfg.MSVCVer)
} }
@@ -61,7 +69,24 @@ func TestMsbuildEnvDiscoversEveryToolsetVersion(t *testing.T) {
cfg := &wineenv.Config{Arch: "x64", Host: "x64", DotnetHost: "amd64", MSVCVer: "14.51.36231", SDKVer: "10.0.26100.0"} cfg := &wineenv.Config{Arch: "x64", Host: "x64", DotnetHost: "amd64", MSVCVer: "14.51.36231", SDKVer: "10.0.26100.0"}
paths, base := newTestPaths(t, cfg) paths, base := newTestPaths(t, cfg)
for _, v := range []string{"v145", "v180", "not-a-version"} { // 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 { if err := os.MkdirAll(filepath.Join(base, "MSBuild", "Microsoft", "VC", v), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -69,7 +94,7 @@ func TestMsbuildEnvDiscoversEveryToolsetVersion(t *testing.T) {
env := msbuildEnv(cfg, paths) env := msbuildEnv(cfg, paths)
for _, n := range []string{"145", "180"} { for _, n := range []string{"145", "143", "180"} {
if _, ok := env["VCInstallDir_"+n]; !ok { if _, ok := env["VCInstallDir_"+n]; !ok {
t.Errorf("expected VCInstallDir_%s to be set", n) t.Errorf("expected VCInstallDir_%s to be set", n)
} }
@@ -82,6 +107,64 @@ func TestMsbuildEnvDiscoversEveryToolsetVersion(t *testing.T) {
} }
} }
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) { func TestMsbuildEnvDetectsWDKContentRoot(t *testing.T) {
cfg := &wineenv.Config{Arch: "x64", Host: "x64", DotnetHost: "amd64", MSVCVer: "14.51.36231", SDKVer: "10.0.26100.0"} cfg := &wineenv.Config{Arch: "x64", Host: "x64", DotnetHost: "amd64", MSVCVer: "14.51.36231", SDKVer: "10.0.26100.0"}
paths, base := newTestPaths(t, cfg) paths, base := newTestPaths(t, cfg)
@@ -115,3 +198,25 @@ func TestMsbuildEnvPreferredToolArchitecture(t *testing.T) {
t.Errorf(`with DotnetHost=arm64, PreferredToolArchitecture = %q, want unset`, env["PreferredToolArchitecture"]) t.Errorf(`with DotnetHost=arm64, PreferredToolArchitecture = %q, want unset`, env["PreferredToolArchitecture"])
} }
} }
func TestMsbuildNodeReuseArgsForcesOffByDefault(t *testing.T) {
got := msbuildNodeReuseArgs([]string{"Foo.sln", "/p:Configuration=Release"})
want := []string{"/nodeReuse:false"}
if len(got) != 1 || got[0] != want[0] {
t.Errorf("msbuildNodeReuseArgs(...) = %v, want %v", got, want)
}
}
func TestMsbuildNodeReuseArgsRespectsExplicitOverride(t *testing.T) {
for _, explicit := range []string{
"/nodeReuse:true",
"-nodeReuse:true",
"/nr:true",
"/NODEREUSE:FALSE", // caller explicitly wanting it off too - still shouldn't double up
} {
got := msbuildNodeReuseArgs([]string{"Foo.sln", explicit})
if got != nil {
t.Errorf("msbuildNodeReuseArgs with explicit %q = %v, want nil (left alone)", explicit, got)
}
}
}
+13 -8
View File
@@ -1,6 +1,7 @@
package wrapper package wrapper
import ( import (
"fmt"
"os" "os"
"os/exec" "os/exec"
) )
@@ -25,17 +26,21 @@ func execInherit(args []string) int {
if len(args) == 0 { if len(args) == 0 {
return 0 return 0
} }
cmd := exec.Command(args[0], args[1:]...) tc, cleanup := newToolCommand(args[0], args[1:]...)
cmd.Stdin = os.Stdin defer cleanup()
cmd.Stdout = os.Stdout tc.Stdin = os.Stdin
cmd.Stderr = os.Stderr tc.Stdout = os.Stdout
setNewProcessGroup(cmd) tc.Stderr = os.Stderr
if err := cmd.Start(); err != nil { if err := tc.Start(); err != nil {
return 127 return 127
} }
stopSignals := forwardSignals(cmd.Process) stopSignals := forwardSignals(tc.Process)
defer stopSignals() defer stopSignals()
if err := cmd.Wait(); err != nil { if err := tc.Wait(); err != nil {
if tc.timedOut() {
fmt.Fprintln(os.Stderr, tc.timeoutMessage())
return 124
}
if exitErr, ok := err.(*exec.ExitError); ok { if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode() return exitErr.ExitCode()
} }
+72 -44
View File
@@ -30,7 +30,15 @@ const toolRelayName = "toolrelay.exe"
// Run executes the named multi-call tool with args, exactly as the original // Run executes the named multi-call tool with args, exactly as the original
// bash wrappers would, and returns the process exit code. // bash wrappers would, and returns the process exit code.
func Run(tool string, args []string) int { //
// binDir is the <dest>/bin/<arch> directory holding env.json for this
// invocation. Pass "" for the ordinary multi-call case (invoked as `cl`,
// `link`, etc. via a same-directory symlink) to have it resolved from the
// running binary's own location; a non-empty value is for `vintner <tool>
// ...` direct dispatch (see cmd/vintner's runTool), which isn't running
// from inside any particular <dest>/bin/<arch> and so has nothing to
// resolve on its own.
func Run(tool string, args []string, binDir string) int {
if nativeTools[tool] { if nativeTools[tool] {
return runNative(tool, args) return runNative(tool, args)
} }
@@ -41,19 +49,23 @@ func Run(tool string, args []string) int {
return 127 return 127
} }
// os.Executable() (backed by /proc/self/exe on Linux) fully resolves scriptDir := binDir
// symlinks, unlike os.Args[0]: not every shell passes a PATH-resolved if scriptDir == "" {
// absolute path as argv[0] (some just pass the bare command name), which // os.Executable() (backed by /proc/self/exe on Linux) fully resolves
// would make an argv[0]-based lookup resolve against the caller's cwd // symlinks, unlike os.Args[0]: not every shell passes a
// instead of the actual install dir. `install` sets each arch dir up // PATH-resolved absolute path as argv[0] (some just pass the bare
// with its own local copy of the binary precisely so this resolves to // command name), which would make an argv[0]-based lookup resolve
// <dest>/bin/<arch>, not <dest>/bin. // against the caller's cwd instead of the actual install dir.
exePath, err := os.Executable() // `install` sets each arch dir up with its own local copy of the
if err != nil { // binary precisely so this resolves to <dest>/bin/<arch>, not
fmt.Fprintln(os.Stderr, "vintner:", err) // <dest>/bin.
return 1 exePath, err := os.Executable()
if err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err)
return 1
}
scriptDir = filepath.Dir(exePath)
} }
scriptDir := filepath.Dir(exePath)
cfg, err := wineenv.Load(scriptDir) cfg, err := wineenv.Load(scriptDir)
if err != nil { if err != nil {
@@ -83,26 +95,30 @@ func Run(tool string, args []string) int {
// MSBuild: skip all filtering/toolrelay (its output is meant to be // MSBuild: skip all filtering/toolrelay (its output is meant to be
// read as-is), and add the extra environment MSBuild's own // read as-is), and add the extra environment MSBuild's own
// toolset/SDK-detection props need on top of the generic // toolset/SDK-detection props need on top of the generic
// INCLUDE/LIB/WINEPATH. // INCLUDE/LIB/WINEPATH, plus any global properties a project file
cmd := exec.Command(wineBin, append([]string{toolExePath}, rewritten...)...) // itself could otherwise override (see msbuildGlobalArgs) and a
// forced /nodeReuse:false (see msbuildNodeReuseArgs).
msArgs := append(msbuildGlobalArgs(cfg, rewritten), rewritten...)
msArgs = append(msbuildNodeReuseArgs(rewritten), msArgs...)
tc, cleanup := newToolCommand(wineBin, append([]string{toolExePath}, msArgs...)...)
defer cleanup()
env := buildEnv(paths) env := buildEnv(paths)
for k, v := range msbuildEnv(cfg, paths) { for k, v := range msbuildEnv(cfg, paths) {
env = append(env, k+"="+v) env = append(env, k+"="+v)
} }
cmd.Env = env tc.Env = env
cmd.Stdin = os.Stdin tc.Stdin = os.Stdin
setNewProcessGroup(cmd) exitCode = runRawStdout(tc)
exitCode = runRawStdout(cmd)
default: default:
relay := filepath.Join(paths.BaseUnix, "bin", toolRelayName) relay := filepath.Join(paths.BaseUnix, "bin", toolRelayName)
if fi, err := os.Stat(relay); err == nil && !fi.IsDir() { if fi, err := os.Stat(relay); err == nil && !fi.IsDir() {
exitCode = runViaToolRelay(wineBin, relay, toolExePath, rewritten, paths, s.stdoutFilter, s.stderrFilter) exitCode = runViaToolRelay(wineBin, relay, toolExePath, rewritten, paths, s.stdoutFilter, s.stderrFilter)
} else { } else {
cmd := exec.Command(wineBin, append([]string{toolExePath}, rewritten...)...) tc, cleanup := newToolCommand(wineBin, append([]string{toolExePath}, rewritten...)...)
cmd.Env = buildEnv(paths) defer cleanup()
cmd.Stdin = os.Stdin tc.Env = buildEnv(paths)
setNewProcessGroup(cmd) tc.Stdin = os.Stdin
exitCode = runFiltered(cmd, s.stdoutFilter, s.stderrFilter) exitCode = runFiltered(tc, s.stdoutFilter, s.stderrFilter)
} }
} }
@@ -137,20 +153,20 @@ func runViaToolRelay(wineBin, relayExe, exePath string, args []string, paths *wi
defer os.Remove(stderrFifo) defer os.Remove(stderrFifo)
cmdArgs := append([]string{relayExe, exePath}, args...) cmdArgs := append([]string{relayExe, exePath}, args...)
cmd := exec.Command(wineBin, cmdArgs...) tc, cleanup := newToolCommand(wineBin, cmdArgs...)
cmd.Env = append(buildEnv(paths), "MSVCGOWINE_STDOUT="+stdoutFifo, "MSVCGOWINE_STDERR="+stderrFifo) defer cleanup()
setNewProcessGroup(cmd) tc.Env = append(buildEnv(paths), "MSVCGOWINE_STDOUT="+stdoutFifo, "MSVCGOWINE_STDERR="+stderrFifo)
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 tc.Stdout = devNull
cmd.Stderr = devNull tc.Stderr = devNull
} }
if err := cmd.Start(); err != nil { if err := tc.Start(); err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
stopSignals := forwardSignals(cmd.Process) stopSignals := forwardSignals(tc.Process)
defer stopSignals() defer stopSignals()
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -174,10 +190,14 @@ func runViaToolRelay(wineBin, relayExe, exePath string, args []string, paths *wi
pumpLines(f, os.Stderr, stderrF) pumpLines(f, os.Stderr, stderrF)
}() }()
err := cmd.Wait() err := tc.Wait()
wg.Wait() wg.Wait()
if err != nil { if err != nil {
if tc.timedOut() {
fmt.Fprintln(os.Stderr, tc.timeoutMessage())
return 124
}
if exitErr, ok := err.(*exec.ExitError); ok { if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode() return exitErr.ExitCode()
} }
@@ -193,23 +213,23 @@ func runViaToolRelay(wineBin, relayExe, exePath string, args []string, paths *wi
// own copy goroutines - not the caller's terminal or pipe - are exposed to // own copy goroutines - not the caller's terminal or pipe - are exposed to
// Wine's background processes holding those descriptors open; see // Wine's background processes holding those descriptors open; see
// pipeDrainGrace. // pipeDrainGrace.
func runRawStdout(cmd *exec.Cmd) int { func runRawStdout(tc *toolCommand) int {
stdout, err := cmd.StdoutPipe() stdout, err := tc.StdoutPipe()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
stderr, err := cmd.StderrPipe() stderr, err := tc.StderrPipe()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
if err := cmd.Start(); err != nil { if err := tc.Start(); err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
stopSignals := forwardSignals(cmd.Process) stopSignals := forwardSignals(tc.Process)
defer stopSignals() defer stopSignals()
doneOut := make(chan struct{}) doneOut := make(chan struct{})
@@ -217,11 +237,15 @@ func runRawStdout(cmd *exec.Cmd) int {
go func() { io.Copy(os.Stdout, stdout); close(doneOut) }() go func() { io.Copy(os.Stdout, stdout); close(doneOut) }()
go func() { io.Copy(os.Stderr, stderr); close(doneErr) }() go func() { io.Copy(os.Stderr, stderr); close(doneErr) }()
err = cmd.Wait() err = tc.Wait()
drain(doneOut) drain(doneOut)
drain(doneErr) drain(doneErr)
if err != nil { if err != nil {
if tc.timedOut() {
fmt.Fprintln(os.Stderr, tc.timeoutMessage())
return 124
}
if exitErr, ok := err.(*exec.ExitError); ok { if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode() return exitErr.ExitCode()
} }
@@ -269,23 +293,23 @@ func buildEnv(p *wineenv.Paths) []string {
// runFiltered streams stdout/stderr line by line through the tool's // runFiltered streams stdout/stderr line by line through the tool's
// filters (CR-stripping always applied first), then waits for completion. // filters (CR-stripping always applied first), then waits for completion.
func runFiltered(cmd *exec.Cmd, stdoutF, stderrF lineFilter) int { func runFiltered(tc *toolCommand, stdoutF, stderrF lineFilter) int {
stdout, err := cmd.StdoutPipe() stdout, err := tc.StdoutPipe()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
stderr, err := cmd.StderrPipe() stderr, err := tc.StderrPipe()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
if err := cmd.Start(); err != nil { if err := tc.Start(); err != nil {
fmt.Fprintln(os.Stderr, "vintner:", err) fmt.Fprintln(os.Stderr, "vintner:", err)
return 1 return 1
} }
stopSignals := forwardSignals(cmd.Process) stopSignals := forwardSignals(tc.Process)
defer stopSignals() defer stopSignals()
doneOut := make(chan struct{}) doneOut := make(chan struct{})
@@ -293,11 +317,15 @@ func runFiltered(cmd *exec.Cmd, stdoutF, stderrF lineFilter) int {
go func() { pumpLines(stdout, os.Stdout, stdoutF); close(doneOut) }() go func() { pumpLines(stdout, os.Stdout, stdoutF); close(doneOut) }()
go func() { pumpLines(stderr, os.Stderr, stderrF); close(doneErr) }() go func() { pumpLines(stderr, os.Stderr, stderrF); close(doneErr) }()
err = cmd.Wait() err = tc.Wait()
drain(doneOut) drain(doneOut)
drain(doneErr) drain(doneErr)
if err != nil { if err != nil {
if tc.timedOut() {
fmt.Fprintln(os.Stderr, tc.timeoutMessage())
return 124
}
if exitErr, ok := err.(*exec.ExitError); ok { if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode() return exitErr.ExitCode()
} }
+97
View File
@@ -0,0 +1,97 @@
package wrapper
import (
"context"
"fmt"
"os"
"os/exec"
"syscall"
"time"
)
// waitDelay bounds how long cmd.Wait() itself may block after the process
// group has already been told to die (by a timeout or a forwarded signal) -
// usually resolved immediately, but a wedged Wine transport is exactly the
// case that isn't guaranteed to notice a plain SIGKILL right away.
const waitDelay = 5 * time.Second
// commandTimeout returns how long a single tool invocation may run before
// vintner kills it and reports a timeout, from VINTNER_TIMEOUT (a
// time.ParseDuration string, e.g. "30m", "2h"). Unset, empty, or invalid
// all mean "no timeout" (0) - the default stays a plain, unbounded build,
// matching every real build observed so far (Ogre3D's from-scratch build
// alone ran several minutes). This exists for exactly one failure mode: a
// wedged Wine-hosted process (a corrupted MSBuild node-reuse worker in the
// one confirmed case so far, but nothing about the mechanism is
// MSBuild-specific) that will otherwise never exit on its own, hanging
// vintner - and whatever's waiting on vintner - forever with no feedback.
// Automated/scripted callers that would rather fail loudly after N minutes
// than risk hanging indefinitely can set this; interactive use is
// unaffected unless it's set.
func commandTimeout() time.Duration {
v := os.Getenv("VINTNER_TIMEOUT")
if v == "" {
return 0
}
d, err := time.ParseDuration(v)
if err != nil || d <= 0 {
return 0
}
return d
}
// toolCommand wraps the exec.Cmd every wine-hosted tool invocation is built
// from, plus enough state to tell a VINTNER_TIMEOUT kill apart from every
// other failure once Wait() returns.
type toolCommand struct {
*exec.Cmd
ctx context.Context
cancel context.CancelFunc
timeout time.Duration // 0 if VINTNER_TIMEOUT wasn't set
}
// newToolCommand builds a toolCommand: its own process group
// (setNewProcessGroup) and, when VINTNER_TIMEOUT is set, a deadline that
// kills the *whole group* - not just the immediate `wine` process, since a
// wedged Wine-hosted child surviving past its parent is exactly the
// scenario this needs to reach - if the tool hasn't finished in time.
//
// Callers must defer the returned cleanup func, and should call
// timedOut() after Wait() returns to tell a timeout-triggered kill apart
// from every other failure.
func newToolCommand(name string, args ...string) (tc *toolCommand, cleanup func()) {
timeout := commandTimeout()
if timeout <= 0 {
cmd := exec.Command(name, args...)
setNewProcessGroup(cmd)
return &toolCommand{Cmd: cmd, ctx: context.Background()}, func() {}
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
cmd := exec.CommandContext(ctx, name, args...)
setNewProcessGroup(cmd)
// cmd.Cancel's default (Go 1.20+) only signals the immediate child;
// override it to reach the whole process group, same as
// forwardSignals - the wedged process a timeout exists to clean up is
// typically under wine, not wine itself.
cmd.Cancel = func() error {
if cmd.Process == nil {
return nil
}
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
cmd.WaitDelay = waitDelay
tc = &toolCommand{Cmd: cmd, ctx: ctx, cancel: cancel, timeout: timeout}
return tc, cancel
}
// timedOut reports whether this command was killed by its own
// VINTNER_TIMEOUT deadline rather than exiting (however it exited) on its
// own - call after Wait() returns a non-nil error.
func (tc *toolCommand) timedOut() bool {
return tc.ctx.Err() == context.DeadlineExceeded
}
func (tc *toolCommand) timeoutMessage() string {
return fmt.Sprintf("vintner: %s: timed out after %s (VINTNER_TIMEOUT), killed", tc.Path, tc.timeout)
}
+82
View File
@@ -0,0 +1,82 @@
package wrapper
import (
"syscall"
"testing"
"time"
)
func TestCommandTimeout(t *testing.T) {
for _, tc := range []struct {
name string
env string
want time.Duration
}{
{"unset", "", 0},
{"valid", "30m", 30 * time.Minute},
{"invalid unit-less number", "30", 0},
{"zero", "0s", 0},
{"negative", "-5m", 0},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("VINTNER_TIMEOUT", tc.env)
if got := commandTimeout(); got != tc.want {
t.Errorf("commandTimeout() with VINTNER_TIMEOUT=%q = %v, want %v", tc.env, got, tc.want)
}
})
}
}
// TestNewToolCommandKillsOnTimeout is the real end-to-end check: start a
// process that would otherwise run far longer than the timeout (mimicking
// a wedged Wine-hosted tool), and confirm newToolCommand's deadline
// actually kills it - not just that timedOut() would report true in
// principle, but that Wait() actually returns, promptly, with the process
// gone.
func TestNewToolCommandKillsOnTimeout(t *testing.T) {
t.Setenv("VINTNER_TIMEOUT", "300ms")
tc, cleanup := newToolCommand("sleep", "30")
defer cleanup()
if err := tc.Start(); err != nil {
t.Fatalf("starting sleep: %v", err)
}
pid := tc.Process.Pid
done := make(chan error, 1)
go func() { done <- tc.Wait() }()
select {
case err := <-done:
if err == nil {
t.Fatal("expected sleep 30 to be killed by the timeout, but it exited successfully")
}
if !tc.timedOut() {
t.Errorf("Wait() returned an error (%v) but timedOut() = false", err)
}
case <-time.After(5 * time.Second):
t.Fatal("newToolCommand's timeout did not kill the process within 5s of a 300ms deadline")
}
// Belt-and-suspenders: the process should genuinely be gone, not just
// reported as such. Signal 0 sends nothing but still fails with ESRCH
// once the pid is gone - the standard Unix way to probe existence.
if err := syscall.Kill(pid, 0); err == nil {
t.Errorf("pid %d still exists after the timeout killed it", pid)
}
}
func TestNewToolCommandNoTimeoutByDefault(t *testing.T) {
t.Setenv("VINTNER_TIMEOUT", "")
tc, cleanup := newToolCommand("true")
defer cleanup()
if err := tc.Run(); err != nil {
t.Fatalf("running `true` with no VINTNER_TIMEOUT set: %v", err)
}
if tc.timedOut() {
t.Error("timedOut() = true for a command that finished well within any reasonable time, with no timeout configured")
}
}
+31 -1
View File
@@ -4,7 +4,11 @@
// filtering its output. // filtering its output.
package wrapper package wrapper
import "github.com/Cheviiot/vintner/internal/wineenv" import (
"sort"
"github.com/Cheviiot/vintner/internal/wineenv"
)
// dirKind selects which install directory a tool's real .exe lives in. // dirKind selects which install directory a tool's real .exe lives in.
type dirKind int type dirKind int
@@ -46,6 +50,32 @@ var Tools = map[string]spec{
// nativeTools are handled entirely without Wine. // nativeTools are handled entirely without Wine.
var nativeTools = map[string]bool{"cmd": true, "findstr": true} var nativeTools = map[string]bool{"cmd": true, "findstr": true}
// IsTool reports whether name is a recognized wrapped tool - either a
// Wine-hosted one in Tools or a native shim in nativeTools. Shared between
// the ordinary multi-call dispatch (invoked *as* one of these names via a
// same-directory symlink) and `vintner <tool> ...` direct dispatch, so both
// recognize exactly the same set of names.
func IsTool(name string) bool {
_, ok := Tools[name]
return ok || nativeTools[name]
}
// ToolNames returns every recognized tool name, sorted - Tools and
// nativeTools combined. Used to generate shell completion without a
// separate hand-maintained list that could drift from the real set (as
// happened once already with a stale --with-dxsdk completion entry).
func ToolNames() []string {
names := make([]string, 0, len(Tools)+len(nativeTools))
for name := range Tools {
names = append(names, name)
}
for name := range nativeTools {
names = append(names, name)
}
sort.Strings(names)
return names
}
func (s spec) exeDir(p *wineenv.Paths) string { func (s spec) exeDir(p *wineenv.Paths) string {
switch s.dir { switch s.dir {
case dirSDK: case dirSDK:
+18
View File
@@ -43,3 +43,21 @@ func TestEveryToolHasAnExeName(t *testing.T) {
} }
} }
} }
func TestIsTool(t *testing.T) {
for name := range Tools {
if !IsTool(name) {
t.Errorf("IsTool(%q) = false, want true (it's in Tools)", name)
}
}
for name := range nativeTools {
if !IsTool(name) {
t.Errorf("IsTool(%q) = false, want true (it's in nativeTools)", name)
}
}
for _, name := range []string{"download", "install", "env", "version", "help", "completion", "frobnicate", ""} {
if IsTool(name) {
t.Errorf("IsTool(%q) = true, want false", name)
}
}
}