mirror of
https://github.com/Cheviiot/Vintner.git
synced 2026-08-03 15:57:24 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user