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
17 changes: 17 additions & 0 deletions docs/upgrade-guide.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
# 2026-03-02 btrfs-mountoptions-guardrails

Changes:

- Btrfs mount option consistency guardrails were added for mounted subvolumes.
- Disko now enforces consistency by default and throws when mounted subvolumes
disagree on non-whitelisted mount options (Btrfs mount options are largely
filesystem-wide).
- Differences in these per-mount VFS flags are allowed:
`ro/rw`, `nosuid/suid`, `nodev/dev`, `noexec/exec`, and atime variants.
- Subvolumes that do not set `mountOptions` (and therefore use the default
`mountOptions = [ "defaults" ];`) are treated as using the top-level Btrfs
`mountOptions` for this consistency check.
- Set `enforceConsistentMountOptions = false;` to disable strict mode.
- Set `warnOnInconsistentMountOptions = false;` to silence warnings when strict
mode is disabled.

# 2023-07-09 121df48

Changes:
Expand Down
3 changes: 1 addition & 2 deletions example/btrfs-subvolumes.nix
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
content = {
type = "btrfs";
extraArgs = [ "-f" ]; # Override existing partition
mountOptions = [ "compress=zstd" ];
# Subvolumes must set a mountpoint in order to be mounted,
# unless their parent is mounted
subvolumes = {
Expand All @@ -34,15 +35,13 @@
};
# Subvolume name is the same as the mountpoint
"/home" = {
mountOptions = [ "compress=zstd" ];
mountpoint = "/home";
};
# Sub(sub)volume doesn't need a mountpoint as its parent is mounted
"/home/user" = { };
# Parent is not mounted so the mountpoint must be set
"/nix" = {
mountOptions = [
"compress=zstd"
"noatime"
];
mountpoint = "/nix";
Expand Down
10 changes: 5 additions & 5 deletions example/luks-btrfs-raid.nix
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,18 @@
};
content = {
type = "btrfs";
mountOptions = [
"relatime"
"rw"
"ssd"
];
extraArgs = [
"-d raid1"
"/dev/mapper/p1" # Use decrypted mapped device, same name as defined in disk1
];
subvolumes = {
"/root" = {
mountpoint = "/";
mountOptions = [
"rw"
"relatime"
"ssd"
];
};
};
};
Expand Down
17 changes: 5 additions & 12 deletions example/luks-btrfs-subvolumes.nix
Original file line number Diff line number Diff line change
Expand Up @@ -32,27 +32,20 @@
content = {
type = "btrfs";
extraArgs = [ "-f" ];
mountOptions = [
"compress=zstd"
"relatime"
];
subvolumes = {
"/root" = {
mountpoint = "/";
mountOptions = [
"compress=zstd"
"noatime"
];
};
"/home" = {
mountpoint = "/home";
mountOptions = [
"compress=zstd"
"noatime"
];
};
"/nix" = {
mountpoint = "/nix";
mountOptions = [
"compress=zstd"
"noatime"
];
mountOptions = [ "noatime" ];
};
"/swap" = {
mountpoint = "/.swapvol";
Expand Down
157 changes: 150 additions & 7 deletions lib/types/btrfs.nix
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,49 @@
...
}:
let
btrfsPerMountOptionKeys = [
"ro"
"rw"
"nosuid"
"suid"
"dev"
"nodev"
"exec"
"noexec"
"atime"
"lazytime"
"noatime"
"nolazytime"
"norelatime"
"nostrictatime"
"relatime"
"strictatime"
];

getMountOptionKey = option: builtins.head (lib.splitString "=" option);

isIgnoredMountOption =
option:
(
let
optionKey = lib.toLower (getMountOptionKey option);
in
optionKey == "defaults" || optionKey == "subvol" || optionKey == "x-mount.mkdir"
);

isWhitelistedPerMountOption =
option: lib.elem (lib.toLower (getMountOptionKey option)) btrfsPerMountOptionKeys;

fsWideMountOptionSet =
mountOptions:
lib.sort lib.lessThan (
lib.unique (
lib.filter (
option: !(isIgnoredMountOption option || isWhitelistedPerMountOption option)
) mountOptions
)
);

swapType = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule (
Expand Down Expand Up @@ -88,7 +131,27 @@ in
mountOptions = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "defaults" ];
description = "A list of options to pass to mount.";
description = ''
A list of options to pass to mount.
Btrfs mount options are largely filesystem-wide; by default disko enforces
consistency for mounted subvolumes on non-whitelisted options.
'';
};
warnOnInconsistentMountOptions = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Warn when mounted subvolumes use inconsistent non-whitelisted Btrfs mount options.
Set to false to silence these warnings.
'';
};
enforceConsistentMountOptions = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Throw an evaluation error when mounted subvolumes use inconsistent non-whitelisted
Btrfs mount options.
'';
};
subvolumes = lib.mkOption {
type = lib.types.attrsOf (
Expand Down Expand Up @@ -185,13 +248,83 @@ in
inherit config options;
default =
let
subvolDefaults = (options.subvolumes.type.getSubOptions [ ]).mountOptions.default;

mountedSubvolumes = map (name: config.subvolumes.${name}) (
lib.filter (name: config.subvolumes.${name}.mountpoint != null) (
builtins.attrNames config.subvolumes
)
);

baseFsOpts = fsWideMountOptionSet config.mountOptions;
baseFsOptsByKey = builtins.listToAttrs (
map (opt: {
name = lib.toLower (getMountOptionKey opt);
value = opt;
}) baseFsOpts
);

# Subvolumes using the default mountOptions inherit the top-level Btrfs
# mountOptions for the consistency check.
subvolFsOverrides =
subvol:
if subvol.mountOptions == subvolDefaults then [ ] else fsWideMountOptionSet subvol.mountOptions;

optsByKey =
opts:
builtins.listToAttrs (
map (opt: {
name = lib.toLower (getMountOptionKey opt);
value = opt;
}) opts
);

# Effective filesystem-wide options are computed by overlaying subvolume
# overrides on top of the canonical top-level filesystem options by key.
mergedFsOpts =
subvol:
lib.sort lib.lessThan (
builtins.attrValues (baseFsOptsByKey // optsByKey (subvolFsOverrides subvol))
);

mountedSubvols = map (subvol: {
cfg = subvol;
requested = subvolFsOverrides subvol;
eff = mergedFsOpts subvol;
}) mountedSubvolumes;

isConsistentOverrides =
requestedOpts:
lib.all (
opt:
let
optKey = lib.toLower (getMountOptionKey opt);
in
builtins.hasAttr optKey baseFsOptsByKey && baseFsOptsByKey.${optKey} == opt
) requestedOpts;

inconsistentSubvols = lib.filter (subvol: !(isConsistentOverrides subvol.requested)) mountedSubvols;

fmtFsOpts = opts: if opts == [ ] then "(none)" else lib.concatStringsSep ", " opts;

inconsistentMsg =
"Inconsistent Btrfs mountOptions across mounted subvolumes: "
+ "${lib.concatStringsSep ", " (map (subvol: subvol.cfg.name) inconsistentSubvols)}. "
+ "Offending effective filesystem-wide option sets: "
+ "${
lib.concatStringsSep "; " (
map (subvol: "${subvol.cfg.name}=[${fmtFsOpts subvol.eff}]") inconsistentSubvols
)
}. "
+ "Canonical top-level filesystem-wide option set: "
+ "[${fmtFsOpts baseFsOpts}]. "
+ "Btrfs mount options are largely filesystem-wide, so differing filesystem-wide options "
+ "across subvolume mounts are not deterministic. Only per-mount flags are safely allowed "
+ "(ro/rw, nosuid/suid, nodev/dev, noexec/exec, and atime variants).";

subvolMounts = lib.concatMapAttrs (
_: subvol:
lib.warnIf
(
subvol.mountOptions != (options.subvolumes.type.getSubOptions [ ]).mountOptions.default
&& subvol.mountpoint == null
)
lib.warnIf (subvol.mountOptions != subvolDefaults && subvol.mountpoint == null)
"Subvolume ${subvol.name} has mountOptions but no mountpoint. See upgrade guide (2023-07-09 121df48)."
lib.optionalAttrs
(subvol.mountpoint != null)
Expand All @@ -207,10 +340,20 @@ in
'';
}
) config.subvolumes;

guardedSubvolMounts =
if inconsistentSubvols == [ ] then
subvolMounts
else if config.enforceConsistentMountOptions then
throw inconsistentMsg
else if config.warnOnInconsistentMountOptions then
lib.warn inconsistentMsg subvolMounts
else
subvolMounts;
in
{
fs =
subvolMounts
guardedSubvolMounts
// lib.optionalAttrs (config.mountpoint != null) {
${config.mountpoint} = ''
if ! findmnt "${config.device}" "${rootMountPoint}${config.mountpoint}" > /dev/null 2>&1; then
Expand Down
23 changes: 23 additions & 0 deletions tests/btrfs-mountoptions-consistency-guardrail.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
pkgs ? import <nixpkgs> { },
diskoLib ? pkgs.callPackage ../lib { },
}:
let
name = "btrfs-mountoptions-consistency-guardrail";
disko = pkgs.callPackage ../. {
checked = true;
inherit diskoLib;
};
disko-config = pkgs.lib.recursiveUpdate (import ../example/btrfs-subvolumes.nix) {
# Intentionally conflicting filesystem-wide mount options across mounted subvolumes.
disko.devices.disk.main.content.partitions.root.content.subvolumes = {
"/rootfs".mountOptions = [ "compress=zstd" ];
"/home".mountOptions = [ "compress=no" ];
};
};

# The guardrail throws during evaluation; this test validates that behavior directly.
evalResult = builtins.tryEval (builtins.deepSeq (disko._cliMount disko-config pkgs) true);
in
assert (!evalResult.success);
pkgs.writeText name "ok\n"
40 changes: 40 additions & 0 deletions tests/btrfs-mountoptions-per-mount-allowed.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
pkgs ? import <nixpkgs> { },
diskoLib ? pkgs.callPackage ../lib { },
}:
diskoLib.testLib.makeDiskoTest {
inherit pkgs;
name = "btrfs-mountoptions-per-mount-allowed";
disko-config = pkgs.lib.recursiveUpdate (import ../example/btrfs-subvolumes.nix) {
disko.devices.disk.main.content.partitions.root.content.subvolumes = {
"/home".mountOptions = [ "noexec" ];
"/nosuid" = {
mountpoint = "/nosuid";
mountOptions = [ "nosuid" ];
};
"/nodev" = {
mountpoint = "/nodev";
mountOptions = [ "nodev" ];
};
"/noatime" = {
mountpoint = "/noatime";
mountOptions = [ "noatime" ];
};
};
};
extraTestScript = ''
machine.succeed("mountpoint /");
machine.succeed("mountpoint /home");
machine.succeed("mountpoint /nosuid");
machine.succeed("mountpoint /nodev");
machine.succeed("mountpoint /noatime");
machine.succeed("findmnt -no OPTIONS /home | tr ',' '\n' | grep -qx noexec");
machine.succeed("findmnt -no OPTIONS /nosuid | tr ',' '\n' | grep -qx nosuid");
machine.succeed("findmnt -no OPTIONS /nodev | tr ',' '\n' | grep -qx nodev");
machine.succeed("findmnt -no OPTIONS /noatime | tr ',' '\n' | grep -qx noatime");
machine.fail("findmnt -no OPTIONS / | tr ',' '\n' | grep -qx noexec");
machine.fail("findmnt -no OPTIONS / | tr ',' '\n' | grep -qx nosuid");
machine.fail("findmnt -no OPTIONS / | tr ',' '\n' | grep -qx nodev");
machine.fail("findmnt -no OPTIONS / | tr ',' '\n' | grep -qx noatime");
'';
}
Loading