Skip to content

feat: refuse to install over a partition which is in use - #434

Open
otavio wants to merge 1 commit into
masterfrom
protect-in-use-partitions
Open

feat: refuse to install over a partition which is in use#434
otavio wants to merge 1 commit into
masterfrom
protect-in-use-partitions

Conversation

@otavio

@otavio otavio commented Jul 28, 2026

Copy link
Copy Markdown
Member

Closes #353.

An update could write over storage the running system still had mounted. TargetTypeExt::valid() only checked that the target existed and was writable, so a raw object pointed at the live rootfs went straight through the block device while the mounted filesystem kept serving its own page cache and writing it back over what had just been installed.

Audit of the handlers

The issue asked to double-check every mode, so here is where each stood:

Mode Before Now
raw existence + write permission + free space valid(Access::Exclusive)
raw-delta same valid(Access::Exclusive)
flash same, plus MTD tools on PATH valid(Access::Exclusive)
ubifs same, plus UBI tools on PATH valid(Access::Exclusive)
imxkobs only kobs-ng on PATH, no target validation whatsoever each chip path is checked
copy existence + write permission + free space valid(Access::for_format(should_format))
tarball same valid(Access::for_format(should_format))
uboot-env, mender, zephyr no partition target unchanged

How the check works

utils::fs::ensure_not_mounted() reads /proc/self/mountinfo and matches on two axes:

  • By device number. rdev is compared against mountinfo's major:minor, so the by-uuid link, /dev/root and a device mapper name all resolve to the same device. sysfs then extends the comparison to storage the target shares: the partitions a disk holds, the disk a partition belongs to, and whatever is stacked on top through LVM, MD or loop. Sibling partitions are deliberately excluded, since writing to one does not reach the others.
  • By mount source name. UBIFS and JFFS2 mount no block device, so mountinfo carries ubi0:rootfs or mtd:rootfs. Those names are rebuilt from /sys/class/mtd and /sys/class/ubi, which also catches erasing an MTD out from under the UBI volumes attached to it.

Where the policy lives

valid() now takes an Access argument saying how the handler reaches its target — Exclusive for handlers writing the device itself, ThroughFilesystem for those writing through a mount they make. Every handler already called valid(), so each is forced by the compiler to state its intent exactly once, and there is no separate call for the next handler to forget.

copy and tarball pass Access::for_format(should_format): formatting rewrites the whole filesystem and so takes the device for itself, even though the rest of their work is safe on an already-mounted target. That also means the whole package is rejected during validation — before anything is downloaded, and before an earlier object in the same package has written to storage.

format() keeps a check of its own, so the mkfs calls stay guarded regardless of who reaches them. The force flags there (-F/-f/-y) are deliberately kept: dropping them would make the tools prompt on a whole disk or an existing filesystem, and an unattended agent with no tty would hang or abort. The explicit check is stronger anyway, since mkfs's own refusal covers only the exact device path, not partitions or holders.

Verification

cargo test --all --all-features passes, clippy is clean.

Probed against real devices on a development host:

/dev/nvme0n1     => Err(DeviceInUse { mount_point: "/boot" })    # disk, partition mounted
/dev/nvme0n1p1   => Err(DeviceInUse { mount_point: "/boot" })    # the partition itself
/dev/nvme0n1p2   => Ok(())                                       # unmounted sibling
/dev/sda2        => Err(DeviceInUse { mount_point: "/tmp/..." }) # LUKS/dm holder chain
/dev/mapper/keys => Err(DeviceInUse { mount_point: "/tmp/..." })

The /dev/sda2 case surfaced on its own: the imxkobs fixture used /dev/sda1 and /dev/sda2 as placeholders and the new check rejected them, because /dev/sda2 really is a LUKS PV on that host. The fixture now names devices which do not exist, which also exercises the not-found path where a missing device returns Ok and leaves the existence complaint to valid().

Tests cover mountinfo parsing with and without the optional fields, malformed lines, a regular file and a missing device. A loop-device round trip is included as #[ignore]d, following the loopdev pattern the copy/tarball tests already use and sharing their SERIALIZE mutex; note CI runs cargo test without --ignored, so it documents intent rather than running in the pipeline.

Known limitations

mount_sources_for enumerates the spellings a kernel might have recorded for an MTD or UBI target rather than resolving each mountinfo source forward to a canonical device. A spelling that is missing fails open. UBIFS superblocks carry anonymous devnos so some name-based reasoning is unavoidable, but the direction of the mapping could be inverted later, using the fstype field the parser already walks past.

Nothing stopped an update from writing over storage the running system still
had mounted. The requirements check only asked whether the target existed and
was writable, so a raw object pointed at the live rootfs was written straight
through the block device, behind the back of a filesystem which went on
serving its own page cache and writing it back over what had been installed.

Every handler reaching the storage directly is affected, and imxkobs validated
nothing at all, not even that the chips it writes to exist. The two which go
through a filesystem, copy and tarball, are fine while they only write files,
but both format the target first when asked to, and utils::fs::format passes
-F, -f or -y so the mkfs tools run unattended, which also means they will not
refuse a mounted device on their own.

ensure_not_mounted() reads /proc/self/mountinfo and rejects a target backing a
mounted filesystem. Devices are compared by number rather than by path, so the
by-uuid link, /dev/root and the device mapper name all resolve to the same
device, and sysfs extends the comparison to the storage the target shares: the
partitions a disk holds, the disk a partition belongs to, and whatever is
stacked on top through LVM, MD or loop. Sibling partitions are left out, as
writing to one does not reach the others.

UBIFS and JFFS2 mount no block device, so there is no number to compare and
mountinfo carries a name instead, as ubi0:rootfs or mtd:rootfs. Those are
rebuilt from /sys/class/mtd and /sys/class/ubi, which also covers erasing an
MTD out from under the UBI volumes attached to it.

Rather than let a handler ask for the check separately, and leave the next one
free to forget it, valid() now takes how the handler reaches its target. Every
handler already calls it, so each is made to say whether it writes the device
itself or goes through a filesystem it mounts, and the safe answer is no
longer the one you have to remember to ask for. copy and tarball answer with
whichever applies, as formatting takes the device for itself even though the
rest of their work does not.

The whole package is therefore rejected during validation, before anything is
downloaded and before an earlier object has written to storage. format() keeps
a check of its own, so the mkfs calls stay guarded no matter who reaches them.

The imxkobs tests named /dev/sda1 and /dev/sda2 as stand-ins for chips they
never touch, which the check now rejects on any host where those are mounted,
so they name devices which do not exist instead.
@otavio
otavio force-pushed the protect-in-use-partitions branch from bc75fbc to 50421e3 Compare July 28, 2026 21:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Protect to avoid installing over in use partitions

1 participant