cosboot: Add functionality for determining boot path

For now the boot path can either be grub or UKI. Currently we just use
if the bootx64.efi is the shim. More analysis is possible, but is not
necessary right now. Non-EFI boot paths are not considered.

BUG=b/465210631
TEST=unit tests

Change-Id: Ia9973459958c1d99223607ccf6b5e82b2512b7f1
Reviewed-on: https://cos-review.googlesource.com/c/cos/tools/+/130161
Tested-by: Robert Kolchmeyer <rkolchmeyer@google.com>
Reviewed-by: Kevin Berry <kpberry@google.com>
Cloud-Build: GCB Service account <228075978874@cloudbuild.gserviceaccount.com>
Reviewed-by: He Gao <hegao@google.com>
diff --git a/src/pkg/cosboot/cosboot.go b/src/pkg/cosboot/cosboot.go
new file mode 100644
index 0000000..dd7fdc0
--- /dev/null
+++ b/src/pkg/cosboot/cosboot.go
@@ -0,0 +1,70 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package cosboot exports utilities for interacting with the COS boot process.
+package cosboot
+
+import (
+	"bytes"
+	"debug/pe"
+	"fmt"
+	"strings"
+)
+
+type BootPath string
+
+const (
+	UnknownBoot = BootPath("UnknownBoot")
+	GRUBBoot    = BootPath("GRUBBoot")
+	UKI         = BootPath("UKI")
+)
+
+func isShim(exe []byte) (bool, error) {
+	exeFile, err := pe.NewFile(bytes.NewReader(exe))
+	if err != nil {
+		// Invalid PE file - not shim
+		return false, nil
+	}
+	defer exeFile.Close()
+	section := exeFile.Section(".sbat")
+	if section == nil {
+		// No SBAT - not shim
+		return false, nil
+	}
+	data, err := section.Data()
+	if err != nil {
+		return false, err
+	}
+	sbat := string(data)
+	if strings.Contains(sbat, "https://github.com/rhboot/shim") {
+		return true, nil
+	}
+	return false, nil
+}
+
+func DiskBootPath(disk string) (BootPath, error) {
+	bootx64, err := ReadEFIFile(disk, "/efi/boot/bootx64.efi")
+	if err != nil {
+		return UnknownBoot, fmt.Errorf("could not read bootx64.efi: %v", err)
+	}
+	bootx64IsShim, err := isShim(bootx64)
+	if err != nil {
+		return UnknownBoot, fmt.Errorf("could not determine if bootx64.efi is shim: %v", err)
+	}
+	if bootx64IsShim {
+		return GRUBBoot, nil
+	} else {
+		return UKI, nil
+	}
+}
diff --git a/src/pkg/cosboot/cosboot_test.go b/src/pkg/cosboot/cosboot_test.go
new file mode 100644
index 0000000..f189ac8
--- /dev/null
+++ b/src/pkg/cosboot/cosboot_test.go
@@ -0,0 +1,48 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cosboot
+
+import "testing"
+
+func TestDiskBootPath(t *testing.T) {
+	tests := []struct {
+		name  string
+		input string
+		want  BootPath
+	}{
+		{
+			name:  "GRUB",
+			input: "testdata/shimefi.img",
+			want:  GRUBBoot,
+		},
+		{
+			name:  "Kernel",
+			input: "testdata/kernelefi.img",
+			want:  UKI,
+		},
+	}
+	for _, test := range tests {
+		test := test
+		t.Run(test.name, func(t *testing.T) {
+			got, err := DiskBootPath(test.input)
+			if err != nil {
+				t.Fatalf("DiskBootPath(%q) = %v; want nil", test.input, err)
+			}
+			if got != test.want {
+				t.Errorf("DiskBootPath(%q) = %v; want %v", test.input, got, test.want)
+			}
+		})
+	}
+}
diff --git a/src/pkg/cosboot/efi.go b/src/pkg/cosboot/efi.go
new file mode 100644
index 0000000..f42158f
--- /dev/null
+++ b/src/pkg/cosboot/efi.go
@@ -0,0 +1,101 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cosboot
+
+import (
+	"bytes"
+	"fmt"
+	"log"
+	"os"
+	"os/exec"
+	"path/filepath"
+
+	"cos.googlesource.com/cos/tools.git/src/pkg/tools/partutil"
+)
+
+func readEFIFileOnCOS(path string) ([]byte, error) {
+	efiPath, err := partutil.MountEFIPartition()
+	if err != nil {
+		return nil, err
+	}
+	data, err := os.ReadFile(filepath.Join(efiPath, path))
+	if err != nil {
+		unmountErr := partutil.UnmountEFIPartition()
+		if unmountErr != nil {
+			log.Printf("ERROR: failed to unmount EFI partition: %v", unmountErr)
+		}
+		return nil, fmt.Errorf("failed to read EFI file %q: %v", path, err)
+	}
+	if err := partutil.UnmountEFIPartition(); err != nil {
+		return nil, fmt.Errorf("failed to unmount EFI partition: %v", err)
+	}
+	return data, nil
+}
+
+func readEFIFileOffline(disk, path string) ([]byte, error) {
+	sfdiskTable, err := partutil.ReadPartitionTableJSON(disk)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get GPT from %q: %v", disk, err)
+	}
+	partTable := sfdiskTable.PartitionTable
+	var efiPart *partutil.Partition
+	for i := range partTable.Partitions {
+		if partTable.Partitions[i].Name == "EFI-SYSTEM" {
+			efiPart = &partTable.Partitions[i]
+			break
+		}
+	}
+	if efiPart == nil {
+		return nil, fmt.Errorf("failed to find EFI partition on %q", disk)
+	}
+	tmpDir, err := os.MkdirTemp("", "cos-efi")
+	if err != nil {
+		return nil, err
+	}
+	defer os.RemoveAll(tmpDir)
+	start := efiPart.Start * partTable.SectorSize
+	size := efiPart.Size * partTable.SectorSize
+	buf := make([]byte, size)
+	in, err := os.Open(disk)
+	if err != nil {
+		return nil, err
+	}
+	defer in.Close()
+	if _, err := in.ReadAt(buf, int64(start)); err != nil {
+		return nil, err
+	}
+	efiImage := filepath.Join(tmpDir, "efi.bin")
+	if err := os.WriteFile(efiImage, buf, 0644); err != nil {
+		return nil, err
+	}
+	var stdout bytes.Buffer
+	var stderr bytes.Buffer
+	cmd := exec.Command("mcopy", "-i", efiImage, "::"+path, "-")
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+	if err := cmd.Run(); err != nil {
+		log.Printf("Command %q failed with stderr: %v", cmd.String(), string(stderr.Bytes()))
+		return nil, fmt.Errorf("cannot mcopy from EFI image sourced from %q: %v", disk, err)
+	}
+	return stdout.Bytes(), nil
+}
+
+func ReadEFIFile(disk, path string) ([]byte, error) {
+	if disk == "" {
+		return readEFIFileOnCOS(path)
+	} else {
+		return readEFIFileOffline(disk, path)
+	}
+}
diff --git a/src/pkg/cosboot/testdata/create_efi_gpt.sh b/src/pkg/cosboot/testdata/create_efi_gpt.sh
new file mode 100644
index 0000000..b139529
--- /dev/null
+++ b/src/pkg/cosboot/testdata/create_efi_gpt.sh
@@ -0,0 +1,60 @@
+#!/bin/bash
+set -o errexit
+set -o pipefail
+set -o nounset
+
+# Script to generate kernelefi.img and shimefi.img.
+#
+# kernelefi.img uses a kernel from cos-125-19216-104-133 and shimefi.img uses a
+# shim from the same COS image.
+
+if [[ $# -ne 2 ]]; then
+    echo "Usage: $0 <output_image_path> <bootx64_efi_source>"
+    exit 1
+fi
+
+readonly OUTPUT_IMAGE="$1"
+readonly BOOT_SOURCE="$2"
+readonly EFI_PART_IMAGE="efi_part.img"
+
+# 1. Calculate required size for the partition
+SOURCE_SIZE=$(stat -c%s "$BOOT_SOURCE")
+# Add overhead for FAT metadata (at least 512KB for small files, total min 1MB)
+EFI_SIZE_BYTES=$((SOURCE_SIZE + 512 * 1024))
+if [[ $EFI_SIZE_BYTES -lt $((1024 * 1024)) ]]; then
+    EFI_SIZE_BYTES=$((1024 * 1024))
+fi
+# Align partition size to 512-byte sectors
+PART_SIZE_SECTORS=$(( (EFI_SIZE_BYTES + 511) / 512 ))
+
+# 2. Create the FAT image
+truncate -s "$((PART_SIZE_SECTORS * 512))" "$EFI_PART_IMAGE"
+mkfs.vfat "$EFI_PART_IMAGE"
+
+# 3. Create directory structure and copy the EFI file
+mmd -i "$EFI_PART_IMAGE" ::/efi
+mmd -i "$EFI_PART_IMAGE" ::/efi/boot
+mcopy -i "$EFI_PART_IMAGE" "$BOOT_SOURCE" ::/efi/boot/bootx64.efi
+
+# 4. Define GPT layout
+# Start at sector 2048 (1MiB) for alignment compatibility and to avoid
+# "sector already used" errors with some versions of sfdisk.
+readonly START_SECTOR=2048
+# Backup GPT is 33 sectors at the end
+TOTAL_SECTORS=$(( START_SECTOR + PART_SIZE_SECTORS + 33 ))
+
+# 5. Create the final container
+truncate -s "$((TOTAL_SECTORS * 512))" "$OUTPUT_IMAGE"
+
+# 6. Initialize GPT and create the EFI partition
+# EFI System Partition GUID: C12A7328-F81F-11D2-BA4B-00A0C93EC93B
+echo "label: gpt
+start=${START_SECTOR}, size=${PART_SIZE_SECTORS}, type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B, name=\"EFI-SYSTEM\"" | sfdisk "$OUTPUT_IMAGE"
+
+# 7. DD the EFI partition into the GPT image
+dd if="$EFI_PART_IMAGE" of="$OUTPUT_IMAGE" bs=512 seek="${START_SECTOR}" conv=notrunc status=none
+
+# Cleanup
+rm "$EFI_PART_IMAGE"
+
+echo "Successfully created minimal GPT image: $OUTPUT_IMAGE ($((TOTAL_SECTORS * 512 / 1024)) KiB)"
diff --git a/src/pkg/cosboot/testdata/kernelefi.img b/src/pkg/cosboot/testdata/kernelefi.img
new file mode 100644
index 0000000..a849172
--- /dev/null
+++ b/src/pkg/cosboot/testdata/kernelefi.img
Binary files differ
diff --git a/src/pkg/cosboot/testdata/shimefi.img b/src/pkg/cosboot/testdata/shimefi.img
new file mode 100644
index 0000000..8839be4
--- /dev/null
+++ b/src/pkg/cosboot/testdata/shimefi.img
Binary files differ
diff --git a/src/pkg/tools/partutil/handle_partition_table.go b/src/pkg/tools/partutil/handle_partition_table.go
index 594048e..c924ad8 100644
--- a/src/pkg/tools/partutil/handle_partition_table.go
+++ b/src/pkg/tools/partutil/handle_partition_table.go
@@ -16,6 +16,7 @@
 
 import (
 	"bytes"
+	"encoding/json"
 	"fmt"
 	"log"
 	"os"
@@ -111,6 +112,50 @@
 	return string(table), nil
 }
 
+type Partition struct {
+	Node  string `json:"node"`
+	Start int    `json:"start"`
+	Size  int    `json:"size"`
+	Type  string `json:"type"`
+	UUID  string `json:"uuid"`
+	Name  string `json:"name"`
+	Attrs string `json:"attrs"`
+}
+
+type PartitionTable struct {
+	Label      string      `json:"label"`
+	ID         string      `json:"id"`
+	Device     string      `json:"device"`
+	Unit       string      `json:"unit"`
+	FirstLBA   int         `json:"firstlba"`
+	SectorSize int         `json:"sectorsize"`
+	Partitions []Partition `json:"partitions"`
+}
+
+type SFDiskTable struct {
+	PartitionTable PartitionTable `json:"partitiontable"`
+}
+
+// ReadPartitionTableJSON reads a partition table into a SFDiskTable type.
+func ReadPartitionTableJSON(disk string) (SFDiskTable, error) {
+	var result SFDiskTable
+	var stdout bytes.Buffer
+	var stderr bytes.Buffer
+	cmd := exec.Command("sfdisk", "--json", disk)
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+	if err := cmd.Run(); err != nil {
+		log.Printf("Command %q failed with stderr: %v", cmd.String(), string(stderr.Bytes()))
+		return result, fmt.Errorf("cannot dump partition table of %q: %v", disk, err)
+	}
+	table := stdout.Bytes()
+	if err := json.Unmarshal(table, &result); err != nil {
+		log.Printf("Command %q returned invalid output: %v", cmd.String(), string(stdout.Bytes()))
+		return result, fmt.Errorf("cannot interpret sfdisk output as json: %v", err)
+	}
+	return result, nil
+}
+
 // ReadPartitionSize reads the size of a partition (unit:sectors of 512 Bytes).
 func ReadPartitionSize(disk string, partNumInt int) (uint64, error) {
 	if len(disk) <= 0 || partNumInt <= 0 {