Initial implementation of msvc-go-wine

A single-binary Go tool for cross compiling with the real MSVC toolchain
on Linux via Wine. Behaves as cl/link/lib/rc/midl/mt/dumpbin/msbuild/
nmake/ml/ml64/armasm/armasm64/cmd/findstr depending on the name it's
invoked as, plus download/install/env/version management subcommands.

- download: fetches the MSVC/WinSDK installer manifest, resolves package
  selection and dependencies, downloads and verifies payloads, unpacks
  VSIX/MSI packages, and applies a handful of compatibility patches so
  VsDevCmd.bat and MSBuild's SDK detection work without a Windows
  Registry (which doesn't exist under Wine).
- install: locates the installed toolchain/SDK versions, normalizes
  header/library name casing, lays out per-architecture tool symlinks
  with an env.json config each, and compiles a small native launcher
  (toolrelay.exe) that lets mt.exe's CMake-compatibility exit code
  survive Wine's own exit-code truncation.
- The wrapper runtime rewrites absolute unix paths in tool arguments into
  Wine's z:\... form, runs the real .exe under wine, and rewrites the
  tool's output back to plain unix paths.

Verified end-to-end against a real MSVC/WinSDK download: cl, link, mt and
the resulting hello.exe all work under Wine, including through the
toolrelay.exe relay path and with paths containing non-ASCII characters.

Offline unit tests cover the wrapper's path-rewrite/output-filter logic,
install-time header lowercasing, and download package-selection/
dependency-resolution.
This commit is contained in:
Cheviiot
2026-07-25 00:57:41 +10:00
commit 6464da7847
41 changed files with 4207 additions and 0 deletions
+309
View File
@@ -0,0 +1,309 @@
package download
import (
"archive/zip"
"encoding/xml"
"fmt"
"io"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
)
// UnpackSelectedPackages unpacks every selected package's cached payloads
// into unpack (VSIX packages as plain zips, Win10SDK/Win11SDK via the
// external msiextract).
func UnpackSelectedPackages(selected []*Package, cacheDir, unpack string) error {
if err := os.MkdirAll(unpack, 0o755); err != nil {
return err
}
if err := os.MkdirAll(filepath.Join(unpack, "MSBuild"), 0o755); err != nil {
return err
}
for _, p := range selected {
dir := filepath.Join(cacheDir, p.Key())
switch p.Type {
case "Component", "Workload", "Group":
continue
case "Vsix":
fmt.Println("Unpacking", p.ID)
for _, pl := range p.Payloads {
listing := filepath.Join(unpack, p.Key()+"-listing.txt")
if err := extractVSIXPackage(filepath.Join(dir, pl.Name()), unpack, listing); err != nil {
return fmt.Errorf("unpacking %s: %w", p.ID, err)
}
}
default:
if strings.HasPrefix(p.ID, "Win10SDK") || strings.HasPrefix(p.ID, "Win11SDK") {
fmt.Println("Unpacking", p.ID)
if err := extractWindowsSDKPackage(dir, p.Payloads, unpack); err != nil {
return fmt.Errorf("unpacking %s: %w", p.ID, err)
}
} else {
fmt.Println("Skipping unpacking of", p.ID, "of type", p.Type)
}
}
}
return nil
}
// extractVSIXPackage extracts a VSIX (plain zip) into a scratch dir under
// dest, then merges its "Contents" (and WDK's "$MSBuild") subtree into
// dest.
func extractVSIXPackage(file, dest, listingPath string) error {
r, err := zip.OpenReader(file)
if err != nil {
return err
}
defer r.Close()
tmp := filepath.Join(dest, "vsix")
if err := os.RemoveAll(tmp); err != nil {
return err
}
var names []string
for _, f := range r.File {
names = append(names, f.Name)
name, err := url.PathUnescape(f.Name)
if err != nil {
name = f.Name
}
target := filepath.Join(tmp, name)
if f.FileInfo().IsDir() {
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
if err := extractZipEntry(f, target); err != nil {
return err
}
}
if err := os.WriteFile(listingPath, []byte(strings.Join(names, "\n")+"\n"), 0o644); err != nil {
return err
}
if contents := filepath.Join(tmp, "Contents"); isDir(contents) {
if err := combineDirTrees(contents, dest); err != nil {
return err
}
}
// This archive directory structure is used by the WDK.vsix.
if msbuild := filepath.Join(tmp, "$MSBuild"); isDir(msbuild) {
if err := combineDirTrees(msbuild, filepath.Join(dest, "MSBuild")); err != nil {
return err
}
}
return os.RemoveAll(tmp)
}
func extractZipEntry(f *zip.File, dest string) error {
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
mode := f.Mode()
if mode == 0 {
mode = 0o644
}
out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, rc)
return err
}
// extractWindowsSDKPackage extracts every .msi payload of a WinSDK package
// via msiextract, and symlinks "Program Files" to "." so files msiextract
// unpacks there land at the unpack root (matching msiexec's own behavior on
// Windows).
func extractWindowsSDKPackage(src string, payloads []Payload, dest string) error {
pf := filepath.Join(dest, "Program Files")
if !exists(pf) {
if err := os.Symlink(".", pf); err != nil {
return err
}
}
for _, pl := range payloads {
name := pl.Name()
if !strings.HasSuffix(strings.ToLower(name), ".msi") {
continue
}
fmt.Println("Extracting", name)
srcFile := filepath.Join(src, name)
logPath := filepath.Join(dest, "WinSDK-"+name+"-listing.txt")
if err := runMsiExtract(srcFile, dest, logPath); err != nil {
return fmt.Errorf("msiextract %s: %w", name, err)
}
}
return nil
}
func runMsiExtract(srcFile, dest, logPath string) error {
if _, err := exec.LookPath("msiextract"); err != nil {
return fmt.Errorf("msiextract not found in PATH (install the msitools package): %w", err)
}
logFile, err := os.Create(logPath)
if err != nil {
return err
}
defer logFile.Close()
cmd := exec.Command("msiextract", "-C", dest, srcFile)
cmd.Stdout = logFile
cmd.Stderr = os.Stderr
return cmd.Run()
}
// RelocateBuildTools relocates the components CLI tools actually need (VC,
// Windows Kits, DIA SDK, MSBuild, Common7/Tools) from the scratch unpack
// dir into dest, so the rest of unpack can be discarded.
func RelocateBuildTools(unpack, dest string) error {
components := []string{"VC", "Windows Kits", "DIA SDK", "MSBuild", filepath.Join("Common7", "Tools")}
for _, c := range components {
if err := combineDirTrees(filepath.Join(unpack, c), filepath.Join(dest, c)); err != nil {
return fmt.Errorf("moving %s: %w", c, err)
}
}
return nil
}
// combineDirTrees moves src into dest, recursively merging where a
// same-named directory already exists in dest (matching case-insensitively)
// rather than clobbering it - MSVC/WinSDK packages aren't casing-consistent
// about where they put things.
func combineDirTrees(src, dest string) error {
if !isDir(src) {
return nil
}
if !isDir(dest) {
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
}
return os.Rename(src, dest)
}
entries, err := os.ReadDir(src)
if err != nil {
return err
}
destEntries, err := os.ReadDir(dest)
if err != nil {
return err
}
destNames := map[string]string{}
for _, e := range destEntries {
destNames[strings.ToLower(e.Name())] = e.Name()
}
for _, e := range entries {
n := e.Name()
srcName := filepath.Join(src, n)
destName := filepath.Join(dest, n)
if e.IsDir() {
if isDir(destName) {
if err := combineDirTrees(srcName, destName); err != nil {
return err
}
continue
}
if actual, ok := destNames[strings.ToLower(n)]; ok {
if err := combineDirTrees(srcName, filepath.Join(dest, actual)); err != nil {
return err
}
continue
}
if err := os.Rename(srcName, destName); err != nil {
return err
}
continue
}
if err := os.Rename(srcName, destName); err != nil {
return err
}
}
return nil
}
// CopyRedirectedAssemblies works around Wine not honoring <dependentAssembly>
// codeBase redirects in an .exe.config file, by copying the referenced DLL
// next to the executable directly.
func CopyRedirectedAssemblies(app string) error {
cfgPath := app + ".config"
if !isFile(cfgPath) {
return nil
}
data, err := os.ReadFile(cfgPath)
if err != nil {
return err
}
var cfg struct {
Runtime struct {
AssemblyBinding struct {
DependentAssembly []struct {
CodeBase struct {
Href string `xml:"href,attr"`
} `xml:"codeBase"`
} `xml:"dependentAssembly"`
} `xml:"assemblyBinding"`
} `xml:"runtime"`
}
if err := xml.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("parsing %s: %w", cfgPath, err)
}
dest := filepath.Dir(app)
for _, da := range cfg.Runtime.AssemblyBinding.DependentAssembly {
href := strings.ReplaceAll(da.CodeBase.Href, "\\", "/")
if href == "" {
continue
}
src := filepath.Join(dest, href)
if isFile(src) {
if err := copyFile(src, filepath.Join(dest, filepath.Base(src)), 0o644); err != nil {
return err
}
}
}
return nil
}
func isDir(path string) bool {
fi, err := os.Stat(path)
return err == nil && fi.IsDir()
}
func isFile(path string) bool {
fi, err := os.Stat(path)
return err == nil && !fi.IsDir()
}
func exists(path string) bool {
_, err := os.Lstat(path)
return err == nil
}
func copyFile(src, dst string, mode os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
_ = os.Remove(dst)
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
+194
View File
@@ -0,0 +1,194 @@
package download
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
)
const maxConcurrentDownloads = 5
const maxDownloadAttempts = 5
// FetchPayloads fetches every payload of every selected package into
// cacheDir/<packageKey>/<payloadName>, verifying sha256 and skipping files
// already present and correct. allowHashMismatch (used for --only-download)
// warns instead of failing on a hash mismatch.
func FetchPayloads(selected []*Package, cacheDir string, allowHashMismatch bool) error {
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
return err
}
type task struct {
payload Payload
dest string
fileID string
}
var tasks []task
for _, p := range selected {
if len(p.Payloads) == 0 {
continue
}
dir := filepath.Join(cacheDir, p.Key())
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
for _, pl := range p.Payloads {
name := pl.Name()
tasks = append(tasks, task{
payload: pl,
dest: filepath.Join(dir, name),
fileID: filepath.Join(p.Key(), name),
})
}
}
sem := make(chan struct{}, maxConcurrentDownloads)
var wg sync.WaitGroup
var totalDownloaded int64
errCh := make(chan error, len(tasks))
for _, t := range tasks {
t := t
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
n, err := fetchOnePayloadWithRetries(t.payload, t.dest, t.fileID, allowHashMismatch)
if err != nil {
errCh <- err
return
}
atomic.AddInt64(&totalDownloaded, n)
}()
}
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
return err
}
}
fmt.Printf("Downloaded %s in total\n", HumanizeBytes(totalDownloaded))
return nil
}
func fetchOnePayloadWithRetries(payload Payload, dest, fileID string, allowHashMismatch bool) (int64, error) {
var lastErr error
for attempt := 0; attempt < maxDownloadAttempts; attempt++ {
n, err := tryDownloadPayload(payload, dest, fileID, allowHashMismatch)
if err == nil {
return n, nil
}
lastErr = err
fmt.Printf("%v\n", err)
}
return 0, fmt.Errorf("giving up on %s after %d attempts: %w", fileID, maxDownloadAttempts, lastErr)
}
func tryDownloadPayload(payload Payload, dest, fileID string, allowHashMismatch bool) (int64, error) {
if fi, err := os.Stat(dest); err == nil && fi.Mode().IsRegular() {
if payload.SHA256 != "" {
sum, err := sha256File(dest)
if err != nil {
return 0, err
}
if !equalFoldHex(sum, payload.SHA256) {
fmt.Printf("Incorrect existing file %s, removing\n", fileID)
os.Remove(dest)
} else {
fmt.Printf("Using existing file %s\n", fileID)
return 0, nil
}
} else {
return 0, nil
}
}
fmt.Printf("Downloading %s (%s)\n", fileID, HumanizeBytes(payload.Size))
if err := httpDownloadFile(payload.URL, dest); err != nil {
return 0, err
}
if payload.SHA256 != "" {
sum, err := sha256File(dest)
if err != nil {
return 0, err
}
if !equalFoldHex(sum, payload.SHA256) {
if allowHashMismatch {
fmt.Printf("WARNING: incorrect hash for downloaded file %s\n", fileID)
} else {
return 0, fmt.Errorf("incorrect hash for downloaded file %s, aborting", fileID)
}
}
}
return payload.Size, nil
}
var downloadHTTPClient = &http.Client{Timeout: 30 * time.Minute}
func httpDownloadFile(url, dest string) error {
resp, err := downloadHTTPClient.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s: %s", url, resp.Status)
}
tmp := dest + ".part"
out, err := os.Create(tmp)
if err != nil {
return err
}
if _, err := io.Copy(out, resp.Body); err != nil {
out.Close()
os.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, dest)
}
func sha256File(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func equalFoldHex(a, b string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
ca, cb := a[i], b[i]
if ca >= 'A' && ca <= 'Z' {
ca += 'a' - 'A'
}
if cb >= 'A' && cb <= 'Z' {
cb += 'a' - 'A'
}
if ca != cb {
return false
}
}
return true
}
+170
View File
@@ -0,0 +1,170 @@
package download
import (
"regexp"
"sort"
"strings"
)
// Index maps a lowercased package id to every variant sharing that id,
// sorted by arch/language priority (best match first).
type Index map[string][]*Package
// BuildIndex indexes every package in m, prioritizing variants matching
// hostArch and language ("" language means no language preference beyond
// the default "en").
func BuildIndex(m *Manifest, hostArch, language string) Index {
if language == "" {
language = "en"
}
idx := Index{}
for i := range m.Packages {
p := &m.Packages[i]
key := strings.ToLower(p.ID)
idx[key] = append(idx[key], p)
}
for key, list := range idx {
list := list
sort.SliceStable(list, func(i, j int) bool {
return comparePackages(hostArch, language, list[i], list[j]) < 0
})
idx[key] = list
}
return idx
}
// comparePackages ranks arch match first, then language match.
func comparePackages(arch, language string, a, b *Package) int {
archOrd := func(field string, x *Package) int {
if arch == "" {
return 0
}
var v string
switch field {
case "chip":
v = x.Chip
case "machineArch":
v = x.MachineArch
case "productArch":
v = x.ProductArch
}
v = strings.ToLower(v)
if v == "" || v == "neutral" {
return 0
}
if v == arch {
return -1
}
return 1
}
for _, field := range []string{"chip", "machineArch", "productArch"} {
if r := archOrd(field, a) - archOrd(field, b); r != 0 {
return r
}
}
lang := strings.ToLower(language)
countryLang := strings.Contains(lang, "-")
langPriority := func(x *Package) int {
xl := strings.ToLower(x.Language)
if xl == "" {
return 0
}
if (countryLang && xl == lang) || (!countryLang && strings.HasPrefix(xl, lang+"-")) {
return 2
}
if strings.HasPrefix(xl, "en-") {
return 1
}
return 0
}
ap, bp := langPriority(a), langPriority(b)
if ap > bp {
return -1
}
if ap < bp {
return 1
}
return 0
}
// Find looks up id (case-insensitive), preferring a candidate matching
// constraints' chip/machineArch, falling back to the highest-priority
// variant. Returns nil if id isn't in the index at all.
func (idx Index) Find(id string, constraints map[string]string) *Package {
candidates, ok := idx[strings.ToLower(id)]
if !ok || len(candidates) == 0 {
return nil
}
for _, p := range candidates {
matched := true
for _, k := range []string{"chip", "machineArch"} {
want, has := constraints[k]
if !has {
continue
}
var got string
if k == "chip" {
got = p.Chip
} else {
got = p.MachineArch
}
if !strings.EqualFold(got, want) {
matched = false
break
}
}
if matched {
return p
}
}
return candidates[0]
}
var knownHostArchs = []string{"x86", "x64", "arm64"}
// HostArchCompatible reports whether p can run on host: some packages
// encode their host arch in the id itself (e.g.
// Microsoft.VisualCpp.Tools.HostARM64.*).
func HostArchCompatible(p *Package, host string) bool {
if host == "" {
return true
}
id := strings.ToLower(p.ID)
for _, a := range knownHostArchs {
if strings.Contains(id, "host"+a) {
return a == host
}
}
for _, v := range []string{p.Chip, p.MachineArch, p.ProductArch} {
v = strings.ToLower(v)
if v == "" || v == "neutral" {
continue
}
if v != host {
return false
}
}
return true
}
var reTargetArch = regexp.MustCompile(`\.target(x86|x64|arm64|arm)(\W|$)`)
// TargetArchCompatible reports whether p is wanted for archs: packages
// naming a target arch in their id (e.g. ...HostX64.TargetX64) are only
// wanted when that arch was requested.
func TargetArchCompatible(p *Package, archs []string) bool {
if archs == nil {
return true
}
m := reTargetArch.FindStringSubmatch(strings.ToLower(p.ID))
if m == nil {
return true
}
for _, a := range archs {
if a == m[1] {
return true
}
}
return false
}
+237
View File
@@ -0,0 +1,237 @@
// Package download fetches and unpacks MSVC/WinSDK using the same installer
// manifests Visual Studio's own installer uses.
package download
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Payload is one downloadable file belonging to a Package.
type Payload struct {
FileName string `json:"fileName"`
URL string `json:"url"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
}
// Name returns the payload's bare file name, stripping any directory
// components the manifest's fileName might carry.
func (p Payload) Name() string {
name := p.FileName
if i := strings.LastIndexByte(name, '\\'); i >= 0 {
name = name[i+1:]
}
if i := strings.LastIndexByte(name, '/'); i >= 0 {
name = name[i+1:]
}
return name
}
// Dependency is a normalized package dependency: manifests encode these
// either as a bare version string or as an object with version/type/id.
type Dependency struct {
TargetID string // the id to depend on (overrides the map key if set)
Version string
Type string // "", "Optional" or "Recommended"
}
// LocalizedResource carries the license URL shown before accepting a
// package's terms.
type LocalizedResource struct {
Language string `json:"language"`
License string `json:"license"`
}
// Package is one entry from the installer manifest's "packages" array.
type Package struct {
ID string `json:"id"`
Type string `json:"type"`
Version string `json:"version"`
Chip string `json:"chip"`
MachineArch string `json:"machineArch"`
ProductArch string `json:"productArch"`
Language string `json:"language"`
Payloads []Payload `json:"payloads"`
InstallSizes map[string]int64 `json:"installSizes"`
LocalizedResources []LocalizedResource `json:"localizedResources"`
DependenciesRaw map[string]json.RawMessage `json:"dependencies"`
dependencies map[string]Dependency
}
// Dependencies lazily normalizes DependenciesRaw into Dependency values.
func (p *Package) Dependencies() map[string]Dependency {
if p.dependencies != nil {
return p.dependencies
}
p.dependencies = map[string]Dependency{}
for key, raw := range p.DependenciesRaw {
var version string
if err := json.Unmarshal(raw, &version); err == nil {
p.dependencies[key] = Dependency{Version: version}
continue
}
var d struct {
Version string `json:"version"`
Type string `json:"type"`
ID string `json:"id"`
}
if err := json.Unmarshal(raw, &d); err == nil {
p.dependencies[key] = Dependency{TargetID: d.ID, Version: d.Version, Type: d.Type}
}
}
return p.dependencies
}
// Key uniquely identifies a specific package variant (id + version + arch),
// used to dedupe an already-included package during dependency resolution
// and to name its cache directory. Mirrors getPackageKey.
func (p *Package) Key() string {
key := p.ID
if p.Version != "" {
key += "-" + p.Version
}
if p.Chip != "" {
key += "-chip." + p.Chip
}
if p.MachineArch != "" {
key += "-machineArch." + p.MachineArch
}
if p.ProductArch != "" {
key += "-productArch." + p.ProductArch
}
return key
}
func (p *Package) InstalledSize() int64 {
var sum int64
for _, v := range p.InstallSizes {
sum += v
}
return sum
}
func (p *Package) DownloadSize() int64 {
var sum int64
for _, pl := range p.Payloads {
sum += pl.Size
}
return sum
}
// ChannelItem is one entry of a channel manifest's "channelItems", used only
// to locate the installer manifest URL.
type ChannelItem struct {
Type string `json:"type"`
Payloads []Payload `json:"payloads"`
}
// Manifest is the top-level installer manifest (or channel manifest, which
// shares the "info" field used for logging).
type Manifest struct {
Info struct {
ProductDisplayVersion string `json:"productDisplayVersion"`
} `json:"info"`
Packages []Package `json:"packages"`
ChannelItems []ChannelItem `json:"channelItems"`
}
// Manifests (particularly the installer manifest, a single JSON file
// listing every package) can be tens of MB, so give this a generous timeout
// and a few retries - transient network hiccups shouldn't need a full
// restart of `download`.
var httpClient = &http.Client{Timeout: 5 * time.Minute}
const maxManifestAttempts = 5
func httpGet(url string) ([]byte, error) {
var lastErr error
for attempt := 0; attempt < maxManifestAttempts; attempt++ {
data, err := tryHTTPGet(url)
if err == nil {
return data, nil
}
lastErr = err
fmt.Printf("GET %s: %v (retrying)\n", url, err)
}
return nil, fmt.Errorf("GET %s: giving up after %d attempts: %w", url, maxManifestAttempts, lastErr)
}
func tryHTTPGet(url string) ([]byte, error) {
resp, err := httpClient.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s", resp.Status)
}
return io.ReadAll(resp.Body)
}
// FetchChannelManifest downloads the top-level channel manifest for the
// given major VS version ("18" and up use the "stable"/"insiders" channel
// naming, earlier ones use "release"/"pre"), and returns the URL of the
// installer manifest it references.
func FetchChannelManifest(major int, preview bool) (string, error) {
kind := "stable"
if major < 18 {
kind = "release"
}
if preview {
kind = "insiders"
if major < 18 {
kind = "pre"
}
}
url := fmt.Sprintf("https://aka.ms/vs/%d/%s/channel", major, kind)
fmt.Println("Fetching", url)
data, err := httpGet(url)
if err != nil {
return "", err
}
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return "", fmt.Errorf("parsing channel manifest: %w", err)
}
fmt.Printf("Got toplevel manifest for %s\n", m.Info.ProductDisplayVersion)
for _, item := range m.ChannelItems {
if item.Type == "Manifest" && len(item.Payloads) > 0 {
return item.Payloads[0].URL, nil
}
}
return "", fmt.Errorf("unable to find an installer manifest")
}
// FetchInstallerManifest downloads and parses the installer manifest at url.
func FetchInstallerManifest(url string) (*Manifest, error) {
data, err := httpGet(url)
if err != nil {
return nil, err
}
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("parsing installer manifest: %w", err)
}
fmt.Printf("Loaded installer manifest for %s\n", m.Info.ProductDisplayVersion)
return &m, nil
}
// HumanizeBytes renders a byte count as a friendly "1.2 GB" style string.
func HumanizeBytes(s int64) string {
switch {
case s > 900*1024*1024:
return fmt.Sprintf("%.1f GB", float64(s)/(1024*1024*1024))
case s > 900*1024:
return fmt.Sprintf("%.1f MB", float64(s)/(1024*1024))
case s > 1024:
return fmt.Sprintf("%.1f KB", float64(s)/1024)
default:
return fmt.Sprintf("%d bytes", s)
}
}
+90
View File
@@ -0,0 +1,90 @@
package download
import (
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/Cheviiot/msvc-go-wine/assets"
)
// ApplyCompatibilityFixes applies the embedded Wine compatibility patches
// (assets/patches) against a freshly unpacked+moved dest tree: a
// "foo.props.patch" is git-applied over dest/foo.props (skipped if already
// applied), a "foo.patch"-less file is copied verbatim, and a "foo.remove"
// marker deletes dest/foo.
func ApplyCompatibilityFixes(dest string) error {
return fs.WalkDir(assets.Patches, "patches", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
rel := strings.TrimPrefix(path, "patches/")
ext := filepath.Ext(rel)
target := strings.TrimSuffix(rel, ext)
switch ext {
case ".patch":
return applyGitPatch(dest, path, target)
case ".remove":
full := filepath.Join(dest, target)
if isFile(full) {
fmt.Println("Removing", target)
return os.Remove(full)
}
return nil
default:
fmt.Println("Copying", rel)
data, err := assets.Patches.ReadFile(path)
if err != nil {
return err
}
full := filepath.Join(dest, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return err
}
return os.WriteFile(full, data, 0o644)
}
})
}
func applyGitPatch(dest, embeddedPath, target string) error {
full := filepath.Join(dest, target)
if !isFile(full) {
return nil
}
data, err := assets.Patches.ReadFile(embeddedPath)
if err != nil {
return err
}
tmp, err := os.CreateTemp("", "msvc-go-wine-*.patch")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
tmp.Close()
// Skip if the patch has already been applied (reverse-apply check),
// so re-running download/install stays idempotent.
check := exec.Command("git", "--work-tree=.", "apply", "--quiet", "--reverse", "--check", tmp.Name())
check.Dir = dest
if check.Run() == nil {
return nil
}
fmt.Println("Patching", target)
apply := exec.Command("git", "--work-tree=.", "apply", tmp.Name())
apply.Dir = dest
apply.Stderr = os.Stderr
return apply.Run()
}
+369
View File
@@ -0,0 +1,369 @@
package download
import (
"fmt"
"regexp"
"strings"
)
var reSDKVersion = regexp.MustCompile(`^\d+\.\d+\.\d+`)
// TriState represents a `--with-*` style flag: nil means "unset, use the
// applicable default"; a set value means the user (or a higher-level
// default) explicitly chose to include/exclude the component.
type TriState = *bool
func on() TriState { v := true; return &v }
func off() TriState { v := false; return &v }
// Options holds every flag that feeds package selection and download.
type Options struct {
Package []string
Ignore []string
Architecture []string // subset of x86,x64,arm,arm64,host
HostArch string // x86, x64 or arm64
OnlyHost bool
MSVCVersion string // "", "preview", "16.0".."18.0", "15.4".."15.9"
SDKVersion string
WithDefault TriState
WithWorkload TriState
WithMSVC TriState
WithASAN TriState
WithSDK TriState
WithATL TriState
WithDIA TriState
WithMSBuild TriState
WithDevCmd TriState
IncludeOptional bool
SkipRecommended bool
Language string
WithWDKInstallers string
}
func addIfWanted(opts *Options, flag TriState, pkg string) {
if flag == nil {
return
}
if *flag {
opts.Package = append(opts.Package, pkg)
} else {
opts.Ignore = append(opts.Ignore, pkg)
}
}
type msvcVersionEntry struct {
gen string // "15" or "16" - which package-name scheme applies
sdk string
toolVersion string
}
// msvcVersionTable maps a --msvc-version value to the SDK it pulls in by
// default and the toolset version fragment used in package ids.
var msvcVersionTable = map[string]msvcVersionEntry{
"preview": {"16", "", "Preview"},
"16.0": {"16", "10.0.17763", "14.20"},
"16.1": {"16", "10.0.18362", "14.21"},
"16.2": {"16", "10.0.18362", "14.22"},
"16.3": {"16", "10.0.18362", "14.23"},
"16.4": {"16", "10.0.18362", "14.24"},
"16.5": {"16", "10.0.18362", "14.25"},
"16.6": {"16", "10.0.18362", "14.26"},
"16.7": {"16", "10.0.18362", "14.27"},
"16.8": {"16", "10.0.18362", "14.28"},
"16.9": {"16", "10.0.19041", "14.28.16.9"},
"16.10": {"16", "10.0.19041", "14.29.16.10"},
"16.11": {"16", "10.0.19041", "14.29.16.11"},
"17.0": {"16", "10.0.19041", "14.30.17.0"},
"17.1": {"16", "10.0.19041", "14.31.17.1"},
"17.2": {"16", "10.0.19041", "14.32.17.2"},
"17.3": {"16", "10.0.19041", "14.33.17.3"},
"17.4": {"16", "10.0.22621", "14.34.17.4"},
"17.5": {"16", "10.0.22621", "14.35.17.5"},
"17.6": {"16", "10.0.22621", "14.36.17.6"},
"17.7": {"16", "10.0.22621", "14.37.17.7"},
"17.8": {"16", "10.0.22621", "14.38.17.8"},
"17.9": {"16", "10.0.22621", "14.39.17.9"},
"17.10": {"16", "10.0.22621", "14.40.17.10"},
"17.11": {"16", "10.0.22621", "14.41.17.11"},
"17.12": {"16", "10.0.22621", "14.42.17.12"},
"17.13": {"16", "10.0.22621", "14.43.17.13"},
"17.14": {"16", "10.0.26100", "14.44.17.14"},
"18.0": {"16", "10.0.26100", "14.50.18.0"},
"15.4": {"15", "10.0.16299", "14.11"},
"15.5": {"15", "10.0.16299", "14.12"},
"15.6": {"15", "10.0.16299", "14.13"},
"15.7": {"15", "10.0.17134", "14.14"},
"15.8": {"15", "10.0.17134", "14.15"},
"15.9": {"15", "10.0.17763", "14.16"},
}
func selectToolsetV16(opts *Options, idx Index, userVersion, sdk, toolVersion string, defaultPkgs, defaultIgnores []string) {
ext := ""
if toolVersion == "Preview" {
ext = ".Tools"
}
base := "Microsoft.VisualStudio.Component.VC." + toolVersion + ext
if idx.Find(base+".x86.x64", nil) != nil {
if contains(opts.Architecture, "x86") || contains(opts.Architecture, "x64") {
addIfWanted(opts, opts.WithMSVC, base+".x86.x64")
addIfWanted(opts, opts.WithASAN, "Microsoft.VC."+toolVersion+".ASAN.X86")
addIfWanted(opts, opts.WithATL, "Microsoft.VisualStudio.Component.VC."+toolVersion+".ATL")
}
if contains(opts.Architecture, "arm") {
addIfWanted(opts, opts.WithMSVC, "Microsoft.VisualStudio.Component.VC."+toolVersion+".ARM")
addIfWanted(opts, opts.WithATL, "Microsoft.VisualStudio.Component.VC."+toolVersion+".ATL.ARM")
}
if contains(opts.Architecture, "arm64") {
addIfWanted(opts, opts.WithMSVC, "Microsoft.VisualStudio.Component.VC."+toolVersion+".ARM64")
addIfWanted(opts, opts.WithATL, "Microsoft.VisualStudio.Component.VC."+toolVersion+".ATL.ARM64")
}
if opts.SDKVersion == "" {
opts.SDKVersion = sdk
}
} else {
fmt.Printf("Didn't find exact version packages for %s, assuming this is provided by the default/latest version\n", userVersion)
opts.Package = append(opts.Package, defaultPkgs...)
opts.Ignore = append(opts.Ignore, defaultIgnores...)
}
}
func selectToolsetV15(opts *Options, idx Index, userVersion, sdk, toolVersion string, defaultPkgs, defaultIgnores []string) {
id := "Microsoft.VisualStudio.Component.VC.Tools." + toolVersion
if idx.Find(id, nil) != nil {
addIfWanted(opts, opts.WithMSVC, id)
if opts.SDKVersion == "" {
opts.SDKVersion = sdk
}
} else {
fmt.Printf("Didn't find exact version packages for %s, assuming this is provided by the default/latest version\n", userVersion)
opts.Package = append(opts.Package, defaultPkgs...)
opts.Ignore = append(opts.Ignore, defaultIgnores...)
}
}
// ResolveSelection turns Options' high-level flags (--with-*,
// --msvc-version, --sdk-version, explicit packages) into the final
// opts.Package/opts.Ignore lists ready for ExpandSelection.
func ResolveSelection(opts *Options, idx Index) error {
if len(opts.Architecture) == 0 {
opts.Architecture = []string{"host", "x86", "x64", "arm", "arm64"}
}
if opts.HostArch != "" && contains(opts.Architecture, "host") {
opts.Architecture = append(opts.Architecture, opts.HostArch)
}
if opts.MSVCVersion != "" {
if opts.WithMSVC == nil {
opts.WithMSVC = on()
}
if opts.WithASAN == nil {
opts.WithASAN = on()
}
if opts.WithATL == nil {
opts.WithATL = on()
}
if opts.WithSDK == nil {
opts.WithSDK = on()
}
}
if opts.SDKVersion != "" && opts.WithSDK == nil {
opts.WithSDK = on()
}
if opts.WithDefault == nil && opts.MSVCVersion == "" && len(opts.Package) == 0 {
opts.WithDefault = on()
}
if opts.WithDefault != nil {
for _, pair := range []struct {
flag *TriState
}{
{&opts.WithWorkload}, {&opts.WithMSVC}, {&opts.WithASAN}, {&opts.WithSDK},
{&opts.WithATL}, {&opts.WithDIA}, {&opts.WithMSBuild}, {&opts.WithDevCmd},
} {
if *pair.flag == nil {
v := *opts.WithDefault
*pair.flag = &v
}
}
}
defaultPkgs, defaultIgnores := opts.Package, opts.Ignore
opts.Package, opts.Ignore = nil, nil
addIfWanted(opts, opts.WithWorkload, "Microsoft.VisualStudio.Workload.VCTools")
if contains(opts.Architecture, "x86") || contains(opts.Architecture, "x64") {
addIfWanted(opts, opts.WithMSVC, "Microsoft.VisualStudio.Component.VC.Tools.x86.x64")
addIfWanted(opts, opts.WithASAN, "Microsoft.VisualCpp.ASAN.X86")
addIfWanted(opts, opts.WithATL, "Microsoft.VisualStudio.Component.VC.ATL")
}
if contains(opts.Architecture, "arm") {
addIfWanted(opts, opts.WithMSVC, "Microsoft.VisualStudio.Component.VC.Tools.ARM")
addIfWanted(opts, opts.WithATL, "Microsoft.VisualStudio.Component.VC.ATL.ARM")
}
if contains(opts.Architecture, "arm64") {
addIfWanted(opts, opts.WithMSVC, "Microsoft.VisualStudio.Component.VC.Tools.ARM64")
addIfWanted(opts, opts.WithATL, "Microsoft.VisualStudio.Component.VC.ATL.ARM64")
}
defaultPkgs, opts.Package = opts.Package, defaultPkgs
defaultIgnores, opts.Ignore = opts.Ignore, defaultIgnores
switch {
case opts.MSVCVersion == "":
opts.Package = append(opts.Package, defaultPkgs...)
opts.Ignore = append(opts.Ignore, defaultIgnores...)
default:
entry, ok := msvcVersionTable[opts.MSVCVersion]
if !ok {
return fmt.Errorf("unsupported MSVC toolchain version %s", opts.MSVCVersion)
}
if entry.gen == "15" {
selectToolsetV15(opts, idx, opts.MSVCVersion, entry.sdk, entry.toolVersion, defaultPkgs, defaultIgnores)
} else {
selectToolsetV16(opts, idx, opts.MSVCVersion, entry.sdk, entry.toolVersion, defaultPkgs, defaultIgnores)
}
}
if err := selectSDK(opts, idx); err != nil {
return err
}
addIfWanted(opts, opts.WithDIA, "Microsoft.VisualCpp.DIA.SDK")
addIfWanted(opts, opts.WithMSBuild, "Microsoft.Build")
addIfWanted(opts, opts.WithMSBuild, "Microsoft.Build.Dependencies")
addIfWanted(opts, opts.WithDevCmd, "Microsoft.VisualStudio.VC.vcvars")
addIfWanted(opts, opts.WithDevCmd, "Microsoft.VisualStudio.PackageGroup.VsDevCmd")
if opts.WithWDKInstallers != "" {
opts.Package = append(opts.Package, "Component.Microsoft.Windows.DriverKit.BuildTools")
}
normalizeIgnoreCase(opts)
return nil
}
func selectSDK(opts *Options, idx Index) error {
switch {
case opts.WithSDK == nil:
return nil
case !*opts.WithSDK:
for key := range idx {
if strings.HasPrefix(key, "win10sdk") || strings.HasPrefix(key, "win11sdk") {
opts.Ignore = append(opts.Ignore, key)
}
}
case opts.SDKVersion == "":
saved := *opts
opts.Package = []string{"Microsoft.VisualStudio.Workload.VCTools"}
opts.IncludeOptional = false
opts.SkipRecommended = false
recommended, err := ExpandSelection(idx, opts)
*opts = saved
if err != nil {
return err
}
for _, p := range recommended {
key := strings.ToLower(p.ID)
if strings.HasPrefix(key, "win10sdk") || strings.HasPrefix(key, "win11sdk") {
opts.Package = append(opts.Package, key)
}
}
default:
found := false
var versions []string
for key := range idx {
if !strings.HasPrefix(key, "win10sdk") && !strings.HasPrefix(key, "win11sdk") {
continue
}
version := key[9:]
if reSDKVersion.MatchString(version) {
versions = append(versions, version)
}
if key == key[:8]+"_"+opts.SDKVersion {
found = true
opts.Package = append(opts.Package, key)
} else {
opts.Ignore = append(opts.Ignore, key)
}
}
if !found {
return fmt.Errorf("WinSDK version %s not found (available: %s)", opts.SDKVersion, strings.Join(versions, ", "))
}
}
return nil
}
func normalizeIgnoreCase(opts *Options) {
for i, s := range opts.Ignore {
opts.Ignore[i] = strings.ToLower(s)
}
}
func contains(list []string, v string) bool {
for _, s := range list {
if s == v {
return true
}
}
return false
}
// collectDependencyClosure recursively resolves target's dependency tree,
// honoring arch matching, --ignore, and Optional/Recommended filtering.
func collectDependencyClosure(idx Index, included map[string]bool, target string, constraints map[string]string, opts *Options) []*Package {
if contains(opts.Ignore, strings.ToLower(target)) {
return nil
}
p := idx.Find(target, constraints)
if p == nil {
return nil
}
if opts.OnlyHost && !HostArchCompatible(p, opts.HostArch) {
return nil
}
if !TargetArchCompatible(p, opts.Architecture) {
return nil
}
key := p.Key()
if included[key] {
return nil
}
included[key] = true
ret := []*Package{p}
for target, dep := range p.Dependencies() {
id := target
if dep.TargetID != "" {
id = dep.TargetID
}
if dep.Type == "Optional" && !opts.IncludeOptional {
continue
}
if dep.Type == "Recommended" && opts.SkipRecommended {
continue
}
c := map[string]string{}
if dep.Version != "" {
c["version"] = dep.Version
}
ret = append(ret, collectDependencyClosure(idx, included, id, c, opts)...)
}
return ret
}
// ExpandSelection resolves opts.Package (and everything they transitively
// depend on) into a flat, deduped package list.
func ExpandSelection(idx Index, opts *Options) ([]*Package, error) {
included := map[string]bool{}
var ret []*Package
for _, id := range opts.Package {
ret = append(ret, collectDependencyClosure(idx, included, id, nil, opts)...)
}
return ret, nil
}
+221
View File
@@ -0,0 +1,221 @@
package download
import (
"encoding/json"
"testing"
)
// fixtureManifest is a trimmed-down stand-in for a real installer manifest,
// covering: a workload -> component -> {required, Optional, Recommended}
// dependency chain, host-arch variants of the same package id, and
// language variants - enough to exercise BuildIndex/collectDependencyClosure
// offline, without ever hitting the network.
const fixtureManifestJSON = `{
"info": {"productDisplayVersion": "test"},
"packages": [
{
"id": "Microsoft.VisualStudio.Workload.VCTools",
"type": "Workload",
"dependencies": {
"Microsoft.VisualStudio.Component.VC.Tools.x86.x64": "1.0"
}
},
{
"id": "Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
"type": "Component",
"dependencies": {
"Microsoft.VC.Tools.Core": "1.0",
"Microsoft.VC.Tools.Optional": {"version": "1.0", "type": "Optional"},
"Microsoft.VC.Tools.Recommended": {"version": "1.0", "type": "Recommended"}
}
},
{
"id": "Microsoft.VC.Tools.Core",
"type": "Msi",
"machineArch": "x86",
"payloads": [{"fileName": "core-x86.msi", "url": "https://example.invalid/core-x86.msi", "sha256": "aa", "size": 100}]
},
{
"id": "Microsoft.VC.Tools.Core",
"type": "Msi",
"machineArch": "arm64",
"payloads": [{"fileName": "core-arm64.msi", "url": "https://example.invalid/core-arm64.msi", "sha256": "bb", "size": 200}]
},
{
"id": "Microsoft.VC.Tools.Optional",
"type": "Msi",
"payloads": [{"fileName": "optional.msi", "url": "https://example.invalid/optional.msi", "size": 10}]
},
{
"id": "Microsoft.VC.Tools.Recommended",
"type": "Msi",
"payloads": [{"fileName": "recommended.msi", "url": "https://example.invalid/recommended.msi", "size": 20}]
},
{
"id": "Microsoft.VisualStudio.Resources",
"type": "Msi",
"language": "en-US",
"payloads": [{"fileName": "res-en.msi", "url": "https://example.invalid/res-en.msi", "size": 1}]
},
{
"id": "Microsoft.VisualStudio.Resources",
"type": "Msi",
"language": "de-DE",
"payloads": [{"fileName": "res-de.msi", "url": "https://example.invalid/res-de.msi", "size": 1}]
}
]
}`
func fixtureIndex(t *testing.T, hostArch string) Index {
t.Helper()
var m Manifest
if err := json.Unmarshal([]byte(fixtureManifestJSON), &m); err != nil {
t.Fatalf("parsing fixture manifest: %v", err)
}
return BuildIndex(&m, hostArch, "en")
}
func TestBuildIndexPrioritizesHostArch(t *testing.T) {
idx := fixtureIndex(t, "x86")
p := idx.Find("Microsoft.VC.Tools.Core", nil)
if p == nil {
t.Fatal("expected to find Microsoft.VC.Tools.Core")
}
if p.MachineArch != "x86" {
t.Errorf("expected x86 variant to sort first for host x86, got machineArch=%q", p.MachineArch)
}
idx64 := fixtureIndex(t, "arm64")
p64 := idx64.Find("Microsoft.VC.Tools.Core", nil)
if p64.MachineArch != "arm64" {
t.Errorf("expected arm64 variant to sort first for host arm64, got machineArch=%q", p64.MachineArch)
}
}
func TestBuildIndexPrioritizesLanguage(t *testing.T) {
idx := fixtureIndex(t, "x86")
p := idx.Find("Microsoft.VisualStudio.Resources", nil)
if p == nil {
t.Fatal("expected to find Microsoft.VisualStudio.Resources")
}
if p.Language != "en-US" {
t.Errorf("expected en-US variant to sort first for language=en, got %q", p.Language)
}
}
func TestAggregateDependsDefaultExcludesOptionalOnly(t *testing.T) {
// Optional deps are skipped unless --include-optional is passed, but
// Recommended deps are pulled in unless --skip-recommended is passed -
// it's "recommended", not "extra".
idx := fixtureIndex(t, "x86")
opts := &Options{
Package: []string{"Microsoft.VisualStudio.Workload.VCTools"},
HostArch: "x86",
OnlyHost: true,
}
selected, err := ExpandSelection(idx, opts)
if err != nil {
t.Fatal(err)
}
ids := idsOf(selected)
mustContain(t, ids, "Microsoft.VisualStudio.Workload.VCTools")
mustContain(t, ids, "Microsoft.VisualStudio.Component.VC.Tools.x86.x64")
mustContain(t, ids, "Microsoft.VC.Tools.Core")
mustContain(t, ids, "Microsoft.VC.Tools.Recommended")
mustNotContain(t, ids, "Microsoft.VC.Tools.Optional")
}
func TestAggregateDependsIncludeOptional(t *testing.T) {
idx := fixtureIndex(t, "x86")
opts := &Options{
Package: []string{"Microsoft.VisualStudio.Workload.VCTools"},
HostArch: "x86",
OnlyHost: true,
IncludeOptional: true,
}
selected, err := ExpandSelection(idx, opts)
if err != nil {
t.Fatal(err)
}
ids := idsOf(selected)
mustContain(t, ids, "Microsoft.VC.Tools.Optional")
// Recommended is included by default (only --skip-recommended excludes it).
mustContain(t, ids, "Microsoft.VC.Tools.Recommended")
}
func TestAggregateDependsSkipRecommended(t *testing.T) {
idx := fixtureIndex(t, "x86")
opts := &Options{
Package: []string{"Microsoft.VisualStudio.Workload.VCTools"},
HostArch: "x86",
OnlyHost: true,
SkipRecommended: true,
}
selected, err := ExpandSelection(idx, opts)
if err != nil {
t.Fatal(err)
}
ids := idsOf(selected)
mustNotContain(t, ids, "Microsoft.VC.Tools.Recommended")
}
func TestAggregateDependsRespectsIgnore(t *testing.T) {
idx := fixtureIndex(t, "x86")
opts := &Options{
Package: []string{"Microsoft.VisualStudio.Workload.VCTools"},
Ignore: []string{"microsoft.vc.tools.core"},
HostArch: "x86",
OnlyHost: true,
}
selected, err := ExpandSelection(idx, opts)
if err != nil {
t.Fatal(err)
}
ids := idsOf(selected)
mustNotContain(t, ids, "Microsoft.VC.Tools.Core")
mustContain(t, ids, "Microsoft.VisualStudio.Component.VC.Tools.x86.x64")
}
func TestAggregateDependsHostArchMismatchExcludes(t *testing.T) {
idx := fixtureIndex(t, "x86")
opts := &Options{
Package: []string{"Microsoft.VC.Tools.Core"},
HostArch: "arm", // no arm variant exists, only x86/arm64
OnlyHost: true,
}
selected, err := ExpandSelection(idx, opts)
if err != nil {
t.Fatal(err)
}
if len(selected) != 0 {
t.Errorf("expected no packages selected for mismatched host arch, got %v", idsOf(selected))
}
}
func idsOf(pkgs []*Package) []string {
var ids []string
for _, p := range pkgs {
ids = append(ids, p.ID)
}
return ids
}
func mustContain(t *testing.T, ids []string, want string) {
t.Helper()
for _, id := range ids {
if id == want {
return
}
}
t.Errorf("expected %v to contain %q", ids, want)
}
func mustNotContain(t *testing.T, ids []string, unwanted string) {
t.Helper()
for _, id := range ids {
if id == unwanted {
t.Errorf("expected %v to NOT contain %q", ids, unwanted)
}
}
}