Skip to content

Commit 6c9f64f

Browse files
committed
store: Preserve /sysroot readonly for read-only operations
PR bootc-dev#1718 introduced a regression where /sysroot was left writable after running `bootc status`. This occurred because BootedStorage::new() unconditionally calls set_mount_namespace_in_use(), which tells ostree it can safely remount /sysroot as writable. When sysroot.load() is called without actually being in a mount namespace, it leaves the global /sysroot writable. Fix by introducing an Environment enum that detects the runtime environment (ostree, composefs, container, or other) early in the execution flow. Callers now detect the environment and call prepare_for_write() if needed before constructing BootedStorage. This ensures a single call to prepare_for_write() per execution path and eliminates the previous layering violation where storage code called into CLI code. The Environment abstraction also makes it clearer when mount namespace setup is required and provides a foundation for future environment-specific behavior. Assisted-by: Claude Code (Sonnet 4.5) Signed-off-by: Colin Walters <[email protected]>
1 parent d596a32 commit 6c9f64f

File tree

5 files changed

+106
-43
lines changed

5 files changed

+106
-43
lines changed

crates/lib/src/bootc_composefs/status.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ use crate::composefs_consts::{
3636
use crate::spec::Bootloader;
3737

3838
/// A parsed composefs command line
39+
#[derive(Clone)]
3940
pub(crate) struct ComposefsCmdline {
4041
#[allow(dead_code)]
4142
pub insecure: bool,

crates/lib/src/cli.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -721,8 +721,11 @@ pub(crate) fn ensure_self_unshared_mount_namespace() -> Result<()> {
721721
/// This prepares the process for write operations (re-exec, mount namespace, etc).
722722
#[context("Initializing storage")]
723723
pub(crate) async fn get_storage() -> Result<crate::store::BootedStorage> {
724-
prepare_for_write()?;
725-
let r = BootedStorage::new()
724+
let env = crate::store::Environment::detect()?;
725+
if env.needs_mount_namespace() {
726+
prepare_for_write()?;
727+
}
728+
let r = BootedStorage::new(env)
726729
.await?
727730
.ok_or_else(|| anyhow!("System not booted via bootc"))?;
728731
Ok(r)

crates/lib/src/status.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,12 @@ pub(crate) fn get_status(
369369
}
370370

371371
async fn get_host() -> Result<Host> {
372-
let Some(storage) = BootedStorage::new().await? else {
372+
let env = crate::store::Environment::detect()?;
373+
if env.needs_mount_namespace() {
374+
crate::cli::prepare_for_write()?;
375+
}
376+
377+
let Some(storage) = BootedStorage::new(env).await? else {
373378
// If we're not booted, then return a default.
374379
return Ok(Host::default());
375380
};

crates/lib/src/store/mod.rs

Lines changed: 81 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,43 @@ pub struct BootedComposefs {
9797

9898
/// Discriminated union representing the boot storage backend.
9999
///
100+
/// The runtime environment in which bootc is executing.
101+
pub(crate) enum Environment {
102+
/// System booted via ostree
103+
OstreeBooted,
104+
/// System booted via composefs
105+
ComposefsBooted(ComposefsCmdline),
106+
/// Running in a container
107+
Container,
108+
/// Other (not booted via bootc)
109+
Other,
110+
}
111+
112+
impl Environment {
113+
/// Detect the current runtime environment.
114+
pub(crate) fn detect() -> Result<Self> {
115+
if ostree_ext::container_utils::running_in_container() {
116+
return Ok(Self::Container);
117+
}
118+
119+
if let Some(cmdline) = composefs_booted()? {
120+
return Ok(Self::ComposefsBooted(cmdline.clone()));
121+
}
122+
123+
if ostree_booted()? {
124+
return Ok(Self::OstreeBooted);
125+
}
126+
127+
Ok(Self::Other)
128+
}
129+
130+
/// Returns true if this environment requires entering a mount namespace
131+
/// before loading storage (to avoid leaving /sysroot writable).
132+
pub(crate) fn needs_mount_namespace(&self) -> bool {
133+
matches!(self, Self::OstreeBooted | Self::ComposefsBooted(_))
134+
}
135+
}
136+
100137
/// A system can boot via either ostree or composefs; this enum
101138
/// allows code to handle both cases while maintaining type safety.
102139
pub(crate) enum BootedStorageKind<'a> {
@@ -105,54 +142,58 @@ pub(crate) enum BootedStorageKind<'a> {
105142
}
106143

107144
impl BootedStorage {
108-
/// Create a new booted storage accessor.
109-
///
110-
/// This detects whether the system is booted via composefs or ostree
111-
/// and initializes the appropriate storage backend.
145+
/// Create a new booted storage accessor for the given environment.
112146
///
113-
/// Note: For write operations, callers should call `prepare_for_write()`
114-
/// before calling this function to ensure the process is in the correct
115-
/// mount namespace.
116-
pub(crate) async fn new() -> Result<Option<Self>> {
147+
/// The caller must have already called `prepare_for_write()` if
148+
/// `env.needs_mount_namespace()` is true.
149+
pub(crate) async fn new(env: Environment) -> Result<Option<Self>> {
117150
let physical_root = Dir::open_ambient_dir("/sysroot", cap_std::ambient_authority())
118151
.context("Opening /sysroot")?;
119152

120153
let run =
121154
Dir::open_ambient_dir("/run", cap_std::ambient_authority()).context("Opening /run")?;
122155

123-
let r = if let Some(cmdline) = composefs_booted()? {
124-
let mut composefs = ComposefsRepository::open_path(&physical_root, COMPOSEFS)?;
125-
if cmdline.insecure {
126-
composefs.set_insecure(true);
156+
let r = match env {
157+
Environment::ComposefsBooted(cmdline) => {
158+
let mut composefs = ComposefsRepository::open_path(&physical_root, COMPOSEFS)?;
159+
if cmdline.insecure {
160+
composefs.set_insecure(true);
161+
}
162+
let composefs = Arc::new(composefs);
163+
164+
let storage = Storage {
165+
physical_root,
166+
run,
167+
ostree: Default::default(),
168+
composefs: OnceCell::from(composefs),
169+
imgstore: Default::default(),
170+
};
171+
172+
Some(Self { storage })
127173
}
128-
let composefs = Arc::new(composefs);
129-
130-
let storage = Storage {
131-
physical_root,
132-
run,
133-
ostree: Default::default(),
134-
composefs: OnceCell::from(composefs),
135-
imgstore: Default::default(),
136-
};
137-
138-
Some(Self { storage })
139-
} else if ostree_booted()? {
140-
let sysroot = ostree::Sysroot::new_default();
141-
sysroot.set_mount_namespace_in_use();
142-
let sysroot = ostree_ext::sysroot::SysrootLock::new_from_sysroot(&sysroot).await?;
143-
sysroot.load(gio::Cancellable::NONE)?;
144-
145-
let storage = Storage {
146-
physical_root,
147-
run,
148-
ostree: OnceCell::from(sysroot),
149-
composefs: Default::default(),
150-
imgstore: Default::default(),
151-
};
152-
153-
Some(Self { storage })
154-
} else {
155-
None
174+
Environment::OstreeBooted => {
175+
// The caller must have entered a private mount namespace before
176+
// calling this function. This is because ostree's sysroot.load() will
177+
// remount /sysroot as writable, and we call set_mount_namespace_in_use()
178+
// to indicate we're in a mount namespace. Without actually being in a
179+
// mount namespace, this would leave the global /sysroot writable.
180+
181+
let sysroot = ostree::Sysroot::new_default();
182+
sysroot.set_mount_namespace_in_use();
183+
let sysroot = ostree_ext::sysroot::SysrootLock::new_from_sysroot(&sysroot).await?;
184+
sysroot.load(gio::Cancellable::NONE)?;
185+
186+
let storage = Storage {
187+
physical_root,
188+
run,
189+
ostree: OnceCell::from(sysroot),
190+
composefs: Default::default(),
191+
imgstore: Default::default(),
192+
};
193+
194+
Some(Self { storage })
195+
}
196+
Environment::Container | Environment::Other => None,
156197
};
157198
Ok(r)
158199
}

tmt/tests/booted/readonly/001-test-status.nu

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@ use tap.nu
33

44
tap begin "verify bootc status output formats"
55

6+
# Verify /sysroot is not writable initially (read-only operations should not make it writable)
7+
let is_writable = (do -i { /bin/test -w /sysroot } | complete | get exit_code) == 0
8+
assert (not $is_writable) "/sysroot should not be writable initially"
9+
10+
# Double-check with findmnt
11+
let mnt = (findmnt /sysroot -J | from json)
12+
let opts = ($mnt.filesystems.0.options | split row ",")
13+
assert ($opts | any { |o| $o == "ro" }) "/sysroot should be mounted read-only"
14+
615
let st = bootc status --json | from json
716
assert equal $st.apiVersion org.containers.bootc/v1
817

@@ -31,4 +40,8 @@ if not $is_composefs {
3140
assert (($st.status | get rollback | default null) == null)
3241
assert (($st.status | get staged | default null) == null)
3342

43+
# Verify /sysroot is still not writable after bootc status (regression test for PR #1718)
44+
let is_writable = (do -i { /bin/test -w /sysroot } | complete | get exit_code) == 0
45+
assert (not $is_writable) "/sysroot should remain read-only after bootc status"
46+
3447
tap ok

0 commit comments

Comments
 (0)