Skip to content
Merged
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
13 changes: 9 additions & 4 deletions src/cli/src/commands/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,15 @@ pub async fn execute(args: CommitArgs) -> Result<(), Box<dyn std::error::Error>>
let state = StateFile::load_default()?;
let record = resolve::resolve(&state, &args.name)?;

let rootfs_dir = record.box_dir.join("rootfs");
if !rootfs_dir.exists() {
return Err(format!("Rootfs not found at {}", rootfs_dir.display()).into());
}
let rootfs_dir = super::resolve_box_rootfs(&record.box_dir).ok_or_else(|| {
format!(
"Rootfs not found for box '{}' under {} (looked for merged/ and rootfs/). \
For overlay-backed boxes the filesystem is only available while the box exists; \
commit a running box.",
args.name,
record.box_dir.display()
)
})?;

let reference = args.repository.unwrap_or_else(|| {
format!(
Expand Down
19 changes: 13 additions & 6 deletions src/cli/src/commands/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,13 @@ pub async fn execute(args: DiffArgs) -> Result<(), Box<dyn std::error::Error>> {
let state = StateFile::load_default()?;
let record = resolve::resolve(&state, &args.name)?;

let rootfs_dir = record.box_dir.join("rootfs");
if !rootfs_dir.exists() {
return Err(format!("Rootfs not found at {}", rootfs_dir.display()).into());
}
let rootfs_dir = super::resolve_box_rootfs(&record.box_dir).ok_or_else(|| {
format!(
"Rootfs not found for box '{}' under {} (looked for merged/ and rootfs/)",
args.name,
record.box_dir.display()
)
})?;

// Snapshot the original image to compare against
let snapshot_path = record.box_dir.join("rootfs_snapshot.json");
Expand Down Expand Up @@ -102,9 +105,13 @@ pub async fn execute(args: DiffArgs) -> Result<(), Box<dyn std::error::Error>> {
pub(crate) fn create_box_baseline_snapshot(
box_dir: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
let rootfs_dir = box_dir.join("rootfs");
let snapshot_path = box_dir.join("rootfs_snapshot.json");
if rootfs_dir.exists() && !snapshot_path.exists() {
if snapshot_path.exists() {
return Ok(());
}
// Resolve the provider's rootfs: `merged` (overlay) is the freshly-mounted
// pristine image at boot time; `rootfs` (plain provider) likewise.
if let Some(rootfs_dir) = super::resolve_box_rootfs(box_dir) {
create_snapshot(&rootfs_dir, &snapshot_path)?;
}
Ok(())
Expand Down
13 changes: 9 additions & 4 deletions src/cli/src/commands/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,15 @@ pub async fn execute(args: ExportArgs) -> Result<(), Box<dyn std::error::Error>>
let state = StateFile::load_default()?;
let record = resolve::resolve(&state, &args.name)?;

let rootfs_dir = record.box_dir.join("rootfs");
if !rootfs_dir.exists() {
return Err(format!("Rootfs not found at {}", rootfs_dir.display()).into());
}
let rootfs_dir = super::resolve_box_rootfs(&record.box_dir).ok_or_else(|| {
format!(
"Rootfs not found for box '{}' under {} (looked for merged/ and rootfs/). \
For overlay-backed boxes the filesystem is only available while the box exists; \
export a running box.",
args.name,
record.box_dir.display()
)
})?;

let file = std::fs::File::create(&args.output)
.map_err(|e| format!("Failed to create {}: {e}", args.output))?;
Expand Down
25 changes: 25 additions & 0 deletions src/cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,31 @@ pub(crate) fn open_image_store() -> Result<a3s_box_runtime::ImageStore, Box<dyn
Ok(store)
}

/// Resolve a box's on-disk full root filesystem directory.
///
/// The overlay provider (default on Linux) materializes the rootfs at
/// `<box_dir>/merged`, while the plain/copy provider uses `<box_dir>/rootfs`.
/// Returns the first that exists and is non-empty so that `export`/`commit`
/// work regardless of provider. Returns `None` if neither is available (e.g.
/// the overlay is unmounted because the box is stopped).
pub(crate) fn resolve_box_rootfs(box_dir: &std::path::Path) -> Option<PathBuf> {
let is_populated = |p: &std::path::Path| -> bool {
p.is_dir()
&& std::fs::read_dir(p)
.map(|mut it| it.next().is_some())
.unwrap_or(false)
};
let merged = box_dir.join("merged");
if is_populated(&merged) {
return Some(merged);
}
let rootfs = box_dir.join("rootfs");
if rootfs.is_dir() {
return Some(rootfs);
}
None
}

/// Tail a file, printing new content as it appears.
///
/// Waits for the file to exist, then continuously reads and prints new data.
Expand Down
15 changes: 13 additions & 2 deletions src/cli/src/commands/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,16 @@ async fn execute_create(args: SnapshotCreateArgs) -> Result<(), Box<dyn std::err
meta.description = desc.clone();
}

// Snapshot the rootfs
let rootfs_path = record.box_dir.join("rootfs");
// Snapshot the box's current root filesystem (overlay `merged` or the plain
// provider's `rootfs`), so runtime changes are captured — not an empty dir.
let rootfs_path = super::resolve_box_rootfs(&record.box_dir).ok_or_else(|| {
format!(
"Rootfs not found for box '{}' under {} (looked for merged/ and rootfs/); \
snapshot a running box",
record.name,
record.box_dir.display()
)
})?;
let store = SnapshotStore::default_path()?;
let saved = store.save(meta, &rootfs_path)?;

Expand Down Expand Up @@ -155,6 +163,9 @@ async fn execute_restore(args: SnapshotRestoreArgs) -> Result<(), Box<dyn std::e
let box_rootfs = box_dir.join("rootfs");
if snap_rootfs.exists() {
copy_dir_recursive_io(&snap_rootfs, &box_rootfs)?;
// Mark the box so the runtime boots directly from this restored rootfs
// instead of rebuilding from the image (preserves the snapshot's fs).
std::fs::write(box_dir.join(".snapshot-rootfs"), b"")?;
}

let record = BoxRecord {
Expand Down
30 changes: 26 additions & 4 deletions src/core/src/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,16 +253,21 @@ impl Ipam {
.parse()
.map_err(|e| format!("invalid prefix length '{}': {}", parts[1], e))?;

if prefix_len > 30 {
if prefix_len == 0 || prefix_len > 30 {
return Err(format!(
"prefix length {} too large (max 30 for usable hosts)",
"prefix length {} out of range (must be 1-30 for a usable subnet)",
prefix_len
));
}

// Gateway is network + 1
// Gateway is network + 1. Use checked arithmetic so a network address of
// 255.255.255.255 cannot overflow (panic in debug / wrap in release).
let net_u32 = u32::from(network);
let gateway = Ipv4Addr::from(net_u32 + 1);
let gateway = Ipv4Addr::from(
net_u32
.checked_add(1)
.ok_or_else(|| format!("network address '{}' has no room for a gateway", network))?,
);

Ok(Self {
network,
Expand Down Expand Up @@ -582,6 +587,23 @@ mod tests {
assert_eq!(ipam16.broadcast(), Ipv4Addr::new(172, 20, 255, 255));
}

#[test]
fn test_ipam_rejects_zero_and_oversized_prefix() {
// /0 previously caused a shift-overflow panic in broadcast()/capacity().
assert!(Ipam::new("0.0.0.0/0").is_err());
assert!(Ipam::new("10.0.0.0/31").is_err());
assert!(Ipam::new("10.0.0.0/32").is_err());
// Valid bounds still parse.
assert!(Ipam::new("10.0.0.0/1").is_ok());
assert!(Ipam::new("10.0.0.0/30").is_ok());
}

#[test]
fn test_ipam_gateway_overflow_is_rejected_not_panic() {
// 255.255.255.255 + 1 would overflow; must error, not panic.
assert!(Ipam::new("255.255.255.255/30").is_err());
}

#[test]
fn test_ipam_capacity() {
let ipam = Ipam::new("10.88.0.0/24").unwrap();
Expand Down
94 changes: 80 additions & 14 deletions src/runtime/src/network/passt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,19 @@ pub struct PasstManager {
impl PasstManager {
/// Create a new PasstManager.
///
/// The socket and PID file are placed under the box's sockets directory:
/// `~/.a3s/boxes/<box_id>/sockets/passt.sock`
/// `~/.a3s/boxes/<box_id>/sockets/passt.pid`
pub fn new(box_dir: &Path) -> Self {
let sockets_dir = box_dir.join("sockets");
/// The socket and PID file are placed directly in the provided runtime
/// socket directory (the same directory that holds the exec/PTY control
/// sockets, e.g. `/tmp/a3s-box-sockets/<box_id>`).
///
/// This directory MUST be reachable by the user passt runs as. When passt
/// is started as root it drops privileges to `nobody`, so the socket
/// directory has to be world-traversable — the box's `~/.a3s/boxes/<id>`
/// home is mode 0700 for root and would leave passt unable to bind its
/// socket, silently breaking all bridge networking and Compose.
pub fn new(socket_dir: &Path) -> Self {
Self {
socket_path: sockets_dir.join("passt.sock"),
pid_file: sockets_dir.join("passt.pid"),
socket_path: socket_dir.join("passt.sock"),
pid_file: socket_dir.join("passt.pid"),
child: None,
}
}
Expand All @@ -54,7 +59,7 @@ impl PasstManager {
prefix_len: u8,
dns_servers: &[Ipv4Addr],
) -> Result<()> {
// Ensure parent directory exists
// Ensure parent directory exists.
if let Some(parent) = self.socket_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
BoxError::NetworkError(format!(
Expand All @@ -63,6 +68,26 @@ impl PasstManager {
e
))
})?;

// passt drops privileges to `nobody` when launched as root, so the
// directory it binds its socket (and writes its PID file) in must be
// writable by that user. Widen the directory permissions; the path
// is an ephemeral, per-box runtime directory under a world-traversable
// base, so this only affects this box's control sockets.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) =
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o777))
{
tracing::warn!(
dir = %parent.display(),
error = %e,
"Failed to widen passt socket directory permissions; \
passt may be unable to bind its socket after dropping privileges"
);
}
}
}

// Remove stale socket if it exists
Expand Down Expand Up @@ -90,9 +115,23 @@ impl PasstManager {
cmd.arg("--dns").arg(dns.to_string());
}

// Suppress stdout/stderr to avoid noise
cmd.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
// Capture passt's stderr to a log file so spawn failures (bad args,
// unsupported flags, permission errors after dropping privileges) are
// diagnosable instead of silently discarded to /dev/null.
cmd.stdout(std::process::Stdio::null());
match self
.socket_path
.parent()
.map(|p| p.join("passt.stderr.log"))
.and_then(|p| std::fs::File::create(p).ok())
{
Some(file) => {
cmd.stderr(std::process::Stdio::from(file));
}
None => {
cmd.stderr(std::process::Stdio::null());
}
}

let child = cmd.spawn().map_err(|e| {
BoxError::NetworkError(format!(
Expand All @@ -118,18 +157,45 @@ impl PasstManager {
}

/// Wait for the passt socket to become available.
fn wait_for_socket(&self) -> Result<()> {
///
/// Also detects immediate passt exit (e.g. bad args or a permission failure
/// after dropping privileges) so the real cause is surfaced instead of a
/// misleading 5-second timeout.
fn wait_for_socket(&mut self) -> Result<()> {
let stderr_path = self.socket_path.parent().map(|p| p.join("passt.stderr.log"));
let read_stderr = |path: &Option<PathBuf>| -> String {
path.as_ref()
.and_then(|p| std::fs::read_to_string(p).ok())
.map(|s| {
let mut tail: Vec<&str> = s.lines().rev().take(4).collect();
tail.reverse();
tail.join("; ")
})
.filter(|s| !s.trim().is_empty())
.map(|s| format!(" (passt stderr: {s})"))
.unwrap_or_default()
};

let max_attempts = 50; // 5 seconds total
for _ in 0..max_attempts {
if self.socket_path.exists() {
return Ok(());
}
if let Some(child) = self.child.as_mut() {
if let Ok(Some(status)) = child.try_wait() {
return Err(BoxError::NetworkError(format!(
"passt exited early with {status} before creating its socket{}",
read_stderr(&stderr_path)
)));
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
}

Err(BoxError::NetworkError(format!(
"passt socket {} did not appear within 5 seconds",
self.socket_path.display()
"passt socket {} did not appear within 5 seconds{}",
self.socket_path.display(),
read_stderr(&stderr_path)
)))
}

Expand Down
Loading