Skip to content
Draft
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
30 changes: 21 additions & 9 deletions src/action/common/provision_nix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,14 @@ Place Nix and it's requirements onto the target
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]
#[serde(tag = "action_name", rename = "provision_nix")]
pub struct ProvisionNix {
nix_store_gid: u32,
/// `None` when no build group is being created (`--nix-build-user-count 0`),
/// which also covers `--rootless`. Chowning to a nonexistent gid only
/// produces a wall of EINVAL warnings in a single-uid user namespace.
///
/// Receipt compatibility: old receipts store this as a bare integer,
/// which serde deserializes into `Some(gid)`. `Some` serializes back to
/// a bare integer, so non-rootless receipts are byte-identical to before.
nix_store_gid: Option<u32>,

pub(crate) fetch_nix: StatefulAction<FetchAndUnpackNix>,
pub(crate) create_nix_tree: StatefulAction<CreateNixTree>,
Expand All @@ -35,7 +42,8 @@ impl ProvisionNix {
let move_unpacked_nix =
MoveUnpackedNix::plan(PathBuf::from(SCRATCH_DIR)).map_err(Self::error)?;
Ok(Self {
nix_store_gid: settings.nix_build_group_id,
nix_store_gid: (settings.nix_build_user_count > 0)
.then_some(settings.nix_build_group_id),
fetch_nix,
create_nix_tree,
move_unpacked_nix,
Expand Down Expand Up @@ -71,12 +79,14 @@ impl Action for ProvisionNix {
buf.append(&mut create_nix_tree.describe_execute());
buf.append(&mut move_unpacked_nix.describe_execute());

buf.push(ActionDescription::new(
"Synchronize /nix/store ownership".to_string(),
vec![format!(
"Will update existing files in the Nix Store to use the Nix build group ID {nix_store_gid}"
)],
));
if let Some(gid) = nix_store_gid {
buf.push(ActionDescription::new(
"Synchronize /nix/store ownership".to_string(),
vec![format!(
"Will update existing files in the Nix Store to use the Nix build group ID {gid}"
)],
));
}

buf
}
Expand All @@ -90,7 +100,9 @@ impl Action for ProvisionNix {

self.move_unpacked_nix.try_execute().map_err(Self::error)?;

ensure_nix_store_group(self.nix_store_gid).map_err(Self::error)?;
if let Some(gid) = self.nix_store_gid {
ensure_nix_store_group(gid).map_err(Self::error)?;
}

Ok(())
}
Expand Down
25 changes: 23 additions & 2 deletions src/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,14 @@ impl InstallPlan {
if self.daemon_expected() {
Self::wait_for_daemon_socket();
}
if let Err(err) = crate::self_test::self_test().map_err(NixInstallerError::SelfTest) {
tracing::warn!("{err:?}")
if self.shell_profile_modified() {
if let Err(err) = crate::self_test::self_test().map_err(NixInstallerError::SelfTest) {
tracing::warn!("{err:?}")
}
} else {
tracing::debug!(
"skipping self-test: shell profile not modified, `nix` won't be on PATH"
);
}

Ok(())
Expand Down Expand Up @@ -345,6 +351,21 @@ impl InstallPlan {
/// This checks the planner settings for `start_daemon` (Linux) and
/// `init` (all platforms). macOS always starts the daemon so we default
/// to `true` when the setting is absent.
/// The self-test execs `sh -lc "nix build ..."`, which only finds `nix`
/// if we wrote the shell profile hooks. `--no-modify-profile` (and
/// therefore `--rootless`) leave PATH alone, so the test would just
/// report "nix: not found" and alarm the user about a perfectly
/// functional install.
fn shell_profile_modified(&self) -> bool {
match self.planner.settings() {
Ok(s) => s
.get("modify_profile")
.and_then(|v| v.as_bool())
.unwrap_or(true),
Err(_) => true,
}
}

fn daemon_expected(&self) -> bool {
let settings = match self.planner.settings() {
Ok(s) => s,
Expand Down
104 changes: 90 additions & 14 deletions src/planner/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,54 @@ pub struct Linux {
pub settings: CommonSettings,
#[cfg_attr(feature = "cli", clap(flatten))]
pub init: InitSettings,

/// Install without touching anything outside `/nix`.
///
/// Intended for running inside an unprivileged user namespace (as set up
/// by nix-user-chroot, toolbox, or a plain `unshare -Urm`) where `/etc`
/// is a read-only bind mount from the host and only a single uid/gid is
/// mapped. In that environment `groupadd` cannot allocate the nixbld gid
/// and any write under `/etc` fails with EROFS.
///
/// Implies `--init none`, `--nix-build-user-count 0`, `--no-modify-profile`
/// and `--skip-nix-conf`, and additionally suppresses the `/etc/tmpfiles.d`
/// and `/etc/environment` writes that are otherwise unconditional.
///
/// The resulting install is single-user and daemonless. Callers are
/// responsible for setting `NIX_CONF_DIR` (typically `/nix/etc/nix`) and
/// putting the profile bin dir on `PATH`.
#[cfg_attr(
feature = "cli",
clap(
long,
action(clap::ArgAction::SetTrue),
default_value = "false",
env = "NIX_INSTALLER_ROOTLESS"
)
)]
#[serde(default)]
pub rootless: bool,
}

impl Linux {
/// Apply the implications of `--rootless` to the flattened settings.
///
/// clap has no "this flag forces these other flags" primitive, so we
/// resolve it here before planning. Returns the effective settings so
/// `plan()` can stay oblivious to whether the user spelled out the
/// individual flags or used the shorthand.
fn resolve_rootless(&self) -> (CommonSettings, InitSettings) {
let mut settings = self.settings.clone();
let mut init = self.init.clone();
if self.rootless {
settings.nix_build_user_count = 0;
settings.modify_profile = false;
settings.skip_nix_conf = true;
init.init = InitSystem::None;
init.start_daemon = false;
}
(settings, init)
}
}

#[typetag::serde(name = "linux")]
Expand All @@ -35,10 +83,12 @@ impl Planner for Linux {
Ok(Self {
settings: CommonSettings::try_default()?,
init: InitSettings::try_default()?,
rootless: false,
})
}

fn plan(&self) -> Result<Vec<StatefulAction<Box<dyn Action>>>, PlannerError> {
let (settings, init) = self.resolve_rootless();
let has_selinux = detect_selinux()?;

let mut shell_profile_locations = ShellProfileLocations::default();
Expand Down Expand Up @@ -75,18 +125,30 @@ impl Planner for Linux {
CreateDirectory::plan("/nix", None, None, 0o0755, true)
.map_err(PlannerError::Action)?
.boxed(),
ProvisionNix::plan(&self.settings.clone())
ProvisionNix::plan(&settings)
.map_err(PlannerError::Action)?
.boxed(),
CreateUsersAndGroups::plan(self.settings.clone())
.map_err(PlannerError::Action)?
.boxed(),
ConfigureNix::plan(shell_profile_locations, &self.settings)
];

// A build-user count of 0 means no nixbld group/users. Skipping the
// action entirely (rather than letting it plan an empty user list)
// avoids the unconditional groupadd, which cannot succeed inside a
// single-uid user namespace.
if settings.nix_build_user_count > 0 {
plan.push(
CreateUsersAndGroups::plan(settings.clone())
.map_err(PlannerError::Action)?
.boxed(),
);
}

plan.push(
ConfigureNix::plan(shell_profile_locations, &settings)
.map_err(PlannerError::Action)?
.boxed(),
];
);

if LinuxDistro::detect() == LinuxDistro::Arch {
if !self.rootless && LinuxDistro::detect() == LinuxDistro::Arch {
// On Arch, /etc/bash.bashrc guards non-interactive shells with
// `[[ $- != *i* ]] && return`, so the Nix snippet we prepend there
// never runs for `ssh host 'command'` (non-interactive, non-login).
Expand Down Expand Up @@ -114,11 +176,18 @@ impl Planner for Linux {
);
}

// tmpfiles.d is only consulted by systemd-tmpfiles; a rootless
// install has no init and /etc is read-only anyway.
if !self.rootless {
plan.push(
CreateDirectory::plan("/etc/tmpfiles.d", None, None, 0o0755, false)
.map_err(PlannerError::Action)?
.boxed(),
);
}

plan.extend([
CreateDirectory::plan("/etc/tmpfiles.d", None, None, 0o0755, false)
.map_err(PlannerError::Action)?
.boxed(),
ConfigureUpstreamInitService::plan(self.init.init, self.init.start_daemon)
ConfigureUpstreamInitService::plan(init.init, init.start_daemon)
.map_err(PlannerError::Action)?
.boxed(),
RemoveDirectory::plan(crate::settings::SCRATCH_DIR)
Expand All @@ -130,11 +199,16 @@ impl Planner for Linux {
}

fn settings(&self) -> Result<HashMap<String, serde_json::Value>, InstallSettingsError> {
let Self { settings, init } = self;
// Report the *resolved* settings so downstream consumers (the receipt,
// daemon_expected(), configured_settings diffing) see what --rootless
// actually implied rather than the raw clap defaults. Otherwise the
// plan executor waits 10s for a daemon socket that will never appear.
let (settings, init) = self.resolve_rootless();
let mut map = HashMap::default();

map.extend(settings.settings()?);
map.extend(init.settings()?);
map.insert("rootless".into(), serde_json::to_value(self.rootless)?);

Ok(map)
}
Expand Down Expand Up @@ -165,23 +239,25 @@ impl Planner for Linux {
}

fn pre_uninstall_check(&self) -> Result<(), PlannerError> {
let (_, init) = self.resolve_rootless();
check_not_wsl1()?;

if self.init.init == InitSystem::Systemd && self.init.start_daemon {
if init.init == InitSystem::Systemd && init.start_daemon {
check_systemd_active()?;
}

Ok(())
}

fn pre_install_check(&self) -> Result<(), PlannerError> {
let (_, init) = self.resolve_rootless();
check_not_nixos()?;

check_nix_not_already_installed()?;

check_not_wsl1()?;

if self.init.init == InitSystem::Systemd && self.init.start_daemon {
if init.init == InitSystem::Systemd && init.start_daemon {
check_systemd_active()?;
}

Expand Down
3 changes: 3 additions & 0 deletions src/profile/nixenv/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,9 @@ impl NixCommandExt for std::process::Command {
Ok(self
.args(["--option", "substitute", "false"])
.args(["--option", "post-build-hook", ""])
// See the identical comment in nixprofile/mod.rs.
.args(["--option", "experimental-features", "nix-command flakes"])
.args(["--option", "build-users-group", ""])
.env_remove("NIX_REMOTE")
.env("HOME", dirs::home_dir().ok_or(super::Error::NoRootHome)?)
.env(
Expand Down
9 changes: 9 additions & 0 deletions src/profile/nixprofile/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,15 @@ impl NixCommandExt for std::process::Command {
Ok(self
.args(["--option", "substitute", "false"])
.args(["--option", "post-build-hook", ""])
// Don't assume the nix.conf we wrote is visible to this process:
// a rootless install may have placed it outside /etc/nix, or a
// caller may have NIX_CONF_DIR pointed elsewhere. Passing the
// required settings explicitly keeps profile setup self-contained.
// build-users-group must be cleared because nix's compiled-in
// default is "nixbld", which doesn't exist when installing with
// --nix-build-user-count 0.
.args(["--option", "experimental-features", "nix-command flakes"])
.args(["--option", "build-users-group", ""])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like a pretty bad idea... considering that if any build is run with this flag configured (and the installer is privileged) the build would be run without dropping privileges. Doesn't seem great at all

.env_remove("NIX_REMOTE")
.env("HOME", dirs::home_dir().ok_or(super::Error::NoRootHome)?)
.env(
Expand Down
Loading