diff --git a/internal/install/install.go b/internal/install/install.go index 73c03f4..15d3b63 100644 --- a/internal/install/install.go +++ b/internal/install/install.go @@ -62,6 +62,10 @@ func Install(dest, selfBinary string) error { return err } + if err := aliasPlatformToolsets(dest); err != nil { + return err + } + includeDir := filepath.Join(msvcDir, "include") if err := Lowercase(includeDir, LowercaseOptions{Symlink: true}); err != nil { return fmt.Errorf("lowercasing %s: %w", includeDir, err) diff --git a/internal/install/platformtoolsets.go b/internal/install/platformtoolsets.go new file mode 100644 index 0000000..a4cc67d --- /dev/null +++ b/internal/install/platformtoolsets.go @@ -0,0 +1,92 @@ +package install + +import ( + "os" + "path/filepath" + "regexp" + + "github.com/Cheviiot/vintner/internal/wineenv" +) + +var rePlatformToolsetDir = regexp.MustCompile(`^v(\d+)$`) + +// aliasPlatformToolsets makes every historical PlatformToolset name in +// wineenv.KnownPlatformToolsets resolve to the one compiler `download` +// actually fetched. +// +// MSBuild decides whether a PlatformToolset is "installed" at all - the +// check behind MSB8020 - by testing whether +// MSBuild/Microsoft/VC/v/Platforms//PlatformToolsets// +// exists on disk (Microsoft.Cpp.props, via +// ToolLocationHelper.FindRootFolderWhereAllFilesExist). That's a plain file +// lookup, not influenced by any environment variable - unlike the later, +// env-var-driven VCInstallDir_ checks internal/wrapper's msbuildEnv +// covers, this one needs the actual directory to exist under dest. +// Microsoft's own downloaded MSBuild package only ships a PlatformToolsets +// entry for the exact generation matching the fetched compiler, so any +// project pinned to an older PlatformToolset (v142 for a project last saved +// under VS2019, say) fails this check outright even though the one real +// toolchain installed could easily build it. +// +// Toolset.props/Toolset.targets don't hardcode a version number (they just +// import version-agnostic files like Microsoft.Cpp.MSVC.Toolset..props), +// so a symlink under any other historical name is a correct, transparent +// alias rather than a divergent copy. +func aliasPlatformToolsets(dest string) error { + schemaDirs, err := filepath.Glob(filepath.Join(dest, "MSBuild", "Microsoft", "VC", "v*")) + if err != nil { + return err + } + for _, schemaDir := range schemaDirs { + archDirs, err := filepath.Glob(filepath.Join(schemaDir, "Platforms", "*", "PlatformToolsets")) + if err != nil { + return err + } + for _, toolsetsDir := range archDirs { + if err := aliasOneDir(toolsetsDir); err != nil { + return err + } + } + } + return nil +} + +// aliasOneDir symlinks every name in wineenv.KnownPlatformToolsets that +// doesn't already exist in toolsetsDir onto whichever real v toolset +// subdirectory is actually present there. +func aliasOneDir(toolsetsDir string) error { + entries, err := os.ReadDir(toolsetsDir) + if err != nil { + return err + } + var real string + for _, e := range entries { + if !e.IsDir() { + continue + } + if rePlatformToolsetDir.MatchString(e.Name()) { + real = e.Name() + break + } + } + if real == "" { + // Nothing numeric here (e.g. only the WindowsKernelModeDriver10.0-style + // WDK toolsets) - nothing to alias. + return nil + } + + for _, n := range wineenv.KnownPlatformToolsets { + alias := "v" + n + if alias == real { + continue + } + aliasPath := filepath.Join(toolsetsDir, alias) + if exists(aliasPath) { + continue + } + if err := os.Symlink(real, aliasPath); err != nil { + return err + } + } + return nil +} diff --git a/internal/install/platformtoolsets_test.go b/internal/install/platformtoolsets_test.go new file mode 100644 index 0000000..ff1a75b --- /dev/null +++ b/internal/install/platformtoolsets_test.go @@ -0,0 +1,106 @@ +package install + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Cheviiot/vintner/internal/wineenv" +) + +func TestAliasPlatformToolsetsAliasesRealToolset(t *testing.T) { + dest := t.TempDir() + toolsetsDir := filepath.Join(dest, "MSBuild", "Microsoft", "VC", "v180", "Platforms", "x64", "PlatformToolsets") + realDir := filepath.Join(toolsetsDir, "v145") + if err := os.MkdirAll(realDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(realDir, "Toolset.props"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + // A WDK toolset entry alongside it - must not be mistaken for the real + // numeric toolset or itself get aliased over. + if err := os.MkdirAll(filepath.Join(toolsetsDir, "WindowsKernelModeDriver10.0"), 0o755); err != nil { + t.Fatal(err) + } + + if err := aliasPlatformToolsets(dest); err != nil { + t.Fatalf("aliasPlatformToolsets: %v", err) + } + + for _, n := range wineenv.KnownPlatformToolsets { + alias := filepath.Join(toolsetsDir, "v"+n) + fi, err := os.Lstat(alias) + if err != nil { + t.Errorf("expected v%s alias to exist: %v", n, err) + continue + } + if fi.Mode()&os.ModeSymlink == 0 { + t.Errorf("v%s should be a symlink, got mode %v", n, fi.Mode()) + continue + } + target, err := os.Readlink(alias) + if err != nil { + t.Fatal(err) + } + if target != "v145" { + t.Errorf("v%s symlink target = %q, want \"v145\"", n, target) + } + // Follow the alias and confirm it actually reaches the real content. + if !isFile(filepath.Join(toolsetsDir, "v"+n, "Toolset.props")) { + t.Errorf("v%s/Toolset.props not reachable through the alias", n) + } + } + + if exists(filepath.Join(toolsetsDir, "WindowsKernelModeDriver10.0", "v"+wineenv.KnownPlatformToolsets[0])) { + t.Error("WDK toolset directory should not have been touched") + } +} + +func TestAliasPlatformToolsetsDoesNotOverwriteExisting(t *testing.T) { + dest := t.TempDir() + toolsetsDir := filepath.Join(dest, "MSBuild", "Microsoft", "VC", "v180", "Platforms", "x64", "PlatformToolsets") + if err := os.MkdirAll(filepath.Join(toolsetsDir, "v145"), 0o755); err != nil { + t.Fatal(err) + } + // v142 already genuinely installed (e.g. a real VS install with several + // side-by-side toolsets) - must be left alone, not replaced with an alias. + real142 := filepath.Join(toolsetsDir, "v142") + if err := os.MkdirAll(real142, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(real142, "Toolset.props"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + + if err := aliasPlatformToolsets(dest); err != nil { + t.Fatalf("aliasPlatformToolsets: %v", err) + } + + fi, err := os.Lstat(real142) + if err != nil { + t.Fatal(err) + } + if fi.Mode()&os.ModeSymlink != 0 { + t.Error("pre-existing v142 directory should not have been replaced with a symlink") + } +} + +func TestAliasPlatformToolsetsNoNumericToolset(t *testing.T) { + dest := t.TempDir() + // Only a WDK-style toolset present, nothing numeric to alias from. + toolsetsDir := filepath.Join(dest, "MSBuild", "Microsoft", "VC", "v180", "Platforms", "x64", "PlatformToolsets") + if err := os.MkdirAll(filepath.Join(toolsetsDir, "WindowsUserModeDriver10.0"), 0o755); err != nil { + t.Fatal(err) + } + + if err := aliasPlatformToolsets(dest); err != nil { + t.Fatalf("aliasPlatformToolsets: %v", err) + } + + for _, n := range wineenv.KnownPlatformToolsets { + if exists(filepath.Join(toolsetsDir, "v"+n)) { + t.Errorf("v%s should not have been created with no real numeric toolset present", n) + } + } +} diff --git a/internal/wineenv/platformtoolsets.go b/internal/wineenv/platformtoolsets.go new file mode 100644 index 0000000..7006af6 --- /dev/null +++ b/internal/wineenv/platformtoolsets.go @@ -0,0 +1,15 @@ +package wineenv + +// KnownPlatformToolsets are every numeric PlatformToolset short name +// Microsoft.Cpp.Default.props has ever defined a +// _PlatformToolsetShortNameFor_v entry for (VS2013 through the VS2022 +// initial release; excludes the _xp/_wp80/_wp81 variants, which aren't +// purely numeric). vintner only ever installs one compiler generation, but +// real .vcxproj files in the wild are pinned to whichever generation they +// were last edited under - v142 (VS2019) for anything not yet retargeted is +// extremely common. Shared between internal/install (which symlinks these +// names onto the one real MSBuild PlatformToolsets directory) and +// internal/wrapper (which mirrors the same names onto VCInstallDir_/ +// VCToolsInstallDir_ for the older, environment-variable-driven toolset +// redirect chain) - see doc comments there for why both are needed. +var KnownPlatformToolsets = []string{"90", "100", "110", "120", "140", "141", "142", "143"} diff --git a/internal/wrapper/msbuildenv.go b/internal/wrapper/msbuildenv.go index 40bedf1..be0f764 100644 --- a/internal/wrapper/msbuildenv.go +++ b/internal/wrapper/msbuildenv.go @@ -29,10 +29,31 @@ func msbuildEnv(cfg *wineenv.Config, paths *wineenv.Paths) map[string]string { // tools onto the same UTC clock removes the mismatch. "TZ": "UTC", - "DisableRegistryUse": "true", - "VCToolsVersion": cfg.MSVCVer, - "VsInstallRoot": paths.BaseWin + `\`, - "VSInstallDir": paths.BaseWin + `\`, + // VCToolsVersion must be a real version string, not left unset: + // Microsoft.Cpp.VCTools.props itself falls back to the literal + // placeholder "VCToolsVersion_is_not_defined" whenever it's empty, + // and that placeholder then reaches unconditional (not gated behind + // CheckMSVCComponents) version-string comparisons elsewhere in + // Microsoft.CppBuild.targets, e.g. the SegmentHeap manifest check's + // VersionGreaterThanOrEquals(), which throws MSB4184 on a + // non-version string. + // + // CheckMSVCComponents=false is what actually makes an aliased + // PlatformToolset safe to combine with that real version: without + // it, CheckVCToolsetVersion (Microsoft.CppBuild.targets, MSB8052) + // rejects the combination whenever VCToolsVersion's numeric + // generation doesn't match PlatformToolset's - exactly the legacy- + // project case toolsetSuffixes/aliasPlatformToolsets exist for (a + // v142 project against the one real, newer compiler actually + // installed). Everything else CheckMSVCComponents gates + // (Microsoft.CppBuild.targets ~495-535) is diagnostic-only - MFC/ATL/ + // Spectre component presence warnings, none of it feeding into the + // actual compile/link - so disabling it costs nothing here. + "DisableRegistryUse": "true", + "CheckMSVCComponents": "false", + "VCToolsVersion": cfg.MSVCVer, + "VsInstallRoot": paths.BaseWin + `\`, + "VSInstallDir": paths.BaseWin + `\`, "SDKReferenceDirectoryRoot": paths.BaseWin + `\`, "SDKExtensionDirectoryRoot": paths.BaseWin + `\`, @@ -58,20 +79,29 @@ func msbuildEnv(cfg *wineenv.Config, paths *wineenv.Paths) map[string]string { "Platform": msbuildPlatform(cfg.Arch), } - // Microsoft.Cpp.props resolves the compiler/toolset location through - // VCInstallDir_/VCToolsInstallDir_, where is whatever numeric - // suffix the installed MSBuild toolset property sheets use (e.g. - // .../MSBuild/Microsoft/VC/v180 -> "180"). Populate every one actually - // present, so a project pinned to any of them resolves to the one real - // toolchain that's installed. - matches, _ := filepath.Glob(filepath.Join(paths.BaseUnix, "MSBuild", "Microsoft", "VC", "v*")) - for _, m := range matches { - sub := reToolsetDir.FindStringSubmatch(filepath.Base(m)) - if sub == nil { - continue - } - env["VCInstallDir_"+sub[1]] = paths.MSVCBaseWin + `\` - env["VCToolsInstallDir_"+sub[1]] = paths.MSVCDirWin + `\` + // VCInstallDir_/VCToolsInstallDir_ are consulted under two + // completely different numbering schemes, both needing the single real + // toolchain behind every they might ask for: + // + // - Microsoft.Cpp.Default.props keys its early "is this toolset even + // installed" check (the one MSB8020 comes from) off = the + // PlatformToolset suffix a .vcxproj actually declares (v142, v143, + // v145, ...) - the same short name Microsoft stamps on + // VC/Auxiliary/Build/Microsoft.VCToolsVersion.v.default.props for + // the downloaded compiler. + // - Microsoft.CppBuild.targets (MSB8070) instead keys off = the + // MSBuild targets-schema version whose Microsoft.Cpp.props ended up + // imported for this run (MSBuild/Microsoft/VC/v150|v160|v170|v180 - + // fixed, shipped identically with every MSBuild release, unrelated to + // which compiler is installed), to locate the specific toolset + // version subfolder. + // + // Populate every from both sources, all pointing at the one real + // toolchain that's installed, so a project pinned to any PlatformToolset + // resolves at every stage MSBuild checks it. + for _, n := range toolsetSuffixes(paths.BaseUnix) { + env["VCInstallDir_"+n] = paths.MSVCBaseWin + `\` + env["VCToolsInstallDir_"+n] = paths.MSVCDirWin + `\` } if strings.HasSuffix(paths.MSBuildBinDir, "amd64") { @@ -94,6 +124,100 @@ func msbuildEnv(cfg *wineenv.Config, paths *wineenv.Paths) map[string]string { return env } +// toolsetSuffixes collects every numeric that either VCInstallDir_ +// lookup mechanism (see msbuildEnv) might be asked to resolve for this +// installation: PlatformToolset short names from +// vc/Auxiliary/Build/Microsoft.VCToolsVersion.v.default.props, MSBuild +// targets-schema versions from MSBuild/Microsoft/VC/v, and every +// historical PlatformToolset name (see wineenv.KnownPlatformToolsets) - a +// project pinned to any of them all resolves to the one real toolchain +// installed. +func toolsetSuffixes(baseUnix string) []string { + seen := map[string]bool{} + var suffixes []string + + record := func(n string) { + if seen[n] { + return + } + seen[n] = true + suffixes = append(suffixes, n) + } + + add := func(dir, prefix, suffix string) { + matches, _ := filepath.Glob(filepath.Join(dir, "*")) + for _, m := range matches { + name := filepath.Base(m) + if prefix != "" { + if !strings.HasPrefix(name, prefix) { + continue + } + name = strings.TrimPrefix(name, prefix) + } + name = strings.TrimSuffix(name, suffix) + sub := reToolsetDir.FindStringSubmatch(name) + if sub == nil { + continue + } + record(sub[1]) + } + } + + add(filepath.Join(baseUnix, "vc", "Auxiliary", "Build"), "Microsoft.VCToolsVersion.", ".default.props") + add(filepath.Join(baseUnix, "MSBuild", "Microsoft", "VC"), "", "") + for _, n := range wineenv.KnownPlatformToolsets { + record(n) + } + + return suffixes +} + +// reGlobalProp matches an MSBuild global-property command-line switch +// ("/p:Name=...", "-property:Name=...", case-insensitive on both the +// -p/-property spelling and the property name) so msbuildGlobalArgs can tell +// whether the caller already pinned a given property themselves. +func reGlobalProp(name string) *regexp.Regexp { + return regexp.MustCompile(`(?i)^[-/](p|property):` + regexp.QuoteMeta(name) + `=`) +} + +// msbuildGlobalArgs returns /p: switches to prepend to an MSBuild invocation, +// one per forced property not already present in args. +// +// WindowsTargetPlatformVersion is the one property that needs this rather +// than an env var: unlike VCInstallDir_ (an input to a props-file +// *lookup*, so any value msbuildEnv sets is visible no matter what a project +// pins its PlatformToolset to), WindowsTargetPlatformVersion is itself the +// value most legacy .vcxproj files hardcode directly in a PropertyGroup - +// and an explicit PropertyGroup assignment always wins over an inherited +// environment variable of the same name. A command-line global property is +// the one thing a project file can't override, which is exactly what's +// needed here: vintner only ever installs one Windows SDK version, so - same +// reasoning as the PlatformToolset fallback above - any project should +// transparently build against that one installed version rather than fail +// outright over an exact version string it happened to be pinned to when +// last saved from a real Windows SDK selector dropdown. +func msbuildGlobalArgs(cfg *wineenv.Config, args []string) []string { + forced := map[string]string{ + "WindowsTargetPlatformVersion": cfg.SDKVer, + } + + var out []string + for name, value := range forced { + re := reGlobalProp(name) + alreadySet := false + for _, a := range args { + if re.MatchString(a) { + alreadySet = true + break + } + } + if !alreadySet { + out = append(out, "/p:"+name+"="+value) + } + } + return out +} + func msbuildPlatform(arch string) string { switch arch { case "x86": diff --git a/internal/wrapper/msbuildenv_test.go b/internal/wrapper/msbuildenv_test.go index 3bbd568..13d36a5 100644 --- a/internal/wrapper/msbuildenv_test.go +++ b/internal/wrapper/msbuildenv_test.go @@ -39,6 +39,14 @@ func TestMsbuildEnvBasics(t *testing.T) { if env["DisableRegistryUse"] != "true" { t.Errorf(`env["DisableRegistryUse"] = %q, want "true"`, env["DisableRegistryUse"]) } + if env["CheckMSVCComponents"] != "false" { + t.Errorf(`env["CheckMSVCComponents"] = %q, want "false" (else CheckVCToolsetVersion errors on an aliased PlatformToolset)`, env["CheckMSVCComponents"]) + } + // VCToolsVersion must be a real version string (see msbuildEnv's doc + // comment on it: leaving it unset makes Microsoft.Cpp.VCTools.props + // substitute a placeholder that then breaks unconditional version + // comparisons elsewhere). CheckMSVCComponents=false is what keeps this + // safe to combine with an aliased PlatformToolset. if env["VCToolsVersion"] != cfg.MSVCVer { t.Errorf(`env["VCToolsVersion"] = %q, want %q`, env["VCToolsVersion"], cfg.MSVCVer) } @@ -61,7 +69,24 @@ func TestMsbuildEnvDiscoversEveryToolsetVersion(t *testing.T) { cfg := &wineenv.Config{Arch: "x64", Host: "x64", DotnetHost: "amd64", MSVCVer: "14.51.36231", SDKVer: "10.0.26100.0"} paths, base := newTestPaths(t, cfg) - for _, v := range []string{"v145", "v180", "not-a-version"} { + // Real layout has two independent sources feeding VCInstallDir_/ + // VCToolsInstallDir_ (see toolsetSuffixes' doc comment for why both + // are needed): the PlatformToolset short names a downloaded compiler + // ships default-props for, and MSBuild's own fixed schema-version dirs. + buildDir := filepath.Join(base, "vc", "Auxiliary", "Build") + if err := os.MkdirAll(buildDir, 0o755); err != nil { + t.Fatal(err) + } + for _, name := range []string{ + "Microsoft.VCToolsVersion.v145.default.props", + "Microsoft.VCToolsVersion.v143.default.props", + "Microsoft.VCToolsVersion.default.props", // no version suffix - must not match + } { + if err := os.WriteFile(filepath.Join(buildDir, name), nil, 0o644); err != nil { + t.Fatal(err) + } + } + for _, v := range []string{"v180", "not-a-version"} { if err := os.MkdirAll(filepath.Join(base, "MSBuild", "Microsoft", "VC", v), 0o755); err != nil { t.Fatal(err) } @@ -69,7 +94,7 @@ func TestMsbuildEnvDiscoversEveryToolsetVersion(t *testing.T) { env := msbuildEnv(cfg, paths) - for _, n := range []string{"145", "180"} { + for _, n := range []string{"145", "143", "180"} { if _, ok := env["VCInstallDir_"+n]; !ok { t.Errorf("expected VCInstallDir_%s to be set", n) } @@ -82,6 +107,64 @@ func TestMsbuildEnvDiscoversEveryToolsetVersion(t *testing.T) { } } +func TestToolsetSuffixesDedupsOverlap(t *testing.T) { + base := t.TempDir() + buildDir := filepath.Join(base, "vc", "Auxiliary", "Build") + if err := os.MkdirAll(buildDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(buildDir, "Microsoft.VCToolsVersion.v180.default.props"), nil, 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(base, "MSBuild", "Microsoft", "VC", "v180"), 0o755); err != nil { + t.Fatal(err) + } + + got := toolsetSuffixes(base) + count := 0 + for _, n := range got { + if n == "180" { + count++ + } + } + if count != 1 { + t.Errorf("toolsetSuffixes() returned %q with %d entries for \"180\" (from both sources), want exactly 1", got, count) + } +} + +func TestMsbuildGlobalArgsForcesWindowsTargetPlatformVersion(t *testing.T) { + cfg := &wineenv.Config{SDKVer: "10.0.26100.0"} + + got := msbuildGlobalArgs(cfg, []string{"Foo.sln", "/p:Configuration=Release"}) + want := "/p:WindowsTargetPlatformVersion=10.0.26100.0" + found := false + for _, a := range got { + if a == want { + found = true + } + } + if !found { + t.Errorf("msbuildGlobalArgs(...) = %v, want it to contain %q", got, want) + } +} + +func TestMsbuildGlobalArgsRespectsExplicitOverride(t *testing.T) { + cfg := &wineenv.Config{SDKVer: "10.0.26100.0"} + + for _, explicit := range []string{ + "/p:WindowsTargetPlatformVersion=10.0.19041.0", + "-p:WindowsTargetPlatformVersion=10.0.19041.0", + "/property:WindowsTargetPlatformVersion=10.0.19041.0", + } { + got := msbuildGlobalArgs(cfg, []string{"Foo.sln", explicit}) + for _, a := range got { + if reGlobalProp("WindowsTargetPlatformVersion").MatchString(a) { + t.Errorf("msbuildGlobalArgs with explicit %q also injected %q - should have left the caller's value alone", explicit, a) + } + } + } +} + func TestMsbuildEnvDetectsWDKContentRoot(t *testing.T) { cfg := &wineenv.Config{Arch: "x64", Host: "x64", DotnetHost: "amd64", MSVCVer: "14.51.36231", SDKVer: "10.0.26100.0"} paths, base := newTestPaths(t, cfg) diff --git a/internal/wrapper/run.go b/internal/wrapper/run.go index 8c2b75c..8c79ef5 100644 --- a/internal/wrapper/run.go +++ b/internal/wrapper/run.go @@ -83,8 +83,10 @@ func Run(tool string, args []string) int { // MSBuild: skip all filtering/toolrelay (its output is meant to be // read as-is), and add the extra environment MSBuild's own // toolset/SDK-detection props need on top of the generic - // INCLUDE/LIB/WINEPATH. - cmd := exec.Command(wineBin, append([]string{toolExePath}, rewritten...)...) + // INCLUDE/LIB/WINEPATH, plus any global properties a project file + // itself could otherwise override (see msbuildGlobalArgs). + msArgs := append(msbuildGlobalArgs(cfg, rewritten), rewritten...) + cmd := exec.Command(wineBin, append([]string{toolExePath}, msArgs...)...) env := buildEnv(paths) for k, v := range msbuildEnv(cfg, paths) { env = append(env, k+"="+v)