diff --git a/internal/image/imageinspect/bootloader_config.go b/internal/image/imageinspect/bootloader_config.go new file mode 100644 index 00000000..0678ffe2 --- /dev/null +++ b/internal/image/imageinspect/bootloader_config.go @@ -0,0 +1,439 @@ +package imageinspect + +import ( + "fmt" + "regexp" + "strings" +) + +// uuidRegex matches UUID format: 8-4-4-4-12 hex digits +var uuidRegex = regexp.MustCompile(`[0-9a-fA-F]{8}[-_]?[0-9a-fA-F]{4}[-_]?[0-9a-fA-F]{4}[-_]?[0-9a-fA-F]{4}[-_]?[0-9a-fA-F]{12}`) + +// extractUUIDsFromString finds all UUIDs in a string and returns them normalized +func extractUUIDsFromString(s string) []string { + if s == "" { + return nil + } + matches := uuidRegex.FindAllString(s, -1) + if matches == nil { + return nil + } + // Deduplicate and normalize + seen := make(map[string]struct{}) + var result []string + for _, m := range matches { + normalized := normalizeUUID(m) + if _, ok := seen[normalized]; !ok { + seen[normalized] = struct{}{} + result = append(result, normalized) + } + } + return result +} + +// normalizeUUID removes hyphens and converts to lowercase +func normalizeUUID(uuid string) string { + return strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(uuid, "-", ""), "_", "")) +} + +// parseGrubConfigContent extracts boot entries and kernel references from grub.cfg content. +func parseGrubConfigContent(content string) BootloaderConfig { + cfg := BootloaderConfig{ + ConfigRaw: make(map[string]string), + KernelReferences: []KernelReference{}, + BootEntries: []BootEntry{}, + UUIDReferences: []UUIDReference{}, + Notes: []string{}, + } + + if content == "" { + cfg.Notes = append(cfg.Notes, "grub.cfg is empty") + return cfg + } + + // Store raw content (truncated if too large) + if len(content) > 10240 { // 10KB limit + cfg.ConfigRaw["grub.cfg"] = content[:10240] + "\n[truncated...]" + } else { + cfg.ConfigRaw["grub.cfg"] = content + } + + // Extract critical metadata from the config + lines := strings.Split(content, "\n") + var configfilePath string + var grubPrefix string + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + // Capture GRUB device notation like (hd0,gpt2) or (hd0,msdos1) + if strings.Contains(trimmed, "(hd") { + // find all occurrences of gptN or msdosN inside parentheses + // crude scan: look for "gpt" or "msdos" and digits following + parts := strings.FieldsFunc(trimmed, func(r rune) bool { return r == '(' || r == ')' || r == ',' || r == ' ' }) + for _, p := range parts { + p = strings.TrimSpace(p) + if strings.HasPrefix(strings.ToLower(p), "gpt") || strings.HasPrefix(strings.ToLower(p), "msdos") { + // extract trailing digits + var num string + for i := len(p) - 1; i >= 0; i-- { + if p[i] < '0' || p[i] > '9' { + num = p[i+1:] + break + } + if i == 0 { + num = p + } + } + if num != "" { + // store as a reference like gpt2 or msdos1 + id := strings.ToLower(strings.TrimSpace(p)) + cfg.UUIDReferences = append(cfg.UUIDReferences, UUIDReference{UUID: id, Context: "grub_root_hd"}) + } + } + } + } + + // Extract set prefix value: set prefix=($root)"/boot/grub2" + if strings.HasPrefix(trimmed, "set prefix") { + parts := strings.Split(trimmed, "=") + if len(parts) == 2 { + prefixVal := strings.TrimSpace(parts[1]) + prefixVal = strings.Trim(prefixVal, `"'`) + // If it contains ($root), we'll expand it when we find the root value + if strings.HasPrefix(prefixVal, "(") && strings.Contains(prefixVal, ")") { + // Extract the path part: ($root)"/boot/grub2" -> /boot/grub2 + if idx := strings.Index(prefixVal, ")"); idx >= 0 { + grubPrefix = strings.Trim(prefixVal[idx+1:], `"'`) + } + } else { + grubPrefix = prefixVal + } + } + } + + // Look for configfile directive (loads external config) + if strings.HasPrefix(trimmed, "configfile") { + parts := strings.Fields(trimmed) + if len(parts) > 1 { + configfilePath = strings.Trim(parts[1], `"'`) + // Remove variable prefix if present + if strings.HasPrefix(configfilePath, "(") { + // Format like ($root)"/boot/grub2/grub.cfg" + if idx := strings.Index(configfilePath, ")"); idx >= 0 { + configfilePath = configfilePath[idx+1:] + configfilePath = strings.Trim(configfilePath, `"'`) + } + } else if strings.HasPrefix(configfilePath, "$prefix") { + // Expand $prefix variable + if grubPrefix != "" { + configfilePath = strings.Replace(configfilePath, "$prefix", grubPrefix, 1) + } + } + } + } + + // Look for search commands which may reference partition UUIDs + if strings.HasPrefix(trimmed, "search") { + // pick up any UUID-like token on the line + for _, token := range strings.Fields(trimmed) { + if strings.HasPrefix(token, "PARTUUID=") || strings.HasPrefix(token, "UUID=") { + val := token + if idx := strings.Index(val, "="); idx >= 0 { + val = val[idx+1:] + } + val = strings.Trim(val, `"'`) + for _, u := range extractUUIDsFromString(val) { + cfg.UUIDReferences = append(cfg.UUIDReferences, UUIDReference{UUID: u, Context: "grub_search"}) + } + } else { + // also check raw tokens for UUIDs + for _, u := range extractUUIDsFromString(token) { + cfg.UUIDReferences = append(cfg.UUIDReferences, UUIDReference{UUID: u, Context: "grub_search"}) + } + } + } + } + } + + // If this is a stub config (has configfile), add metadata note + if configfilePath != "" { + note := fmt.Sprintf("Configuration note: This is a UEFI stub config that loads the main GRUB configuration from the root partition at '%s'. The actual boot entries are defined in that file.", configfilePath) + cfg.Notes = append(cfg.Notes, note) + + // Add a synthetic entry showing where the config is + stubEntry := BootEntry{ + Name: "[External config] " + configfilePath, + Kernel: configfilePath, + } + cfg.BootEntries = append(cfg.BootEntries, stubEntry) + } + + // Simple parsing of menuentry blocks (for cases where config is inline) + var currentEntry *BootEntry + var inMenuEntry bool + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + // Detect menuentry start + if strings.HasPrefix(trimmed, "menuentry") { + if currentEntry != nil { + cfg.BootEntries = append(cfg.BootEntries, *currentEntry) + } + currentEntry = parseGrubMenuEntry(trimmed) + inMenuEntry = true + continue + } + + if inMenuEntry && currentEntry != nil { + // Parse commonbootloader options + if strings.HasPrefix(trimmed, "linux") || strings.HasPrefix(trimmed, "vmlinuz") { + parts := strings.Fields(trimmed) + if len(parts) > 1 { + currentEntry.Kernel = parts[1] + if len(parts) > 2 { + currentEntry.Cmdline = strings.Join(parts[2:], " ") + // Parse kernel cmdline tokens for root=PARTUUID=/UUID= references + for _, tok := range strings.Fields(currentEntry.Cmdline) { + if strings.HasPrefix(tok, "root=") { + val := strings.TrimPrefix(tok, "root=") + val = strings.Trim(val, `"'`) + currentEntry.RootDevice = val + // PARTUUID= or UUID= forms + if strings.HasPrefix(val, "PARTUUID=") || strings.HasPrefix(val, "UUID=") { + if idx := strings.Index(val, "="); idx >= 0 { + id := val[idx+1:] + id = strings.Trim(id, `"'`) + for _, u := range extractUUIDsFromString(id) { + currentEntry.PartitionUUID = u + cfg.UUIDReferences = append(cfg.UUIDReferences, UUIDReference{UUID: u, Context: "kernel_cmdline"}) + } + } + } else { + // bare UUIDs or device paths may still include UUIDs + for _, u := range extractUUIDsFromString(val) { + currentEntry.PartitionUUID = u + cfg.UUIDReferences = append(cfg.UUIDReferences, UUIDReference{UUID: u, Context: "kernel_cmdline"}) + } + } + } + } + } + } + } + if strings.HasPrefix(trimmed, "initrd") { + parts := strings.Fields(trimmed) + if len(parts) > 1 { + currentEntry.Initrd = parts[1] + } + } + + // Check for root device reference + if strings.Contains(trimmed, "root=") { + if idx := strings.Index(trimmed, "root="); idx >= 0 { + rest := trimmed[idx+5:] + // Extract the device/UUID value (up to next space) + if spaceIdx := strings.IndexByte(rest, ' '); spaceIdx >= 0 { + currentEntry.RootDevice = rest[:spaceIdx] + } else { + currentEntry.RootDevice = rest + } + } + } + + // End of entry (closing brace or next menuentry) + if strings.HasPrefix(trimmed, "}") { + inMenuEntry = false + } + } + } + + // Add last entry if exists + if currentEntry != nil { + cfg.BootEntries = append(cfg.BootEntries, *currentEntry) + } + + // Extract kernel references + for _, entry := range cfg.BootEntries { + if entry.Kernel != "" { + ref := KernelReference{ + Path: entry.Kernel, + BootEntry: entry.Name, + } + if entry.RootDevice != "" { + ref.RootUUID = entry.RootDevice + } + if entry.PartitionUUID != "" { + ref.PartitionUUID = entry.PartitionUUID + } + cfg.KernelReferences = append(cfg.KernelReferences, ref) + } + } + + return cfg +} + +// parseGrubMenuEntry extracts title/name from a menuentry line. +func parseGrubMenuEntry(menuLine string) *BootEntry { + entry := &BootEntry{} + + // Extract text between quotes: menuentry 'Title' { or menuentry "Title" { + for _, q := range []rune{'\'', '"'} { + start := strings.IndexRune(menuLine, q) + if start >= 0 { + end := strings.IndexRune(menuLine[start+1:], q) + if end >= 0 { + entry.Name = menuLine[start+1 : start+1+end] + return entry + } + } + } + + // Fallback: extract whatever is between "menuentry" and "{", if safely available. + prefix := "menuentry" + if strings.HasPrefix(menuLine, prefix) { + if idx := strings.Index(menuLine, "{"); idx > len(prefix) { + entry.Name = strings.TrimSpace(menuLine[len(prefix):idx]) + } + } + + return entry +} + +// parseSystemdBootEntries extracts boot entries from systemd-boot loader config. +func parseSystemdBootEntries(content string) BootloaderConfig { + cfg := BootloaderConfig{ + ConfigRaw: make(map[string]string), + KernelReferences: []KernelReference{}, + BootEntries: []BootEntry{}, + UUIDReferences: []UUIDReference{}, + Notes: []string{}, + } + + if content == "" { + cfg.Notes = append(cfg.Notes, "loader.conf is empty") + return cfg + } + + if len(content) > 10240 { + cfg.ConfigRaw["loader.conf"] = content[:10240] + "\n[truncated...]" + } else { + cfg.ConfigRaw["loader.conf"] = content + } + + // Extract UUIDs from config + uuids := extractUUIDsFromString(content) + for _, uuid := range uuids { + cfg.UUIDReferences = append(cfg.UUIDReferences, UUIDReference{ + UUID: uuid, + Context: "systemd_boot_config", + }) + } + + // Parse simple key=value pairs + lines := strings.Split(content, "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + + if strings.HasPrefix(trimmed, "default") { + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) == 2 { + cfg.DefaultEntry = strings.TrimSpace(parts[1]) + } + } + } + + return cfg +} + +// resolveUUIDsToPartitions matches UUIDs in bootloader config against partition GUIDs. +// It returns a map of UUID -> partition index. +func resolveUUIDsToPartitions(uuidRefs []UUIDReference, pt PartitionTableSummary) map[string]int { + result := make(map[string]int) + + for _, ref := range uuidRefs { + // If the token is a GPT/MSDOS partition spec like 'gpt2' or 'msdos1', map directly + low := strings.ToLower(ref.UUID) + if strings.HasPrefix(low, "gpt") || strings.HasPrefix(low, "msdos") { + // Extract trailing number + digits := "" + for i := len(low) - 1; i >= 0; i-- { + if low[i] < '0' || low[i] > '9' { + digits = low[i+1:] + break + } + if i == 0 { + digits = low + } + } + if digits != "" { + // convert to int + var idx int + if _, err := fmt.Sscanf(digits, "%d", &idx); err == nil { + if idx > 0 { + result[ref.UUID] = idx + continue + } + } + } + } + + // Otherwise, try to match GUIDs (partition GUIDs) or filesystem UUIDs + normalized := normalizeUUID(ref.UUID) + for _, p := range pt.Partitions { + if normalizeUUID(p.GUID) == normalized { + result[ref.UUID] = p.Index + break + } + // Also check filesystem UUID + if p.Filesystem != nil && normalizeUUID(p.Filesystem.UUID) == normalized { + result[ref.UUID] = p.Index + break + } + } + } + + return result +} + +// ValidateBootloaderConfig checks for common configuration issues. +func ValidateBootloaderConfig(cfg *BootloaderConfig, pt PartitionTableSummary) { + if cfg == nil { + return + } + + // Check for missing config files + if len(cfg.ConfigFiles) == 0 && len(cfg.ConfigRaw) == 0 { + cfg.Notes = append(cfg.Notes, "No bootloader configuration files found") + } + + // Resolve UUIDs and check for mismatches + uuidMap := resolveUUIDsToPartitions(cfg.UUIDReferences, pt) + for i, uuidRef := range cfg.UUIDReferences { + if _, found := uuidMap[uuidRef.UUID]; found { + cfg.UUIDReferences[i].ReferencedPartition = uuidMap[uuidRef.UUID] + } else { + cfg.UUIDReferences[i].Mismatch = true + cfg.Notes = append(cfg.Notes, + fmt.Sprintf("UUID %s referenced in %s not found in partition table", uuidRef.UUID, uuidRef.Context)) + } + } + + // Check for kernel references without valid paths + for _, kernRef := range cfg.KernelReferences { + if kernRef.Path == "" { + cfg.Notes = append(cfg.Notes, fmt.Sprintf("Boot entry %s has no kernel path", kernRef.BootEntry)) + } + } + + // Check for boot entries without kernel + for _, entry := range cfg.BootEntries { + if entry.Kernel == "" { + cfg.Notes = append(cfg.Notes, fmt.Sprintf("Boot entry '%s' has no kernel path", entry.Name)) + } + } +} diff --git a/internal/image/imageinspect/bootloader_config_test.go b/internal/image/imageinspect/bootloader_config_test.go new file mode 100644 index 00000000..cc112918 --- /dev/null +++ b/internal/image/imageinspect/bootloader_config_test.go @@ -0,0 +1,725 @@ +package imageinspect + +import ( + "strings" + "testing" +) + +func TestParseGrubConfig(t *testing.T) { + grubContent := ` +menuentry 'Ubuntu 24.04 LTS (5.15.0-105-generic)' { + search --no-floppy --label BOOT --set root + echo 'Loading Ubuntu 24.04 LTS (5.15.0-105-generic)' + linux /vmlinuz-5.15.0-105-generic root=UUID=550e8400-e29b-41d4-a716-446655440000 ro quiet splash + echo 'Loading initial ramdisk' + initrd /initrd.img-5.15.0-105-generic +} + +menuentry 'Ubuntu 24.04 LTS (5.14.0-104-generic)' { + search --no-floppy --label BOOT --set root + echo 'Loading Ubuntu 24.04 LTS (5.14.0-104-generic)' + linux /vmlinuz-5.14.0-104-generic root=UUID=550e8400-e29b-41d4-a716-446655440000 ro quiet splash + echo 'Loading initial ramdisk' + initrd /initrd.img-5.14.0-104-generic +} +` + + cfg := parseGrubConfigContent(grubContent) + + // Verify boot entries were parsed + if len(cfg.BootEntries) != 2 { + t.Errorf("Expected 2 boot entries, got %d", len(cfg.BootEntries)) + } + + // Verify kernel references were extracted + if len(cfg.KernelReferences) != 2 { + t.Errorf("Expected 2 kernel references, got %d", len(cfg.KernelReferences)) + } + + // Verify UUIDs were extracted + if len(cfg.UUIDReferences) == 0 { + t.Errorf("Expected UUID references, got none") + } + + // Check specific entry + if cfg.BootEntries[0].Name != "Ubuntu 24.04 LTS (5.15.0-105-generic)" { + t.Errorf("Boot entry name mismatch: %s", cfg.BootEntries[0].Name) + } + + if cfg.BootEntries[0].Kernel != "/vmlinuz-5.15.0-105-generic" { + t.Errorf("Kernel path mismatch: %s", cfg.BootEntries[0].Kernel) + } + + if cfg.BootEntries[0].Initrd != "/initrd.img-5.15.0-105-generic" { + t.Errorf("Initrd path mismatch: %s", cfg.BootEntries[0].Initrd) + } + + if cfg.BootEntries[0].RootDevice == "" { + t.Errorf("Root device not extracted") + } +} + +func TestExtractUUIDs(t *testing.T) { + testCases := []struct { + input string + expected int + }{ + { + input: "UUID=550e8400-e29b-41d4-a716-446655440000", + expected: 1, + }, + { + input: "PARTUUID=550e8400-e29b-41d4-a716-446655440000 root=/dev/vda2", + expected: 1, + }, + { + input: "UUID=550e8400-e29b-41d4-a716-446655440000 and UUID=550e8400-e29b-41d4-a716-446655440001", + expected: 2, + }, + { + input: "no uuids here", + expected: 0, + }, + } + + for _, tc := range testCases { + uuids := extractUUIDsFromString(tc.input) + if len(uuids) != tc.expected { + t.Errorf("Input: %q - Expected %d UUIDs, got %d", tc.input, tc.expected, len(uuids)) + } + } +} + +func TestCompareBootloaderConfigs(t *testing.T) { + cfgFrom := &BootloaderConfig{ + ConfigFiles: map[string]string{ + "/boot/grub/grub.cfg": "abc123", + }, + BootEntries: []BootEntry{ + { + Name: "Linux (old)", + Kernel: "/vmlinuz-5.14", + Initrd: "/initrd-5.14", + }, + }, + KernelReferences: []KernelReference{ + { + Path: "/vmlinuz-5.14", + }, + }, + } + + cfgTo := &BootloaderConfig{ + ConfigFiles: map[string]string{ + "/boot/grub/grub.cfg": "def456", // Changed + }, + BootEntries: []BootEntry{ + { + Name: "Linux (old)", + Kernel: "/vmlinuz-5.15", // Changed + Initrd: "/initrd-5.15", // Changed + }, + { + Name: "Linux (new)", + Kernel: "/vmlinuz-5.16", + Initrd: "/initrd-5.16", + }, + }, + KernelReferences: []KernelReference{ + { + Path: "/vmlinuz-5.15", + }, + { + Path: "/vmlinuz-5.16", + }, + }, + } + + diff := compareBootloaderConfigs(cfgFrom, cfgTo) + + if diff == nil { + t.Fatalf("Expected diff, got nil") + } + + // Should detect config file change + if len(diff.ConfigFileChanges) != 1 || diff.ConfigFileChanges[0].Status != "modified" { + t.Errorf("Expected 1 modified config file, got %d changes", len(diff.ConfigFileChanges)) + } + + // Should detect boot entry modification + if len(diff.BootEntryChanges) != 2 { + t.Errorf("Expected 2 boot entry changes (1 modified, 1 added), got %d", len(diff.BootEntryChanges)) + } + + modifiedFound := false + for _, change := range diff.BootEntryChanges { + if change.Status != "modified" || change.Name != "Linux (old)" { + continue + } + modifiedFound = true + if change.InitrdFrom != "/initrd-5.14" || change.InitrdTo != "/initrd-5.15" { + t.Errorf("Expected initrd change /initrd-5.14 -> /initrd-5.15, got %q -> %q", change.InitrdFrom, change.InitrdTo) + } + } + if !modifiedFound { + t.Errorf("Expected modified boot entry change for Linux (old)") + } + + // Should detect kernel reference changes + // Old vmlinuz-5.14 removed, vmlinuz-5.15 modified, vmlinuz-5.16 added = 3 changes + if len(diff.KernelRefChanges) != 3 { + t.Errorf("Expected 3 kernel reference changes, got %d", len(diff.KernelRefChanges)) + } +} + +func TestUUIDResolution(t *testing.T) { + pt := PartitionTableSummary{ + Partitions: []PartitionSummary{ + { + Index: 1, + GUID: "550e8400-e29b-41d4-a716-446655440000", + }, + { + Index: 2, + GUID: "550e8400-e29b-41d4-a716-446655440001", + Filesystem: &FilesystemSummary{ + UUID: "550e8400-e29b-41d4-a716-446655440002", + }, + }, + }, + } + + uuidRefs := []UUIDReference{ + { + UUID: "550e8400-e29b-41d4-a716-446655440000", + }, + { + UUID: "550e8400-e29b-41d4-a716-446655440002", + }, + { + UUID: "99999999-9999-9999-9999-999999999999", // Non-existent + }, + } + + resolved := resolveUUIDsToPartitions(uuidRefs, pt) + + if len(resolved) != 2 { + t.Errorf("Expected 2 resolved UUIDs, got %d", len(resolved)) + } + + if part, ok := resolved["550e8400-e29b-41d4-a716-446655440000"]; !ok || part != 1 { + t.Errorf("First UUID should resolve to partition 1") + } + + if part, ok := resolved["550e8400-e29b-41d4-a716-446655440002"]; !ok || part != 2 { + t.Errorf("Second UUID should resolve to partition 2 (filesystem UUID)") + } +} + +func TestValidateBootloaderConfig(t *testing.T) { + pt := PartitionTableSummary{ + Partitions: []PartitionSummary{ + { + Index: 1, + GUID: "550e8400-e29b-41d4-a716-446655440000", + }, + }, + } + + cfg := &BootloaderConfig{ + BootEntries: []BootEntry{ + { + Name: "Test", + Kernel: "", // Empty kernel - should trigger issue + }, + }, + UUIDReferences: []UUIDReference{ + { + UUID: "99999999-9999-9999-9999-999999999999", // Invalid UUID + Context: "test", + }, + }, + } + + ValidateBootloaderConfig(cfg, pt) + + if len(cfg.Notes) == 0 { + t.Errorf("Expected validation notes, got none") + } + + // Should have note about missing kernel path + hasKernelIssue := false + hasMismatchIssue := false + + for _, note := range cfg.Notes { + if len(note) > 0 { + if note[0] == 'B' { // "Boot entry..." + hasKernelIssue = true + } + if note[0] == 'U' { // "UUID..." + hasMismatchIssue = true + } + } + } + + if !hasKernelIssue { + t.Errorf("Expected kernel issue not found") + } + + if !hasMismatchIssue { + t.Errorf("Expected UUID mismatch issue not found") + } +} + +func ExampleBootloaderConfig() { + // Simulate extracting config from two images + grubConfig1 := ` +menuentry 'Linux' { + linux /vmlinuz-5.14 root=UUID=550e8400-e29b-41d4-a716-446655440000 ro + initrd /initrd-5.14 +} +` + + grubConfig2 := ` +menuentry 'Linux' { + linux /vmlinuz-5.15 root=UUID=550e8400-e29b-41d4-a716-446655440000 ro + initrd /initrd-5.15 +} +` + + cfg1 := parseGrubConfigContent(grubConfig1) + cfg2 := parseGrubConfigContent(grubConfig2) + + // Compare configurations + diff := compareBootloaderConfigs(&cfg1, &cfg2) + + if diff != nil && len(diff.BootEntryChanges) > 0 { + // Kernel was updated in the boot entry + for _, change := range diff.BootEntryChanges { + if change.Status == "modified" && change.KernelFrom != change.KernelTo { + // Log that kernel version changed + } + } + } + + // Check for UUID mismatches + pt := PartitionTableSummary{ + Partitions: []PartitionSummary{ + {Index: 1, GUID: "550e8400-e29b-41d4-a716-446655440000"}, + }, + } + + ValidateBootloaderConfig(&cfg1, pt) + // cfg1.Notes would contain any validation problems +} + +// TestParseGrubConfigWithSearchPartuuid tests GRUB search directive with PARTUUID +func TestParseGrubConfigWithSearchPartuuid(t *testing.T) { + grubCfg := `search --fs-uuid --no-floppy --set=root f4633aa1-3137-4424-ad60-c680a5016ee2 +menuentry 'Linux' { + linux /vmlinuz-5.14 root=PARTUUID=f4633aa1-3137-4424-ad60-c680a5016ee2 ro + initrd /initrd-5.14 +}` + cfg := parseGrubConfigContent(grubCfg) + + if len(cfg.UUIDReferences) == 0 { + t.Fatal("Expected UUID references extracted from search directive and kernel cmdline") + } + + // Verify context annotation + foundSearchUUID := false + foundCmdlineUUID := false + for _, ref := range cfg.UUIDReferences { + if ref.Context == "grub_search" { + foundSearchUUID = true + } + if ref.Context == "kernel_cmdline" { + foundCmdlineUUID = true + } + } + + if !foundSearchUUID { + t.Error("Expected UUID from search directive with context 'grub_search'") + } + if !foundCmdlineUUID { + t.Error("Expected UUID from kernel cmdline with context 'kernel_cmdline'") + } +} + +// TestParseGrubConfigWithDeviceSpec tests GRUB device notation +func TestParseGrubConfigWithDeviceSpec(t *testing.T) { + grubCfg := `menuentry 'Linux' { + insmod gzio + set root='(hd0,gpt2)' + linux /vmlinuz +}` + cfg := parseGrubConfigContent(grubCfg) + + // Verify gpt2 is captured as a reference + foundGpt2 := false + for _, ref := range cfg.UUIDReferences { + if strings.Contains(ref.UUID, "gpt2") && ref.Context == "grub_root_hd" { + foundGpt2 = true + break + } + } + + if !foundGpt2 { + t.Error("Expected 'gpt2' captured from device spec (hd0,gpt2) with context 'grub_root_hd'") + } +} + +// TestParseGrubConfigWithMsdosDeviceSpec tests GRUB device notation with MBR +func TestParseGrubConfigWithMsdosDeviceSpec(t *testing.T) { + grubCfg := `menuentry 'Windows' { + insmod registry + set root='(hd0,msdos1)' + linux /vmlinuz +}` + cfg := parseGrubConfigContent(grubCfg) + + foundMsdos1 := false + for _, ref := range cfg.UUIDReferences { + if strings.Contains(ref.UUID, "msdos1") && ref.Context == "grub_root_hd" { + foundMsdos1 = true + break + } + } + + if !foundMsdos1 { + t.Error("Expected 'msdos1' captured from device spec (hd0,msdos1)") + } +} + +// TestParseGrubConfigMultipleMenuEntries tests multiple boot entries +func TestParseGrubConfigMultipleMenuEntries(t *testing.T) { + grubCfg := `menuentry 'Linux First' { + linux /vmlinuz-5.14 root=/dev/sda1 ro +} +menuentry 'Linux Second' { + linux /vmlinuz-5.15 root=/dev/sda1 ro +}` + cfg := parseGrubConfigContent(grubCfg) + + if len(cfg.BootEntries) != 2 { + t.Errorf("Expected 2 boot entries, got %d", len(cfg.BootEntries)) + } + if cfg.BootEntries[0].Name != "Linux First" { + t.Errorf("Expected first entry 'Linux First', got %s", cfg.BootEntries[0].Name) + } + if cfg.BootEntries[1].Name != "Linux Second" { + t.Errorf("Expected second entry 'Linux Second', got %s", cfg.BootEntries[1].Name) + } +} + +// TestParseGrubConfigWithExternalConfigfile tests stub config with config +func TestParseGrubConfigWithExternalConfigfile(t *testing.T) { + grubCfg := `set prefix=($root)"/boot/grub2" +configfile ($root)"/boot/grub2/grub.cfg"` + + cfg := parseGrubConfigContent(grubCfg) + + if len(cfg.Notes) == 0 { + t.Error("Expected note about external configfile") + } + + found := false + for _, note := range cfg.Notes { + if strings.Contains(note, "stub config") && strings.Contains(note, "/boot/grub2/grub.cfg") { + found = true + } + } + if !found { + t.Error("Expected note about UEFI stub config and external config file path") + } +} + +// TestParseSystemdBootConfig tests systemd-boot loader configuration +func TestParseSystemdBootConfig(t *testing.T) { + loaderCfg := `timeout=5 +default=linux +editor=no +auto-firmware=no` + + cfg := parseSystemdBootEntries(loaderCfg) + + if cfg.DefaultEntry != "linux" { + t.Errorf("Expected default entry 'linux', got %s", cfg.DefaultEntry) + } + if len(cfg.ConfigRaw) == 0 { + t.Error("Expected raw config to be stored") + } +} + +// TestResolvePartitionSpecGpt tests resolving gpt2 to partition index +func TestResolvePartitionSpecGpt(t *testing.T) { + refs := []UUIDReference{{UUID: "gpt2", Context: "test"}} + pt := PartitionTableSummary{ + Partitions: []PartitionSummary{ + {Index: 1, GUID: "ABC123"}, + {Index: 2, GUID: "DEF456"}, + }, + } + + result := resolveUUIDsToPartitions(refs, pt) + + if result["gpt2"] != 2 { + t.Errorf("Expected partition 2 for 'gpt2', got %d", result["gpt2"]) + } +} + +// TestResolvePartitionSpecMsdos tests resolving msdos1 to partition index +func TestResolvePartitionSpecMsdos(t *testing.T) { + refs := []UUIDReference{{UUID: "msdos1", Context: "test"}} + pt := PartitionTableSummary{ + Partitions: []PartitionSummary{ + {Index: 1, GUID: "ABC123"}, + {Index: 2, GUID: "DEF456"}, + }, + } + + result := resolveUUIDsToPartitions(refs, pt) + + if result["msdos1"] != 1 { + t.Errorf("Expected partition 1 for 'msdos1', got %d", result["msdos1"]) + } +} + +// TestResolveUUIDAgainstPartitionGUID tests UUID resolution against partition GUID +func TestResolveUUIDAgainstPartitionGUID(t *testing.T) { + refs := []UUIDReference{{UUID: "f4633aa1-3137-4424-ad60-c680a5016ee2", Context: "test"}} + pt := PartitionTableSummary{ + Partitions: []PartitionSummary{ + {Index: 1, GUID: "11111111-1111-1111-1111-111111111111"}, + {Index: 2, GUID: "f4633aa1-3137-4424-ad60-c680a5016ee2"}, + }, + } + + result := resolveUUIDsToPartitions(refs, pt) + + if result["f4633aa1-3137-4424-ad60-c680a5016ee2"] != 2 { + t.Errorf("Expected partition 2, got %d", result["f4633aa1-3137-4424-ad60-c680a5016ee2"]) + } +} + +// TestResolveUUIDAgainstFilesystemUUID tests UUID resolution against filesystem UUID +func TestResolveUUIDAgainstFilesystemUUID(t *testing.T) { + refs := []UUIDReference{{UUID: "f4633aa1-3137-4424-ad60-c680a5016ee2", Context: "test"}} + pt := PartitionTableSummary{ + Partitions: []PartitionSummary{ + { + Index: 2, + GUID: "11111111-1111-1111-1111-111111111111", + Filesystem: &FilesystemSummary{ + UUID: "f4633aa1-3137-4424-ad60-c680a5016ee2", + }, + }, + }, + } + + result := resolveUUIDsToPartitions(refs, pt) + + if result["f4633aa1-3137-4424-ad60-c680a5016ee2"] != 2 { + t.Errorf("Expected partition 2 from filesystem UUID, got %d", result["f4633aa1-3137-4424-ad60-c680a5016ee2"]) + } +} + +// TestSynthesizeBootConfigFromUKI_BootUUID tests boot_uuid extraction from UKI cmdline +func TestSynthesizeBootConfigFromUKI_BootUUID(t *testing.T) { + uki := &EFIBinaryEvidence{ + Path: "EFI/Linux/test.efi", + Cmdline: "root=/dev/mapper/root boot_uuid=f4633aa1-3137-4424-ad60-c680a5016ee2 other=value", + } + cfg := synthesizeBootConfigFromUKI(uki) + + // Check that boot_uuid is extracted with proper context + foundBootUUID := false + for _, ref := range cfg.UUIDReferences { + if ref.Context == "uki_boot_uuid" { + foundBootUUID = true + break + } + } + if !foundBootUUID { + t.Error("Expected boot_uuid extracted to UUIDReferences with context 'uki_boot_uuid'") + } +} + +// TestSynthesizeBootConfigFromUKI_RootDevice tests root device extraction from UKI +func TestSynthesizeBootConfigFromUKI_RootDevice(t *testing.T) { + uki := &EFIBinaryEvidence{ + Path: "EFI/Linux/test.efi", + Cmdline: "root=/dev/mapper/rootfs_verity quiet splash", + } + cfg := synthesizeBootConfigFromUKI(uki) + + if len(cfg.BootEntries) != 1 { + t.Fatal("Expected 1 synthesized boot entry") + } + + entry := cfg.BootEntries[0] + if entry.RootDevice != "/dev/mapper/rootfs_verity" { + t.Errorf("Expected root device '/dev/mapper/rootfs_verity', got %s", entry.RootDevice) + } +} + +// TestSynthesizeBootConfigFromUKI_Empty tests edge case with empty UKI cmdline +func TestSynthesizeBootConfigFromUKI_Empty(t *testing.T) { + uki := &EFIBinaryEvidence{ + Path: "EFI/Linux/test.efi", + Cmdline: "", + } + cfg := synthesizeBootConfigFromUKI(uki) + + if len(cfg.Notes) == 0 { + t.Error("Expected note about empty UKI cmdline") + } +} + +// TestValidateBootloaderConfig_UUIDMismatch tests detection of UUID mismatches +func TestValidateBootloaderConfig_UUIDMismatch(t *testing.T) { + cfg := &BootloaderConfig{ + UUIDReferences: []UUIDReference{ + {UUID: "99999999-9999-9999-9999-999999999999", Context: "test"}, + }, + Notes: []string{}, + } + pt := PartitionTableSummary{ + Partitions: []PartitionSummary{ + {Index: 1, GUID: "11111111-1111-1111-1111-111111111111"}, + }, + } + + ValidateBootloaderConfig(cfg, pt) + + // Should have marked UUID as mismatch + if !cfg.UUIDReferences[0].Mismatch { + t.Error("Expected UUID mismatch to be detected") + } + if len(cfg.Notes) == 0 { + t.Error("Expected notes about mismatched UUID") + } +} + +// TestValidateBootloaderConfig_MultipleIssues tests detection of multiple issues +func TestValidateBootloaderConfig_MultipleIssues(t *testing.T) { + cfg := &BootloaderConfig{ + BootEntries: []BootEntry{ + {Name: "Boot1", Kernel: ""}, // Missing kernel + {Name: "Boot2", Kernel: "/vmlinuz"}, + }, + KernelReferences: []KernelReference{ + {Path: "", BootEntry: "BadEntry"}, // Missing path + }, + Notes: []string{}, + } + pt := PartitionTableSummary{Partitions: []PartitionSummary{}} + + ValidateBootloaderConfig(cfg, pt) + + // Should have multiple notes + if len(cfg.Notes) < 2 { + t.Errorf("Expected at least 2 notes, got %d", len(cfg.Notes)) + } +} + +// TestValidateBootloaderConfig_NoConfigFiles tests detection of missing config +func TestValidateBootloaderConfig_NoConfigFiles(t *testing.T) { + cfg := &BootloaderConfig{ + ConfigFiles: map[string]string{}, + ConfigRaw: map[string]string{}, + Notes: []string{}, + } + pt := PartitionTableSummary{Partitions: []PartitionSummary{}} + + ValidateBootloaderConfig(cfg, pt) + + if len(cfg.Notes) == 0 || !strings.Contains(cfg.Notes[0], "No bootloader configuration") { + t.Error("Expected note about missing config files") + } +} + +// TestExtractUUIDsFromString tests UUID extraction and normalization +func TestExtractUUIDsFromString(t *testing.T) { + tests := []struct { + name string + input string + expected int + contains string + }{ + { + name: "Standard UUID", + input: "f4633aa1-3137-4424-ad60-c680a5016ee2", + expected: 1, + contains: "f4633aa13137442", + }, + { + name: "Multiple UUIDs", + input: "root=f4633aa1-3137-4424-ad60-c680a5016ee2 boot=11111111-1111-1111-1111-111111111111", + expected: 2, + contains: "f4633aa13137442", + }, + { + name: "Empty string", + input: "", + expected: 0, + contains: "", + }, + { + name: "No UUIDs", + input: "root=/dev/sda1 boot=/boot", + expected: 0, + contains: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractUUIDsFromString(tt.input) + if len(result) != tt.expected { + t.Errorf("Expected %d UUIDs, got %d", tt.expected, len(result)) + } + if tt.expected > 0 && !strings.Contains(result[0], tt.contains) { + t.Errorf("Expected UUID containing %s, got %s", tt.contains, result[0]) + } + }) + } +} + +// TestNormalizeUUID tests UUID normalization +func TestNormalizeUUID(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"F4633AA1-3137-4424-AD60-C680A5016EE2", "f4633aa13137442"}, + {"f4633aa1_3137_4424_ad60_c680a5016ee2", "f4633aa13137442"}, + {"f4633aa13137442ad60c680a5016ee2", "f4633aa13137442"}, + } + + for _, tt := range tests { + result := normalizeUUID(tt.input) + if !strings.HasPrefix(result, tt.expected) { + t.Errorf("Expected %s, got %s", tt.expected, result) + } + } +} + +// TestParseGrubMenuEntry tests menu entry name extraction +func TestParseGrubMenuEntry(t *testing.T) { + tests := []struct { + menuLine string + expected string + }{ + {`menuentry 'Ubuntu 20.04' {`, "Ubuntu 20.04"}, + {`menuentry "Fedora System" {`, "Fedora System"}, + {`menuentry Ubuntu-20.04 {`, "Ubuntu-20.04"}, + } + + for _, tt := range tests { + entry := parseGrubMenuEntry(tt.menuLine) + if entry.Name != tt.expected { + t.Errorf("Expected %s, got %s", tt.expected, entry.Name) + } + } +} diff --git a/internal/image/imageinspect/bootloader_efi.go b/internal/image/imageinspect/bootloader_efi.go index 4b1847a8..b7fc2c0b 100755 --- a/internal/image/imageinspect/bootloader_efi.go +++ b/internal/image/imageinspect/bootloader_efi.go @@ -17,7 +17,7 @@ func ParsePEFromBytes(p string, blob []byte) (EFIBinaryEvidence, error) { Kind: BootloaderUnknown, // set after we have more evidence } - ev.SHA256 = sha256Hex(blob) + ev.SHA256 = hashBytesHex(blob) r := bytes.NewReader(blob) f, err := pe.NewFile(r) @@ -61,7 +61,7 @@ func ParsePEFromBytes(p string, blob []byte) (EFIBinaryEvidence, error) { ev.Notes = append(ev.Notes, fmt.Sprintf("read section %s: %v", name, err)) continue } - ev.SectionSHA256[name] = sha256Hex(data) + ev.SectionSHA256[name] = hashBytesHex(data) switch name { case ".linux": diff --git a/internal/image/imageinspect/compare.go b/internal/image/imageinspect/compare.go index 22ec20c4..b7d5d906 100644 --- a/internal/image/imageinspect/compare.go +++ b/internal/image/imageinspect/compare.go @@ -178,7 +178,8 @@ type ModifiedEFIBinaryEvidence struct { To EFIBinaryEvidence `json:"to"` Changes []FieldChange `json:"changes,omitempty"` - UKI *UKIDiff `json:"uki,omitempty"` + UKI *UKIDiff `json:"uki,omitempty"` + BootConfig *BootloaderConfigDiff `json:"bootConfig,omitempty"` } // UKIDiff represents differences in the UKI-related fields of an EFI binary. @@ -201,6 +202,54 @@ type SectionMapDiff struct { Modified map[string]ValueDiff[string] `json:"modified,omitempty"` } +// BootloaderConfigDiff represents differences in bootloader configuration. +type BootloaderConfigDiff struct { + ConfigFileChanges []ConfigFileChange `json:"configFileChanges,omitempty"` + BootEntryChanges []BootEntryChange `json:"bootEntryChanges,omitempty"` + KernelRefChanges []KernelRefChange `json:"kernelRefChanges,omitempty"` + UUIDReferenceChanges []UUIDRefChange `json:"uuidReferenceChanges,omitempty"` + NotesAdded []string `json:"notesAdded,omitempty"` + NotesRemoved []string `json:"notesRemoved,omitempty"` +} + +// ConfigFileChange represents a change to a bootloader config file. +type ConfigFileChange struct { + Path string `json:"path" yaml:"path"` + Status string `json:"status" yaml:"status"` // "added", "removed", "modified" + HashFrom string `json:"hashFrom,omitempty" yaml:"hashFrom,omitempty"` + HashTo string `json:"hashTo,omitempty" yaml:"hashTo,omitempty"` +} + +// BootEntryChange represents a change to a boot entry. +type BootEntryChange struct { + Name string `json:"name" yaml:"name"` + Status string `json:"status" yaml:"status"` // "added", "removed", "modified" + KernelFrom string `json:"kernelFrom,omitempty" yaml:"kernelFrom,omitempty"` + KernelTo string `json:"kernelTo,omitempty" yaml:"kernelTo,omitempty"` + InitrdFrom string `json:"initrdFrom,omitempty" yaml:"initrdFrom,omitempty"` + InitrdTo string `json:"initrdTo,omitempty" yaml:"initrdTo,omitempty"` + CmdlineFrom string `json:"cmdlineFrom,omitempty" yaml:"cmdlineFrom,omitempty"` + CmdlineTo string `json:"cmdlineTo,omitempty" yaml:"cmdlineTo,omitempty"` +} + +// KernelRefChange represents a change to a kernel reference. +type KernelRefChange struct { + Path string `json:"path" yaml:"path"` + Status string `json:"status" yaml:"status"` // "added", "removed", "modified" + UUIDFrom string `json:"uuidFrom,omitempty" yaml:"uuidFrom,omitempty"` + UUIDTo string `json:"uuidTo,omitempty" yaml:"uuidTo,omitempty"` +} + +// UUIDRefChange represents a change to a UUID reference. +type UUIDRefChange struct { + UUID string `json:"uuid" yaml:"uuid"` + Status string `json:"status" yaml:"status"` // "added", "removed", "modified" + ContextFrom string `json:"contextFrom,omitempty" yaml:"contextFrom,omitempty"` + ContextTo string `json:"contextTo,omitempty" yaml:"contextTo,omitempty"` + MismatchFrom bool `json:"mismatchFrom,omitempty" yaml:"mismatchFrom,omitempty"` + MismatchTo bool `json:"mismatchTo,omitempty" yaml:"mismatchTo,omitempty"` +} + // CompareImages compares two ImageSummary objects and returns a structured diff. func CompareImages(from, to *ImageSummary) ImageCompareResult { if from == nil || to == nil { @@ -797,7 +846,6 @@ func tallyDiffs(d ImageDiff) diffTally { if d.PartitionTable.MisalignedParts != nil { t.addMeaningful(1, "PT MisalignedParts") } - if len(d.Partitions.Added) > 0 { t.addMeaningful(len(d.Partitions.Added), "Partitions Added") } @@ -930,32 +978,35 @@ func tallyEFIBinaryDiff(t *diffTally, d EFIBinaryDiff) { } // UKI diffs - if m.UKI == nil || !m.UKI.Changed { - continue - } - - if m.UKI.KernelSHA256 != nil { - t.addMeaningful(1, "EFI "+m.Key+" UKI KernelSHA") - } - if m.UKI.OSRelSHA256 != nil { - t.addMeaningful(1, "EFI "+m.Key+" UKI OSRelSHA") - } - if m.UKI.UnameSHA256 != nil { - t.addMeaningful(1, "EFI "+m.Key+" UKI UnameSHA") - } + if m.UKI != nil && m.UKI.Changed { + if m.UKI.KernelSHA256 != nil { + t.addMeaningful(1, "EFI "+m.Key+" UKI KernelSHA") + } + if m.UKI.OSRelSHA256 != nil { + t.addMeaningful(1, "EFI "+m.Key+" UKI OSRelSHA") + } + if m.UKI.UnameSHA256 != nil { + t.addMeaningful(1, "EFI "+m.Key+" UKI UnameSHA") + } - otherSectionChanged := false - for sec := range m.UKI.SectionSHA256.Modified { - secL := strings.ToLower(strings.TrimSpace(sec)) - if secL == ".cmdline" || secL == "cmdline" || - secL == ".initrd" || secL == "initrd" { - continue + otherSectionChanged := false + for sec := range m.UKI.SectionSHA256.Modified { + secL := strings.ToLower(strings.TrimSpace(sec)) + if secL == ".cmdline" || secL == "cmdline" || + secL == ".initrd" || secL == "initrd" { + continue + } + otherSectionChanged = true + break + } + if otherSectionChanged { + t.addMeaningful(1, "EFI "+m.Key+" UKI otherSectionChanged") } - otherSectionChanged = true - break } - if otherSectionChanged { - t.addMeaningful(1, "EFI "+m.Key+" UKI otherSectionChanged") + + // Bootloader config diffs + if m.BootConfig != nil { + tallyBootloaderConfigDiff(t, m.BootConfig, m.Key) } } } @@ -1009,3 +1060,96 @@ func tallyFilesystemChange(t *diffTally, fs *FilesystemChange) { } } } + +func tallyBootloaderConfigDiff(t *diffTally, diff *BootloaderConfigDiff, efiKey string) { + if diff == nil { + return + } + + // Config file changes are meaningful (actual bootloader configuration changed) + for _, cf := range diff.ConfigFileChanges { + switch cf.Status { + case "added": + t.addMeaningful(1, "BootConfig["+efiKey+"] config file added: "+cf.Path) + case "removed": + t.addMeaningful(1, "BootConfig["+efiKey+"] config file removed: "+cf.Path) + case "modified": + t.addMeaningful(1, "BootConfig["+efiKey+"] config file modified: "+cf.Path) + } + } + + // Boot entry changes are meaningful (boot menu changed) + for _, be := range diff.BootEntryChanges { + switch be.Status { + case "added": + t.addMeaningful(1, "BootConfig["+efiKey+"] boot entry added: "+be.Name) + case "removed": + t.addMeaningful(1, "BootConfig["+efiKey+"] boot entry removed: "+be.Name) + case "modified": + // Check if kernel path or cmdline actually changed (meaningful) + // vs just the display name changed + if be.KernelFrom != be.KernelTo { + t.addMeaningful(1, "BootConfig["+efiKey+"] boot entry kernel changed: "+be.Name) + } else if be.InitrdFrom != be.InitrdTo { + t.addMeaningful(1, "BootConfig["+efiKey+"] boot entry initrd changed: "+be.Name) + } else if normalizeKernelCmdline(be.CmdlineFrom) != normalizeKernelCmdline(be.CmdlineTo) { + t.addMeaningful(1, "BootConfig["+efiKey+"] boot entry cmdline changed: "+be.Name) + } else { + // Only cosmetic/metadata changes + t.addVolatile(1, "BootConfig["+efiKey+"] boot entry metadata changed: "+be.Name) + } + } + } + + // Kernel reference changes + for _, kr := range diff.KernelRefChanges { + switch kr.Status { + case "added": + t.addMeaningful(1, "BootConfig["+efiKey+"] kernel ref added: "+kr.Path) + case "removed": + t.addMeaningful(1, "BootConfig["+efiKey+"] kernel ref removed: "+kr.Path) + case "modified": + // UUID change is typically volatile (regenerated each build) + if kr.UUIDFrom != kr.UUIDTo { + t.addVolatile(1, "BootConfig["+efiKey+"] kernel ref UUID changed: "+kr.Path) + } else { + t.addMeaningful(1, "BootConfig["+efiKey+"] kernel ref modified: "+kr.Path) + } + } + } + + // UUID reference changes - typically volatile (UUIDs regenerate) + for _, ur := range diff.UUIDReferenceChanges { + switch ur.Status { + case "added": + // New UUID reference found - could be meaningful or volatile depending on context + if ur.MismatchTo { + // UUID mismatch is a potential issue - meaningful + t.addMeaningful(1, "BootConfig["+efiKey+"] UUID ref mismatch added: "+ur.UUID) + } else { + t.addVolatile(1, "BootConfig["+efiKey+"] UUID ref added: "+ur.UUID) + } + case "removed": + if ur.MismatchFrom { + t.addMeaningful(1, "BootConfig["+efiKey+"] UUID ref mismatch removed: "+ur.UUID) + } else { + t.addVolatile(1, "BootConfig["+efiKey+"] UUID ref removed: "+ur.UUID) + } + case "modified": + // UUID context changed - typically volatile unless introducing/fixing mismatch + if ur.MismatchFrom != ur.MismatchTo { + t.addMeaningful(1, "BootConfig["+efiKey+"] UUID ref mismatch status changed: "+ur.UUID) + } else { + t.addVolatile(1, "BootConfig["+efiKey+"] UUID ref context changed: "+ur.UUID) + } + } + } + + // Notes changes are informational - count as volatile + if len(diff.NotesAdded) > 0 { + t.addVolatile(len(diff.NotesAdded), "BootConfig["+efiKey+"] notes added") + } + if len(diff.NotesRemoved) > 0 { + t.addVolatile(len(diff.NotesRemoved), "BootConfig["+efiKey+"] notes removed") + } +} diff --git a/internal/image/imageinspect/compare_efi.go b/internal/image/imageinspect/compare_efi.go index 35b4afa8..7353ca63 100644 --- a/internal/image/imageinspect/compare_efi.go +++ b/internal/image/imageinspect/compare_efi.go @@ -102,6 +102,7 @@ func compareEFIBinaries(from, to []EFIBinaryEvidence) EFIBinaryDiff { } mod.Changes = appendEFIBinaryFieldChanges(nil, f, t) mod.UKI = buildUKIDiffIfRelevant(f, t) + mod.BootConfig = compareBootloaderConfigs(f.BootConfig, t.BootConfig) out.Modified = append(out.Modified, mod) } } @@ -251,3 +252,321 @@ func diffStringMap(a, b map[string]string) SectionMapDiff { return out } + +// compareBootloaderConfigs compares bootloader configuration between two EFI binaries. +// It returns a BootloaderConfigDiff containing detected changes. +func compareBootloaderConfigs(a, b *BootloaderConfig) *BootloaderConfigDiff { + if a == nil && b == nil { + return nil + } + + diff := &BootloaderConfigDiff{ + ConfigFileChanges: []ConfigFileChange{}, + BootEntryChanges: []BootEntryChange{}, + KernelRefChanges: []KernelRefChange{}, + UUIDReferenceChanges: []UUIDRefChange{}, + NotesAdded: []string{}, + NotesRemoved: []string{}, + } + + // Handle nil cases + if a == nil { + a = &BootloaderConfig{} + } + if b == nil { + b = &BootloaderConfig{} + } + + // Compare config files + diff.ConfigFileChanges = compareConfigFiles(a.ConfigFiles, b.ConfigFiles) + + // Compare boot entries + diff.BootEntryChanges = compareBootEntries(a.BootEntries, b.BootEntries) + + // Compare kernel references + diff.KernelRefChanges = compareKernelReferences(a.KernelReferences, b.KernelReferences) + + // Compare UUID references + diff.UUIDReferenceChanges = compareUUIDReferences(a.UUIDReferences, b.UUIDReferences) + + // Compare issues + diff.NotesRemoved = findRemovedStrings(a.Notes, b.Notes) + diff.NotesAdded = findRemovedStrings(b.Notes, a.Notes) + + // Check if anything actually changed + if len(diff.ConfigFileChanges) == 0 && + len(diff.BootEntryChanges) == 0 && + len(diff.KernelRefChanges) == 0 && + len(diff.UUIDReferenceChanges) == 0 && + len(diff.NotesAdded) == 0 && + len(diff.NotesRemoved) == 0 { + return nil + } + + return diff +} + +// compareConfigFiles compares bootloader config file hashes. +func compareConfigFiles(a, b map[string]string) []ConfigFileChange { + changes := []ConfigFileChange{} + + pathsA := sortedMapKeys(a) + pathsB := sortedMapKeys(b) + + allPaths := mergeStrings(pathsA, pathsB) + + for _, path := range allPaths { + hashA := a[path] + hashB := b[path] + + switch { + case hashA != "" && hashB == "": + changes = append(changes, ConfigFileChange{ + Path: path, + Status: "removed", + HashFrom: hashA, + }) + case hashA == "" && hashB != "": + changes = append(changes, ConfigFileChange{ + Path: path, + Status: "added", + HashTo: hashB, + }) + case hashA != "" && hashB != "" && hashA != hashB: + changes = append(changes, ConfigFileChange{ + Path: path, + Status: "modified", + HashFrom: hashA, + HashTo: hashB, + }) + } + } + + return changes +} + +// compareBootEntries compares boot menu entries. +func compareBootEntries(a, b []BootEntry) []BootEntryChange { + changes := []BootEntryChange{} + + aMap := bootEntryMapByName(a) + bMap := bootEntryMapByName(b) + + allNames := mergeStrings(sortedMapKeys(aMap), sortedMapKeys(bMap)) + + for _, name := range allNames { + entryA := aMap[name] + entryB := bMap[name] + + switch { + case entryA != nil && entryB == nil: + changes = append(changes, BootEntryChange{ + Name: name, + Status: "removed", + KernelFrom: entryA.Kernel, + InitrdFrom: entryA.Initrd, + CmdlineFrom: entryA.Cmdline, + }) + case entryA == nil && entryB != nil: + changes = append(changes, BootEntryChange{ + Name: name, + Status: "added", + KernelTo: entryB.Kernel, + InitrdTo: entryB.Initrd, + CmdlineTo: entryB.Cmdline, + }) + case entryA != nil && entryB != nil: + // Check for changes within the entry + if entryA.Kernel != entryB.Kernel || entryA.Initrd != entryB.Initrd || entryA.Cmdline != entryB.Cmdline { + changes = append(changes, BootEntryChange{ + Name: name, + Status: "modified", + KernelFrom: entryA.Kernel, + KernelTo: entryB.Kernel, + InitrdFrom: entryA.Initrd, + InitrdTo: entryB.Initrd, + CmdlineFrom: entryA.Cmdline, + CmdlineTo: entryB.Cmdline, + }) + } + } + } + + return changes +} + +// compareKernelReferences compares kernel references in bootloader config. +func compareKernelReferences(a, b []KernelReference) []KernelRefChange { + changes := []KernelRefChange{} + + aMap := kernelRefMapByPath(a) + bMap := kernelRefMapByPath(b) + + allPaths := mergeStrings(sortedMapKeys(aMap), sortedMapKeys(bMap)) + + for _, path := range allPaths { + refA := aMap[path] + refB := bMap[path] + + switch { + case refA != nil && refB == nil: + changes = append(changes, KernelRefChange{ + Path: path, + Status: "removed", + UUIDFrom: refA.PartitionUUID, + }) + case refA == nil && refB != nil: + changes = append(changes, KernelRefChange{ + Path: path, + Status: "added", + UUIDTo: refB.PartitionUUID, + }) + case refA != nil && refB != nil: + if refA.PartitionUUID != refB.PartitionUUID { + changes = append(changes, KernelRefChange{ + Path: path, + Status: "modified", + UUIDFrom: refA.PartitionUUID, + UUIDTo: refB.PartitionUUID, + }) + } + } + } + + return changes +} + +// compareUUIDReferences compares UUID references in bootloader config. +func compareUUIDReferences(a, b []UUIDReference) []UUIDRefChange { + changes := []UUIDRefChange{} + + aMap := uuidRefMapByUUID(a) + bMap := uuidRefMapByUUID(b) + + allUUIDs := mergeStrings(sortedMapKeys(aMap), sortedMapKeys(bMap)) + + for _, uuid := range allUUIDs { + refA := aMap[uuid] + refB := bMap[uuid] + + switch { + case refA != nil && refB == nil: + changes = append(changes, UUIDRefChange{ + UUID: uuid, + Status: "removed", + ContextFrom: refA.Context, + MismatchFrom: refA.Mismatch, + }) + case refA == nil && refB != nil: + changes = append(changes, UUIDRefChange{ + UUID: uuid, + Status: "added", + ContextTo: refB.Context, + MismatchTo: refB.Mismatch, + }) + case refA != nil && refB != nil: + if refA.Mismatch != refB.Mismatch || refA.Context != refB.Context { + changes = append(changes, UUIDRefChange{ + UUID: uuid, + Status: "modified", + ContextFrom: refA.Context, + ContextTo: refB.Context, + MismatchFrom: refA.Mismatch, + MismatchTo: refB.Mismatch, + }) + } + } + } + + return changes +} + +// Helper functions for comparison + +func bootEntryMapByName(entries []BootEntry) map[string]*BootEntry { + m := make(map[string]*BootEntry) + for i := range entries { + m[entries[i].Name] = &entries[i] + } + return m +} + +func kernelRefMapByPath(refs []KernelReference) map[string]*KernelReference { + m := make(map[string]*KernelReference) + for i := range refs { + m[refs[i].Path] = &refs[i] + } + return m +} + +func uuidRefMapByUUID(refs []UUIDReference) map[string]*UUIDReference { + m := make(map[string]*UUIDReference) + for i := range refs { + m[refs[i].UUID] = &refs[i] + } + return m +} + +func sortedMapKeys(m interface{}) []string { + switch v := m.(type) { + case map[string]string: + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + return keys + case map[string]*BootEntry: + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + return keys + case map[string]*KernelReference: + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + return keys + case map[string]*UUIDReference: + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + return keys + } + return nil +} + +func mergeStrings(slices ...[]string) []string { + seen := make(map[string]struct{}) + var result []string + for _, slice := range slices { + for _, s := range slice { + if _, ok := seen[s]; !ok { + seen[s] = struct{}{} + result = append(result, s) + } + } + } + sort.Strings(result) + return result +} + +func findRemovedStrings(old, new []string) []string { + newSet := make(map[string]struct{}) + for _, s := range new { + newSet[s] = struct{}{} + } + + var removed []string + for _, s := range old { + if _, ok := newSet[s]; !ok { + removed = append(removed, s) + } + } + return removed +} diff --git a/internal/image/imageinspect/compare_test.go b/internal/image/imageinspect/compare_test.go index ce24784b..9d2969f0 100644 --- a/internal/image/imageinspect/compare_test.go +++ b/internal/image/imageinspect/compare_test.go @@ -1,6 +1,7 @@ package imageinspect import ( + "strings" "testing" ) @@ -975,3 +976,205 @@ func TestCompareImages_EFISigningStatusChanges(t *testing.T) { t.Fatalf("expected signed false->true, got %v->%v", res.Diff.EFIBinaries.Modified[0].From.Signed, res.Diff.EFIBinaries.Modified[0].To.Signed) } } +func TestCompareUUIDReferences_Branches(t *testing.T) { + from := []UUIDReference{ + {UUID: "00000000-0000-0000-0000-000000000001", Context: "kernel_cmdline", Mismatch: false}, // removed + {UUID: "00000000-0000-0000-0000-000000000002", Context: "kernel_cmdline", Mismatch: false}, // mismatch changed + {UUID: "00000000-0000-0000-0000-000000000003", Context: "grub_search", Mismatch: false}, // context changed only + {UUID: "00000000-0000-0000-0000-000000000004", Context: "root_device", Mismatch: true}, // unchanged + } + to := []UUIDReference{ + {UUID: "00000000-0000-0000-0000-000000000002", Context: "root_device", Mismatch: true}, + {UUID: "00000000-0000-0000-0000-000000000003", Context: "kernel_cmdline", Mismatch: false}, + {UUID: "00000000-0000-0000-0000-000000000004", Context: "root_device", Mismatch: true}, + {UUID: "00000000-0000-0000-0000-000000000005", Context: "kernel_cmdline", Mismatch: false}, // added + } + + changes := compareUUIDReferences(from, to) + if len(changes) != 4 { + t.Fatalf("expected 4 UUID reference changes, got %d: %+v", len(changes), changes) + } + + byUUID := map[string]UUIDRefChange{} + for _, ch := range changes { + byUUID[ch.UUID] = ch + } + + removed, ok := byUUID["00000000-0000-0000-0000-000000000001"] + if !ok || removed.Status != "removed" || removed.ContextFrom != "kernel_cmdline" || removed.MismatchFrom { + t.Fatalf("unexpected removed UUID change: %+v", removed) + } + + added, ok := byUUID["00000000-0000-0000-0000-000000000005"] + if !ok || added.Status != "added" || added.ContextTo != "kernel_cmdline" || added.MismatchTo { + t.Fatalf("unexpected added UUID change: %+v", added) + } + + mismatchChanged, ok := byUUID["00000000-0000-0000-0000-000000000002"] + if !ok || mismatchChanged.Status != "modified" || mismatchChanged.MismatchFrom != false || mismatchChanged.MismatchTo != true { + t.Fatalf("unexpected mismatch-changed UUID entry: %+v", mismatchChanged) + } + // When both mismatch and context change, the single entry must carry both pieces. + if mismatchChanged.ContextFrom != "kernel_cmdline" || mismatchChanged.ContextTo != "root_device" { + t.Fatalf("expected context change to be captured when mismatch also differs: %+v", mismatchChanged) + } + + contextChanged, ok := byUUID["00000000-0000-0000-0000-000000000003"] + if !ok || contextChanged.Status != "modified" || contextChanged.ContextFrom != "grub_search" || contextChanged.ContextTo != "kernel_cmdline" { + t.Fatalf("unexpected context-changed UUID entry: %+v", contextChanged) + } +} + +func TestUkiOnlyVolatile_Branches(t *testing.T) { + tests := []struct { + name string + mod ModifiedEFIBinaryEvidence + want bool + }{ + { + name: "kernel hash changed is meaningful", + mod: ModifiedEFIBinaryEvidence{ + UKI: &UKIDiff{KernelSHA256: &ValueDiff[string]{From: "k1", To: "k2"}, Changed: true}, + }, + want: false, + }, + { + name: "uname hash changed is meaningful", + mod: ModifiedEFIBinaryEvidence{ + UKI: &UKIDiff{UnameSHA256: &ValueDiff[string]{From: "u1", To: "u2"}, Changed: true}, + }, + want: false, + }, + { + name: "cmdline changed only by UUID is volatile", + mod: ModifiedEFIBinaryEvidence{ + From: EFIBinaryEvidence{Cmdline: "root=UUID=11111111-1111-1111-1111-111111111111 ro quiet"}, + To: EFIBinaryEvidence{Cmdline: "root=UUID=22222222-2222-2222-2222-222222222222 ro quiet"}, + UKI: &UKIDiff{CmdlineSHA256: &ValueDiff[string]{From: "c1", To: "c2"}, Changed: true}, + }, + want: true, + }, + { + name: "cmdline semantic change is meaningful", + mod: ModifiedEFIBinaryEvidence{ + From: EFIBinaryEvidence{Cmdline: "root=UUID=11111111-1111-1111-1111-111111111111 ro quiet"}, + To: EFIBinaryEvidence{Cmdline: "root=UUID=22222222-2222-2222-2222-222222222222 rw quiet"}, + UKI: &UKIDiff{CmdlineSHA256: &ValueDiff[string]{From: "c1", To: "c2"}, Changed: true}, + }, + want: false, + }, + { + name: "no uki details defaults volatile", + mod: ModifiedEFIBinaryEvidence{}, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := ukiOnlyVolatile(tc.mod) + if got != tc.want { + t.Fatalf("expected %v, got %v", tc.want, got) + } + }) + } +} +func TestCompareVerity_NilCasesAndFieldChanges(t *testing.T) { + if got := compareVerity(nil, nil); got != nil { + t.Fatalf("expected nil diff when both verity values are nil") + } + + added := compareVerity(nil, &VerityInfo{Enabled: true, Method: "systemd-verity", RootDevice: "/dev/vda2", HashPartition: 3}) + if added == nil || !added.Changed || added.Added == nil { + t.Fatalf("expected added verity diff") + } + + removed := compareVerity(&VerityInfo{Enabled: true, Method: "systemd-verity"}, nil) + if removed == nil || !removed.Changed || removed.Removed == nil { + t.Fatalf("expected removed verity diff") + } + + from := &VerityInfo{ + Enabled: true, + Method: "systemd-verity", + RootDevice: "/dev/vda2", + HashPartition: 3, + } + to := &VerityInfo{ + Enabled: false, + Method: "custom-initramfs", + RootDevice: "/dev/vda3", + HashPartition: 4, + } + + changed := compareVerity(from, to) + if changed == nil || !changed.Changed { + t.Fatalf("expected changed verity diff") + } + if changed.Enabled == nil || changed.Enabled.From != true || changed.Enabled.To != false { + t.Fatalf("expected enabled diff to be set") + } + if changed.Method == nil || changed.Method.From != "systemd-verity" || changed.Method.To != "custom-initramfs" { + t.Fatalf("expected method diff to be set") + } + if changed.RootDevice == nil || changed.RootDevice.From != "/dev/vda2" || changed.RootDevice.To != "/dev/vda3" { + t.Fatalf("expected root device diff to be set") + } + if changed.HashPartition == nil || changed.HashPartition.From != 3 || changed.HashPartition.To != 4 { + t.Fatalf("expected hash partition diff to be set") + } +} + +func TestTallyBootloaderConfigDiff_ClassificationCoverage(t *testing.T) { + tally := &diffTally{} + d := &BootloaderConfigDiff{ + ConfigFileChanges: []ConfigFileChange{ + {Path: "/boot/grub/grub.cfg", Status: "added"}, + {Path: "/loader/entries/old.conf", Status: "removed"}, + {Path: "/loader/entries/default.conf", Status: "modified"}, + }, + BootEntryChanges: []BootEntryChange{ + {Name: "entry-added", Status: "added"}, + {Name: "entry-removed", Status: "removed"}, + {Name: "entry-kernel", Status: "modified", KernelFrom: "/vmlinuz-a", KernelTo: "/vmlinuz-b"}, + {Name: "entry-initrd", Status: "modified", KernelFrom: "/vmlinuz", KernelTo: "/vmlinuz", InitrdFrom: "/initrd-a", InitrdTo: "/initrd-b"}, + {Name: "entry-cmdline", Status: "modified", KernelFrom: "/vmlinuz", KernelTo: "/vmlinuz", InitrdFrom: "/initrd", InitrdTo: "/initrd", CmdlineFrom: "root=UUID=11111111-1111-1111-1111-111111111111 ro", CmdlineTo: "root=UUID=22222222-2222-2222-2222-222222222222 rw"}, + {Name: "entry-meta", Status: "modified", KernelFrom: "/vmlinuz", KernelTo: "/vmlinuz", InitrdFrom: "/initrd", InitrdTo: "/initrd", CmdlineFrom: " root=UUID=aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa ro ", CmdlineTo: "root=UUID=bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb ro"}, + }, + KernelRefChanges: []KernelRefChange{ + {Path: "EFI/Linux/new.efi", Status: "added"}, + {Path: "EFI/Linux/old.efi", Status: "removed"}, + {Path: "EFI/Linux/uuid.efi", Status: "modified", UUIDFrom: "u1", UUIDTo: "u2"}, + {Path: "EFI/Linux/meta.efi", Status: "modified", UUIDFrom: "same", UUIDTo: "same"}, + }, + UUIDReferenceChanges: []UUIDRefChange{ + {UUID: "a", Status: "added", MismatchTo: true}, + {UUID: "b", Status: "added", MismatchTo: false}, + {UUID: "c", Status: "removed", MismatchFrom: true}, + {UUID: "d", Status: "removed", MismatchFrom: false}, + {UUID: "e", Status: "modified", MismatchFrom: false, MismatchTo: true}, + {UUID: "f", Status: "modified", MismatchFrom: false, MismatchTo: false}, + }, + NotesAdded: []string{"note-a", "note-b"}, + NotesRemoved: []string{"note-c"}, + } + + tallyBootloaderConfigDiff(tally, d, "EFI/BOOT/BOOTX64.EFI") + + if tally.meaningful != 14 { + t.Fatalf("expected meaningful=14, got %d (reasons=%v)", tally.meaningful, tally.mReasons) + } + if tally.volatile != 8 { + t.Fatalf("expected volatile=8, got %d (reasons=%v)", tally.volatile, tally.vReasons) + } + + meaningfulJoined := strings.Join(tally.mReasons, "\n") + volatileJoined := strings.Join(tally.vReasons, "\n") + + if !strings.Contains(meaningfulJoined, "boot entry initrd changed: entry-initrd") { + t.Fatalf("expected initrd change to be classified meaningful, reasons=%v", tally.mReasons) + } + if !strings.Contains(volatileJoined, "boot entry metadata changed: entry-meta") { + t.Fatalf("expected metadata-only boot entry change to be classified volatile, reasons=%v", tally.vReasons) + } +} diff --git a/internal/image/imageinspect/fs_raw.go b/internal/image/imageinspect/fs_raw.go index 29228683..1182dc15 100755 --- a/internal/image/imageinspect/fs_raw.go +++ b/internal/image/imageinspect/fs_raw.go @@ -1,9 +1,7 @@ package imageinspect import ( - "crypto/sha256" "encoding/binary" - "encoding/hex" "fmt" "io" "os" @@ -143,16 +141,423 @@ func scanAndHashEFIFromRawFAT(r io.ReaderAt, partOff int64, out *FilesystemSumma out.HasShim = out.HasShim || hasShim out.HasUKI = out.HasUKI || hasUKI + + // Extract bootloader configuration for known bootloader types + for i := range out.EFIBinaries { + efi := &out.EFIBinaries[i] + switch efi.Kind { + case BootloaderGrub, BootloaderSystemdBoot: + // Try to extract config files + efi.BootConfig = extractBootloaderConfigFromFAT(v, efi.Kind) + // For systemd-boot on UKI systems, also synthesize boot config from UKI + if efi.Kind == BootloaderSystemdBoot && out.HasUKI && efi.BootConfig != nil && len(efi.BootConfig.ConfigFiles) == 0 { + // No loader.conf found on UKI system; synthesize from UKI cmdline + for _, uki := range out.EFIBinaries { + if uki.IsUKI && uki.Cmdline != "" { + // Create synthetic boot config from UKI + efi.BootConfig = synthesizeBootConfigFromUKI(&uki) + break + } + } + } + } + } + return nil } -// sha256Hex returns the SHA256 hash of the given byte slice as a hex string. -func sha256Hex(b []byte) string { - h := sha256.Sum256(b) - return hex.EncodeToString(h[:]) +// synthesizeBootConfigFromUKI creates a BootloaderConfig from a UKI binary's cmdline. +// This is used for UKI-based systems that don't have a separate loader.conf file. +func synthesizeBootConfigFromUKI(uki *EFIBinaryEvidence) *BootloaderConfig { + cfg := &BootloaderConfig{ + ConfigFiles: make(map[string]string), + ConfigRaw: make(map[string]string), + KernelReferences: []KernelReference{}, + BootEntries: []BootEntry{}, + UUIDReferences: []UUIDReference{}, + Notes: []string{}, + } + + if uki == nil || uki.Cmdline == "" { + cfg.Notes = append(cfg.Notes, "No UKI cmdline available for boot config synthesis") + return cfg + } + + // Store the UKI cmdline as ConfigRaw + cfg.ConfigRaw["uki_cmdline"] = uki.Cmdline + + // Parse the UKI cmdline to extract boot parameters + // Create a synthetic boot entry for the UKI + entry := BootEntry{ + Name: "UKI Boot Entry", + Kernel: uki.Path, + Cmdline: uki.Cmdline, + IsDefault: true, + UKIPath: uki.Path, + } + + // Extract root= and UUIDs from cmdline + for _, token := range strings.Fields(uki.Cmdline) { + if strings.HasPrefix(token, "root=") { + entry.RootDevice = strings.TrimPrefix(token, "root=") + entry.RootDevice = strings.Trim(entry.RootDevice, `"'`) + // Extract UUID if present + for _, u := range extractUUIDsFromString(entry.RootDevice) { + entry.PartitionUUID = u + cfg.UUIDReferences = append(cfg.UUIDReferences, UUIDReference{UUID: u, Context: "uki_cmdline"}) + } + } else if strings.HasPrefix(token, "boot_uuid=") { + // boot_uuid parameter points to the root filesystem UUID + id := strings.TrimPrefix(token, "boot_uuid=") + id = strings.Trim(id, `"'`) + for _, u := range extractUUIDsFromString(id) { + cfg.UUIDReferences = append(cfg.UUIDReferences, UUIDReference{UUID: u, Context: "uki_boot_uuid"}) + } + } + } + + cfg.BootEntries = append(cfg.BootEntries, entry) + + // Create kernel reference + kernRef := KernelReference{ + Path: uki.Path, + BootEntry: entry.Name, + RootUUID: entry.RootDevice, + } + if entry.PartitionUUID != "" { + kernRef.PartitionUUID = entry.PartitionUUID + } + cfg.KernelReferences = append(cfg.KernelReferences, kernRef) + + // Note that this is a synthesized config + cfg.Notes = append(cfg.Notes, fmt.Sprintf("Boot configuration extracted from UKI binary %s (no loader.conf found)", uki.Path)) + + return cfg +} + +// extractBootloaderConfigFromFAT attempts to read and parse bootloader config files +// from the FAT filesystem for the given bootloader kind. +func extractBootloaderConfigFromFAT(v *fatVol, kind BootloaderKind) *BootloaderConfig { + cfg := &BootloaderConfig{ + ConfigFiles: make(map[string]string), + ConfigRaw: make(map[string]string), + KernelReferences: []KernelReference{}, + BootEntries: []BootEntry{}, + UUIDReferences: []UUIDReference{}, + Notes: []string{}, + } + + // Generate candidate config paths based on filesystem layout and bootloader kind + configPaths := generateBootloaderConfigPaths(v, kind) + if len(configPaths) == 0 { + return nil + } + + // Try to read each config file + for _, cfgPath := range configPaths { + content, err := readFileFromFAT(v, cfgPath) + if err == nil && content != "" { + // Calculate hash + hash := hashBytesHex([]byte(content)) + cfg.ConfigFiles[cfgPath] = hash + + // Store raw content (truncated if large) + if len(content) > 10240 { + cfg.ConfigRaw[cfgPath] = content[:10240] + "\n[truncated...]" + } else { + cfg.ConfigRaw[cfgPath] = content + } + + // Parse based on bootloader kind + switch kind { + case BootloaderGrub: + parsed := parseGrubConfigContent(content) + cfg.BootEntries = parsed.BootEntries + cfg.KernelReferences = parsed.KernelReferences + cfg.UUIDReferences = parsed.UUIDReferences + cfg.Notes = append(cfg.Notes, parsed.Notes...) + // Don't overwrite ConfigRaw since we set it above + cfg.DefaultEntry = parsed.DefaultEntry + case BootloaderSystemdBoot: + parsed := parseSystemdBootEntries(content) + cfg.BootEntries = parsed.BootEntries + cfg.DefaultEntry = parsed.DefaultEntry + cfg.UUIDReferences = parsed.UUIDReferences + cfg.Notes = append(cfg.Notes, parsed.Notes...) + } + + break // Found config file, stop trying alternatives + } else if err != nil { + if !strings.Contains(err.Error(), "does not exist") && !strings.Contains(err.Error(), "not found") { + // Only report non-file-not-found errors + cfg.Notes = append(cfg.Notes, fmt.Sprintf("Failed to read %s: %v", cfgPath, err)) + } + } + } + + // If no config found with hardcoded paths, try dynamic search in /EFI/*/grub.cfg + if len(cfg.ConfigFiles) == 0 && kind == BootloaderGrub { + if dynamicPath, dynamicContent := searchBootloaderConfigInEFI(v, "grub.cfg"); dynamicPath != "" && dynamicContent != "" { + hash := hashBytesHex([]byte(dynamicContent)) + cfg.ConfigFiles[dynamicPath] = hash + + if len(dynamicContent) > 10240 { + cfg.ConfigRaw[dynamicPath] = dynamicContent[:10240] + "\n[truncated...]" + } else { + cfg.ConfigRaw[dynamicPath] = dynamicContent + } + + parsed := parseGrubConfigContent(dynamicContent) + cfg.BootEntries = parsed.BootEntries + cfg.KernelReferences = parsed.KernelReferences + cfg.UUIDReferences = parsed.UUIDReferences + cfg.Notes = append(cfg.Notes, parsed.Notes...) + cfg.DefaultEntry = parsed.DefaultEntry + } + } + + // If no config file found, add note (not an error, might be acceptable for minimal configs) + if len(cfg.ConfigFiles) == 0 { + switch kind { + case BootloaderSystemdBoot: + cfg.Notes = append(cfg.Notes, "No systemd-boot configuration file found (may be normal for UKI-based systems)") + case BootloaderGrub: + cfg.Notes = append(cfg.Notes, "No GRUB configuration file found on ESP. Some distributions may store GRUB config on the root partition (/boot/grub/grub.cfg)") + } + } + + return cfg +} + +// searchBootloaderConfigInEFI dynamically searches for a bootloader config file +// in any subdirectory of /EFI/, including nested directories. +// Returns the path and content if found, or empty strings if not found. +func searchBootloaderConfigInEFI(v *fatVol, filename string) (string, string) { + // First try /EFI/ root level + if content, err := readFileFromFAT(v, fmt.Sprintf("/EFI/%s", filename)); err == nil && content != "" { + return fmt.Sprintf("/EFI/%s", filename), content + } + + // List /EFI directory + efiEntries, err := v.listDir("EFI") + if err != nil { + return "", "" + } + + // Try each subdirectory in /EFI/ (one level deep) + for _, entry := range efiEntries { + if !entry.isDir || entry.name == "." || entry.name == ".." { + continue + } + + // Try to read the file in this subdirectory + testPath := fmt.Sprintf("/EFI/%s/%s", entry.name, filename) + if content, err := readFileFromFAT(v, testPath); err == nil && content != "" { + return testPath, content + } + + // Also search nested directories within /EFI/[name]/ + // This handles cases like /EFI/BOOT/x64-efi/grub.cfg + if nestedPath, nestedContent := searchBootloaderConfigInNestedDir(v, fmt.Sprintf("EFI/%s", entry.name), filename); nestedPath != "" { + return nestedPath, nestedContent + } + } + + // If no grub.cfg found, try alternative names like grub-efi.cfg or grubx64.cfg.signed + if strings.Contains(filename, "grub") { + alternativeNames := []string{"grub-efi.cfg", "grubx64.cfg", "grub.cfg.signed"} + for _, altName := range alternativeNames { + // Try in each subdirectory + for _, entry := range efiEntries { + if !entry.isDir || entry.name == "." || entry.name == ".." { + continue + } + testPath := fmt.Sprintf("/EFI/%s/%s", entry.name, altName) + if content, err := readFileFromFAT(v, testPath); err == nil && content != "" { + return testPath, content + } + // Also search nested + if nestedPath, nestedContent := searchBootloaderConfigInNestedDir(v, fmt.Sprintf("EFI/%s", entry.name), altName); nestedPath != "" { + return nestedPath, nestedContent + } + } + } + } + + return "", "" +} + +// searchBootloaderConfigInNestedDir recursively searches within a directory for a config file +func searchBootloaderConfigInNestedDir(v *fatVol, dirPath, filename string) (string, string) { + // Avoid infinite recursion - limit to 3 levels deep + depth := strings.Count(dirPath, "/") + if depth > 4 { // EFI is level 1, subdirs are 2+ + return "", "" + } + + entries, err := v.listDir(dirPath) + if err != nil { + return "", "" + } + + for _, entry := range entries { + if entry.name == "." || entry.name == ".." { + continue + } + + if !entry.isDir { + // Check if this is our target file (case-insensitive) + if strings.EqualFold(entry.name, filename) { + testPath := fmt.Sprintf("/%s/%s", dirPath, entry.name) + if content, err := readFileFromFAT(v, testPath); err == nil && content != "" { + return testPath, content + } + } + continue + } + + // Recursively search in subdirectories + nestedPath := fmt.Sprintf("%s/%s", dirPath, entry.name) + if foundPath, foundContent := searchBootloaderConfigInNestedDir(v, nestedPath, filename); foundPath != "" { + return foundPath, foundContent + } + } + + return "", "" +} + +// readFileFromFAT reads a file from the FAT filesystem by path. +// Returns the file content as a string, or an error if the file cannot be read. +func readFileFromFAT(v *fatVol, filePath string) (string, error) { + // Normalize path + filePath = strings.TrimPrefix(filePath, "/") + filePath = strings.ReplaceAll(filePath, "\\", "/") + + parts := strings.Split(filePath, "/") + if len(parts) == 0 { + return "", fmt.Errorf("invalid file path") + } + + // Navigate through directory structure + for i, part := range parts { + if part == "" { + continue + } + + // Determine which directory to list + // For the first part, list the root; for subsequent parts, list the path so far + var dirPath string + if i == 0 { + dirPath = "" // List root to find first part + } else { + dirPath = strings.Join(parts[:i], "/") // List accumulated path + } + + entries, err := v.listDir(dirPath) + if err != nil { + return "", err + } + + // Find matching entry (case-insensitive) + found := false + var entry fatDirEntry + for _, e := range entries { + if strings.EqualFold(e.name, part) { + entry = e + found = true + break + } + } + + if !found { + return "", fmt.Errorf("file not found: %s", filePath) + } + + // Last part - if this is a file, read it + if i == len(parts)-1 && !entry.isDir { + content, _, err := v.readFileByEntry(&entry) + if err != nil { + return "", err + } + return string(content), nil + } + } + + return "", fmt.Errorf("file not found: %s", filePath) +} + +// generateBootloaderConfigPaths builds a prioritized list of candidate configuration +// file paths for a given `kind` on the provided FAT volume. It prefers files +// under /EFI/* (inspecting actual subdirectories) and falls back to common +// /boot locations. Returned paths are normalized (leading '/') and deduplicated +// while preserving order. +func generateBootloaderConfigPaths(v *fatVol, kind BootloaderKind) []string { + seen := map[string]struct{}{} + var out []string + add := func(p string) { + if p == "" { + return + } + // normalize + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + if _, ok := seen[p]; ok { + return + } + seen[p] = struct{}{} + out = append(out, p) + } + + // Inspect /EFI directory to discover vendor-specific subdirs + var efiSubdirs []string + if ents, err := v.listDir("EFI"); err == nil { + for _, e := range ents { + if e.isDir { + efiSubdirs = append(efiSubdirs, e.name) + } + } + } + + switch kind { + case BootloaderGrub: + // Prefer vendor-specific locations under /EFI//*.cfg + for _, d := range efiSubdirs { + // common candidate names + add(path.Join("EFI", d, "grub.cfg")) + add(path.Join("EFI", d, "grub-efi.cfg")) + add(path.Join("EFI", d, "grubx64.cfg")) + add(path.Join("EFI", d, "grub.cfg.signed")) + // nested possibilities + add(path.Join("EFI", d, "grub", "grub.cfg")) + add(path.Join("EFI", d, "boot", "grub.cfg")) + } + // Common ESP-wide locations + add("EFI/BOOT/grub.cfg") + add("EFI/boot/grub.cfg") + add("EFI/grub/grub.cfg") + + // Fallbacks on possible non-ESP /boot locations + add("/grub/grub.cfg") + add("/boot/grub/grub.cfg") + add("/boot/grub2/grub.cfg") + + case BootloaderSystemdBoot: + // Prefer loader.conf under /loader and vendor dirs + add("loader/loader.conf") + add("EFI/systemd/loader.conf") + for _, d := range efiSubdirs { + add(path.Join("EFI", d, "loader.conf")) + add(path.Join("EFI", d, "loader", "loader.conf")) + } + add("/boot/loader.conf") + default: + // Unknown bootloader + } + + return out } -// readFileByEntry reads the contents of the file represented by the given fatDirEntry. func (v *fatVol) readFileByEntry(e *fatDirEntry) ([]byte, int64, error) { remaining := int64(e.size) var out []byte diff --git a/internal/image/imageinspect/helpers_test.go b/internal/image/imageinspect/helpers_test.go index 0d725c8f..f55acf91 100644 --- a/internal/image/imageinspect/helpers_test.go +++ b/internal/image/imageinspect/helpers_test.go @@ -203,7 +203,7 @@ func TestParseOSRelease_Empty(t *testing.T) { func TestSHA256Hex_Format(t *testing.T) { data := []byte("test data") - hash := sha256Hex(data) + hash := hashBytesHex(data) if len(hash) != 64 { t.Fatalf("expected 64 char hash, got %d", len(hash)) } diff --git a/internal/image/imageinspect/imageinspect.go b/internal/image/imageinspect/imageinspect.go index 380734ee..4a5ca79b 100755 --- a/internal/image/imageinspect/imageinspect.go +++ b/internal/image/imageinspect/imageinspect.go @@ -151,6 +151,9 @@ type EFIBinaryEvidence struct { OSRelSHA256 string `json:"osrelSha256,omitempty" yaml:"osrelSha256,omitempty"` UnameSHA256 string `json:"unameSha256,omitempty" yaml:"unameSha256,omitempty"` + // Bootloader configuration (for GRUB, systemd-boot, etc.) + BootConfig *BootloaderConfig `json:"bootConfig,omitempty" yaml:"bootConfig,omitempty"` + Notes []string `json:"notes,omitempty" yaml:"notes,omitempty"` } @@ -168,6 +171,56 @@ const ( BootloaderLinuxEFIStub BootloaderKind = "linux-efi-stub" // optional ) +// BootloaderConfig captures bootloader configuration data and kernel references. +type BootloaderConfig struct { + // Configuration file paths and hashes + ConfigFiles map[string]string `json:"configFiles,omitempty" yaml:"configFiles,omitempty"` // path -> SHA256 + ConfigRaw map[string]string `json:"configRaw,omitempty" yaml:"configRaw,omitempty"` // path -> raw content (truncated if large) + + // Kernel location references extracted from config + KernelReferences []KernelReference `json:"kernelReferences,omitempty" yaml:"kernelReferences,omitempty"` + + // Boot entries (GRUB/systemd-boot/EFI boot order) + BootEntries []BootEntry `json:"bootEntries,omitempty" yaml:"bootEntries,omitempty"` + + // UUID resolution: UUIDs found in config and whether they match partition table + UUIDReferences []UUIDReference `json:"uuidReferences,omitempty" yaml:"uuidReferences,omitempty"` + + // Default boot target/entry + DefaultEntry string `json:"defaultEntry,omitempty" yaml:"defaultEntry,omitempty"` + + // Configuration issues detected during parsing + Notes []string `json:"notes,omitempty" yaml:"notes,omitempty"` +} + +// KernelReference represents a kernel file reference found in bootloader config. +type KernelReference struct { + Path string `json:"path" yaml:"path"` // Kernel path as specified in config + PartitionUUID string `json:"partitionUuid,omitempty" yaml:"partitionUuid,omitempty"` // UUID reference if present + RootUUID string `json:"rootUuid,omitempty" yaml:"rootUuid,omitempty"` // root device UUID reference if present + BootEntry string `json:"bootEntry,omitempty" yaml:"bootEntry,omitempty"` // Which boot entry this references +} + +// BootEntry represents a single boot entry (GRUB menu item, systemd-boot entry, etc.). +type BootEntry struct { + Name string `json:"name" yaml:"name"` // Entry name/title + Kernel string `json:"kernel" yaml:"kernel"` // Kernel path + Initrd string `json:"initrd,omitempty" yaml:"initrd,omitempty"` // Initrd path + Cmdline string `json:"cmdline,omitempty" yaml:"cmdline,omitempty"` // Kernel cmdline + IsDefault bool `json:"isDefault,omitempty" yaml:"isDefault,omitempty"` // Whether this is default + PartitionUUID string `json:"partitionUuid,omitempty" yaml:"partitionUuid,omitempty"` // Root partition UUID + RootDevice string `json:"rootDevice,omitempty" yaml:"rootDevice,omitempty"` // Root device reference + UKIPath string `json:"ukiPath,omitempty" yaml:"ukiPath,omitempty"` // For systemd-boot unified kernel image +} + +// UUIDReference tracks UUIDs found in bootloader config and partition table resolution. +type UUIDReference struct { + UUID string `json:"uuid" yaml:"uuid"` + Context string `json:"context" yaml:"context"` // Where found: "kernel_cmdline", "root_device", "boot_entry", etc. + ReferencedPartition int `json:"referencedPartition,omitempty" yaml:"referencedPartition,omitempty"` // Partition index (1-based) if resolved + Mismatch bool `json:"mismatch" yaml:"mismatch"` // True if UUID not found in partition table +} + // File system constants const ( unrealisticSectorSize = 65535 @@ -569,6 +622,13 @@ func computeFileSHA256(f *os.File) (string, error) { return hex.EncodeToString(h.Sum(nil)), nil } +// hashBytesHex computes SHA256 hash of a byte slice and returns hex string +func hashBytesHex(data []byte) string { + h := sha256.New() + h.Write(data) + return hex.EncodeToString(h.Sum(nil)) +} + // detectVerity inspects the partition table and UKI cmdline to detect dm-verity configuration func detectVerity(pt PartitionTableSummary) *VerityInfo { info := &VerityInfo{} diff --git a/internal/image/imageinspect/renderer_text.go b/internal/image/imageinspect/renderer_text.go index e01332d3..920e6448 100755 --- a/internal/image/imageinspect/renderer_text.go +++ b/internal/image/imageinspect/renderer_text.go @@ -316,6 +316,12 @@ func renderEFIBinaryDiffText(w io.Writer, d EFIBinaryDiff, indent string) { shortHash(m.UKI.UnameSHA256.From), shortHash(m.UKI.UnameSHA256.To)) } } + + // Bootloader configuration diff (if present) + if m.BootConfig != nil { + fmt.Fprintf(w, "%s Bootloader config:\n", indent) + renderBootloaderConfigDiffText(w, m.BootConfig, indent+" ") + } } } } @@ -501,6 +507,13 @@ func renderPartitionFilesystemDetails(w io.Writer, p PartitionSummary) { if uki, ok := firstUKI(arts); ok { renderUKIDetailsBlock(w, uki) } + + // Print bootloader config details if present + for _, art := range arts { + if art.BootConfig != nil { + renderBootloaderConfigDetails(w, art.Path, art.BootConfig) + } + } } // Squashfs-specific @@ -716,6 +729,234 @@ func renderEqualityReasonsBlock(w io.Writer, r *ImageCompareResult) { } } +// renderBootloaderConfigDetails renders bootloader configuration details for a single image. +func renderBootloaderConfigDetails(w io.Writer, efiPath string, cfg *BootloaderConfig) { + if cfg == nil { + return + } + + fmt.Fprintln(w) + fmt.Fprintln(w, "Bootloader configuration") + fmt.Fprintln(w, "------------------------") + + // Config file hashes and content preview + if len(cfg.ConfigFiles) > 0 { + fmt.Fprintf(w, "EFI Path: %s\n", efiPath) + fmt.Fprintln(w, "Config files:") + + // Collect and sort paths for deterministic output + paths := make([]string, 0, len(cfg.ConfigFiles)) + for path := range cfg.ConfigFiles { + paths = append(paths, path) + } + sort.Strings(paths) + for _, path := range paths { + hash := cfg.ConfigFiles[path] + fmt.Fprintf(w, " %s: %s\n", path, shortHash(hash)) + + // Show raw config content (first 30 lines or up to 2KB) + if raw, ok := cfg.ConfigRaw[path]; ok && raw != "" { + lines := strings.Split(raw, "\n") + maxLines := 30 + if len(lines) > maxLines { + lines = lines[:maxLines] + } + + fmt.Fprintln(w, " Content preview:") + for _, line := range lines { + if strings.TrimSpace(line) != "" { + // Truncate very long lines + if len(line) > 100 { + fmt.Fprintf(w, " %s...\n", line[:97]) + } else { + fmt.Fprintf(w, " %s\n", line) + } + } + } + if len(cfg.ConfigRaw[path]) > 2048 { + fmt.Fprintf(w, " [... config truncated, total size: %d bytes]\n", len(cfg.ConfigRaw[path])) + } + } + } + } + + // Display boot entries + if len(cfg.BootEntries) > 0 { + fmt.Fprintln(w, "Boot entries:") + for i, entry := range cfg.BootEntries { + mark := " " + if entry.IsDefault { + mark = "*" + } + fmt.Fprintf(w, " %s [%d] %s\n", mark, i+1, entry.Name) + if entry.Kernel != "" { + fmt.Fprintf(w, " kernel: %s\n", entry.Kernel) + } + if entry.Initrd != "" { + fmt.Fprintf(w, " initrd: %s\n", entry.Initrd) + } + if entry.RootDevice != "" { + fmt.Fprintf(w, " root: %s\n", entry.RootDevice) + } + if entry.Cmdline != "" { + if len(entry.Cmdline) > 100 { + fmt.Fprintf(w, " cmdline: %s...\n", entry.Cmdline[:97]) + } else { + fmt.Fprintf(w, " cmdline: %s\n", entry.Cmdline) + } + } + } + if cfg.DefaultEntry != "" { + fmt.Fprintf(w, " Default: %s\n", cfg.DefaultEntry) + } + } + + // Display kernel references + if len(cfg.KernelReferences) > 0 { + fmt.Fprintln(w, "Kernel references:") + for _, ref := range cfg.KernelReferences { + fmt.Fprintf(w, " %s\n", ref.Path) + if ref.PartitionUUID != "" { + fmt.Fprintf(w, " partition uuid: %s\n", ref.PartitionUUID) + } + if ref.RootUUID != "" { + fmt.Fprintf(w, " root uuid: %s\n", ref.RootUUID) + } + if ref.BootEntry != "" { + fmt.Fprintf(w, " boot entry: %s\n", ref.BootEntry) + } + } + } + + // Display UUID references with validation status + if len(cfg.UUIDReferences) > 0 { + hasIssues := false + for _, ref := range cfg.UUIDReferences { + if ref.Mismatch { + hasIssues = true + break + } + } + + if hasIssues { + fmt.Fprintln(w, "UUID validation:") + for _, ref := range cfg.UUIDReferences { + status := "✓" + if ref.Mismatch { + status = "✗ MISMATCH" + } + fmt.Fprintf(w, " [%s] %s (%s)\n", status, ref.UUID, ref.Context) + if ref.ReferencedPartition > 0 { + fmt.Fprintf(w, " -> partition %d\n", ref.ReferencedPartition) + } + } + } + } + + // Display validation notes + if len(cfg.Notes) > 0 { + fmt.Fprintln(w, "Notes:") + for _, note := range cfg.Notes { + fmt.Fprintf(w, " - %s\n", note) + } + } +} + +// renderBootloaderConfigDiffText renders bootloader configuration differences in a comparison. +func renderBootloaderConfigDiffText(w io.Writer, diff *BootloaderConfigDiff, indent string) { + if diff == nil { + return + } + + // Config file changes + if len(diff.ConfigFileChanges) > 0 { + fmt.Fprintf(w, "%sConfig files:\n", indent) + for _, change := range diff.ConfigFileChanges { + status := change.Status + fmt.Fprintf(w, "%s %s: %s\n", indent, status, change.Path) + if status == "modified" { + fmt.Fprintf(w, "%s hash: %s -> %s\n", indent, shortHash(change.HashFrom), shortHash(change.HashTo)) + } + } + } + + // Boot entry changes + if len(diff.BootEntryChanges) > 0 { + fmt.Fprintf(w, "%sBoot entries:\n", indent) + for _, change := range diff.BootEntryChanges { + fmt.Fprintf(w, "%s %s: %s\n", indent, change.Status, change.Name) + if change.Status == "modified" { + if change.KernelFrom != change.KernelTo { + fmt.Fprintf(w, "%s kernel: %s -> %s\n", indent, change.KernelFrom, change.KernelTo) + } + if change.InitrdFrom != change.InitrdTo { + fmt.Fprintf(w, "%s initrd: %s -> %s\n", indent, change.InitrdFrom, change.InitrdTo) + } + if change.CmdlineFrom != change.CmdlineTo { + fromCmdline := change.CmdlineFrom + toCmdline := change.CmdlineTo + if len(fromCmdline) > 80 { + fromCmdline = fromCmdline[:77] + "..." + } + if len(toCmdline) > 80 { + toCmdline = toCmdline[:77] + "..." + } + fmt.Fprintf(w, "%s cmdline: %s -> %s\n", indent, fromCmdline, toCmdline) + } + } + } + } + + // Kernel reference changes + if len(diff.KernelRefChanges) > 0 { + fmt.Fprintf(w, "%sKernel references:\n", indent) + for _, change := range diff.KernelRefChanges { + fmt.Fprintf(w, "%s %s: %s\n", indent, change.Status, change.Path) + if change.Status == "modified" && change.UUIDFrom != change.UUIDTo { + fmt.Fprintf(w, "%s uuid: %s -> %s\n", indent, change.UUIDFrom, change.UUIDTo) + } + } + } + + // UUID reference changes + if len(diff.UUIDReferenceChanges) > 0 { + hasMismatch := false + for _, change := range diff.UUIDReferenceChanges { + if change.MismatchTo { + hasMismatch = true + break + } + } + + if hasMismatch { + fmt.Fprintf(w, "%sUUID validation:\n", indent) + for _, change := range diff.UUIDReferenceChanges { + if change.MismatchTo { + fmt.Fprintf(w, "%s ✗ CRITICAL: %s not found in partition table\n", indent, change.UUID) + if change.ContextTo != "" { + fmt.Fprintf(w, "%s context: %s\n", indent, change.ContextTo) + } + } + } + } + } + + // Configuration notes + if len(diff.NotesAdded) > 0 { + fmt.Fprintf(w, "%sNew issues:\n", indent) + for _, note := range diff.NotesAdded { + fmt.Fprintf(w, "%s - %s\n", indent, note) + } + } + + if len(diff.NotesRemoved) > 0 { + fmt.Fprintf(w, "%sResolved issues:\n", indent) + for _, note := range diff.NotesRemoved { + fmt.Fprintf(w, "%s - %s\n", indent, note) + } + } +} + func humanBytes(n int64) string { if n < 0 { return fmt.Sprintf("%d B", n) @@ -761,7 +1002,6 @@ func gptTypeName(guid string) string { return "Linux filesystem" case "21686148-6449-6E6F-744E-656564454649": return "BIOS boot partition" - // Add more as you run into them (BIOS boot, swap, LVM, etc.) default: return "" } diff --git a/internal/image/imageinspect/renderer_text_test.go b/internal/image/imageinspect/renderer_text_test.go new file mode 100644 index 00000000..01d1f351 --- /dev/null +++ b/internal/image/imageinspect/renderer_text_test.go @@ -0,0 +1,193 @@ +package imageinspect + +import ( + "bytes" + "strings" + "testing" +) + +func TestRenderBootloaderConfigDiffText_IncludesInitrdAndSections(t *testing.T) { + diff := &BootloaderConfigDiff{ + ConfigFileChanges: []ConfigFileChange{{ + Path: "/boot/grub/grub.cfg", + Status: "modified", + HashFrom: strings.Repeat("a", 64), + HashTo: strings.Repeat("b", 64), + }}, + BootEntryChanges: []BootEntryChange{{ + Name: "UKI Boot Entry", + Status: "modified", + KernelFrom: "/vmlinuz-old", + KernelTo: "/vmlinuz-new", + InitrdFrom: "/initrd-old", + InitrdTo: "/initrd-new", + CmdlineFrom: "root=UUID=11111111-1111-1111-1111-111111111111 ro", + CmdlineTo: "root=UUID=22222222-2222-2222-2222-222222222222 rw", + }}, + KernelRefChanges: []KernelRefChange{{ + Path: "EFI/Linux/linux.efi", + Status: "modified", + UUIDFrom: "olduuid", + UUIDTo: "newuuid", + }}, + UUIDReferenceChanges: []UUIDRefChange{{ + UUID: "33333333-3333-3333-3333-333333333333", + Status: "modified", + MismatchTo: true, + ContextTo: "kernel_cmdline", + }}, + NotesAdded: []string{"new issue"}, + NotesRemoved: []string{"fixed issue"}, + } + + var buf bytes.Buffer + renderBootloaderConfigDiffText(&buf, diff, " ") + out := buf.String() + + wants := []string{ + "Config files:", + "Boot entries:", + "kernel: /vmlinuz-old -> /vmlinuz-new", + "initrd: /initrd-old -> /initrd-new", + "Kernel references:", + "UUID validation:", + "CRITICAL: 33333333-3333-3333-3333-333333333333 not found in partition table", + "New issues:", + "Resolved issues:", + } + + for _, want := range wants { + if !strings.Contains(out, want) { + t.Fatalf("expected output to contain %q, got:\n%s", want, out) + } + } +} + +func TestRenderPartitionSummaryLine_AndFilesystemChangeText(t *testing.T) { + var partBuf bytes.Buffer + renderPartitionSummaryLine(&partBuf, " +", PartitionSummary{ + Index: 1, + Name: "ESP", + Type: "efi", + StartLBA: 2048, + EndLBA: 4095, + SizeBytes: 1024 * 1024, + Flags: "", + Filesystem: &FilesystemSummary{ + Type: "vfat", + FATType: "FAT32", + Label: "EFI", + UUID: "ABCD-1234", + }, + }) + + partOut := partBuf.String() + if !strings.Contains(partOut, "idx=1") || !strings.Contains(partOut, "fs=vfat(FAT32)") { + t.Fatalf("unexpected partition summary output: %s", partOut) + } + + var fsBuf bytes.Buffer + renderFilesystemChangeText(&fsBuf, &FilesystemChange{Added: &FilesystemSummary{Type: "ext4", UUID: "u1", Label: "rootfs"}}) + renderFilesystemChangeText(&fsBuf, &FilesystemChange{Removed: &FilesystemSummary{Type: "vfat", UUID: "u2", Label: "EFI"}}) + renderFilesystemChangeText(&fsBuf, &FilesystemChange{Modified: &ModifiedFilesystemSummary{ + From: FilesystemSummary{Type: "ext4", UUID: "u-old"}, + To: FilesystemSummary{Type: "ext4", UUID: "u-new"}, + Changes: []FieldChange{{ + Field: "uuid", + From: "u-old", + To: "u-new", + }}, + }}) + + fsOut := fsBuf.String() + if !strings.Contains(fsOut, "FS: added type=ext4") { + t.Fatalf("expected added FS line, got: %s", fsOut) + } + if !strings.Contains(fsOut, "FS: removed type=vfat") { + t.Fatalf("expected removed FS line, got: %s", fsOut) + } + if !strings.Contains(fsOut, "FS: modified ext4(u-old) -> ext4(u-new)") { + t.Fatalf("expected modified FS line, got: %s", fsOut) + } +} + +func TestRenderEFIBinaryDiffText_FullBranches(t *testing.T) { + var buf bytes.Buffer + + diff := EFIBinaryDiff{ + Added: []EFIBinaryEvidence{{ + Path: "EFI/BOOT/NEW.EFI", + Kind: BootloaderShim, + Arch: "x86_64", + Signed: true, + SHA256: strings.Repeat("1", 64), + }}, + Removed: []EFIBinaryEvidence{{ + Path: "EFI/BOOT/OLD.EFI", + Kind: BootloaderGrub, + Arch: "x86_64", + Signed: false, + SHA256: strings.Repeat("2", 64), + }}, + Modified: []ModifiedEFIBinaryEvidence{{ + Key: "EFI/BOOT/BOOTX64.EFI", + From: EFIBinaryEvidence{ + Kind: BootloaderGrub, + SHA256: strings.Repeat("a", 64), + Signed: true, + Cmdline: "root=UUID=11111111-1111-1111-1111-111111111111 ro", + OSReleaseRaw: "ID=old", + }, + To: EFIBinaryEvidence{ + Kind: BootloaderSystemdBoot, + SHA256: strings.Repeat("b", 64), + Signed: false, + Cmdline: "root=UUID=22222222-2222-2222-2222-222222222222 rw", + OSReleaseRaw: "ID=new", + }, + UKI: &UKIDiff{ + Changed: true, + KernelSHA256: &ValueDiff[string]{From: strings.Repeat("c", 64), To: strings.Repeat("d", 64)}, + InitrdSHA256: &ValueDiff[string]{From: strings.Repeat("e", 64), To: strings.Repeat("f", 64)}, + CmdlineSHA256: &ValueDiff[string]{From: strings.Repeat("3", 64), To: strings.Repeat("4", 64)}, + OSRelSHA256: &ValueDiff[string]{From: strings.Repeat("5", 64), To: strings.Repeat("6", 64)}, + UnameSHA256: &ValueDiff[string]{From: strings.Repeat("7", 64), To: strings.Repeat("8", 64)}, + }, + BootConfig: &BootloaderConfigDiff{ + BootEntryChanges: []BootEntryChange{{ + Name: "entry", + Status: "modified", + InitrdFrom: "/initrd-old", + InitrdTo: "/initrd-new", + }}, + }, + }}, + } + + renderEFIBinaryDiffText(&buf, diff, " ") + out := buf.String() + + wants := []string{ + "Added:", + "Removed:", + "Modified:", + "kind: grub -> systemd-boot", + "sha256:", + "cmdline:", + "signed: true -> false", + "UKI payload:", + "kernel:", + "initrd:", + "cmdline:", + "osrel:", + "uname:", + "os release raw:", + "Bootloader config:", + "initrd: /initrd-old -> /initrd-new", + } + for _, want := range wants { + if !strings.Contains(out, want) { + t.Fatalf("expected output to contain %q, got:\n%s", want, out) + } + } +}