diff --git a/internal/detector/extension.go b/internal/detector/extension.go index e1591ae..7f1cb19 100644 --- a/internal/detector/extension.go +++ b/internal/detector/extension.go @@ -52,6 +52,9 @@ func (d *ExtensionDetector) Detect(ctx context.Context, searchDirs []string, ide // Eclipse plugins — use detected IDE install paths for accurate discovery results = append(results, d.DetectEclipsePlugins(ctx, ides)...) + // Classic Visual Studio extensions (extension.vsixmanifest format, Windows-only) + results = append(results, d.DetectVisualStudioExtensions(ctx)...) + return results } diff --git a/internal/detector/ide.go b/internal/detector/ide.go index 122744d..63654e4 100644 --- a/internal/detector/ide.go +++ b/internal/detector/ide.go @@ -246,6 +246,10 @@ func (d *IDEDetector) Detect(ctx context.Context) []model.IDE { } } + // Classic Visual Studio (Windows-only): discovered via VS setup instance + // data, not a fixed install path, so it isn't part of ideDefinitions. + results = append(results, d.detectVisualStudio()...) + return results } diff --git a/internal/detector/visualstudio.go b/internal/detector/visualstudio.go new file mode 100644 index 0000000..71760ac --- /dev/null +++ b/internal/detector/visualstudio.go @@ -0,0 +1,113 @@ +package detector + +import ( + "encoding/json" + "path/filepath" + "strings" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// Classic Visual Studio is discovered very differently from the editor IDEs in +// ideDefinitions: there is no single fixed install path or app bundle. The +// authoritative source is the VS setup instance data under +// %PROGRAMDATA%\Microsoft\VisualStudio\Packages\_Instances\\state.json, +// which records the install path and version for each installed instance +// (multiple editions/years can coexist). This file holds the shared instance +// discovery used by both IDE detection and extension scanning. + +// vsInstance is a discovered classic Visual Studio installation. +type vsInstance struct { + InstallPath string + Version string +} + +// discoverVisualStudioInstances finds installed classic Visual Studio instances +// (Windows). The authoritative source is the VS setup instance data, with a +// Program Files glob as a fallback. Returns nil on non-Windows hosts (the +// Windows env vars it reads are empty there). +func discoverVisualStudioInstances(exec executor.Executor) []vsInstance { + var instances []vsInstance + seen := make(map[string]bool) + add := func(inst vsInstance) { + if inst.InstallPath == "" { + return + } + key := strings.ToLower(filepath.Clean(inst.InstallPath)) + if seen[key] { + return + } + seen[key] = true + instances = append(instances, inst) + } + + // Primary: VS setup instance data — %PROGRAMDATA%\Microsoft\VisualStudio\Packages\_Instances\\state.json + if programData := exec.Getenv("PROGRAMDATA"); programData != "" { + pattern := filepath.Join(programData, "Microsoft", "VisualStudio", "Packages", "_Instances", "*", "state.json") + matches, _ := exec.Glob(pattern) + for _, stateFile := range matches { + add(readVSInstanceState(exec, stateFile)) + } + } + + // Fallback: well-known install locations. VS 2017/2019 are 32-bit (x86). + for _, base := range []string{exec.Getenv("PROGRAMFILES"), exec.Getenv("PROGRAMFILES(X86)")} { + if base == "" { + continue + } + // e.g. C:\Program Files\Microsoft Visual Studio\2022\Community + pattern := filepath.Join(base, "Microsoft Visual Studio", "*", "*") + matches, _ := exec.Glob(pattern) + for _, m := range matches { + if exec.DirExists(m) { + add(vsInstance{InstallPath: m}) + } + } + } + + return instances +} + +// readVSInstanceState reads installationPath and installationVersion from a VS +// setup state.json. +func readVSInstanceState(exec executor.Executor, stateFile string) vsInstance { + data, err := exec.ReadFile(stateFile) + if err != nil { + return vsInstance{} + } + var state struct { + InstallationPath string `json:"installationPath"` + InstallationVersion string `json:"installationVersion"` + } + if err := json.Unmarshal(data, &state); err != nil { + return vsInstance{} + } + return vsInstance{InstallPath: state.InstallationPath, Version: state.InstallationVersion} +} + +// detectVisualStudio reports installed classic Visual Studio instances as IDEs. +// Windows-only. VS is discovered via setup instance data rather than a fixed +// install path, so it isn't part of ideDefinitions. Each installed instance +// (e.g. different editions or years) is reported separately. +func (d *IDEDetector) detectVisualStudio() []model.IDE { + if d.exec.GOOS() != model.PlatformWindows { + return nil + } + + var results []model.IDE + for _, inst := range discoverVisualStudioInstances(d.exec) { + version := inst.Version + if version == "" { + version = "unknown" + } + results = append(results, model.IDE{ + IDEType: "visual_studio", + Version: version, + InstallPath: inst.InstallPath, + Vendor: "Microsoft", + IsInstalled: true, + }) + } + return results +} diff --git a/internal/detector/visualstudio_extensions.go b/internal/detector/visualstudio_extensions.go new file mode 100644 index 0000000..b7a57e5 --- /dev/null +++ b/internal/detector/visualstudio_extensions.go @@ -0,0 +1,202 @@ +package detector + +import ( + "context" + "encoding/xml" + "path/filepath" + "strings" + + "github.com/step-security/dev-machine-guard/internal/model" +) + +// Classic Visual Studio (Community/Professional/Enterprise) stores extensions +// very differently from VS Code: instead of a "publisher.name-version" +// directory, each extension lives in a randomly named folder containing an +// extension.vsixmanifest XML file that carries its identity. This detector is +// Windows-only and reads those manifests directly. +// +// Locations scanned: +// - Per-user (user_installed): %LOCALAPPDATA%\Microsoft\VisualStudio\_\Extensions\\ +// - All-users (bundled): %PROGRAMDATA%\Microsoft\VisualStudio\\Extensions\\ +// - Install-dir built-ins (bundled): \Common7\IDE\Extensions\... +// +// Built-ins are tagged "bundled" so the existing Windows default filter +// (model.FilterUserInstalledExtensions) hides them unless +// --include-bundled-plugins is set, mirroring Eclipse/JetBrains behavior. + +// vsixIdentity holds the attributes common to both VSIX schemas. +type vsixIdentity struct { + ID string `xml:"Id,attr"` + Version string `xml:"Version,attr"` + Publisher string `xml:"Publisher,attr"` +} + +// vsixManifest captures the fields we read from an extension.vsixmanifest. +// It tolerates both VSIX schema versions; encoding/xml matches local element +// names regardless of the differing root element and XML namespace: +// - v2 (2011): +// - v1 (2010): +type vsixManifest struct { + // VSIX v2 (2011): Identity is nested under . + Metadata struct { + Identity vsixIdentity `xml:"Identity"` + DisplayName string `xml:"DisplayName"` + } `xml:"Metadata"` + // VSIX v1 (2010): Identity is a direct child of the root element. + Identity vsixIdentity `xml:"Identity"` + Name string `xml:"Name"` + Author string `xml:"Author"` +} + +// toExtension normalizes a parsed manifest into a model.Extension, or nil if +// it lacks the minimum identity (Id + Version). +func (m *vsixManifest) toExtension() *model.Extension { + // VSIX v2 nests Identity under ; v1 has it as a direct child. + identity := m.Metadata.Identity + if identity.ID == "" { + identity = m.Identity + } + if identity.ID == "" || identity.Version == "" { + return nil + } + + publisher := identity.Publisher + if publisher == "" { + publisher = m.Author // v1 carries the publisher in + } + + name := m.Metadata.DisplayName + if name == "" { + name = m.Name // v1 uses + } + if name == "" { + name = identity.ID + } + + return &model.Extension{ + ID: identity.ID, + Name: name, + Version: identity.Version, + Publisher: publisher, + IDEType: "visual_studio", + } +} + +// DetectVisualStudioExtensions discovers classic Visual Studio extensions. +// Windows-only; returns nil on other platforms. +func (d *ExtensionDetector) DetectVisualStudioExtensions(_ context.Context) []model.Extension { + if d.exec.GOOS() != model.PlatformWindows { + return nil + } + + seen := make(map[string]bool) + var results []model.Extension + + collect := func(roots []string, source string) { + for _, root := range roots { + for _, ext := range d.collectVSExtensionsFromDir(root, source) { + key := strings.ToLower(ext.ID) + "@" + ext.Version + if seen[key] { + continue + } + seen[key] = true + results = append(results, ext) + } + } + } + + // User-installed first so a user copy wins over a bundled duplicate. + collect(d.visualStudioUserExtensionRoots(), "user_installed") + collect(d.visualStudioBundledExtensionRoots(), "bundled") + + return results +} + +// visualStudioUserExtensionRoots returns the per-user "Extensions" directories, +// one per installed VS instance. +func (d *ExtensionDetector) visualStudioUserExtensionRoots() []string { + localAppData := d.exec.Getenv("LOCALAPPDATA") + if localAppData == "" { + return nil + } + // Each instance is %LOCALAPPDATA%\Microsoft\VisualStudio\._\Extensions + pattern := filepath.Join(localAppData, "Microsoft", "VisualStudio", "*", "Extensions") + matches, _ := d.exec.Glob(pattern) + return matches +} + +// visualStudioBundledExtensionRoots returns the all-users / install-dir +// "Extensions" directories. Everything found under these is treated as bundled. +func (d *ExtensionDetector) visualStudioBundledExtensionRoots() []string { + var roots []string + + // Install-dir built-ins: \Common7\IDE\Extensions + for _, inst := range discoverVisualStudioInstances(d.exec) { + roots = append(roots, filepath.Join(inst.InstallPath, "Common7", "IDE", "Extensions")) + } + + // All-users VSIX: %PROGRAMDATA%\Microsoft\VisualStudio\\Extensions + if programData := d.exec.Getenv("PROGRAMDATA"); programData != "" { + pattern := filepath.Join(programData, "Microsoft", "VisualStudio", "*", "Extensions") + matches, _ := d.exec.Glob(pattern) + roots = append(roots, matches...) + } + + return roots +} + +// collectVSExtensionsFromDir scans an "Extensions" root directory. Each +// immediate subdirectory is one installed extension containing an +// extension.vsixmanifest. +func (d *ExtensionDetector) collectVSExtensionsFromDir(extRoot, source string) []model.Extension { + if !d.exec.DirExists(extRoot) { + return nil + } + + entries, err := d.exec.ReadDir(extRoot) + if err != nil { + return nil + } + + var results []model.Extension + for _, entry := range entries { + if !entry.IsDir() { + continue + } + extPath := filepath.Join(extRoot, entry.Name()) + ext := d.parseVSIXManifestDir(extPath) + if ext == nil { + continue + } + + ext.Source = source + ext.InstallPath = extPath + if info, err := d.exec.Stat(extPath); err == nil { + ext.InstallDate = info.ModTime().Unix() + } + + results = append(results, *ext) + } + + return results +} + +// parseVSIXManifestDir reads and parses /extension.vsixmanifest. +func (d *ExtensionDetector) parseVSIXManifestDir(extPath string) *model.Extension { + manifestPath := filepath.Join(extPath, "extension.vsixmanifest") + data, err := d.exec.ReadFile(manifestPath) + if err != nil { + return nil + } + return parseVSIXManifest(data) +} + +// parseVSIXManifest parses extension.vsixmanifest XML bytes into a +// model.Extension, or nil if the content is malformed or lacks an identity. +func parseVSIXManifest(data []byte) *model.Extension { + var m vsixManifest + if err := xml.Unmarshal(data, &m); err != nil { + return nil + } + return m.toExtension() +} diff --git a/internal/detector/visualstudio_extensions_test.go b/internal/detector/visualstudio_extensions_test.go new file mode 100644 index 0000000..e2e76bf --- /dev/null +++ b/internal/detector/visualstudio_extensions_test.go @@ -0,0 +1,244 @@ +package detector + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// VSIX v2 (2011 schema): Identity is nested under , name in . +const vsixManifestV2 = ` + + + + VsVim + Vim emulation for Visual Studio. + + + + +` + +// VSIX v1 (2010 schema): Identity is a direct child of , name in , +// publisher in . +const vsixManifestV1 = ` + + + Legacy Extension + My Company + An older VSIX manifest format. +` + +func TestParseVSIXManifest_V2(t *testing.T) { + ext := parseVSIXManifest([]byte(vsixManifestV2)) + if ext == nil { + t.Fatal("expected a parsed extension, got nil") + } + if ext.ID != "VsVim.Microsoft.e1f4f3a0" { + t.Errorf("ID: got %q", ext.ID) + } + if ext.Version != "2.8.0.0" { + t.Errorf("Version: got %q", ext.Version) + } + if ext.Publisher != "Jared Parsons" { + t.Errorf("Publisher: got %q", ext.Publisher) + } + if ext.Name != "VsVim" { + t.Errorf("Name: got %q", ext.Name) + } + if ext.IDEType != "visual_studio" { + t.Errorf("IDEType: got %q", ext.IDEType) + } +} + +func TestParseVSIXManifest_V1(t *testing.T) { + ext := parseVSIXManifest([]byte(vsixManifestV1)) + if ext == nil { + t.Fatal("expected a parsed extension, got nil") + } + if ext.ID != "MyCompany.LegacyExtension.12345" { + t.Errorf("ID: got %q", ext.ID) + } + if ext.Version != "1.4.2" { + t.Errorf("Version: got %q", ext.Version) + } + // v1 carries publisher in . + if ext.Publisher != "My Company" { + t.Errorf("Publisher: got %q", ext.Publisher) + } + // v1 uses for the display name. + if ext.Name != "Legacy Extension" { + t.Errorf("Name: got %q", ext.Name) + } +} + +func TestParseVSIXManifest_Invalid(t *testing.T) { + cases := []struct { + name string + data string + }{ + {"empty", ""}, + {"not xml", "this is not xml at all"}, + {"missing identity id", `No Id`}, + {"missing version", ``}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if ext := parseVSIXManifest([]byte(tc.data)); ext != nil { + t.Errorf("expected nil, got %+v", ext) + } + }) + } +} + +func TestParseVSIXManifest_DisplayNameFallsBackToID(t *testing.T) { + manifest := `` + ext := parseVSIXManifest([]byte(manifest)) + if ext == nil { + t.Fatal("expected a parsed extension, got nil") + } + if ext.Name != "pub.ext" { + t.Errorf("expected Name to fall back to ID, got %q", ext.Name) + } +} + +func TestCollectVSExtensionsFromDir(t *testing.T) { + mock := executor.NewMock() + extRoot := `C:\Users\dev\AppData\Local\Microsoft\VisualStudio\17.0_abc\Extensions` + mock.SetDir(extRoot) + mock.SetDirEntries(extRoot, []os.DirEntry{ + executor.MockDirEntry("rand1", true), + executor.MockDirEntry("rand2", true), + executor.MockDirEntry("noManifest", true), + executor.MockDirEntry("extensions.configurationchanged", false), + }) + mock.SetFile(filepath.Join(extRoot, "rand1", "extension.vsixmanifest"), []byte(vsixManifestV2)) + mock.SetFile(filepath.Join(extRoot, "rand2", "extension.vsixmanifest"), []byte(vsixManifestV1)) + // "noManifest" dir has no manifest file -> skipped. The non-dir entry -> skipped. + + det := &ExtensionDetector{exec: mock} + results := det.collectVSExtensionsFromDir(extRoot, "user_installed") + + if len(results) != 2 { + t.Fatalf("expected 2 extensions, got %d", len(results)) + } + for _, ext := range results { + if ext.Source != "user_installed" { + t.Errorf("%s: expected source user_installed, got %q", ext.ID, ext.Source) + } + if ext.InstallPath == "" { + t.Errorf("%s: expected InstallPath to be set", ext.ID) + } + } +} + +func TestCollectVSExtensionsFromDir_Missing(t *testing.T) { + mock := executor.NewMock() + det := &ExtensionDetector{exec: mock} + if results := det.collectVSExtensionsFromDir(`C:\nope`, "bundled"); results != nil { + t.Errorf("expected nil for missing dir, got %v", results) + } +} + +func TestDetectVisualStudioExtensions_NonWindows(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("darwin") + det := &ExtensionDetector{exec: mock} + if results := det.DetectVisualStudioExtensions(context.Background()); results != nil { + t.Errorf("expected nil on non-Windows, got %v", results) + } +} + +func TestDetectVisualStudioExtensions_EndToEnd(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + + localAppData := `C:\Users\dev\AppData\Local` + programData := `C:\ProgramData` + mock.SetEnv("LOCALAPPDATA", localAppData) + mock.SetEnv("PROGRAMDATA", programData) + + // --- Per-user extension (user_installed) --- + userRoot := filepath.Join(localAppData, "Microsoft", "VisualStudio", "17.0_abc", "Extensions") + mock.SetGlob(filepath.Join(localAppData, "Microsoft", "VisualStudio", "*", "Extensions"), []string{userRoot}) + mock.SetDir(userRoot) + mock.SetDirEntries(userRoot, []os.DirEntry{executor.MockDirEntry("userext", true)}) + mock.SetFile(filepath.Join(userRoot, "userext", "extension.vsixmanifest"), []byte(vsixManifestV2)) + + // --- Built-in extension via VS setup instance data (bundled) --- + stateFile := filepath.Join(programData, "Microsoft", "VisualStudio", "Packages", "_Instances", "abc", "state.json") + mock.SetGlob(filepath.Join(programData, "Microsoft", "VisualStudio", "Packages", "_Instances", "*", "state.json"), []string{stateFile}) + installPath := `C:\Program Files\Microsoft Visual Studio\2022\Community` + // Marshal so the Windows backslashes are JSON-escaped, exactly as VS writes state.json. + stateJSON, _ := json.Marshal(map[string]string{"installationPath": installPath, "installationVersion": "17.0"}) + mock.SetFile(stateFile, stateJSON) + bundledRoot := filepath.Join(installPath, "Common7", "IDE", "Extensions") + mock.SetDir(bundledRoot) + mock.SetDirEntries(bundledRoot, []os.DirEntry{executor.MockDirEntry("builtin", true)}) + mock.SetFile(filepath.Join(bundledRoot, "builtin", "extension.vsixmanifest"), []byte(vsixManifestV1)) + + det := &ExtensionDetector{exec: mock} + results := det.DetectVisualStudioExtensions(context.Background()) + + if len(results) != 2 { + t.Fatalf("expected 2 extensions, got %d: %+v", len(results), results) + } + + bySource := map[string]model.Extension{} + for _, ext := range results { + bySource[ext.Source] = ext + if ext.IDEType != "visual_studio" { + t.Errorf("%s: expected ide_type visual_studio, got %q", ext.ID, ext.IDEType) + } + } + if bySource["user_installed"].ID != "VsVim.Microsoft.e1f4f3a0" { + t.Errorf("user_installed: got %q", bySource["user_installed"].ID) + } + if bySource["bundled"].ID != "MyCompany.LegacyExtension.12345" { + t.Errorf("bundled: got %q", bySource["bundled"].ID) + } + + // The Windows default filter hides the bundled built-in, leaving only the + // user-installed extension — same behavior as Eclipse/JetBrains. + filtered := model.FilterUserInstalledExtensions(results) + if len(filtered) != 1 || filtered[0].Source != "user_installed" { + t.Errorf("expected only the user_installed extension after filtering, got %+v", filtered) + } +} + +func TestDetectVisualStudioExtensions_Dedup(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + localAppData := `C:\Users\dev\AppData\Local` + programData := `C:\ProgramData` + mock.SetEnv("LOCALAPPDATA", localAppData) + mock.SetEnv("PROGRAMDATA", programData) + + // Same extension present in both the per-user dir and an all-users dir. + userRoot := filepath.Join(localAppData, "Microsoft", "VisualStudio", "17.0_abc", "Extensions") + allUsersRoot := filepath.Join(programData, "Microsoft", "VisualStudio", "17.0", "Extensions") + mock.SetGlob(filepath.Join(localAppData, "Microsoft", "VisualStudio", "*", "Extensions"), []string{userRoot}) + mock.SetGlob(filepath.Join(programData, "Microsoft", "VisualStudio", "*", "Extensions"), []string{allUsersRoot}) + + for _, root := range []string{userRoot, allUsersRoot} { + mock.SetDir(root) + mock.SetDirEntries(root, []os.DirEntry{executor.MockDirEntry("dup", true)}) + mock.SetFile(filepath.Join(root, "dup", "extension.vsixmanifest"), []byte(vsixManifestV2)) + } + + det := &ExtensionDetector{exec: mock} + results := det.DetectVisualStudioExtensions(context.Background()) + + if len(results) != 1 { + t.Fatalf("expected 1 deduped extension, got %d", len(results)) + } + // User-installed is collected first, so it wins the dedup. + if results[0].Source != "user_installed" { + t.Errorf("expected user_installed to win dedup, got %q", results[0].Source) + } +} diff --git a/internal/detector/visualstudio_test.go b/internal/detector/visualstudio_test.go new file mode 100644 index 0000000..785faf7 --- /dev/null +++ b/internal/detector/visualstudio_test.go @@ -0,0 +1,90 @@ +package detector + +import ( + "encoding/json" + "path/filepath" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +func TestDetectVisualStudio_IDE(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + programData := `C:\ProgramData` + mock.SetEnv("PROGRAMDATA", programData) + + stateFile := filepath.Join(programData, "Microsoft", "VisualStudio", "Packages", "_Instances", "abc123", "state.json") + mock.SetGlob( + filepath.Join(programData, "Microsoft", "VisualStudio", "Packages", "_Instances", "*", "state.json"), + []string{stateFile}, + ) + installPath := `C:\Program Files\Microsoft Visual Studio\2022\Community` + stateJSON, _ := json.Marshal(map[string]string{ + "installationPath": installPath, + "installationVersion": "17.14.36310.24", + }) + mock.SetFile(stateFile, stateJSON) + + ides := NewIDEDetector(mock).detectVisualStudio() + if len(ides) != 1 { + t.Fatalf("expected 1 VS IDE, got %d", len(ides)) + } + ide := ides[0] + if ide.IDEType != "visual_studio" { + t.Errorf("IDEType: got %q", ide.IDEType) + } + if ide.Version != "17.14.36310.24" { + t.Errorf("Version: got %q", ide.Version) + } + if ide.InstallPath != installPath { + t.Errorf("InstallPath: got %q", ide.InstallPath) + } + if ide.Vendor != "Microsoft" { + t.Errorf("Vendor: got %q", ide.Vendor) + } + if !ide.IsInstalled { + t.Error("expected IsInstalled true") + } +} + +func TestDetectVisualStudio_NonWindows(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("darwin") + if ides := NewIDEDetector(mock).detectVisualStudio(); ides != nil { + t.Errorf("expected nil on non-Windows, got %v", ides) + } +} + +func TestDiscoverVisualStudioInstances_FallbackAndDedup(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + programData := `C:\ProgramData` + programFiles := `C:\Program Files` + mock.SetEnv("PROGRAMDATA", programData) + mock.SetEnv("PROGRAMFILES", programFiles) + + installPath := filepath.Join(programFiles, "Microsoft Visual Studio", "2022", "Community") + + // Setup instance data points at installPath (with version). + stateFile := filepath.Join(programData, "Microsoft", "VisualStudio", "Packages", "_Instances", "abc", "state.json") + mock.SetGlob( + filepath.Join(programData, "Microsoft", "VisualStudio", "Packages", "_Instances", "*", "state.json"), + []string{stateFile}, + ) + stateJSON, _ := json.Marshal(map[string]string{"installationPath": installPath, "installationVersion": "17.14.0"}) + mock.SetFile(stateFile, stateJSON) + + // Program Files fallback also finds the same install — must dedup. + mock.SetGlob(filepath.Join(programFiles, "Microsoft Visual Studio", "*", "*"), []string{installPath}) + mock.SetDir(installPath) + + instances := discoverVisualStudioInstances(mock) + if len(instances) != 1 { + t.Fatalf("expected 1 deduped instance, got %d: %+v", len(instances), instances) + } + // The setup-instance entry (added first, carries the version) wins the dedup. + if instances[0].Version != "17.14.0" { + t.Errorf("expected version from state.json, got %q", instances[0].Version) + } +}