m/p/efivarfs: refactor

This accomplishes three things:

First, split out the variable access layer from the rest of the code.
This cleans up the attribute handling, which is now done centrally as
well as making the high-level functions very short and clean. They now
also return better errors.

Second this introduces proper types for LoadOption, which can now also
be unmarshaled which was a requirement for A/B updates. This required
implementation of EFI's DevicePath structure.
While refactoring the higher-level functions for this, this also
fixes a bug where the variable index (the 4 hex nibbles at the end) were
improperly generated as lowercase hex.

Third, this adds new high-level functions for interacting with more
boot-related variables needed for the A/B effort.

Change-Id: I53490fa4898a5e7a5498ecc05a9078bd2d66c26e
Reviewed-on: https://review.monogon.dev/c/monogon/+/1855
Tested-by: Jenkins CI
Reviewed-by: Serge Bazanski <serge@monogon.tech>
diff --git a/metropolis/pkg/efivarfs/efivarfs.go b/metropolis/pkg/efivarfs/efivarfs.go
index 8132579..e751145 100644
--- a/metropolis/pkg/efivarfs/efivarfs.go
+++ b/metropolis/pkg/efivarfs/efivarfs.go
@@ -14,24 +14,36 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-// This package was written with the aim of easing efivarfs integration.
+// Package efivarfs provides functions to read and manipulate UEFI runtime
+// variables. It uses Linux's efivarfs [1] to access the variables and all
+// functions generally require that this is mounted at
+// "/sys/firmware/efi/efivars".
 //
-// https://www.kernel.org/doc/html/latest/filesystems/efivarfs.html
+// [1] https://www.kernel.org/doc/html/latest/filesystems/efivarfs.html
 package efivarfs
 
 import (
-	"bytes"
+	"encoding/binary"
+	"errors"
 	"fmt"
+	"io/fs"
 	"os"
-	"path/filepath"
+	"strings"
 
 	"github.com/google/uuid"
 	"golang.org/x/text/encoding/unicode"
 )
 
 const (
-	Path       = "/sys/firmware/efi/efivars"
-	GlobalGuid = "8be4df61-93ca-11d2-aa0d-00e098032b8c"
+	Path = "/sys/firmware/efi/efivars"
+)
+
+var (
+	// ScopeGlobal is the scope of variables defined by the EFI specification
+	// itself.
+	ScopeGlobal = uuid.MustParse("8be4df61-93ca-11d2-aa0d-00e098032b8c")
+	// ScopeSystemd is the scope of variables defined by Systemd/bootspec.
+	ScopeSystemd = uuid.MustParse("4a67b082-0a4c-41cf-b6c7-440b29bb8c4f")
 )
 
 // Encoding defines the Unicode encoding used by UEFI, which is UCS-2 Little
@@ -39,79 +51,108 @@
 // Spec 2.9, Sections 33.2.6 and 1.8.1.
 var Encoding = unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM)
 
-// ExtractString returns EFI variable data based on raw variable file contents.
-// It returns string-represented data, or an error.
-func ExtractString(contents []byte) (string, error) {
-	// Fail if total length is shorter than attribute length.
-	if len(contents) < 4 {
-		return "", fmt.Errorf("contents too short.")
-	}
-	// Skip attributes, see @linux//Documentation/filesystems:efivarfs.rst for format
-	efiVarData := contents[4:]
-	espUUIDNullTerminated, err := Encoding.NewDecoder().Bytes(efiVarData)
-	if err != nil {
-		// Pass the decoding error unwrapped.
-		return "", err
-	}
-	// Remove the null suffix.
-	return string(bytes.TrimSuffix(espUUIDNullTerminated, []byte{0})), nil
+// Attribute contains a bitset of EFI variable attributes.
+type Attribute uint32
+
+const (
+	// If set the value of the variable is is persistent across resets and
+	// power cycles. Variables without this set cannot be created or modified
+	// after UEFI boot services are terminated.
+	AttrNonVolatile Attribute = 1 << iota
+	// If set allows access to this variable from UEFI boot services.
+	AttrBootserviceAccess
+	// If set allows access to this variable from an operating system after
+	// UEFI boot services are terminated. Variables setting this must also
+	// set AttrBootserviceAccess. This is automatically taken care of by Write
+	// in this package.
+	AttrRuntimeAccess
+	// Marks a variable as being a hardware error record. See UEFI 2.10 section
+	// 8.2.8 for more information about this.
+	AttrHardwareErrorRecord
+	// Deprecated, should not be used for new variables.
+	AttrAuthenticatedWriteAccess
+	// Variable requires special authentication to write. These variables
+	// cannot be written with this package.
+	AttrTimeBasedAuthenticatedWriteAccess
+	// If set in a Write() call, tries to append the data instead of replacing
+	// it completely.
+	AttrAppendWrite
+	// Variable requires special authentication to access and write. These
+	// variables cannot be accessed with this package.
+	AttrEnhancedAuthenticatedAccess
+)
+
+func varPath(scope uuid.UUID, varName string) string {
+	return fmt.Sprintf("/sys/firmware/efi/efivars/%s-%s", varName, scope.String())
 }
 
-// ReadLoaderDevicePartUUID reads the ESP UUID from an EFI variable. It
-// depends on efivarfs being already mounted.
-func ReadLoaderDevicePartUUID() (uuid.UUID, error) {
-	// Read the EFI variable file containing the ESP UUID.
-	espUuidPath := filepath.Join(Path, "LoaderDevicePartUUID-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f")
-	efiVar, err := os.ReadFile(espUuidPath)
+// Write writes the value of the named variable in the given scope.
+func Write(scope uuid.UUID, varName string, attrs Attribute, value []byte) error {
+	// Write attributes, see @linux//Documentation/filesystems:efivarfs.rst for format
+	f, err := os.OpenFile(varPath(scope, varName), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
 	if err != nil {
-		return uuid.Nil, fmt.Errorf("couldn't read the LoaderDevicePartUUID file at %q: %w", espUuidPath, err)
-	}
-	contents, err := ExtractString(efiVar)
-	if err != nil {
-		return uuid.Nil, fmt.Errorf("couldn't decode an EFI variable: %w", err)
-	}
-	out, err := uuid.Parse(contents)
-	if err != nil {
-		return uuid.Nil, fmt.Errorf("value in LoaderDevicePartUUID could not be parsed as UUID: %w", err)
-	}
-	return out, nil
-}
-
-// CreateBootEntry creates an EFI boot entry variable and returns its
-// non-negative index on success. It may return an io error.
-func CreateBootEntry(be *BootEntry) (int, error) {
-	// Find the index by looking up the first empty slot.
-	var ep string
-	var n int
-	for ; ; n++ {
-		en := fmt.Sprintf("Boot%04x-%s", n, GlobalGuid)
-		ep = filepath.Join(Path, en)
-		_, err := os.Stat(ep)
-		if os.IsNotExist(err) {
-			break
+		e := err
+		// Unwrap PathError here as we wrap our own parameter message around it
+		var perr *fs.PathError
+		if errors.As(err, &perr) {
+			e = perr.Err
 		}
-		if err != nil {
-			return -1, err
-		}
+		return fmt.Errorf("writing %q in scope %s: %w", varName, scope, e)
 	}
-
-	// Create the boot variable.
-	bem, err := be.Marshal()
-	if err != nil {
-		return -1, fmt.Errorf("while marshaling the EFI boot entry: %w", err)
+	// Required by UEFI 2.10 Section 8.2.3:
+	// Runtime access to a data variable implies boot service access. Attributes
+	// that have EFI_VARIABLE_RUNTIME_ACCESS set must also have
+	// EFI_VARIABLE_BOOTSERVICE_ACCESS set. The caller is responsible for
+	// following this rule.
+	if attrs&AttrRuntimeAccess != 0 {
+		attrs |= AttrBootserviceAccess
 	}
-	if err := os.WriteFile(ep, bem, 0644); err != nil {
-		return -1, fmt.Errorf("while creating a boot entry variable: %w", err)
+	// Linux wants everything in on write, so assemble an intermediate buffer
+	buf := make([]byte, len(value)+4)
+	binary.LittleEndian.PutUint32(buf[:4], uint32(attrs))
+	copy(buf[4:], value)
+	_, err = f.Write(buf)
+	if err1 := f.Close(); err1 != nil && err == nil {
+		err = err1
 	}
-	return n, nil
+	return err
 }
 
-// SetBootOrder replaces contents of the boot order variable with the order
-// specified in ord. It may return an io error.
-func SetBootOrder(ord *BootOrder) error {
-	op := filepath.Join(Path, fmt.Sprintf("BootOrder-%s", GlobalGuid))
-	if err := os.WriteFile(op, ord.Marshal(), 0644); err != nil {
-		return fmt.Errorf("while creating a boot order variable: %w", err)
+// Read reads the value of the named variable in the given scope.
+func Read(scope uuid.UUID, varName string) ([]byte, Attribute, error) {
+	val, err := os.ReadFile(varPath(scope, varName))
+	if err != nil {
+		e := err
+		// Unwrap PathError here as we wrap our own parameter message around it
+		var perr *fs.PathError
+		if errors.As(err, &perr) {
+			e = perr.Err
+		}
+		return nil, Attribute(0), fmt.Errorf("reading %q in scope %s: %w", varName, scope, e)
 	}
-	return nil
+	if len(val) < 4 {
+		return nil, Attribute(0), fmt.Errorf("reading %q in scope %s: malformed, less than 4 bytes long", varName, scope)
+	}
+	return val[4:], Attribute(binary.LittleEndian.Uint32(val[:4])), nil
+}
+
+// List lists all variable names present for a given scope sorted by their names
+// in Go's "native" string sort order.
+func List(scope uuid.UUID) ([]string, error) {
+	vars, err := os.ReadDir(Path)
+	if err != nil {
+		return nil, fmt.Errorf("failed to list variable directory: %w", err)
+	}
+	var outVarNames []string
+	suffix := fmt.Sprintf("-%v", scope)
+	for _, v := range vars {
+		if v.IsDir() {
+			continue
+		}
+		if !strings.HasSuffix(v.Name(), suffix) {
+			continue
+		}
+		outVarNames = append(outVarNames, strings.TrimSuffix(v.Name(), suffix))
+	}
+	return outVarNames, nil
 }