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
2 changes: 2 additions & 0 deletions tools/tailor/crates/tailor-core/src/ports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,8 @@ pub struct ContainerConfig {
pub args: Vec<String>,
pub binds: Vec<String>,
pub privileged: bool,
/// Disable SELinux container labeling for this request only (janitor cleanup on labeled hosts).
pub disable_selinux_label: bool,
/// The cell slug, tagged onto every re-emitted IC log event (`cell = <slug>`); empty for
/// non-cell containers such as the ownership janitor (`meta/docs/2026-06-29-logging.md` §5.3).
pub cell_slug: String,
Expand Down
78 changes: 65 additions & 13 deletions tools/tailor/crates/tailor-exec/src/container/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,23 +181,13 @@ impl ContainerRuntime for BollardRuntime {
let cell_slug = config.cell_slug.clone();
let log_source = config.log_source;
let log_file = config.log_file.clone();
let docker_config = DockerConfig {
image: Some(config.image_ref),
cmd: Some(config.args),
attach_stdout: Some(true),
attach_stderr: Some(true),
host_config: Some(HostConfig {
binds: Some(config.binds),
privileged: Some(config.privileged),
..Default::default()
}),
..Default::default()
};
let platform = config.platform.clone();
let docker_config = container_docker_config(config);
self.docker
.create_container(
Some(CreateContainerOptions {
name: name.clone(),
platform: Some(config.platform),
platform: Some(platform),
}),
docker_config,
)
Expand Down Expand Up @@ -313,6 +303,24 @@ impl ContainerRuntime for BollardRuntime {
}
}

fn container_docker_config(config: ContainerConfig) -> DockerConfig<String> {
DockerConfig {
image: Some(config.image_ref),
cmd: Some(config.args),
attach_stdout: Some(true),
attach_stderr: Some(true),
host_config: Some(HostConfig {
binds: Some(config.binds),
privileged: Some(config.privileged),
security_opt: config
.disable_selinux_label
.then(|| vec!["label=disable".to_owned()]),
..Default::default()
}),
..Default::default()
}
}

/// A [`ContainerRuntime`] that never connects to an engine — the stand-in for `build --dry-run`,
/// which renders container invocations without contacting any daemon (`meta/docs/2026-06-29-container-runtimes.md`
/// §4). Dry-run execution returns before touching the runtime, so these methods are never reached; if
Expand Down Expand Up @@ -479,6 +487,50 @@ fn map_runtime_error(err: &BollardError) -> ExecError {
mod tests {
use super::*;

#[test]
fn selinux_label_opt_out_is_per_container_not_a_runtime_default() {
let ic = ContainerConfig {
image_ref: "example/ic".to_owned(),
platform: "linux/amd64".to_owned(),
name: "ic".to_owned(),
args: vec!["customize".to_owned()],
binds: vec!["/build:/build".to_owned()],
privileged: true,
disable_selinux_label: false,
cell_slug: "cell".to_owned(),
log_source: LogSource::ImageCustomizer,
log_file: None,
};
let normal = container_docker_config(ic.clone());
let host = normal.host_config.as_ref().unwrap();
assert!(host.security_opt.is_none());
assert_eq!(host.privileged, Some(true));
assert!(
serde_json::to_value(&normal).unwrap()["HostConfig"]
.get("SecurityOpt")
.is_none()
);

let janitor = container_docker_config(ContainerConfig {
privileged: false,
disable_selinux_label: true,
log_source: LogSource::Janitor,
..ic
});
let host = janitor.host_config.as_ref().unwrap();
assert_eq!(
host.security_opt.as_deref(),
Some(&["label=disable".to_owned()][..])
);
assert_eq!(host.privileged, Some(false));
let payload = serde_json::to_value(&janitor).unwrap();
assert_eq!(
payload["HostConfig"]["SecurityOpt"],
serde_json::json!(["label=disable"])
);
assert_eq!(payload["HostConfig"]["Privileged"], false);
}

#[test]
fn parse_daemon_info_detects_docker_rootless_and_userns() {
let options = vec!["name=seccomp".to_owned(), "name=rootless".to_owned()];
Expand Down
1 change: 1 addition & 0 deletions tools/tailor/crates/tailor-exec/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ impl<R: ContainerRuntime> IcExecutor<R> {
args,
binds: arg_builder::container_binds(cell, context, extra_rw)?,
privileged: context.runtime.privileged,
disable_selinux_label: false,
cell_slug: cell.slug.as_ref().to_owned(),
log_source: tailor_core::LogSource::ImageCustomizer,
log_file: log_file.cloned(),
Expand Down
101 changes: 93 additions & 8 deletions tools/tailor/crates/tailor-exec/src/janitor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,18 +69,12 @@ pub(crate) async fn remove_paths<R: ContainerRuntime>(
}
let mut args = vec![RM_BINARY.to_owned(), "-rf".to_owned(), "--".to_owned()];
args.extend(existing.iter().map(|path| path_arg(path)));
// Bind each target's PARENT, not the target itself. If we identity-bound the target, it would be
// an active mountpoint inside the janitor container: `rm -rf` clears its contents but cannot
// `rmdir` a busy mountpoint, so the top directory survives and rm exits non-zero (EBUSY) — the
// recurring "cannot remove '<dir>': Device or resource busy" that failed every successful build.
// With the parent bound, the target is an ordinary child directory that rm removes top-and-all.
// Binding the target makes it a mountpoint that `rm` cannot remove (EBUSY).
let binds = removal_binds(&existing)?;
run_janitor(runtime, config, args, binds, cancel).await
}

/// Bind sources for a removal: each distinct target **parent**, guarded so the janitor never binds
/// `/`, an ancestor of the working directory, or another unsafe directory. The target itself is then
/// an ordinary child under its bound parent (see [`remove_paths`]).
/// Bind each target's parent for removal; refuse binding `/`.
fn removal_binds(paths: &[PathBuf]) -> Result<Vec<String>, ExecError> {
let mut parents = std::collections::BTreeSet::new();
for path in paths {
Expand Down Expand Up @@ -118,6 +112,7 @@ async fn run_janitor<R: ContainerRuntime>(
args,
binds,
privileged: false,
disable_selinux_label: true,
cell_slug: String::new(),
log_source: LogSource::Janitor,
log_file: None,
Expand Down Expand Up @@ -152,6 +147,96 @@ fn path_arg(path: &Path) -> String {
mod tests {
use super::*;

use std::sync::Mutex;

use tailor_core::{ContainerResult, DaemonInfo, LocalImage};

#[derive(Default)]
struct RecordingRuntime {
configs: Mutex<Vec<ContainerConfig>>,
}

impl ContainerRuntime for RecordingRuntime {
async fn pull_image(&self, _reference: &str) -> Result<(), ExecError> {
Ok(())
}

async fn inspect_image(&self, _reference: &str) -> Result<Option<LocalImage>, ExecError> {
Ok(None)
}

async fn create_and_run(
&self,
config: ContainerConfig,
_cancel: CancellationToken,
) -> Result<ContainerResult, ExecError> {
self.configs.lock().unwrap().push(config);
Ok(ContainerResult {
exit_code: 0,
logs: String::new(),
failure_dump: None,
})
}

async fn daemon_info(&self) -> Result<DaemonInfo, ExecError> {
Ok(DaemonInfo::default())
}

async fn export_container(
&self,
_image_ref: &str,
_platform: &str,
_pull: bool,
_dest_dir: &Path,
_cancel: CancellationToken,
) -> Result<(), ExecError> {
Ok(())
}
}

#[tokio::test]
async fn janitor_requests_disable_selinux_labels_without_privilege_or_broad_mounts() {
let dir = tempfile::Builder::new()
.prefix("tailor-janitor-")
.tempdir_in(std::env::current_dir().unwrap())
.unwrap();
let target = dir.path().join("tools-dir");
std::fs::create_dir(&target).unwrap();
let runtime = RecordingRuntime::default();
let config = RuntimeConfig {
janitor_image: "example/janitor@sha256:abc".to_owned(),
..RuntimeConfig::default()
};

chown_paths(
&runtime,
std::slice::from_ref(&target),
&config,
CancellationToken::new(),
)
.await
.unwrap();
remove_paths(
&runtime,
std::slice::from_ref(&target),
&config,
CancellationToken::new(),
)
.await
.unwrap();

let configs = runtime.configs.lock().unwrap();
assert_eq!(configs.len(), 2);
for request in configs.iter() {
assert!(request.disable_selinux_label);
assert!(!request.privileged);
assert_eq!(request.log_source, LogSource::Janitor);
}
assert_eq!(configs[0].binds, vec![identity_bind(&target)]);
assert_eq!(configs[1].binds, vec![identity_bind(dir.path())]);
assert!(target.exists(), "mock janitor must not delete host files");
}

#[test]
fn host_platform_matches_the_running_arch() {
let platform = host_platform();
Expand Down
13 changes: 10 additions & 3 deletions tools/tailor/docs/explanation/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,16 @@ load-bearing — treat a regression in them as a serious bug:
artifact are written to a temporary file and renamed into place, so an
interrupted build cannot leave torn state a later run would trust. See
`crates/tailor-core/src/atomic.rs`.
- **Scoped privileged cleanup.** The janitor only chowns/removes the specific
paths a build produced (the build directory's children, the image cache, the
per-cell log), never arbitrary locations.
- **Scoped cleanup.** The janitor binds a target for chown or its parent for
removal; removal refuses a parent resolving to `/`. A bound parent can still
contain unrelated files. On SELinux hosts, janitors request `label=disable`
without relabeling host files; IC is unchanged and janitors remain
`Privileged: false`. A Fedora 43/Docker 29.8.2 probe with the janitor image
ran as `system_u:system_r:spc_t:s0`, not `unconfined_t`; other engines may
assign a different type. With a rootful daemon, container root can override
ordinary file permissions within these writable binds; use a trusted janitor
image. Tailor does not check for live loop devices or mounts before removal;
do not clean live build trees.

## Supply-chain pinning

Expand Down
Loading