Started to add wrapper for bubblewrap that I want to use to make my integration tests without a heavy container engine as docker. IPC with the client application is not working, yet.

This commit is contained in:
Johannes Leupolz 2025-12-13 20:36:06 +00:00
parent 61cfb84cff
commit ad6a4cc696
6 changed files with 291 additions and 4 deletions

View file

@ -2,7 +2,10 @@
## Integration tests
Run with `cargo test -p vuinputd-tests --features "requires-root requires-uinput"`.
Install bubblewrap:
`apt-get install bubblewrap`.
Run with `cargo test -p vuinputd-tests --features "requires-root requires-uinput requires-bwrap"`.
## Manual end-to-end tests

View file

@ -6,12 +6,16 @@ edition = "2021"
[[bin]]
name = "keyboard-in-container"
[[bin]]
name = "bwrap-ipc"
[dependencies]
uinput-ioctls = { path = "../uinput-ioctls" }
nix = { version = "0.30", features = ["ioctl"] } # ioctl & libc bindings
nix = { version = "0.30", features = ["ioctl","socket"] } # ioctl & libc bindings
libc = "0.2" # raw system calls
libudev = "0.3" # enumerate-udev
[features]
requires-root = []
requires-uinput = []
requires-uinput = []
requires-bwrap = []

View file

@ -0,0 +1,21 @@
use core::panic;
use std::{str::from_utf8_unchecked, time::Duration};
use vuinputd_tests::bwrap::SandboxChildIpc;
fn main() {
println!("starting bwrap-ipc");
let ipc = unsafe { SandboxChildIpc::from_fd() };
let incoming = ipc
.recv(Some(Duration::from_secs(5)))
.expect("error receiving input from ipc as child within 5 seconds");
let incoming_str =
str::from_utf8(&incoming).expect("message received from ipc is not encoded as utf8");
if incoming_str == "continue" {
ipc.send(b"ok").unwrap();
} else {
ipc.send(b"nok").unwrap();
panic!("expected ipc message to be 'continue'");
}
}

207
vuinputd-tests/src/bwrap.rs Normal file
View file

@ -0,0 +1,207 @@
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use std::io;
use std::os::fd::{FromRawFd, IntoRawFd, RawFd};
use std::os::unix::net::UnixDatagram;
use std::os::unix::process::CommandExt;
use std::process::{Command, Output};
use std::time::Duration;
use nix::errno::Errno;
use nix::sys::socket::{socketpair, AddressFamily, SockFlag, SockType};
use nix::unistd::close;
/// Check if bubblewrap is available.
pub fn bwrap_available() -> bool {
Command::new("bwrap")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// IPC handle kept by the parent.
pub struct SandboxIpc {
sock: UnixDatagram,
}
impl SandboxIpc {
pub fn recv(&self, read_timeout: Option<Duration>) -> io::Result<Vec<u8>> {
let mut buf = vec![0u8; 4096];
self.sock.set_read_timeout(read_timeout)?;
let n = self.sock.recv(&mut buf)?;
buf.truncate(n);
Ok(buf)
}
pub fn send(&self, data: &[u8]) -> io::Result<()> {
self.sock.send(data)?;
Ok(())
}
}
/// IPC handle inside the container.
pub struct SandboxChildIpc {
sock: UnixDatagram,
}
impl SandboxChildIpc {
/// FD number is fixed and known.
pub const FD: RawFd = 3;
/// # Safety
/// Must only be called once in the child.
pub unsafe fn from_fd() -> Self {
let sock = UnixDatagram::from_raw_fd(Self::FD);
Self { sock }
}
pub fn send(&self, data: &[u8]) -> io::Result<()> {
self.sock.send(data)?;
Ok(())
}
pub fn recv(&self, read_timeout: Option<Duration>) -> io::Result<Vec<u8>> {
let mut buf = vec![0u8; 4096];
self.sock.set_read_timeout(read_timeout)?;
let n = self.sock.recv(&mut buf)?;
buf.truncate(n);
Ok(buf)
}
}
/// Builder for bubblewrap invocations.
#[derive(Default)]
pub struct BwrapBuilder {
args: Vec<String>,
ipc_child_fd: Option<RawFd>,
}
impl BwrapBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn unshare_all(mut self) -> Self {
self.args.push("--unshare-all".into());
self
}
pub fn unshare_net(mut self) -> Self {
self.args.push("--unshare-net".into());
self
}
pub fn proc(mut self) -> Self {
self.args.push("--proc".into());
self.args.push("/proc".into());
self
}
pub fn tmpfs(mut self, path: &str) -> Self {
self.args.push("--tmpfs".into());
self.args.push(path.into());
self
}
// https://superuser.com/questions/1577262/bwrap-execvp-no-such-file-or-directory-when-ro-binding-non-root-path
pub fn ro_bind(mut self, src: &str, dst: &str) -> Self {
self.args
.extend(["--ro-bind".into(), src.into(), dst.into()]);
self
}
pub fn bind(mut self, src: &str, dst: &str) -> Self {
self.args.extend(["--bind".into(), src.into(), dst.into()]);
self
}
/// Ensure the container dies if the parent dies.
///
/// This uses bwrap's `--die-with-parent` flag, which internally
/// uses a parent-death signal (PR_SET_PDEATHSIG).
pub fn die_with_parent(mut self) -> Self {
self.args.push("--die-with-parent".into());
self
}
/// Enable bidirectional IPC using a Unix seqpacket socketpair.
pub fn with_ipc(mut self) -> io::Result<(Self, SandboxIpc)> {
let (parent, child) = socketpair(
AddressFamily::Unix,
SockType::SeqPacket,
None,
SockFlag::empty(),
)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
// Parent side
let parent_sock = unsafe { UnixDatagram::from_raw_fd(parent.into_raw_fd()) };
// Child side must become FD 3 inside container
self.ipc_child_fd = Some(child.into_raw_fd());
Ok((self, SandboxIpc { sock: parent_sock }))
}
/// Final command executed inside the container.
pub fn command(mut self, cmd: &str) -> Self {
//self.args.push("--".into());
self.args.push(cmd.into());
self
}
pub fn run(mut self) -> io::Result<Output> {
println!("Arguments for bwrap: {:?}", &self.args);
let mut cmd = Command::new("bwrap");
if let Some(fd) = self.ipc_child_fd.take() {
// Move child FD to 3. Note that the FD 3 needs to be linked at the
// beginning of the child program.
unsafe {
cmd.pre_exec(move || {
let res = libc::dup2(fd, SandboxChildIpc::FD);
Errno::result(res)
.map(drop)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
close(fd).ok();
Ok(())
})
};
}
cmd.args(&self.args).output()
}
}
#[cfg(feature = "requires-bwrap")]
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bwrap_works() {
if !bwrap_available() {
panic!("bwrap not available");
}
let out = BwrapBuilder::new()
.unshare_net()
//.proc()
.ro_bind("/", "/")
.tmpfs("/tmp")
.die_with_parent()
.command("/usr/bin/sh")
.run()
.unwrap_or_else(|e| panic!("failed to run bwrap!: {e}"));
println!("Output");
println!("stdout: {}", str::from_utf8(&out.stdout).unwrap());
println!("stderr: {}", str::from_utf8(&out.stderr).unwrap());
assert!(out.status.success());
}
}

View file

@ -0,0 +1 @@
pub mod bwrap;

View file

@ -1,4 +1,55 @@
use std::process::Command;
// SPDX-License-Identifier: MIT
//
// Author: Johannes Leupolz <dev@leupolz.eu>
use std::{process::Command, time::Duration};
use vuinputd_tests::bwrap;
#[cfg(all(feature = "requires-root", feature = "requires-bwrap"))]
#[test]
fn test_bwrap_simple() {
let out = bwrap::BwrapBuilder::new()
.unshare_all()
.ro_bind("/", "/")
.tmpfs("/tmp")
.die_with_parent()
.command("ls /")
.run()
.unwrap_or_else(|e| panic!("failed to run bwrap!: {e}"));
println!("Output");
println!("stdout: {}", str::from_utf8(&out.stdout).unwrap());
println!("stderr: {}", str::from_utf8(&out.stderr).unwrap());
}
#[cfg(all(feature = "requires-root", feature = "requires-bwrap"))]
#[ignore]
#[test]
fn test_bwrap_ipc() {
let bwrap_ipc = env!("CARGO_BIN_EXE_bwrap-ipc");
let (builder, ipc) = bwrap::BwrapBuilder::new()
.unshare_all()
.ro_bind("/", "/")
.tmpfs("/tmp")
.die_with_parent()
.with_ipc()
.expect("failed to create IPC");
let out = builder
.command(bwrap_ipc)
.run()
.unwrap_or_else(|e| panic!("failed to run bwrap!: {e}"));
ipc.send("continue".as_bytes())
.unwrap_or_else(|e| panic!("failed to send data via ipc: {e}"));
ipc.recv(Some(Duration::from_secs(5)))
.expect("error receiving input from ipc as host within 5 seconds");
println!("Output");
println!("stdout: {}", str::from_utf8(&out.stdout).unwrap());
println!("stderr: {}", str::from_utf8(&out.stderr).unwrap());
}
#[cfg(all(feature = "requires-root", feature = "requires-uinput"))]
#[test]