Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions fisherman/internal/recipe/recipe.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,19 @@ type VarDiskSpec struct {
KeepExisting bool `json:"keepExisting"` // if true, mount as-is; if false, format XFS
}

// isSupportedMountFstype reports whether a customMount fstype is one
// disk.formatPartition() can actually act on. Keep in sync with that switch:
// the empty string and "unformatted" mean "mount, do not format", which is what
// a pre-populated partition such as an existing ESP requires.
func isSupportedMountFstype(fstype string) bool {
switch fstype {
case "", "unformatted", "swap", "fat32", "ext3", "ext4", "xfs", "btrfs":
return true
default:
return false
}
}

// CustomMount describes a single partition → mountpoint mapping for manual layouts.
type CustomMount struct {
Partition string `json:"partition"` // e.g. "/dev/sda1"
Expand Down Expand Up @@ -160,10 +173,34 @@ func (r *Recipe) Validate() error {
if cm.Target == "/" {
hasRoot = true
}
// Validate the fstype here, where it is cheap and non-destructive.
// disk.ApplyCustomLayout() only discovers an unsupported value once
// it reaches formatPartition() — by which point the caller may
// already have repartitioned a disk on the strength of this recipe
// validating. A caller passing "vfat" (the obvious spelling, and
// not one we accept) got exactly that: validation passed, the
// install died mid-flight.
if !isSupportedMountFstype(cm.Fstype) {
return fmt.Errorf("customMounts[%d]: unsupported fstype %q "+
"(supported: fat32, ext3, ext4, xfs, btrfs, swap, or "+
"\"unformatted\"/\"\" to mount without formatting)", i, cm.Fstype)
}
}
if !hasRoot {
return fmt.Errorf("customMounts: no root (/) partition specified")
}
// Encryption is NOT applied on the manual path: luksFormat/luksOpen run
// only in the auto-partition branch below, and TPM enrolment needs an
// activeRootPart that manual mode leaves empty. Accepting an encrypted
// manual recipe therefore produces an install that completes
// UNENCRYPTED while the caller believes otherwise — a security-boundary
// failure, so fail closed here rather than silently downgrade.
// (Same shape as the ZFS+LUKS rejection below.)
if r.Encryption.Type != "" && r.Encryption.Type != "none" {
return fmt.Errorf("encryption %q is not supported with customMounts: "+
"manual layouts do not run luksFormat, so the install would complete "+
"unencrypted", r.Encryption.Type)
}
} else {
if r.Disk == "" {
return fmt.Errorf("disk is required")
Expand Down
74 changes: 74 additions & 0 deletions fisherman/internal/recipe/recipe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,3 +283,77 @@ func TestLoad(t *testing.T) {
}
})
}

// Manual (customMounts) layouts: two ways a recipe can be accepted here and
// then do the wrong thing later. Both were hit in practice by
// tuna-os/bootc-installer-asahi.

func manualRecipe(t *testing.T, fstype string, enc string) *recipe.Recipe {
t.Helper()
// Validate() stats the partition paths, so use files that exist.
root := filepath.Join(t.TempDir(), "root")
if err := os.WriteFile(root, []byte{}, 0o600); err != nil {
t.Fatal(err)
}
r := &recipe.Recipe{
Image: "example.invalid/img:latest",
Hostname: "validate-test",
CustomMounts: []recipe.CustomMount{{Partition: root, Target: "/", Fstype: fstype}},
}
r.Encryption.Type = enc
return r
}

func TestValidateRejectsUnsupportedCustomMountFstype(t *testing.T) {
// "vfat" is the obvious spelling for an ESP and is NOT accepted:
// formatPartition knows "fat32". Previously this passed Validate() and
// failed mid-install, after the caller had already committed to the recipe.
err := manualRecipe(t, "vfat", "none").Validate()
if err == nil {
t.Fatal("expected an unsupported-fstype error, got nil")
}
if !strings.Contains(err.Error(), "vfat") {
t.Errorf("error should name the offending value, got: %v", err)
}
}

func TestValidateAcceptsSkipFormatSentinels(t *testing.T) {
// An existing ESP must be mountable WITHOUT being reformatted: it already
// holds the bootloader and, on Apple Silicon, non-redistributable vendor
// firmware. Both spellings must survive validation.
for _, fstype := range []string{"", "unformatted"} {
if err := manualRecipe(t, fstype, "none").Validate(); err != nil {
t.Errorf("fstype %q should be accepted, got: %v", fstype, err)
}
}
}

func TestValidateAcceptsSupportedCustomMountFstypes(t *testing.T) {
for _, fstype := range []string{"fat32", "ext3", "ext4", "xfs", "btrfs"} {
if err := manualRecipe(t, fstype, "none").Validate(); err != nil {
t.Errorf("fstype %q should be accepted, got: %v", fstype, err)
}
}
}

func TestValidateRejectsEncryptionWithCustomMounts(t *testing.T) {
// The manual path never runs luksFormat, so an encrypted manual recipe
// installs UNENCRYPTED while the caller believes otherwise. Fail closed.
for _, enc := range []string{"luks-passphrase", "tpm2-luks"} {
err := manualRecipe(t, "xfs", enc).Validate()
if err == nil {
t.Fatalf("encryption %q with customMounts must be rejected", enc)
}
if !strings.Contains(err.Error(), "unencrypted") {
t.Errorf("error should explain the consequence, got: %v", err)
}
}
}

func TestValidateAllowsNoEncryptionWithCustomMounts(t *testing.T) {
for _, enc := range []string{"", "none"} {
if err := manualRecipe(t, "xfs", enc).Validate(); err != nil {
t.Errorf("encryption %q should be accepted, got: %v", enc, err)
}
}
}
Loading