update docs
This commit is contained in:
@@ -69,11 +69,36 @@ pub fn start(
|
||||
) -> Result<Child> {
|
||||
start_group(&format!("bay{bay}"), arguments, working, environment)
|
||||
}
|
||||
pub fn foreground(
|
||||
bay: Bay,
|
||||
arguments: &[String],
|
||||
working: &str,
|
||||
environment: &[(String, String)],
|
||||
descriptors: [OwnedFd; 3],
|
||||
terminal: bool,
|
||||
) -> Result<Child> {
|
||||
spawn(
|
||||
&format!("bay{bay}"),
|
||||
arguments,
|
||||
working,
|
||||
environment,
|
||||
Some((descriptors, terminal)),
|
||||
)
|
||||
}
|
||||
pub fn start_group(
|
||||
name: &str,
|
||||
arguments: &[String],
|
||||
working: &str,
|
||||
environment: &[(String, String)],
|
||||
) -> Result<Child> {
|
||||
spawn(name, arguments, working, environment, None)
|
||||
}
|
||||
fn spawn(
|
||||
name: &str,
|
||||
arguments: &[String],
|
||||
working: &str,
|
||||
environment: &[(String, String)],
|
||||
io: Option<([OwnedFd; 3], bool)>,
|
||||
) -> Result<Child> {
|
||||
if !fds_common::manifest::identifier(name) {
|
||||
return Err(Error("Invalid process group".into()));
|
||||
@@ -95,11 +120,11 @@ pub fn start_group(
|
||||
.custom_flags(libc::O_CLOEXEC)
|
||||
.open(path.join("cgroup.procs"))?;
|
||||
let mut command = Command::new(&arguments[0]);
|
||||
let working = c(working)?;
|
||||
command
|
||||
.args(&arguments[1..])
|
||||
.current_dir(working)
|
||||
.env_clear()
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.env("PATH", "/usr/bin:/bin:/run/fds/bin")
|
||||
.env("HOME", "/home/fds")
|
||||
.env("USER", "fds")
|
||||
.env("LOGNAME", "fds")
|
||||
@@ -109,6 +134,14 @@ pub fn start_group(
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit());
|
||||
command.envs(environment.iter().cloned());
|
||||
let mut terminal = false;
|
||||
if let Some(([input, output, errors], tty)) = io {
|
||||
command
|
||||
.stdin(Stdio::from(input))
|
||||
.stdout(Stdio::from(output))
|
||||
.stderr(Stdio::from(errors));
|
||||
terminal = tty;
|
||||
}
|
||||
// Only async-signal-safe syscalls are used in the forked child. Writing 0
|
||||
// moves the child itself, avoiding PID reuse and parent/child migration races.
|
||||
unsafe {
|
||||
@@ -119,6 +152,9 @@ pub fn start_group(
|
||||
if libc::setsid() < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if terminal && libc::ioctl(0, libc::TIOCSCTTY, 0) < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if libc::setgroups(0, std::ptr::null()) < 0
|
||||
|| libc::setgid(1000) < 0
|
||||
|| libc::setuid(1000) < 0
|
||||
@@ -128,6 +164,10 @@ pub fn start_group(
|
||||
if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// Resolve client-selected working directories only as the user.
|
||||
if libc::chdir(working.as_ptr()) < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let mut mask: libc::sigset_t = std::mem::zeroed();
|
||||
libc::sigemptyset(&mut mask);
|
||||
if libc::sigprocmask(libc::SIG_SETMASK, &mask, std::ptr::null_mut()) < 0 {
|
||||
|
||||
@@ -4,6 +4,7 @@ mod data_sessions;
|
||||
mod media;
|
||||
mod power;
|
||||
mod profiles;
|
||||
mod programs;
|
||||
mod recovery;
|
||||
mod server;
|
||||
mod software;
|
||||
|
||||
@@ -228,7 +228,7 @@ impl Mounted {
|
||||
("FDS_APP".into(), app.display().to_string()),
|
||||
(
|
||||
"PATH".into(),
|
||||
format!("{}/bin:/usr/bin:/bin", app.display()),
|
||||
format!("{}/bin:/usr/bin:/bin:/run/fds/bin", app.display()),
|
||||
),
|
||||
(
|
||||
"LD_LIBRARY_PATH".into(),
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! A stable PATH directory is updated as validated cartridges appear/disappear.
|
||||
use crate::media::Mounted;
|
||||
use fds_common::{
|
||||
Bay, Error, Result,
|
||||
manifest::{Class, identifier},
|
||||
};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
os::unix::fs::{PermissionsExt, symlink},
|
||||
path::Path,
|
||||
};
|
||||
pub const BIN: &str = "/run/fds/bin";
|
||||
pub type Commands = BTreeMap<String, (Bay, String)>;
|
||||
|
||||
pub fn collect(mounts: &BTreeMap<Bay, Mounted>) -> Result<Commands> {
|
||||
let mut result = BTreeMap::new();
|
||||
for (&bay, mount) in mounts {
|
||||
if mount.manifest.cartridge.class != Class::Program || mount.fault.is_some() {
|
||||
continue;
|
||||
}
|
||||
let mut commands = Vec::new();
|
||||
if let Some(software) = &mount.software {
|
||||
for entry in &software.catalogue.software {
|
||||
for name in entry.commands.keys() {
|
||||
commands.push((
|
||||
name.clone(),
|
||||
format!("{}:{name}", entry.id),
|
||||
format!("b{bay}:{}:{name}", entry.id),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let root = Path::new(&mount.path).join("app/bin");
|
||||
if root.is_dir() {
|
||||
for entry in fs::read_dir(root)? {
|
||||
let entry = entry?;
|
||||
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
|
||||
continue;
|
||||
};
|
||||
if identifier(&name)
|
||||
&& entry.path().is_file()
|
||||
&& entry
|
||||
.path()
|
||||
.canonicalize()?
|
||||
.starts_with(Path::new(&mount.path).join("app"))
|
||||
&& entry.path().metadata()?.permissions().mode() & 0o111 != 0
|
||||
{
|
||||
commands.push((name.clone(), name.clone(), format!("b{bay}:{name}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
commands.sort();
|
||||
for (name, selector, qualified) in commands {
|
||||
result.entry(name).or_insert((bay, selector.clone()));
|
||||
// A fully qualified spelling always identifies this cartridge.
|
||||
result.insert(qualified, (bay, selector));
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
pub fn publish(commands: &Commands) -> Result<()> {
|
||||
fs::create_dir_all(BIN)?;
|
||||
fs::set_permissions(BIN, fs::Permissions::from_mode(0o755))?;
|
||||
for entry in fs::read_dir(BIN)? {
|
||||
let entry = entry?;
|
||||
if !commands.contains_key(&entry.file_name().to_string_lossy().into_owned()) {
|
||||
if !entry.file_type()?.is_symlink() {
|
||||
return Err(Error(
|
||||
"Unexpected file in cartridge command directory".into(),
|
||||
));
|
||||
}
|
||||
fs::remove_file(entry.path())?;
|
||||
}
|
||||
}
|
||||
for name in commands.keys() {
|
||||
let path = Path::new(BIN).join(name);
|
||||
if path.symlink_metadata().is_ok() {
|
||||
if fs::read_link(&path)? != Path::new("/usr/bin/fds-program") {
|
||||
return Err(Error("Unexpected cartridge command link".into()));
|
||||
}
|
||||
} else {
|
||||
symlink("/usr/bin/fds-program", path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -32,6 +32,7 @@ struct State {
|
||||
profiles: profiles::Manager,
|
||||
burning: burning::Manager,
|
||||
power: power::Manager,
|
||||
commands: crate::programs::Commands,
|
||||
}
|
||||
impl State {
|
||||
fn scan(&mut self) -> Result<()> {
|
||||
@@ -63,6 +64,7 @@ impl State {
|
||||
software: None,
|
||||
mount: None,
|
||||
consumers: consumers::count(bay)?,
|
||||
commands: Vec::new(),
|
||||
};
|
||||
// A hub in a bay may contain several functions, but multiple actual
|
||||
// devices are ambiguous until an explicit composite policy exists.
|
||||
@@ -109,8 +111,113 @@ impl State {
|
||||
if !self.power.frozen() {
|
||||
self.profiles.reconcile(&self.mounts, &devices)?;
|
||||
}
|
||||
self.commands = crate::programs::collect(&self.mounts)?;
|
||||
crate::programs::publish(&self.commands)?;
|
||||
for entry in &mut self.bays {
|
||||
entry.commands = self
|
||||
.commands
|
||||
.iter()
|
||||
.filter_map(|(alias, (bay, selector))| {
|
||||
(*bay == entry.bay && alias.starts_with(&format!("b{bay}:"))).then(|| {
|
||||
fds_common::control::PublishedCommand {
|
||||
selector: selector.clone(),
|
||||
alias: alias.clone(),
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn program(&mut self, name: &str) -> Result<(Bay, fds_common::launch::Program)> {
|
||||
if self.recovery || self.power.frozen() {
|
||||
return Err(Error(
|
||||
"Program launches are unavailable during recovery or shutdown".into(),
|
||||
));
|
||||
}
|
||||
let (bay, selector) = self
|
||||
.commands
|
||||
.get(name)
|
||||
.cloned()
|
||||
.ok_or_else(|| Error(format!("Cartridge command {name} is no longer available")))?;
|
||||
let mount = self
|
||||
.mounts
|
||||
.get_mut(&bay)
|
||||
.ok_or_else(|| Error("Cartridge was removed".into()))?;
|
||||
let (arguments, environment) = mount.program(&[selector])?;
|
||||
Ok((
|
||||
bay,
|
||||
fds_common::launch::Program {
|
||||
arguments,
|
||||
environment,
|
||||
},
|
||||
))
|
||||
}
|
||||
fn foreground(
|
||||
&mut self,
|
||||
name: &str,
|
||||
arguments: &[String],
|
||||
terminal: bool,
|
||||
working: &str,
|
||||
term: &str,
|
||||
client: &mut Client,
|
||||
) -> Result<Response> {
|
||||
self.program(name)?;
|
||||
if arguments.len() > 120
|
||||
|| arguments.iter().any(|a| a.contains('\0'))
|
||||
|| !working.starts_with('/')
|
||||
|| working.len() > 4096
|
||||
|| working.contains('\0')
|
||||
|| term.len() > 128
|
||||
|| term.chars().any(char::is_control)
|
||||
{
|
||||
return Err(Error(
|
||||
"Invalid foreground program arguments or environment".into(),
|
||||
));
|
||||
}
|
||||
client.socket.set_nonblocking(false)?;
|
||||
client
|
||||
.socket
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))?;
|
||||
client
|
||||
.socket
|
||||
.set_write_timeout(Some(Duration::from_secs(5)))?;
|
||||
let result = (|| {
|
||||
client.socket.write_all(b"R")?;
|
||||
let descriptors: [OwnedFd; 3] = fds_common::launch::receive_fds(&mut client.socket, 3)?
|
||||
.try_into()
|
||||
.map_err(|_| Error("Expected three program I/O descriptors".into()))?;
|
||||
let (bay, mut program) = self.program(name)?;
|
||||
program.arguments.extend_from_slice(arguments);
|
||||
program.environment.push(("TERM".into(), term.into()));
|
||||
let mut child = consumers::foreground(
|
||||
bay,
|
||||
&program.arguments,
|
||||
working,
|
||||
&program.environment,
|
||||
descriptors,
|
||||
terminal,
|
||||
)?;
|
||||
let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, child.id(), 0) } as i32;
|
||||
if fd < 0 {
|
||||
let _ = child.kill();
|
||||
self.children.push(child);
|
||||
return Err(std::io::Error::last_os_error().into());
|
||||
}
|
||||
let pidfd = unsafe { OwnedFd::from_raw_fd(fd) };
|
||||
if let Err(error) = fds_common::launch::send_fds(&client.socket, &[pidfd.as_raw_fd()]) {
|
||||
let _ = child.kill();
|
||||
self.children.push(child);
|
||||
return Err(error);
|
||||
}
|
||||
client.foreground = Some(child);
|
||||
let mut reply = Response::failure("");
|
||||
reply.error = None;
|
||||
Ok(reply)
|
||||
})();
|
||||
client.socket.set_nonblocking(true)?;
|
||||
result
|
||||
}
|
||||
fn mapped_devices(&self) -> Vec<(Bay, topology::UsbDevice)> {
|
||||
self.bays
|
||||
.iter()
|
||||
@@ -628,6 +735,7 @@ impl State {
|
||||
},
|
||||
media_job: None,
|
||||
recovery: None,
|
||||
exit_status: None,
|
||||
disk: None,
|
||||
power: None,
|
||||
})
|
||||
@@ -796,6 +904,7 @@ struct Client {
|
||||
offset: usize,
|
||||
deadline: Instant,
|
||||
uid: u32,
|
||||
foreground: Option<std::process::Child>,
|
||||
waiting: Option<(String, u64)>,
|
||||
}
|
||||
fn peer_uid(socket: &UnixStream) -> Result<Option<u32>> {
|
||||
@@ -910,6 +1019,7 @@ pub fn run(notify: bool) -> Result<()> {
|
||||
profiles: profiles::Manager::new(!recovery),
|
||||
burning: burning::Manager::load()?,
|
||||
power: power::Manager::load()?,
|
||||
commands: BTreeMap::new(),
|
||||
};
|
||||
for n in 1..=12 {
|
||||
let bay = Bay::try_from(n)?;
|
||||
@@ -965,6 +1075,7 @@ pub fn run(notify: bool) -> Result<()> {
|
||||
state.profiles.shutdown()?;
|
||||
}
|
||||
cleanup_stale_mounts()?;
|
||||
crate::programs::publish(&BTreeMap::new())?;
|
||||
state.scan()?;
|
||||
let mut clients: Vec<Client> = Vec::new();
|
||||
loop {
|
||||
@@ -1000,7 +1111,7 @@ pub fn run(notify: bool) -> Result<()> {
|
||||
fd: client.socket.as_raw_fd(),
|
||||
events: if client.output.is_some() {
|
||||
libc::POLLOUT
|
||||
} else if client.waiting.is_some() {
|
||||
} else if client.waiting.is_some() || client.foreground.is_some() {
|
||||
0
|
||||
} else {
|
||||
libc::POLLIN
|
||||
@@ -1010,6 +1121,7 @@ pub fn run(notify: bool) -> Result<()> {
|
||||
}
|
||||
let timeout = clients
|
||||
.iter()
|
||||
.filter(|c| c.foreground.is_none())
|
||||
.map(|c| {
|
||||
c.deadline
|
||||
.saturating_duration_since(Instant::now())
|
||||
@@ -1071,9 +1183,25 @@ pub fn run(notify: bool) -> Result<()> {
|
||||
if (fds[0].revents != 0 && consume_events(&events)?) || console_changed {
|
||||
state.scan()?;
|
||||
}
|
||||
// Reserve connection capacity for eject/status even with many foreground jobs.
|
||||
let foreground_count = clients.iter().filter(|c| c.foreground.is_some()).count();
|
||||
// Process existing clients before accepting more; vectors stay aligned.
|
||||
for index in (0..clients.len()).rev() {
|
||||
let client = &mut clients[index];
|
||||
if let Some(child) = &mut client.foreground {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
let mut reply = Response::failure("");
|
||||
reply.error = None;
|
||||
reply.exit_status = Some(status.into_raw());
|
||||
let mut output =
|
||||
serde_json::to_vec(&reply).map_err(|e| Error(e.to_string()))?;
|
||||
output.push(b'\n');
|
||||
client.output = Some(output);
|
||||
client.foreground = None;
|
||||
client.deadline = Instant::now() + Duration::from_secs(5);
|
||||
}
|
||||
}
|
||||
let ready = fds[index + 5].revents;
|
||||
if let Some((id, sequence)) = &client.waiting {
|
||||
let job = state.burning.status(id);
|
||||
@@ -1097,8 +1225,8 @@ pub fn run(notify: bool) -> Result<()> {
|
||||
client.deadline = Instant::now() + Duration::from_secs(5);
|
||||
}
|
||||
}
|
||||
let mut remove =
|
||||
Instant::now() >= client.deadline || ready & (libc::POLLERR | libc::POLLNVAL) != 0;
|
||||
let mut remove = (client.foreground.is_none() && Instant::now() >= client.deadline)
|
||||
|| ready & (libc::POLLERR | libc::POLLNVAL) != 0;
|
||||
if !remove && ready & libc::POLLIN != 0 && client.output.is_none() {
|
||||
let mut chunk = [0u8; 4096];
|
||||
match client.socket.read(&mut chunk) {
|
||||
@@ -1124,9 +1252,31 @@ pub fn run(notify: bool) -> Result<()> {
|
||||
client.waiting = Some((id.clone(), *sequence));
|
||||
}
|
||||
}
|
||||
state
|
||||
.reply(request, client.uid)
|
||||
.unwrap_or_else(Response::failure)
|
||||
if let Request::Program {
|
||||
name,
|
||||
arguments,
|
||||
terminal,
|
||||
working,
|
||||
term,
|
||||
} = request
|
||||
{
|
||||
if foreground_count >= 64 {
|
||||
Response::failure(
|
||||
"Too many foreground programs; close a program and retry",
|
||||
)
|
||||
} else {
|
||||
state
|
||||
.foreground(
|
||||
&name, &arguments, terminal, &working,
|
||||
&term, client,
|
||||
)
|
||||
.unwrap_or_else(Response::failure)
|
||||
}
|
||||
} else {
|
||||
state
|
||||
.reply(request, client.uid)
|
||||
.unwrap_or_else(Response::failure)
|
||||
}
|
||||
}
|
||||
Err(_) => Response::failure("Invalid control request"),
|
||||
}
|
||||
@@ -1140,7 +1290,9 @@ pub fn run(notify: bool) -> Result<()> {
|
||||
.unwrap();
|
||||
}
|
||||
output.push(b'\n');
|
||||
if client.waiting.is_some() {
|
||||
if client.foreground.is_some() {
|
||||
// SIGCHLD wakes the loop when this foreground command exits.
|
||||
} else if client.waiting.is_some() {
|
||||
client.deadline = Instant::now() + Duration::from_secs(90);
|
||||
} else {
|
||||
client.output = Some(output);
|
||||
@@ -1173,7 +1325,11 @@ pub fn run(notify: bool) -> Result<()> {
|
||||
remove = true;
|
||||
}
|
||||
if remove {
|
||||
clients.swap_remove(index);
|
||||
let mut removed = clients.swap_remove(index);
|
||||
if let Some(mut child) = removed.foreground.take() {
|
||||
let _ = child.kill();
|
||||
state.children.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
if fds[1].revents & libc::POLLIN != 0 {
|
||||
@@ -1181,7 +1337,7 @@ pub fn run(notify: bool) -> Result<()> {
|
||||
match listener.accept() {
|
||||
Ok((socket, _)) => {
|
||||
let uid = peer_uid(&socket)?;
|
||||
if clients.len() >= 16 || uid.is_none() {
|
||||
if clients.len() >= 128 || uid.is_none() {
|
||||
continue;
|
||||
}
|
||||
socket.set_nonblocking(true)?;
|
||||
@@ -1192,6 +1348,7 @@ pub fn run(notify: bool) -> Result<()> {
|
||||
offset: 0,
|
||||
deadline: Instant::now() + Duration::from_secs(5),
|
||||
uid: uid.unwrap(),
|
||||
foreground: None,
|
||||
waiting: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
use crate::media::{self, c, checked};
|
||||
use fds_burn::{device::Disk, image};
|
||||
use fds_common::{Bay, Error, Result, read_text, sysfs::BlockPartition};
|
||||
use fds_software::{Catalogue, archive};
|
||||
use fds_software::{Catalogue, archive, tree};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs::{self, File, OpenOptions},
|
||||
@@ -136,7 +136,14 @@ impl Mounted {
|
||||
&format!("/proc/self/fd/{}", source.as_raw_fd()),
|
||||
&path,
|
||||
"erofs",
|
||||
libc::MS_RDONLY | libc::MS_NOEXEC | libc::MS_NOSUID | libc::MS_NODEV,
|
||||
libc::MS_RDONLY
|
||||
| libc::MS_NOSUID
|
||||
| libc::MS_NODEV
|
||||
| if result.catalogue.format == 1 {
|
||||
libc::MS_NOEXEC
|
||||
} else {
|
||||
0
|
||||
},
|
||||
"",
|
||||
)?;
|
||||
result.payloads.insert(
|
||||
@@ -148,7 +155,11 @@ impl Mounted {
|
||||
key,
|
||||
},
|
||||
);
|
||||
let bundles = Path::new(&path).join("bundles");
|
||||
let bundles = Path::new(&path).join(if result.catalogue.format == 2 {
|
||||
"programs"
|
||||
} else {
|
||||
"bundles"
|
||||
});
|
||||
if !fs::symlink_metadata(&bundles)?.is_dir() {
|
||||
return Err(Error("Payload bundles must be a real directory".into()));
|
||||
}
|
||||
@@ -157,14 +168,20 @@ impl Mounted {
|
||||
.software
|
||||
.iter()
|
||||
.filter(|s| s.partition == spec.number)
|
||||
.map(|s| format!("{}.tar.xz", s.id))
|
||||
.map(|s| {
|
||||
if s.installed {
|
||||
s.id.clone()
|
||||
} else {
|
||||
format!("{}.tar.xz", s.id)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let actual: BTreeSet<_> = fs::read_dir(&bundles)?
|
||||
.map(|e| Ok(e?.file_name().to_string_lossy().into_owned()))
|
||||
.collect::<Result<_>>()?;
|
||||
if actual != expected {
|
||||
if actual != expected || fs::read_dir(&path)?.count() != 1 {
|
||||
return Err(Error(
|
||||
"Payload archive inventory disagrees with catalogue".into(),
|
||||
"Payload software inventory disagrees with catalogue".into(),
|
||||
));
|
||||
}
|
||||
for software in result
|
||||
@@ -173,6 +190,10 @@ impl Mounted {
|
||||
.iter()
|
||||
.filter(|s| s.partition == spec.number)
|
||||
{
|
||||
if software.installed {
|
||||
tree::verify(&Path::new(&path).join(software.root_path()), software)?;
|
||||
continue;
|
||||
}
|
||||
if archive::open(&Path::new(&path).join(software.archive_path()))?
|
||||
.metadata()?
|
||||
.len()
|
||||
@@ -226,6 +247,32 @@ impl Mounted {
|
||||
if media::key(&payload.partition)? != payload.key {
|
||||
return Err(Error("Software payload was removed".into()));
|
||||
}
|
||||
if software.installed {
|
||||
let root = Path::new(&payload.path).join(software.root_path());
|
||||
let mut args = tree::executable(&root, executable)?;
|
||||
args.extend_from_slice(&arguments[1..]);
|
||||
let root = root.display().to_string();
|
||||
return Ok((
|
||||
args,
|
||||
vec![
|
||||
("FDS_APP".into(), root.clone()),
|
||||
(
|
||||
"PATH".into(),
|
||||
format!("{root}/usr/bin:{root}/bin:/usr/bin:/bin:/run/fds/bin"),
|
||||
),
|
||||
(
|
||||
"LD_LIBRARY_PATH".into(),
|
||||
format!("{root}/usr/lib:{root}/lib"),
|
||||
),
|
||||
(
|
||||
"XDG_DATA_DIRS".into(),
|
||||
format!("{root}/usr/share:/usr/share"),
|
||||
),
|
||||
("DISPLAY".into(), ":0".into()),
|
||||
("XAUTHORITY".into(), "/run/fds/x11/authority".into()),
|
||||
],
|
||||
));
|
||||
}
|
||||
if !self.caches.contains_key(id) {
|
||||
// Each executable tree receives its own bounded, read-only tmpfs.
|
||||
// Root owns every path; the consumer only receives ordinary UID 1000.
|
||||
|
||||
@@ -9,6 +9,10 @@ description = "FDS system and cartridge command interface"
|
||||
name = "fds"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "fds-program"
|
||||
path = "src/program.rs"
|
||||
|
||||
[dependencies]
|
||||
clap.workspace = true
|
||||
fds-burn = { path = "../fds-burn" }
|
||||
|
||||
@@ -159,6 +159,7 @@ fn inspect_manifest(path: &Path, json: bool) -> Result<()> {
|
||||
}
|
||||
fn cartridge(request: Request, json: bool) -> Result<()> {
|
||||
let debug = matches!(request, Request::Topology);
|
||||
let details = matches!(request, Request::Bay { .. });
|
||||
let response = control::request(&request)?;
|
||||
if json {
|
||||
println!(
|
||||
@@ -237,6 +238,11 @@ fn cartridge(request: Request, json: bool) -> Result<()> {
|
||||
if let Some(mount) = bay.mount {
|
||||
println!(" MOUNT {mount}");
|
||||
}
|
||||
if details {
|
||||
for command in bay.commands {
|
||||
println!(" FOREGROUND {}", command.alias);
|
||||
}
|
||||
}
|
||||
if bay.consumers > 0 {
|
||||
println!(" MANAGED PROCESSES {}", bay.consumers);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Foreground cartridge commands preserve terminal I/O and managed process tracking.
|
||||
mod program_io;
|
||||
use clap::{Parser, Subcommand};
|
||||
use fds_common::{Error, Result, launch};
|
||||
use std::{ffi::OsString, path::PathBuf, process::ExitCode};
|
||||
|
||||
// Parse argv[0] as a typed positional: Clap multicall uses file_stem(), which
|
||||
// removes dotted software IDs. All options/arguments still go through Clap.
|
||||
#[derive(Parser)]
|
||||
#[command(no_binary_name = true, disable_help_flag = true)]
|
||||
struct Invocation {
|
||||
executable: PathBuf,
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
arguments: Vec<OsString>,
|
||||
}
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "fds-program",
|
||||
version,
|
||||
disable_help_subcommand = true,
|
||||
about = "Run a published cartridge command in the foreground"
|
||||
)]
|
||||
struct Explicit {
|
||||
#[command(subcommand)]
|
||||
command: Published,
|
||||
}
|
||||
#[derive(Subcommand)]
|
||||
enum Published {
|
||||
#[command(external_subcommand)]
|
||||
Command(Vec<OsString>),
|
||||
}
|
||||
impl Invocation {
|
||||
fn command(self) -> std::result::Result<(String, Vec<OsString>), clap::Error> {
|
||||
let name = self
|
||||
.executable
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| {
|
||||
clap::Error::raw(
|
||||
clap::error::ErrorKind::InvalidUtf8,
|
||||
"Invalid cartridge command name",
|
||||
)
|
||||
})?
|
||||
.to_owned();
|
||||
if name == "fds-program" {
|
||||
let parsed = Explicit::try_parse_from(
|
||||
std::iter::once(self.executable.into_os_string()).chain(self.arguments),
|
||||
)?;
|
||||
let Published::Command(mut arguments) = parsed.command;
|
||||
let command = arguments.remove(0).into_string().map_err(|_| {
|
||||
clap::Error::raw(
|
||||
clap::error::ErrorKind::InvalidUtf8,
|
||||
"Invalid cartridge command name",
|
||||
)
|
||||
})?;
|
||||
Ok((command, arguments))
|
||||
} else {
|
||||
Ok((name, self.arguments))
|
||||
}
|
||||
}
|
||||
}
|
||||
fn run() -> Result<i32> {
|
||||
let (name, arguments) = Invocation::parse()
|
||||
.command()
|
||||
.unwrap_or_else(|error| error.exit());
|
||||
if unsafe { libc::geteuid() } == 0 {
|
||||
if unsafe { libc::setgroups(0, std::ptr::null()) } < 0
|
||||
|| unsafe { libc::setgid(1000) } < 0
|
||||
|| unsafe { libc::setuid(1000) } < 0
|
||||
{
|
||||
return Err(std::io::Error::last_os_error().into());
|
||||
}
|
||||
}
|
||||
let io = program_io::Io::new()?;
|
||||
let arguments = arguments
|
||||
.into_iter()
|
||||
.map(|value| {
|
||||
value
|
||||
.into_string()
|
||||
.map_err(|_| Error("Program arguments must be UTF-8".into()))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let (socket, child) = launch::start(
|
||||
&name,
|
||||
arguments,
|
||||
io.terminal(),
|
||||
std::env::current_dir()?.display().to_string(),
|
||||
std::env::var("TERM").unwrap_or_else(|_| "linux".into()),
|
||||
&io.descriptors(),
|
||||
)?;
|
||||
io.wait(socket, child)
|
||||
}
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(status) => ExitCode::from(if libc::WIFEXITED(status) {
|
||||
libc::WEXITSTATUS(status) as u8
|
||||
} else {
|
||||
(128 + libc::WTERMSIG(status)) as u8
|
||||
}),
|
||||
Err(error) => {
|
||||
eprintln!("fds-program: {error}");
|
||||
ExitCode::from(126)
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::CommandFactory;
|
||||
#[test]
|
||||
fn clap_preserves_dotted_aliases_and_application_options() {
|
||||
<Invocation as CommandFactory>::command().debug_assert();
|
||||
Explicit::command().debug_assert();
|
||||
for name in ["hello", "hello.world", "b01:demo.report:shell"] {
|
||||
let (command, arguments) = Invocation::try_parse_from([
|
||||
format!("/run/fds/bin/{name}"),
|
||||
"--help".into(),
|
||||
"two words".into(),
|
||||
])
|
||||
.unwrap()
|
||||
.command()
|
||||
.unwrap();
|
||||
assert_eq!(command, name);
|
||||
assert_eq!(arguments, ["--help", "two words"]);
|
||||
}
|
||||
let (command, arguments) =
|
||||
Invocation::try_parse_from(["fds-program", "hello", "--version"])
|
||||
.unwrap()
|
||||
.command()
|
||||
.unwrap();
|
||||
assert_eq!(command, "hello");
|
||||
assert_eq!(arguments, ["--version"]);
|
||||
assert_eq!(
|
||||
Invocation::try_parse_from(["fds-program", "--help"])
|
||||
.unwrap()
|
||||
.command()
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
clap::error::ErrorKind::DisplayHelp
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
//! Terminal proxy for foreground programs whose privileged parent is the service.
|
||||
use fds_common::{
|
||||
Error, Result,
|
||||
control::{LIMIT, Response},
|
||||
};
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
os::{
|
||||
fd::{AsRawFd, FromRawFd, OwnedFd},
|
||||
unix::net::UnixStream,
|
||||
},
|
||||
};
|
||||
|
||||
pub struct Io {
|
||||
master: Option<OwnedFd>,
|
||||
slave: Option<OwnedFd>,
|
||||
terminal: Option<libc::termios>,
|
||||
signals: OwnedFd,
|
||||
old_mask: libc::sigset_t,
|
||||
pending: std::collections::VecDeque<u8>,
|
||||
}
|
||||
fn checked(result: i32) -> Result<i32> {
|
||||
if result < 0 {
|
||||
Err(std::io::Error::last_os_error().into())
|
||||
} else {
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
impl Io {
|
||||
pub fn new() -> Result<Self> {
|
||||
let mut mask = unsafe { std::mem::zeroed::<libc::sigset_t>() };
|
||||
let mut old_mask = unsafe { std::mem::zeroed::<libc::sigset_t>() };
|
||||
unsafe {
|
||||
libc::sigemptyset(&mut mask);
|
||||
for signal in [
|
||||
libc::SIGINT,
|
||||
libc::SIGTERM,
|
||||
libc::SIGHUP,
|
||||
libc::SIGQUIT,
|
||||
libc::SIGWINCH,
|
||||
libc::SIGTSTP,
|
||||
libc::SIGCONT,
|
||||
] {
|
||||
libc::sigaddset(&mut mask, signal);
|
||||
}
|
||||
checked(libc::sigprocmask(libc::SIG_BLOCK, &mask, &mut old_mask))?;
|
||||
}
|
||||
let fd = unsafe { libc::signalfd(-1, &mask, libc::SFD_CLOEXEC | libc::SFD_NONBLOCK) };
|
||||
if fd < 0 {
|
||||
let error = std::io::Error::last_os_error();
|
||||
unsafe {
|
||||
libc::sigprocmask(libc::SIG_SETMASK, &old_mask, std::ptr::null_mut());
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
let signals = unsafe { OwnedFd::from_raw_fd(fd) };
|
||||
let mut io = Self {
|
||||
master: None,
|
||||
slave: None,
|
||||
terminal: None,
|
||||
signals,
|
||||
old_mask,
|
||||
pending: Default::default(),
|
||||
};
|
||||
if unsafe { libc::isatty(0) == 1 && libc::isatty(1) == 1 } {
|
||||
let mut term = unsafe { std::mem::zeroed::<libc::termios>() };
|
||||
let mut size = unsafe { std::mem::zeroed::<libc::winsize>() };
|
||||
checked(unsafe { libc::tcgetattr(0, &mut term) })?;
|
||||
checked(unsafe { libc::ioctl(0, libc::TIOCGWINSZ, &mut size) })?;
|
||||
let (mut master, mut slave) = (-1, -1);
|
||||
checked(unsafe {
|
||||
libc::openpty(&mut master, &mut slave, std::ptr::null_mut(), &term, &size)
|
||||
})?;
|
||||
io.master = Some(unsafe { OwnedFd::from_raw_fd(master) });
|
||||
io.slave = Some(unsafe { OwnedFd::from_raw_fd(slave) });
|
||||
for fd in [master, slave] {
|
||||
checked(unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) })?;
|
||||
}
|
||||
checked(unsafe { libc::fcntl(master, libc::F_SETFL, libc::O_NONBLOCK) })?;
|
||||
io.terminal = Some(term);
|
||||
io.raw()?;
|
||||
}
|
||||
Ok(io)
|
||||
}
|
||||
pub fn terminal(&self) -> bool {
|
||||
self.terminal.is_some()
|
||||
}
|
||||
pub fn descriptors(&self) -> [i32; 3] {
|
||||
if let Some(slave) = &self.slave {
|
||||
[
|
||||
slave.as_raw_fd(),
|
||||
slave.as_raw_fd(),
|
||||
if unsafe { libc::isatty(2) } == 1 {
|
||||
slave.as_raw_fd()
|
||||
} else {
|
||||
2
|
||||
},
|
||||
]
|
||||
} else {
|
||||
[0, 1, 2]
|
||||
}
|
||||
}
|
||||
fn raw(&self) -> Result<()> {
|
||||
if let Some(term) = self.terminal {
|
||||
let mut raw = term;
|
||||
unsafe {
|
||||
libc::cfmakeraw(&mut raw);
|
||||
}
|
||||
raw.c_lflag |= libc::ISIG;
|
||||
checked(unsafe { libc::tcsetattr(0, libc::TCSANOW, &raw) })?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn restore(&self) {
|
||||
if let Some(term) = self.terminal {
|
||||
unsafe {
|
||||
libc::tcsetattr(0, libc::TCSANOW, &term);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn write_terminal(&mut self, bytes: &[u8]) -> Result<()> {
|
||||
if self.pending.len() + bytes.len() > 65536 {
|
||||
return Err(Error("Terminal input queue exceeded its limit".into()));
|
||||
}
|
||||
self.pending.extend(bytes);
|
||||
Ok(())
|
||||
}
|
||||
fn flush_input(&mut self) -> Result<()> {
|
||||
let Some(master) = &self.master else {
|
||||
return Ok(());
|
||||
};
|
||||
let bytes = self.pending.as_slices().0;
|
||||
if bytes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let count = unsafe { libc::write(master.as_raw_fd(), bytes.as_ptr().cast(), bytes.len()) };
|
||||
if count > 0 {
|
||||
self.pending.drain(..count as usize);
|
||||
} else if count < 0 {
|
||||
let error = std::io::Error::last_os_error();
|
||||
if !matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::Interrupted | std::io::ErrorKind::WouldBlock
|
||||
) {
|
||||
return Err(error.into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn output(&self) -> Result<bool> {
|
||||
let Some(master) = &self.master else {
|
||||
return Ok(false);
|
||||
};
|
||||
loop {
|
||||
let mut buffer = [0u8; 8192];
|
||||
let count =
|
||||
unsafe { libc::read(master.as_raw_fd(), buffer.as_mut_ptr().cast(), buffer.len()) };
|
||||
if count > 0 {
|
||||
std::io::stdout().write_all(&buffer[..count as usize])?;
|
||||
continue;
|
||||
}
|
||||
if count == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
let error = std::io::Error::last_os_error();
|
||||
if error.kind() == std::io::ErrorKind::Interrupted {
|
||||
continue;
|
||||
}
|
||||
if error.raw_os_error() == Some(libc::EIO) {
|
||||
return Ok(false);
|
||||
}
|
||||
if error.kind() == std::io::ErrorKind::WouldBlock {
|
||||
return Ok(true);
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
}
|
||||
pub fn wait(mut self, mut socket: UnixStream, child: OwnedFd) -> Result<i32> {
|
||||
self.slave.take();
|
||||
let mut bytes = Vec::new();
|
||||
let mut input = self.master.is_some();
|
||||
let mut output = self.master.is_some();
|
||||
loop {
|
||||
let mut fds = [
|
||||
libc::pollfd {
|
||||
fd: socket.as_raw_fd(),
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
},
|
||||
libc::pollfd {
|
||||
fd: self.signals.as_raw_fd(),
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
},
|
||||
libc::pollfd {
|
||||
fd: if input && self.pending.len() < 32768 {
|
||||
0
|
||||
} else {
|
||||
-1
|
||||
},
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
},
|
||||
libc::pollfd {
|
||||
fd: if output {
|
||||
self.master.as_ref().unwrap().as_raw_fd()
|
||||
} else {
|
||||
-1
|
||||
},
|
||||
events: libc::POLLIN
|
||||
| if self.pending.is_empty() {
|
||||
0
|
||||
} else {
|
||||
libc::POLLOUT
|
||||
},
|
||||
revents: 0,
|
||||
},
|
||||
];
|
||||
let result = unsafe { libc::poll(fds.as_mut_ptr(), fds.len() as _, -1) };
|
||||
if result < 0
|
||||
&& std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted
|
||||
{
|
||||
continue;
|
||||
}
|
||||
checked(result)?;
|
||||
if fds[3].revents & (libc::POLLIN | libc::POLLHUP | libc::POLLERR) != 0 {
|
||||
output = self.output()?;
|
||||
}
|
||||
if output && fds[3].revents & libc::POLLOUT != 0 {
|
||||
self.flush_input()?;
|
||||
}
|
||||
if fds[2].revents != 0 {
|
||||
let mut buffer = [0u8; 4096];
|
||||
let count = unsafe { libc::read(0, buffer.as_mut_ptr().cast(), buffer.len()) };
|
||||
if count > 0 {
|
||||
self.write_terminal(&buffer[..count as usize])?;
|
||||
} else if count == 0 {
|
||||
input = false;
|
||||
} else if std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted
|
||||
{
|
||||
return Err(std::io::Error::last_os_error().into());
|
||||
}
|
||||
}
|
||||
if fds[1].revents != 0 {
|
||||
let mut info = unsafe { std::mem::zeroed::<libc::signalfd_siginfo>() };
|
||||
if unsafe {
|
||||
libc::read(
|
||||
self.signals.as_raw_fd(),
|
||||
(&mut info as *mut libc::signalfd_siginfo).cast(),
|
||||
std::mem::size_of_val(&info),
|
||||
)
|
||||
} > 0
|
||||
{
|
||||
let signal = info.ssi_signo as i32;
|
||||
if signal == libc::SIGWINCH {
|
||||
if let Some(master) = &self.master {
|
||||
let mut size = unsafe { std::mem::zeroed::<libc::winsize>() };
|
||||
if unsafe { libc::ioctl(0, libc::TIOCGWINSZ, &mut size) } == 0 {
|
||||
unsafe {
|
||||
libc::ioctl(master.as_raw_fd(), libc::TIOCSWINSZ, &size);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if signal == libc::SIGTSTP {
|
||||
self.restore();
|
||||
if let Some(master) = &self.master {
|
||||
let group = unsafe { libc::tcgetpgrp(master.as_raw_fd()) };
|
||||
if group > 0 {
|
||||
unsafe {
|
||||
libc::kill(-group, libc::SIGSTOP);
|
||||
}
|
||||
}
|
||||
}
|
||||
unsafe {
|
||||
libc::kill(libc::getpid(), libc::SIGSTOP);
|
||||
}
|
||||
self.raw()?;
|
||||
if let Some(master) = &self.master {
|
||||
let group = unsafe { libc::tcgetpgrp(master.as_raw_fd()) };
|
||||
if group > 0 {
|
||||
unsafe {
|
||||
libc::kill(-group, libc::SIGCONT);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if self.master.is_some()
|
||||
&& matches!(signal, libc::SIGINT | libc::SIGQUIT)
|
||||
{
|
||||
let index = if signal == libc::SIGINT {
|
||||
libc::VINTR
|
||||
} else {
|
||||
libc::VQUIT
|
||||
};
|
||||
self.write_terminal(&[self.terminal.as_ref().unwrap().c_cc[index]])?;
|
||||
} else if signal != libc::SIGCONT {
|
||||
unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_pidfd_send_signal,
|
||||
child.as_raw_fd(),
|
||||
signal,
|
||||
std::ptr::null::<libc::siginfo_t>(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if fds[0].revents != 0 {
|
||||
let mut chunk = [0u8; 4096];
|
||||
loop {
|
||||
match socket.read(&mut chunk) {
|
||||
Ok(0) => {
|
||||
let _ = self.output()?;
|
||||
let reply: Response = serde_json::from_slice(&bytes)
|
||||
.map_err(|e| Error(format!("Invalid program completion: {e}")))?;
|
||||
if let Some(error) = reply.error {
|
||||
return Err(Error(error));
|
||||
}
|
||||
return reply
|
||||
.exit_status
|
||||
.ok_or_else(|| Error("Missing program exit status".into()));
|
||||
}
|
||||
Ok(n) => {
|
||||
bytes.extend_from_slice(&chunk[..n]);
|
||||
if bytes.len() > LIMIT {
|
||||
return Err(Error("Program response is too large".into()));
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for Io {
|
||||
fn drop(&mut self) {
|
||||
self.restore();
|
||||
unsafe {
|
||||
libc::sigprocmask(libc::SIG_SETMASK, &self.old_mask, std::ptr::null_mut());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,14 @@ pub enum Request {
|
||||
bay: Bay,
|
||||
arguments: Vec<String>,
|
||||
},
|
||||
/// Foreground PATH launcher; stdio is passed to an ordinary managed child.
|
||||
Program {
|
||||
name: String,
|
||||
arguments: Vec<String>,
|
||||
terminal: bool,
|
||||
working: String,
|
||||
term: String,
|
||||
},
|
||||
MediaPrepare {
|
||||
bay: Bay,
|
||||
image: String,
|
||||
@@ -105,6 +113,11 @@ impl MediaJob {
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PublishedCommand {
|
||||
pub selector: String,
|
||||
pub alias: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BayState {
|
||||
pub bay: Bay,
|
||||
pub state: String,
|
||||
@@ -114,6 +127,8 @@ pub struct BayState {
|
||||
pub manifest: Option<Manifest>,
|
||||
pub mount: Option<String>,
|
||||
pub consumers: usize,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub commands: Vec<PublishedCommand>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub software: Option<crate::software::Catalogue>,
|
||||
}
|
||||
@@ -168,6 +183,8 @@ pub struct Response {
|
||||
pub power: Option<PowerState>,
|
||||
#[serde(default)]
|
||||
pub recovery: Option<RecoveryReport>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub exit_status: Option<i32>,
|
||||
}
|
||||
impl Response {
|
||||
pub fn failure(error: impl ToString) -> Self {
|
||||
@@ -182,6 +199,7 @@ impl Response {
|
||||
disk: None,
|
||||
power: None,
|
||||
recovery: None,
|
||||
exit_status: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Foreground launch transfers ordinary stdio to a managed child and receives its pidfd.
|
||||
use crate::{
|
||||
Error, Result,
|
||||
control::{LIMIT, Request, Response, SOCKET},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
os::{
|
||||
fd::{AsRawFd, FromRawFd, OwnedFd},
|
||||
unix::net::UnixStream,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Program {
|
||||
pub arguments: Vec<String>,
|
||||
pub environment: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
pub fn send_fds(socket: &UnixStream, descriptors: &[i32]) -> Result<()> {
|
||||
if descriptors.is_empty() || descriptors.len() > 3 {
|
||||
return Err(Error("Invalid descriptor count".into()));
|
||||
}
|
||||
let mut marker = b'F';
|
||||
let mut vector = libc::iovec {
|
||||
iov_base: (&mut marker as *mut u8).cast(),
|
||||
iov_len: 1,
|
||||
};
|
||||
let mut control = [0usize; 8];
|
||||
let mut message: libc::msghdr = unsafe { std::mem::zeroed() };
|
||||
message.msg_iov = &mut vector;
|
||||
message.msg_iovlen = 1;
|
||||
message.msg_control = control.as_mut_ptr().cast();
|
||||
message.msg_controllen =
|
||||
unsafe { libc::CMSG_SPACE(std::mem::size_of_val(descriptors) as _) } as _;
|
||||
unsafe {
|
||||
let header = libc::CMSG_FIRSTHDR(&message);
|
||||
(*header).cmsg_level = libc::SOL_SOCKET;
|
||||
(*header).cmsg_type = libc::SCM_RIGHTS;
|
||||
(*header).cmsg_len = libc::CMSG_LEN(std::mem::size_of_val(descriptors) as _) as _;
|
||||
for (index, descriptor) in descriptors.iter().enumerate() {
|
||||
std::ptr::write_unaligned(
|
||||
libc::CMSG_DATA(header).cast::<i32>().add(index),
|
||||
*descriptor,
|
||||
);
|
||||
}
|
||||
if libc::sendmsg(socket.as_raw_fd(), &message, libc::MSG_NOSIGNAL) != 1 {
|
||||
return Err(std::io::Error::last_os_error().into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn receive_fds(socket: &mut UnixStream, expected: usize) -> Result<Vec<OwnedFd>> {
|
||||
let mut marker = 0u8;
|
||||
let mut vector = libc::iovec {
|
||||
iov_base: (&mut marker as *mut u8).cast(),
|
||||
iov_len: 1,
|
||||
};
|
||||
let mut control = [0usize; 8];
|
||||
let mut message: libc::msghdr = unsafe { std::mem::zeroed() };
|
||||
message.msg_iov = &mut vector;
|
||||
message.msg_iovlen = 1;
|
||||
message.msg_control = control.as_mut_ptr().cast();
|
||||
message.msg_controllen = std::mem::size_of_val(&control) as _;
|
||||
let n = unsafe { libc::recvmsg(socket.as_raw_fd(), &mut message, libc::MSG_CMSG_CLOEXEC) };
|
||||
if n < 0 {
|
||||
return Err(std::io::Error::last_os_error().into());
|
||||
}
|
||||
let mut received = Vec::new();
|
||||
unsafe {
|
||||
let mut header = libc::CMSG_FIRSTHDR(&message);
|
||||
while !header.is_null() {
|
||||
if (*header).cmsg_level == libc::SOL_SOCKET && (*header).cmsg_type == libc::SCM_RIGHTS {
|
||||
let count = ((*header).cmsg_len as usize - libc::CMSG_LEN(0) as usize)
|
||||
/ std::mem::size_of::<i32>();
|
||||
for index in 0..count {
|
||||
let fd =
|
||||
std::ptr::read_unaligned(libc::CMSG_DATA(header).cast::<i32>().add(index));
|
||||
received.push(OwnedFd::from_raw_fd(fd));
|
||||
}
|
||||
}
|
||||
header = libc::CMSG_NXTHDR(&message, header);
|
||||
}
|
||||
}
|
||||
if n == 1
|
||||
&& marker == b'F'
|
||||
&& message.msg_flags & libc::MSG_CTRUNC == 0
|
||||
&& received.len() == expected
|
||||
{
|
||||
return Ok(received);
|
||||
}
|
||||
if n == 1 && marker == b'{' && received.is_empty() {
|
||||
let mut bytes = vec![marker];
|
||||
socket.take(LIMIT as u64).read_to_end(&mut bytes)?;
|
||||
if bytes.len() <= LIMIT {
|
||||
if let Ok(reply) = serde_json::from_slice::<Response>(&bytes) {
|
||||
if let Some(error) = reply.error {
|
||||
return Err(Error(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(Error("Invalid managed program handshake".into()))
|
||||
}
|
||||
|
||||
pub fn start(
|
||||
name: &str,
|
||||
arguments: Vec<String>,
|
||||
terminal: bool,
|
||||
working: String,
|
||||
term: String,
|
||||
descriptors: &[i32],
|
||||
) -> Result<(UnixStream, OwnedFd)> {
|
||||
let mut socket = UnixStream::connect(SOCKET)?;
|
||||
socket.set_read_timeout(Some(Duration::from_secs(120)))?;
|
||||
socket.set_write_timeout(Some(Duration::from_secs(5)))?;
|
||||
let mut request = serde_json::to_vec(&Request::Program {
|
||||
name: name.into(),
|
||||
arguments,
|
||||
terminal,
|
||||
working,
|
||||
term,
|
||||
})
|
||||
.map_err(|e| Error(e.to_string()))?;
|
||||
if request.len() >= LIMIT {
|
||||
return Err(Error("Program request is too long".into()));
|
||||
}
|
||||
request.push(b'\n');
|
||||
socket.write_all(&request)?;
|
||||
let mut ready = [0u8];
|
||||
socket.read_exact(&mut ready)?;
|
||||
if ready != [b'R'] {
|
||||
let mut bytes = ready.to_vec();
|
||||
(&mut socket).take(LIMIT as u64).read_to_end(&mut bytes)?;
|
||||
let reply: Response = serde_json::from_slice(&bytes)
|
||||
.map_err(|_| Error("Invalid program handshake".into()))?;
|
||||
return Err(Error(
|
||||
reply
|
||||
.error
|
||||
.unwrap_or_else(|| "Program launch failed".into()),
|
||||
));
|
||||
}
|
||||
send_fds(&socket, descriptors)?;
|
||||
let mut received = receive_fds(&mut socket, 1)?;
|
||||
socket.set_read_timeout(None)?;
|
||||
socket.set_nonblocking(true)?;
|
||||
Ok((socket, received.pop().unwrap()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::File;
|
||||
#[test]
|
||||
fn descriptor_transfer_is_cloexec_and_owned() {
|
||||
let (sender, mut receiver) = UnixStream::pair().unwrap();
|
||||
let original = File::open("/dev/null").unwrap();
|
||||
send_fds(&sender, &[original.as_raw_fd()]).unwrap();
|
||||
let received = receive_fds(&mut receiver, 1).unwrap().pop().unwrap();
|
||||
assert_ne!(received.as_raw_fd(), original.as_raw_fd());
|
||||
assert_ne!(
|
||||
unsafe { libc::fcntl(received.as_raw_fd(), libc::F_GETFD) } & libc::FD_CLOEXEC,
|
||||
0
|
||||
);
|
||||
drop(original);
|
||||
assert!(File::from(received).metadata().is_ok());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Shared data contracts. Cartridge contents are data, never startup commands.
|
||||
pub mod boot;
|
||||
pub mod control;
|
||||
pub mod launch;
|
||||
pub mod machine;
|
||||
pub mod manifest;
|
||||
pub mod software;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Software metadata is descriptive. Bundles never contain privileged build hooks.
|
||||
//! Software metadata describes immutable installed trees or legacy archives.
|
||||
|
||||
use crate::{Error, Result, manifest::identifier};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -21,6 +21,13 @@ pub struct Software {
|
||||
pub architecture: String,
|
||||
/// GPT partition number, starting at 2 after FDS_METADATA.
|
||||
pub partition: u8,
|
||||
/// New media executes installed Void package files directly from EROFS.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub installed: bool,
|
||||
/// Exact XBPS package versions included in this software tree.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub packages: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
pub archive_bytes: u64,
|
||||
pub unpacked_bytes: u64,
|
||||
pub entries: u32,
|
||||
@@ -29,6 +36,9 @@ pub struct Software {
|
||||
pub commands: BTreeMap<String, String>,
|
||||
}
|
||||
impl Software {
|
||||
pub fn root_path(&self) -> String {
|
||||
format!("programs/{}", self.id)
|
||||
}
|
||||
pub fn archive_path(&self) -> String {
|
||||
format!("bundles/{}.tar.xz", self.id)
|
||||
}
|
||||
@@ -41,7 +51,14 @@ impl Software {
|
||||
|| !display(&self.version, 32)
|
||||
|| !matches!(self.architecture.as_str(), "aarch64" | "any")
|
||||
|| !(2..=33).contains(&self.partition)
|
||||
|| !(1..=MAX_ARCHIVE).contains(&self.archive_bytes)
|
||||
|| if self.installed {
|
||||
self.archive_bytes != 0
|
||||
|| self.packages.is_empty()
|
||||
|| self.packages.len() > 512
|
||||
|| self.packages.iter().any(|p| !xbps_identifier(p))
|
||||
} else {
|
||||
!(1..=MAX_ARCHIVE).contains(&self.archive_bytes) || !self.packages.is_empty()
|
||||
}
|
||||
|| self.unpacked_bytes > MAX_UNPACKED
|
||||
|| !(1..=MAX_ENTRIES).contains(&self.entries)
|
||||
|| self.sha256.len() != 64
|
||||
@@ -81,15 +98,20 @@ impl Catalogue {
|
||||
Ok(value)
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.format != 1 || self.software.is_empty() || self.software.len() > 128 {
|
||||
if !matches!(self.format, 1 | 2) || self.software.is_empty() || self.software.len() > 128 {
|
||||
return Err(Error(
|
||||
"Software catalogue requires format 1 and 1..128 software entries".into(),
|
||||
"Software catalogue requires format 1 or 2 and 1..128 software entries".into(),
|
||||
));
|
||||
}
|
||||
let mut ids = BTreeSet::new();
|
||||
let mut partitions = BTreeSet::new();
|
||||
for software in &self.software {
|
||||
software.validate()?;
|
||||
if software.installed != (self.format == 2) {
|
||||
return Err(Error(
|
||||
"Catalogue format disagrees with software storage layout".into(),
|
||||
));
|
||||
}
|
||||
if !ids.insert(&software.id) {
|
||||
return Err(Error("Duplicate software id".into()));
|
||||
}
|
||||
@@ -118,6 +140,18 @@ impl Catalogue {
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
fn is_zero(value: &u64) -> bool {
|
||||
*value == 0
|
||||
}
|
||||
pub fn xbps_identifier(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 256
|
||||
&& value.as_bytes()[0].is_ascii_alphanumeric()
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || b"._-+~".contains(&c))
|
||||
&& !value.contains("..")
|
||||
}
|
||||
pub fn relative(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 1024
|
||||
@@ -141,6 +175,8 @@ mod tests {
|
||||
version: "1".into(),
|
||||
architecture: "aarch64".into(),
|
||||
partition,
|
||||
installed: false,
|
||||
packages: Vec::new(),
|
||||
archive_bytes: 100,
|
||||
unpacked_bytes: 200,
|
||||
entries: 1,
|
||||
@@ -181,4 +217,24 @@ mod tests {
|
||||
}
|
||||
assert!(relative("share/document with spaces.txt"));
|
||||
}
|
||||
#[test]
|
||||
fn installed_trees_use_format_two_and_record_xbps_versions() {
|
||||
let mut entry = software("one", 2);
|
||||
entry.installed = true;
|
||||
entry.archive_bytes = 0;
|
||||
entry.packages = vec!["WindowMaker-0.96.0_1".into(), "libstdc++-14.2.1_1".into()];
|
||||
let mut catalogue = Catalogue {
|
||||
format: 2,
|
||||
software: vec![entry],
|
||||
};
|
||||
assert_eq!(
|
||||
Catalogue::parse(&catalogue.to_toml().unwrap()).unwrap(),
|
||||
catalogue
|
||||
);
|
||||
catalogue.format = 1;
|
||||
assert!(catalogue.validate().is_err());
|
||||
catalogue.format = 2;
|
||||
catalogue.software[0].packages.clear();
|
||||
assert!(catalogue.validate().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "fds-control"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
description = "Native grayscale X11 cartridge control panel"
|
||||
|
||||
[dependencies]
|
||||
clap.workspace = true
|
||||
fds-common = { path = "../fds-common" }
|
||||
libc = "0.2"
|
||||
serde_json = "1"
|
||||
x11rb = "=0.13.2"
|
||||
@@ -0,0 +1,859 @@
|
||||
//! Core X11 drawing keeps the control panel small, static and free of animation.
|
||||
use clap::Parser;
|
||||
use fds_common::{
|
||||
Bay,
|
||||
control::{self, BayState, Request, Response},
|
||||
manifest::Class,
|
||||
};
|
||||
use std::{
|
||||
error::Error,
|
||||
io::{Read, Write},
|
||||
os::{fd::AsRawFd, unix::net::UnixStream},
|
||||
process::{Child, Command},
|
||||
sync::mpsc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use x11rb::{
|
||||
COPY_DEPTH_FROM_PARENT,
|
||||
connection::Connection,
|
||||
protocol::{Event, xproto::*},
|
||||
rust_connection::RustConnection,
|
||||
wrapper::ConnectionExt as _,
|
||||
};
|
||||
type Result<T> = std::result::Result<T, Box<dyn Error>>;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
version,
|
||||
about = "FDS cartridge control panel for X11",
|
||||
after_help = "Select a bay to inspect its cartridge. Run opens a terminal; Eject stops its programs and releases the cartridge. Remove media only after SAFE appears."
|
||||
)]
|
||||
struct Options {
|
||||
/// X display; defaults to DISPLAY.
|
||||
#[arg(long)]
|
||||
display: Option<String>,
|
||||
}
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Focus {
|
||||
Bays,
|
||||
Programs,
|
||||
Run,
|
||||
Eject,
|
||||
Rescan,
|
||||
}
|
||||
impl Focus {
|
||||
fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Bays => Self::Programs,
|
||||
Self::Programs => Self::Run,
|
||||
Self::Run => Self::Eject,
|
||||
Self::Eject => Self::Rescan,
|
||||
Self::Rescan => Self::Bays,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct Program {
|
||||
label: String,
|
||||
alias: String,
|
||||
}
|
||||
struct Model {
|
||||
bays: Vec<BayState>,
|
||||
bay: usize,
|
||||
program: usize,
|
||||
focus: Focus,
|
||||
status: String,
|
||||
busy: bool,
|
||||
available: bool,
|
||||
display: String,
|
||||
}
|
||||
impl Model {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
bays: Vec::new(),
|
||||
bay: 0,
|
||||
program: 0,
|
||||
focus: Focus::Bays,
|
||||
status: "Connecting to the cartridge service...".into(),
|
||||
busy: false,
|
||||
available: false,
|
||||
display: std::env::var("DISPLAY").unwrap_or_else(|_| ":0".into()),
|
||||
}
|
||||
}
|
||||
fn selected(&self) -> Option<&BayState> {
|
||||
self.bays
|
||||
.iter()
|
||||
.find(|b| u8::from(b.bay) as usize == self.bay + 1)
|
||||
}
|
||||
fn programs(&self) -> Vec<Program> {
|
||||
let Some(bay) = self.selected() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if bay.state != "mounted_read_only" {
|
||||
return Vec::new();
|
||||
}
|
||||
bay.commands
|
||||
.iter()
|
||||
.map(|command| Program {
|
||||
label: command.selector.clone(),
|
||||
alias: command.alias.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn can_eject(&self) -> bool {
|
||||
self.available
|
||||
&& !self.busy
|
||||
&& self
|
||||
.selected()
|
||||
.is_some_and(|b| b.mount.is_some() && b.state != "protected")
|
||||
}
|
||||
fn can_use_data(&self) -> bool {
|
||||
self.available
|
||||
&& !self.busy
|
||||
&& self.selected().is_some_and(|b| {
|
||||
b.state == "mounted_read_only"
|
||||
&& b.manifest
|
||||
.as_ref()
|
||||
.is_some_and(|m| m.cartridge.class == Class::Data)
|
||||
})
|
||||
}
|
||||
fn can_run(&self) -> bool {
|
||||
self.available && !self.busy && self.program < self.programs().len()
|
||||
}
|
||||
fn select(&mut self, bay: usize) {
|
||||
if self.bay != bay {
|
||||
self.bay = bay;
|
||||
self.program = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All labels are bounded before core X11 text requests. Cartridge names never
|
||||
// become shell source; launches use an argument vector and the validated alias.
|
||||
fn text_bytes(text: &str, max: usize) -> Vec<u8> {
|
||||
let mut bytes: Vec<_> = text
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii() && !c.is_control() {
|
||||
c as u8
|
||||
} else {
|
||||
b'?'
|
||||
}
|
||||
})
|
||||
.take(max + 1)
|
||||
.collect();
|
||||
if bytes.len() > max {
|
||||
bytes.truncate(max);
|
||||
if max >= 3 {
|
||||
bytes[max - 3..].copy_from_slice(b"...");
|
||||
}
|
||||
}
|
||||
bytes
|
||||
}
|
||||
fn state_label(state: &str) -> String {
|
||||
state.replace('_', " ").to_uppercase()
|
||||
}
|
||||
|
||||
struct View {
|
||||
connection: RustConnection,
|
||||
window: Window,
|
||||
gc: Gcontext,
|
||||
black: u32,
|
||||
white: u32,
|
||||
gray: u32,
|
||||
delete: Atom,
|
||||
protocols: Atom,
|
||||
state_atom: Atom,
|
||||
}
|
||||
impl View {
|
||||
fn new(display: Option<&str>) -> Result<Self> {
|
||||
let (connection, index) = x11rb::connect(display)?;
|
||||
let screen = &connection.setup().roots[index];
|
||||
let (black, white) = (screen.black_pixel, screen.white_pixel);
|
||||
let gray = connection
|
||||
.alloc_color(screen.default_colormap, 0xcccc, 0xcccc, 0xcccc)?
|
||||
.reply()?
|
||||
.pixel;
|
||||
let window = connection.generate_id()?;
|
||||
connection
|
||||
.create_window(
|
||||
COPY_DEPTH_FROM_PARENT,
|
||||
window,
|
||||
screen.root,
|
||||
30,
|
||||
30,
|
||||
900,
|
||||
630,
|
||||
2,
|
||||
WindowClass::INPUT_OUTPUT,
|
||||
0,
|
||||
&CreateWindowAux::new()
|
||||
.background_pixel(white)
|
||||
.border_pixel(black)
|
||||
.event_mask(
|
||||
EventMask::EXPOSURE
|
||||
| EventMask::BUTTON_PRESS
|
||||
| EventMask::KEY_PRESS
|
||||
| EventMask::STRUCTURE_NOTIFY,
|
||||
),
|
||||
)?
|
||||
.check()?;
|
||||
connection.change_property8(
|
||||
PropMode::REPLACE,
|
||||
window,
|
||||
AtomEnum::WM_NAME,
|
||||
AtomEnum::STRING,
|
||||
b"FDS Control",
|
||||
)?;
|
||||
connection.change_property8(
|
||||
PropMode::REPLACE,
|
||||
window,
|
||||
AtomEnum::WM_CLASS,
|
||||
AtomEnum::STRING,
|
||||
b"fds-control\0FdsControl\0",
|
||||
)?;
|
||||
let protocols = connection
|
||||
.intern_atom(false, b"WM_PROTOCOLS")?
|
||||
.reply()?
|
||||
.atom;
|
||||
let delete = connection
|
||||
.intern_atom(false, b"WM_DELETE_WINDOW")?
|
||||
.reply()?
|
||||
.atom;
|
||||
connection.change_property32(
|
||||
PropMode::REPLACE,
|
||||
window,
|
||||
protocols,
|
||||
AtomEnum::ATOM,
|
||||
&[delete],
|
||||
)?;
|
||||
let state_atom = connection
|
||||
.intern_atom(false, b"_FDS_CONTROL_STATE")?
|
||||
.reply()?
|
||||
.atom;
|
||||
// Fixed dimensions keep the bitmap-font layout readable and predictable.
|
||||
let mut hints = x11rb::properties::WmSizeHints::new();
|
||||
hints.min_size = Some((900, 630));
|
||||
hints.max_size = Some((900, 630));
|
||||
hints.set_normal_hints(&connection, window)?;
|
||||
let font = connection.generate_id()?;
|
||||
if connection
|
||||
.open_font(
|
||||
font,
|
||||
b"-*-terminus-medium-r-normal--16-*-*-*-*-*-iso10646-1",
|
||||
)?
|
||||
.check()
|
||||
.is_err()
|
||||
{
|
||||
connection.open_font(font, b"fixed")?.check()?;
|
||||
}
|
||||
let gc = connection.generate_id()?;
|
||||
connection.create_gc(
|
||||
gc,
|
||||
window,
|
||||
&CreateGCAux::new()
|
||||
.foreground(black)
|
||||
.background(white)
|
||||
.font(font)
|
||||
.graphics_exposures(0),
|
||||
)?;
|
||||
connection.map_window(window)?;
|
||||
connection.flush()?;
|
||||
Ok(Self {
|
||||
connection,
|
||||
window,
|
||||
gc,
|
||||
black,
|
||||
white,
|
||||
gray,
|
||||
delete,
|
||||
protocols,
|
||||
state_atom,
|
||||
})
|
||||
}
|
||||
fn fill(&self, x: i16, y: i16, width: u16, height: u16, color: u32) -> Result<()> {
|
||||
self.connection
|
||||
.change_gc(self.gc, &ChangeGCAux::new().foreground(color))?;
|
||||
self.connection.poly_fill_rectangle(
|
||||
self.window,
|
||||
self.gc,
|
||||
&[Rectangle {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
fn border(&self, x: i16, y: i16, width: u16, height: u16) -> Result<()> {
|
||||
self.connection
|
||||
.change_gc(self.gc, &ChangeGCAux::new().foreground(self.black))?;
|
||||
self.connection.poly_rectangle(
|
||||
self.window,
|
||||
self.gc,
|
||||
&[Rectangle {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
fn text(&self, x: i16, y: i16, text: &str, max: usize, inverted: bool) -> Result<()> {
|
||||
self.connection.change_gc(
|
||||
self.gc,
|
||||
&ChangeGCAux::new().foreground(if inverted { self.white } else { self.black }),
|
||||
)?;
|
||||
let bytes = text_bytes(text, max.min(254));
|
||||
// poly_text draws only glyphs, preserving the selection/background fill.
|
||||
let mut data = vec![bytes.len() as u8, 0];
|
||||
data.extend(bytes);
|
||||
self.connection
|
||||
.poly_text8(self.window, self.gc, x, y, &data)?;
|
||||
Ok(())
|
||||
}
|
||||
fn button(&self, x: i16, width: u16, label: &str, enabled: bool, focused: bool) -> Result<()> {
|
||||
self.fill(
|
||||
x,
|
||||
498,
|
||||
width,
|
||||
38,
|
||||
if enabled && focused {
|
||||
self.black
|
||||
} else if enabled {
|
||||
self.white
|
||||
} else {
|
||||
self.gray
|
||||
},
|
||||
)?;
|
||||
self.border(x, 498, width, 38)?;
|
||||
self.text(
|
||||
x + 12,
|
||||
522,
|
||||
label,
|
||||
(width as usize - 24) / 8,
|
||||
enabled && focused,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
fn draw(&self, model: &Model) -> Result<()> {
|
||||
// Expose the same visible state to X11 inspection/accessibility tools.
|
||||
let state = serde_json::json!({"bay": model.bay + 1, "state": model.selected().map(|b| &b.state), "busy": model.busy, "available": model.available, "commands": model.programs().len(), "status": model.status});
|
||||
self.connection.change_property8(
|
||||
PropMode::REPLACE,
|
||||
self.window,
|
||||
self.state_atom,
|
||||
AtomEnum::STRING,
|
||||
serde_json::to_string(&state)?.as_bytes(),
|
||||
)?;
|
||||
self.fill(0, 0, 900, 630, self.white)?;
|
||||
self.fill(0, 0, 900, 72, self.black)?;
|
||||
self.text(22, 29, "FDS / CONTROL", 50, true)?;
|
||||
self.text(22, 53, "Cartridges and programs", 70, true)?;
|
||||
self.text(710, 42, "TWELVE BAYS", 22, true)?;
|
||||
self.text(22, 98, "BAY CARTRIDGE / STATE", 37, false)?;
|
||||
for index in 0..12 {
|
||||
let y = 110 + index as i16 * 31;
|
||||
let selected = model.bay == index;
|
||||
self.fill(
|
||||
20,
|
||||
y,
|
||||
300,
|
||||
31,
|
||||
if selected { self.black } else { self.white },
|
||||
)?;
|
||||
self.border(20, y, 300, 31)?;
|
||||
let bay = model
|
||||
.bays
|
||||
.iter()
|
||||
.find(|b| u8::from(b.bay) as usize == index + 1);
|
||||
let label = match bay {
|
||||
Some(b) => {
|
||||
if b.state == "empty" {
|
||||
"Empty".into()
|
||||
} else if b.state == "safe" {
|
||||
"SAFE - remove cartridge".into()
|
||||
} else {
|
||||
b.name.clone().unwrap_or_else(|| state_label(&b.state))
|
||||
}
|
||||
}
|
||||
None => "Unavailable".into(),
|
||||
};
|
||||
self.text(
|
||||
30,
|
||||
y + 21,
|
||||
&format!("{:02} {label}", index + 1),
|
||||
35,
|
||||
selected,
|
||||
)?;
|
||||
}
|
||||
if model.focus == Focus::Bays {
|
||||
self.border(17, 107, 306, 378)?;
|
||||
}
|
||||
self.text(346, 98, &format!("BAY {:02}", model.bay + 1), 60, false)?;
|
||||
if let Some(bay) = model.selected() {
|
||||
self.text(
|
||||
346,
|
||||
130,
|
||||
bay.name.as_deref().unwrap_or("No cartridge"),
|
||||
66,
|
||||
false,
|
||||
)?;
|
||||
self.text(346, 156, &state_label(&bay.state), 66, false)?;
|
||||
if let Some(manifest) = &bay.manifest {
|
||||
self.text(
|
||||
346,
|
||||
184,
|
||||
&format!("Type: {:?}", manifest.cartridge.class),
|
||||
66,
|
||||
false,
|
||||
)?;
|
||||
self.text(
|
||||
346,
|
||||
207,
|
||||
&format!("ID: {}", manifest.cartridge.id),
|
||||
66,
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
self.text(
|
||||
346,
|
||||
235,
|
||||
&format!("Running processes: {}", bay.consumers),
|
||||
66,
|
||||
false,
|
||||
)?;
|
||||
if let Some(detail) = &bay.detail {
|
||||
self.text(346, 260, detail, 66, false)?;
|
||||
}
|
||||
} else {
|
||||
self.text(346, 132, "Waiting for the cartridge service.", 66, false)?;
|
||||
}
|
||||
self.text(346, 292, "PROGRAMS", 66, false)?;
|
||||
let programs = model.programs();
|
||||
let first = model.program.saturating_sub(4);
|
||||
for row in 0..5 {
|
||||
let index = first + row;
|
||||
let y = 304 + row as i16 * 31;
|
||||
let selected = index == model.program && index < programs.len();
|
||||
self.fill(
|
||||
346,
|
||||
y,
|
||||
532,
|
||||
31,
|
||||
if selected { self.black } else { self.white },
|
||||
)?;
|
||||
self.border(346, y, 532, 31)?;
|
||||
if let Some(program) = programs.get(index) {
|
||||
self.text(356, y + 21, &program.label, 64, selected)?;
|
||||
} else if row == 0 {
|
||||
self.text(
|
||||
356,
|
||||
y + 21,
|
||||
"No software commands on this cartridge",
|
||||
64,
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
if model.focus == Focus::Programs {
|
||||
self.border(343, 301, 538, 161)?;
|
||||
}
|
||||
if !programs.is_empty() {
|
||||
self.text(
|
||||
346,
|
||||
482,
|
||||
&format!("Command {} of {}", model.program + 1, programs.len()),
|
||||
66,
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
self.button(
|
||||
346,
|
||||
192,
|
||||
if model.can_use_data() {
|
||||
"Use DATA"
|
||||
} else {
|
||||
"Run in terminal"
|
||||
},
|
||||
model.can_run() || model.can_use_data(),
|
||||
model.focus == Focus::Run,
|
||||
)?;
|
||||
self.button(
|
||||
554,
|
||||
148,
|
||||
"Safe eject",
|
||||
model.can_eject(),
|
||||
model.focus == Focus::Eject,
|
||||
)?;
|
||||
self.button(
|
||||
718,
|
||||
160,
|
||||
"Rescan",
|
||||
!model.busy,
|
||||
model.focus == Focus::Rescan,
|
||||
)?;
|
||||
self.text(22, 517, "Remove media only after SAFE.", 37, false)?;
|
||||
self.fill(20, 552, 858, 44, self.gray)?;
|
||||
self.text(30, 572, &model.status, 104, false)?;
|
||||
self.text(
|
||||
30,
|
||||
589,
|
||||
&model.status.chars().skip(104).collect::<String>(),
|
||||
104,
|
||||
false,
|
||||
)?;
|
||||
self.text(
|
||||
22,
|
||||
619,
|
||||
"Arrows: select Tab: focus Enter: activate R: rescan Esc: close",
|
||||
106,
|
||||
false,
|
||||
)?;
|
||||
self.connection.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
fn keysym(&self, keycode: u8) -> Result<u32> {
|
||||
let mapping = self.connection.get_keyboard_mapping(keycode, 1)?.reply()?;
|
||||
Ok(mapping.keysyms.first().copied().unwrap_or(0))
|
||||
}
|
||||
}
|
||||
|
||||
struct Update {
|
||||
result: fds_common::Result<Response>,
|
||||
action: Option<String>,
|
||||
}
|
||||
struct Worker {
|
||||
request: mpsc::SyncSender<(Request, Option<String>)>,
|
||||
response: mpsc::Receiver<Update>,
|
||||
wake: UnixStream,
|
||||
in_flight: bool,
|
||||
queued: Option<(Request, Option<String>)>,
|
||||
}
|
||||
impl Worker {
|
||||
fn new() -> Result<Self> {
|
||||
let (tx, rx) = mpsc::sync_channel::<(Request, Option<String>)>(1);
|
||||
let (updates, response) = mpsc::channel();
|
||||
let (wake, mut writer) = UnixStream::pair()?;
|
||||
wake.set_nonblocking(true)?;
|
||||
std::thread::spawn(move || {
|
||||
while let Ok((request, action)) = rx.recv() {
|
||||
let result = control::request(&request).and_then(|reply| {
|
||||
let inventory = if matches!(request, Request::Bays) {
|
||||
reply
|
||||
} else {
|
||||
control::request(&Request::Bays)?
|
||||
};
|
||||
if let Request::Eject { bay } = &request {
|
||||
if !inventory.bays.iter().any(|b| b.bay == *bay && matches!(b.state.as_str(), "safe" | "empty")) {
|
||||
return Err(fds_common::Error("Bay changed after eject; inspect its current state before removing media".into()));
|
||||
}
|
||||
}
|
||||
Ok(inventory)
|
||||
});
|
||||
if updates.send(Update { result, action }).is_err()
|
||||
|| writer.write_all(b"R").is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(Self {
|
||||
request: tx,
|
||||
response,
|
||||
wake,
|
||||
in_flight: false,
|
||||
queued: None,
|
||||
})
|
||||
}
|
||||
fn send(&mut self, model: &mut Model, request: Request, action: Option<String>) -> Result<()> {
|
||||
if self.in_flight {
|
||||
// One user operation may queue behind the background status read.
|
||||
// Polling never disables controls or discards a click.
|
||||
if action.is_some() && self.queued.is_none() {
|
||||
self.queued = Some((request, action));
|
||||
model.busy = true;
|
||||
}
|
||||
} else {
|
||||
model.busy = action.is_some();
|
||||
self.request.send((request, action))?;
|
||||
self.in_flight = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn activate(
|
||||
focus: Focus,
|
||||
model: &mut Model,
|
||||
worker: &mut Worker,
|
||||
children: &mut Vec<Child>,
|
||||
) -> Result<()> {
|
||||
match focus {
|
||||
Focus::Run if model.can_use_data() => {
|
||||
model.status = "Activating DATA...".into();
|
||||
worker.send(
|
||||
model,
|
||||
Request::DataUse {
|
||||
bay: Bay::try_from((model.bay + 1) as u8)?,
|
||||
},
|
||||
Some("DATA is active at /data.".into()),
|
||||
)?;
|
||||
}
|
||||
Focus::Programs | Focus::Run if model.can_run() => {
|
||||
let program = &model.programs()[model.program];
|
||||
match Command::new("/usr/bin/xterm")
|
||||
.env("DISPLAY", &model.display)
|
||||
.args([
|
||||
"-hold",
|
||||
"-T",
|
||||
&program.label,
|
||||
"-fa",
|
||||
"Terminus",
|
||||
"-fs",
|
||||
"16",
|
||||
"-bg",
|
||||
"white",
|
||||
"-fg",
|
||||
"black",
|
||||
"-e",
|
||||
"/usr/bin/fds-program",
|
||||
&program.alias,
|
||||
])
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => {
|
||||
children.push(child);
|
||||
model.status = format!(
|
||||
"Opened {}. Close its terminal when finished.",
|
||||
program.label
|
||||
);
|
||||
}
|
||||
Err(error) => model.status = format!("Cannot open terminal: {error}"),
|
||||
}
|
||||
}
|
||||
Focus::Eject if model.can_eject() => {
|
||||
model.status = format!(
|
||||
"Releasing bay {:02}; waiting for programs and storage...",
|
||||
model.bay + 1
|
||||
);
|
||||
worker.send(
|
||||
model,
|
||||
Request::Eject {
|
||||
bay: Bay::try_from((model.bay + 1) as u8)?,
|
||||
},
|
||||
Some(format!(
|
||||
"Bay {:02} is SAFE. You may remove the cartridge.",
|
||||
model.bay + 1
|
||||
)),
|
||||
)?;
|
||||
}
|
||||
Focus::Rescan if !model.busy => {
|
||||
model.status = "Scanning cartridge bays...".into();
|
||||
worker.send(
|
||||
model,
|
||||
Request::Rescan,
|
||||
Some("Cartridge inventory refreshed.".into()),
|
||||
)?;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn run() -> Result<()> {
|
||||
let options = Options::parse();
|
||||
let view = View::new(options.display.as_deref())?;
|
||||
let mut model = Model::new();
|
||||
if let Some(display) = options.display {
|
||||
model.display = display;
|
||||
}
|
||||
let mut worker = Worker::new()?;
|
||||
let mut children: Vec<Child> = Vec::new();
|
||||
worker.send(
|
||||
&mut model,
|
||||
Request::Bays,
|
||||
Some("Select a bay to inspect its cartridge.".into()),
|
||||
)?;
|
||||
let mut refresh = Instant::now() + Duration::from_secs(2);
|
||||
let mut previous = String::new();
|
||||
let mut redraw = true;
|
||||
let mut painted_busy = false;
|
||||
loop {
|
||||
while let Some(event) = view.connection.poll_for_event()? {
|
||||
match event {
|
||||
Event::Expose(_) => redraw = true,
|
||||
Event::ClientMessage(e)
|
||||
if e.type_ == view.protocols && e.data.as_data32()[0] == view.delete =>
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Event::DestroyNotify(_) => return Ok(()),
|
||||
Event::Error(e) => return Err(format!("X11 protocol error: {e:?}").into()),
|
||||
Event::ButtonPress(e) if e.detail == 1 => {
|
||||
let (x, y) = (e.event_x, e.event_y);
|
||||
if (20..320).contains(&x) && (110..482).contains(&y) {
|
||||
model.select(((y - 110) / 31) as usize);
|
||||
model.focus = Focus::Bays;
|
||||
} else if (346..878).contains(&x) && (304..459).contains(&y) {
|
||||
let index = model.program.saturating_sub(4) + ((y - 304) / 31) as usize;
|
||||
if index < model.programs().len() {
|
||||
model.program = index;
|
||||
model.focus = Focus::Programs;
|
||||
}
|
||||
} else if (498..536).contains(&y) {
|
||||
let focus = if (346..538).contains(&x) {
|
||||
Some(Focus::Run)
|
||||
} else if (554..702).contains(&x) {
|
||||
Some(Focus::Eject)
|
||||
} else if (718..878).contains(&x) {
|
||||
Some(Focus::Rescan)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(focus) = focus {
|
||||
model.focus = focus;
|
||||
activate(focus, &mut model, &mut worker, &mut children)?;
|
||||
}
|
||||
}
|
||||
redraw = true;
|
||||
}
|
||||
Event::KeyPress(e) => {
|
||||
match view.keysym(e.detail)? {
|
||||
0xff1b => return Ok(()),
|
||||
0xff09 => model.focus = model.focus.next(),
|
||||
0xff52 | 0xff54 => {
|
||||
let down = view.keysym(e.detail)? == 0xff54;
|
||||
if model.focus == Focus::Programs {
|
||||
let length = model.programs().len();
|
||||
if length > 0 {
|
||||
model.program = if down {
|
||||
(model.program + 1).min(length - 1)
|
||||
} else {
|
||||
model.program.saturating_sub(1)
|
||||
};
|
||||
}
|
||||
} else {
|
||||
model.select(if down {
|
||||
(model.bay + 1).min(11)
|
||||
} else {
|
||||
model.bay.saturating_sub(1)
|
||||
});
|
||||
}
|
||||
}
|
||||
0xff0d => activate(model.focus, &mut model, &mut worker, &mut children)?,
|
||||
0x72 | 0x52 => {
|
||||
activate(Focus::Rescan, &mut model, &mut worker, &mut children)?
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
redraw = true;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
while let Ok(update) = worker.response.try_recv() {
|
||||
worker.in_flight = false;
|
||||
if update.action.is_some() {
|
||||
model.busy = false;
|
||||
}
|
||||
redraw |= painted_busy;
|
||||
match update.result {
|
||||
Ok(reply) => {
|
||||
let serialized = serde_json::to_string(&reply.bays)?;
|
||||
let changed = serialized != previous;
|
||||
let reconnected = !model.available;
|
||||
model.available = true;
|
||||
model.bays = reply.bays;
|
||||
if model.program >= model.programs().len() {
|
||||
model.program = 0;
|
||||
}
|
||||
if let Some(action) = update.action {
|
||||
model.status = action;
|
||||
redraw = true;
|
||||
} else if changed && !reconnected && !model.busy {
|
||||
model.status =
|
||||
"Cartridge inventory updated. Check the selected bay's current state."
|
||||
.into();
|
||||
redraw = true;
|
||||
} else if reconnected {
|
||||
model.status = "Cartridge service connected.".into();
|
||||
redraw = true;
|
||||
}
|
||||
previous = serialized;
|
||||
redraw |= changed;
|
||||
}
|
||||
Err(error) => {
|
||||
let status = format!("Operation failed: {error}");
|
||||
redraw |= status != model.status;
|
||||
model.status = status;
|
||||
model.available = false;
|
||||
}
|
||||
}
|
||||
if let Some((request, action)) = worker.queued.take() {
|
||||
worker.send(&mut model, request, action)?;
|
||||
}
|
||||
refresh = Instant::now() + Duration::from_secs(2);
|
||||
}
|
||||
children.retain_mut(|child| !matches!(child.try_wait(), Ok(Some(_))));
|
||||
if redraw {
|
||||
view.draw(&model)?;
|
||||
painted_busy = model.busy;
|
||||
redraw = false;
|
||||
}
|
||||
if Instant::now() >= refresh {
|
||||
worker.send(&mut model, Request::Bays, None)?;
|
||||
refresh = Instant::now() + Duration::from_secs(2);
|
||||
}
|
||||
let mut fds = [
|
||||
libc::pollfd {
|
||||
fd: view.connection.stream().as_raw_fd(),
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
},
|
||||
libc::pollfd {
|
||||
fd: worker.wake.as_raw_fd(),
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
},
|
||||
];
|
||||
let remaining = refresh
|
||||
.saturating_duration_since(Instant::now())
|
||||
.as_millis()
|
||||
.min(2000) as i32;
|
||||
if unsafe { libc::poll(fds.as_mut_ptr(), 2, remaining) } < 0
|
||||
&& std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted
|
||||
{
|
||||
return Err(std::io::Error::last_os_error().into());
|
||||
}
|
||||
if fds[1].revents != 0 {
|
||||
let mut buffer = [0; 64];
|
||||
let _ = worker.wake.read(&mut buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn main() {
|
||||
if let Err(error) = run() {
|
||||
eprintln!("fds-control: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn cartridge_labels_are_bounded_and_cannot_inject_x11_text_items() {
|
||||
assert_eq!(text_bytes("abc\n\u{ff}def", 20), b"abc??def");
|
||||
assert_eq!(text_bytes("abcdefgh", 6), b"abc...");
|
||||
assert_eq!(text_bytes("", 10), b"");
|
||||
}
|
||||
#[test]
|
||||
fn disconnected_controls_do_not_allow_operations() {
|
||||
let model = Model::new();
|
||||
assert!(!model.can_eject());
|
||||
assert!(!model.can_run());
|
||||
use clap::CommandFactory;
|
||||
Options::command().debug_assert();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ name = "fds-software"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
description = "Bounded software catalogues and verified xz tar bundles"
|
||||
description = "Verified installed software trees and legacy archive reading"
|
||||
|
||||
[dependencies]
|
||||
fds-common = { path = "../fds-common" }
|
||||
|
||||
@@ -388,6 +388,8 @@ mod tests {
|
||||
use super::*;
|
||||
fn metadata() -> Software {
|
||||
Software {
|
||||
installed: false,
|
||||
packages: Vec::new(),
|
||||
id: "test.tool".into(),
|
||||
name: "Tool".into(),
|
||||
version: "1".into(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//! Verified software archives shared by workstation and guest tools.
|
||||
//! Installed software trees and legacy archive verification for host and guest.
|
||||
pub use fds_common::software::*;
|
||||
pub mod archive;
|
||||
pub mod tree;
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
//! Deterministic integrity checks for programs executed directly from EROFS.
|
||||
use crate::{MAX_ENTRIES, MAX_UNPACKED, Software, relative};
|
||||
use fds_common::{Error, Result};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
fs::{self, File, OpenOptions},
|
||||
io::Read,
|
||||
os::unix::fs::{OpenOptionsExt, PermissionsExt, symlink},
|
||||
path::{Component, Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Inventory {
|
||||
pub bytes: u64,
|
||||
pub entries: u32,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
fn paths(root: &Path, directory: &Path, output: &mut Vec<PathBuf>) -> Result<()> {
|
||||
for entry in fs::read_dir(directory)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let name = path.strip_prefix(root).unwrap();
|
||||
if !name.to_str().is_some_and(relative) || output.len() >= MAX_ENTRIES as usize {
|
||||
return Err(Error(
|
||||
"Invalid software path or too many installed files".into(),
|
||||
));
|
||||
}
|
||||
output.push(name.to_owned());
|
||||
if entry.file_type()?.is_dir() {
|
||||
paths(root, &path, output)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn link_inside(root: &Path, path: &Path, target: &Path) -> Result<()> {
|
||||
if target.is_absolute() || target.as_os_str().is_empty() {
|
||||
return Err(Error(
|
||||
"Installed software links must be relative to their tree".into(),
|
||||
));
|
||||
}
|
||||
let mut depth = path
|
||||
.parent()
|
||||
.unwrap()
|
||||
.strip_prefix(root)
|
||||
.unwrap()
|
||||
.components()
|
||||
.count();
|
||||
for part in target.components() {
|
||||
match part {
|
||||
Component::Normal(_) => depth += 1,
|
||||
Component::CurDir => (),
|
||||
Component::ParentDir if depth > 0 => depth -= 1,
|
||||
_ => return Err(Error("Installed software symlink escapes its tree".into())),
|
||||
}
|
||||
}
|
||||
match path.canonicalize() {
|
||||
Ok(destination) if !destination.starts_with(root) => {
|
||||
return Err(Error(
|
||||
"Installed software symlink resolves outside its tree".into(),
|
||||
));
|
||||
}
|
||||
Ok(_) => (),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn inspect(root: &Path, architecture: &str) -> Result<Inventory> {
|
||||
if !fs::symlink_metadata(root)?.is_dir() {
|
||||
return Err(Error(
|
||||
"Installed software root must be a real directory".into(),
|
||||
));
|
||||
}
|
||||
let root = root.canonicalize()?;
|
||||
let mut entries = Vec::new();
|
||||
paths(&root, &root, &mut entries)?;
|
||||
entries.sort();
|
||||
let mut hash = Sha256::new();
|
||||
let mut bytes = 0u64;
|
||||
for relative in &entries {
|
||||
let path = root.join(relative);
|
||||
let metadata = path.symlink_metadata()?;
|
||||
let name = relative.as_os_str().as_encoded_bytes();
|
||||
hash.update((name.len() as u64).to_le_bytes());
|
||||
hash.update(name);
|
||||
let mode = metadata.permissions().mode();
|
||||
if !metadata.is_symlink() && mode & 0o7022 != 0 {
|
||||
return Err(Error(format!(
|
||||
"Privileged or group/world-writable software file: {}",
|
||||
relative.display()
|
||||
)));
|
||||
}
|
||||
hash.update((mode & 0o777).to_le_bytes());
|
||||
if metadata.is_dir() {
|
||||
hash.update(b"d");
|
||||
} else if metadata.is_symlink() {
|
||||
hash.update(b"l");
|
||||
let target = fs::read_link(&path)?;
|
||||
link_inside(&root, &path, &target)?;
|
||||
let value = target.as_os_str().as_encoded_bytes();
|
||||
hash.update((value.len() as u64).to_le_bytes());
|
||||
hash.update(value);
|
||||
} else if metadata.is_file() {
|
||||
hash.update(b"f");
|
||||
bytes = bytes
|
||||
.checked_add(metadata.len())
|
||||
.ok_or_else(|| Error("Software size overflow".into()))?;
|
||||
if bytes > MAX_UNPACKED {
|
||||
return Err(Error("Installed software tree exceeds 1 GiB".into()));
|
||||
}
|
||||
hash.update(metadata.len().to_le_bytes());
|
||||
let mut file = OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
|
||||
.open(&path)?;
|
||||
let mut prefix = [0u8; 20];
|
||||
let n = file.read(&mut prefix)?;
|
||||
if prefix.starts_with(b"\x7fELF")
|
||||
&& (architecture != "aarch64"
|
||||
|| n < 20
|
||||
|| prefix[4] != 2
|
||||
|| prefix[5] != 1
|
||||
|| prefix[18..20] != [183, 0])
|
||||
{
|
||||
return Err(Error(
|
||||
"Installed software contains an ELF file for the wrong architecture".into(),
|
||||
));
|
||||
}
|
||||
hash.update(&prefix[..n]);
|
||||
let copied = std::io::copy(&mut file, &mut HashWriter(&mut hash))?;
|
||||
if copied + n as u64 != metadata.len() {
|
||||
return Err(Error("Installed software changed during inspection".into()));
|
||||
}
|
||||
} else {
|
||||
return Err(Error(
|
||||
"Installed software permits only files, directories and internal symlinks".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(Inventory {
|
||||
bytes,
|
||||
entries: entries.len() as u32,
|
||||
sha256: format!("{:x}", hash.finalize()),
|
||||
})
|
||||
}
|
||||
|
||||
struct HashWriter<'a>(&'a mut Sha256);
|
||||
impl std::io::Write for HashWriter<'_> {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.update(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn verify(root: &Path, software: &Software) -> Result<()> {
|
||||
software.validate()?;
|
||||
if !software.installed {
|
||||
return Err(Error("Expected an installed software tree".into()));
|
||||
}
|
||||
let actual = inspect(root, &software.architecture)?;
|
||||
if actual.sha256 != software.sha256
|
||||
|| actual.bytes != software.unpacked_bytes
|
||||
|| actual.entries != software.entries
|
||||
{
|
||||
return Err(Error(
|
||||
"Installed software tree digest, size or entry count disagrees with catalogue".into(),
|
||||
));
|
||||
}
|
||||
let canonical = root.canonicalize()?;
|
||||
for command in software.commands.values() {
|
||||
let executable = root.join(command).canonicalize()?;
|
||||
if !executable.starts_with(&canonical)
|
||||
|| !executable.is_file()
|
||||
|| executable.metadata()?.permissions().mode() & 0o111 == 0
|
||||
{
|
||||
return Err(Error(
|
||||
"Software command must resolve to an executable within its installed tree".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// XBPS trees contain root-relative symlinks. Relocate those on the workstation
|
||||
/// so the exact tree can be mounted under /run without pointing into SYSTEM.
|
||||
pub fn relocate(root: &Path) -> Result<()> {
|
||||
let mut entries = Vec::new();
|
||||
paths(root, root, &mut entries)?;
|
||||
for name in entries {
|
||||
let path = root.join(&name);
|
||||
if path.is_symlink() {
|
||||
let target = fs::read_link(&path)?;
|
||||
if target.is_absolute() {
|
||||
let mut relative = PathBuf::new();
|
||||
for _ in name.parent().unwrap().components() {
|
||||
relative.push("..");
|
||||
}
|
||||
relative.push(target.strip_prefix("/").unwrap());
|
||||
fs::remove_file(&path)?;
|
||||
symlink(relative, &path)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn copy(root: &Path, destination: &Path) -> Result<()> {
|
||||
fs::create_dir(destination)?;
|
||||
fs::set_permissions(destination, fs::Permissions::from_mode(0o755))?;
|
||||
let mut entries = Vec::new();
|
||||
paths(root, root, &mut entries)?;
|
||||
entries.sort();
|
||||
for name in entries {
|
||||
let from = root.join(&name);
|
||||
let to = destination.join(name);
|
||||
let metadata = from.symlink_metadata()?;
|
||||
if metadata.is_symlink() {
|
||||
symlink(fs::read_link(from)?, to)?;
|
||||
} else if metadata.is_dir() {
|
||||
fs::create_dir(&to)?;
|
||||
fs::set_permissions(to, metadata.permissions())?;
|
||||
} else if metadata.is_file() {
|
||||
fs::copy(from, to)?;
|
||||
} else {
|
||||
return Err(Error("Unsupported installed software file type".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
struct Fixture(PathBuf);
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"fds-tree-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir(&root).unwrap();
|
||||
fs::create_dir_all(root.join("usr/bin")).unwrap();
|
||||
fs::write(root.join("usr/bin/tool"), b"#!/bin/sh\necho installed\n").unwrap();
|
||||
fs::set_permissions(root.join("usr/bin/tool"), fs::Permissions::from_mode(0o755))
|
||||
.unwrap();
|
||||
symlink("usr/bin", root.join("bin")).unwrap();
|
||||
Self(root)
|
||||
}
|
||||
fn software(&self) -> Software {
|
||||
let found = inspect(&self.0, "aarch64").unwrap();
|
||||
Software {
|
||||
id: "demo.tool".into(),
|
||||
name: "Tool".into(),
|
||||
version: "1".into(),
|
||||
architecture: "aarch64".into(),
|
||||
partition: 2,
|
||||
installed: true,
|
||||
packages: vec!["WindowMaker-0.96.0_1".into()],
|
||||
archive_bytes: 0,
|
||||
unpacked_bytes: found.bytes,
|
||||
entries: found.entries,
|
||||
sha256: found.sha256,
|
||||
commands: [("tool".into(), "bin/tool".into())].into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn installed_tree_roundtrip_and_tampering() {
|
||||
let root = Fixture::new();
|
||||
let software = root.software();
|
||||
verify(&root.0, &software).unwrap();
|
||||
let copy_path = root.0.with_extension("copy");
|
||||
copy(&root.0, ©_path).unwrap();
|
||||
verify(©_path, &software).unwrap();
|
||||
fs::remove_dir_all(copy_path).unwrap();
|
||||
fs::write(root.0.join("usr/bin/tool"), b"changed").unwrap();
|
||||
assert!(verify(&root.0, &software).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn escaping_links_and_privileged_files_are_rejected() {
|
||||
let root = Fixture::new();
|
||||
symlink("../../../etc/passwd", root.0.join("usr/bin/escape")).unwrap();
|
||||
assert!(inspect(&root.0, "aarch64").is_err());
|
||||
fs::remove_file(root.0.join("usr/bin/escape")).unwrap();
|
||||
fs::set_permissions(
|
||||
root.0.join("usr/bin/tool"),
|
||||
fs::Permissions::from_mode(0o4755),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(inspect(&root.0, "aarch64").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn root_relative_xbps_links_are_relocated_and_wrong_elf_is_rejected() {
|
||||
let root = Fixture::new();
|
||||
symlink("/usr/bin/tool", root.0.join("usr/bin/alias")).unwrap();
|
||||
assert!(inspect(&root.0, "aarch64").is_err());
|
||||
relocate(&root.0).unwrap();
|
||||
verify(&root.0, &root.software()).unwrap();
|
||||
fs::write(
|
||||
root.0.join("usr/bin/tool"),
|
||||
b"\x7fELF\x02\x01\0\0\0\0\0\0\0\0\0\0\0\0\x3e\0",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(inspect(&root.0, "aarch64").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
/// Use the package's own glibc loader when present. Static ELFs and scripts
|
||||
/// execute normally; dynamically linked binaries retain their packaged ABI.
|
||||
pub fn executable(root: &Path, relative: &str) -> Result<Vec<String>> {
|
||||
let path = root.join(relative);
|
||||
let mut file = File::open(&path)?;
|
||||
let mut header = [0u8; 64];
|
||||
let n = file.read(&mut header)?;
|
||||
if n == header.len() && header.starts_with(b"\x7fELF") && header[4] == 2 && header[5] == 1 {
|
||||
use std::io::{Seek, SeekFrom};
|
||||
let offset = u64::from_le_bytes(header[32..40].try_into().unwrap());
|
||||
let size = u16::from_le_bytes(header[54..56].try_into().unwrap()) as u64;
|
||||
let count = u16::from_le_bytes(header[56..58].try_into().unwrap()) as u64;
|
||||
if size >= 56 && count <= 1024 {
|
||||
for index in 0..count {
|
||||
file.seek(SeekFrom::Start(
|
||||
offset
|
||||
.checked_add(index * size)
|
||||
.ok_or_else(|| Error("ELF header overflow".into()))?,
|
||||
))?;
|
||||
let mut program = [0u8; 56];
|
||||
file.read_exact(&mut program)?;
|
||||
if u32::from_le_bytes(program[..4].try_into().unwrap()) == 3 {
|
||||
for name in ["lib/ld-linux-aarch64.so.1", "usr/lib/ld-linux-aarch64.so.1"] {
|
||||
let loader = root.join(name);
|
||||
if loader.is_file() {
|
||||
return Ok(vec![
|
||||
loader.display().to_string(),
|
||||
"--library-path".into(),
|
||||
format!("{0}/usr/lib:{0}/lib", root.display()),
|
||||
path.display().to_string(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(vec![path.display().to_string()])
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use fds_common::{
|
||||
manifest::{Cartridge, Class, Manifest, Media},
|
||||
read_text,
|
||||
};
|
||||
use fds_software::{Catalogue, archive};
|
||||
use fds_software::{Catalogue, archive, tree};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
@@ -22,7 +22,8 @@ use std::{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Payload {
|
||||
bundles: Vec<PathBuf>,
|
||||
/// Void software recipes or previously built installed-software directories.
|
||||
sources: Vec<PathBuf>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -55,8 +56,16 @@ fn uuid(bytes: &[u8]) -> [u8; 16] {
|
||||
id[8] = (id[8] & 63) | 0x80;
|
||||
id
|
||||
}
|
||||
pub fn create(recipe: &Path, output: &Path, runner: Option<&Path>) -> Result<Inspection> {
|
||||
pub fn create(
|
||||
recipe: &Path,
|
||||
output: &Path,
|
||||
runner: Option<&Path>,
|
||||
options: &crate::software::BuildOptions,
|
||||
) -> Result<Inspection> {
|
||||
workstation()?;
|
||||
if output.try_exists()? {
|
||||
return Err(Error("Cartridge output already exists".into()));
|
||||
}
|
||||
if unsafe { libc::geteuid() } == 0 {
|
||||
return Err(Error(
|
||||
"Create cartridge images as an ordinary workstation user".into(),
|
||||
@@ -64,12 +73,12 @@ pub fn create(recipe: &Path, output: &Path, runner: Option<&Path>) -> Result<Ins
|
||||
}
|
||||
let plan: Recipe = toml::from_str(&read_text(recipe, 65536)?)
|
||||
.map_err(|e| Error(format!("Invalid cartridge recipe: {e}")))?;
|
||||
if plan.format != 1
|
||||
if plan.format != 2
|
||||
|| !(1..=32).contains(&plan.payload.len())
|
||||
|| plan.payload.iter().any(|p| p.bundles.is_empty())
|
||||
|| plan.payload.iter().any(|p| p.sources.is_empty())
|
||||
{
|
||||
return Err(Error(
|
||||
"Cartridge recipe requires format 1 and 1..32 nonempty payload partitions".into(),
|
||||
"Cartridge recipe requires format 2 and 1..32 nonempty payload partitions".into(),
|
||||
));
|
||||
}
|
||||
let metadata = Manifest {
|
||||
@@ -88,23 +97,33 @@ pub fn create(recipe: &Path, output: &Path, runner: Option<&Path>) -> Result<Ins
|
||||
let base = parent(recipe)?;
|
||||
let tree = work.0.join("metadata");
|
||||
fs::create_dir_all(tree.join("FDS"))?;
|
||||
for directory in [&tree, &tree.join("FDS")] {
|
||||
fs::set_permissions(directory, fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
fs::write(tree.join("FDS/CARTRIDGE.TOML"), metadata.to_toml()?)?;
|
||||
let mut catalogue = Catalogue {
|
||||
format: 1,
|
||||
format: 2,
|
||||
software: Vec::new(),
|
||||
};
|
||||
let mut trees = vec![tree.clone()];
|
||||
for (index, part) in plan.payload.iter().enumerate() {
|
||||
let tree = work.0.join(format!("payload{}", index + 2));
|
||||
fs::create_dir_all(tree.join("bundles"))?;
|
||||
for directory in &part.bundles {
|
||||
let directory = base.join(directory).canonicalize()?;
|
||||
fs::create_dir_all(tree.join("programs"))?;
|
||||
for directory in [&tree, &tree.join("programs")] {
|
||||
fs::set_permissions(directory, fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
for (number, source) in part.sources.iter().enumerate() {
|
||||
let source = base.join(source).canonicalize()?;
|
||||
let directory = if source.is_dir() {
|
||||
source
|
||||
} else {
|
||||
let directory = work.0.join(format!("installed-{index}-{number}"));
|
||||
crate::software::build(&source, &directory, options)?;
|
||||
directory
|
||||
};
|
||||
let mut entry = crate::software::load(&directory)?;
|
||||
entry.partition = (index + 2) as u8;
|
||||
fs::copy(
|
||||
directory.join(format!("{}.tar.xz", entry.id)),
|
||||
tree.join(entry.archive_path()),
|
||||
)?;
|
||||
tree::copy(&directory.join("root"), &tree.join(entry.root_path()))?;
|
||||
catalogue.software.push(entry);
|
||||
}
|
||||
trees.push(tree);
|
||||
@@ -192,7 +211,7 @@ pub fn inspect(path: &Path, runner: Option<&Path>) -> Result<Inspection> {
|
||||
let mut info = image::inspect(&file, file.metadata()?.len())?;
|
||||
if info.filesystem != "erofs" {
|
||||
return Err(Error(
|
||||
"Use fds-burn inspect for DATA geometry; this inspector validates software bundles"
|
||||
"Use fds-burn inspect for DATA geometry; this inspector validates software cartridges"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
@@ -235,6 +254,13 @@ pub fn inspect(path: &Path, runner: Option<&Path>) -> Result<Inspection> {
|
||||
}
|
||||
for software in &catalogue.software {
|
||||
let root = &trees[usize::from(software.partition) - 1];
|
||||
if software.installed {
|
||||
if !root.join("programs").symlink_metadata()?.is_dir() {
|
||||
return Err(Error("Programs must be a real directory".into()));
|
||||
}
|
||||
tree::verify(&root.join(software.root_path()), software)?;
|
||||
continue;
|
||||
}
|
||||
let directory = root.join("bundles");
|
||||
if !directory.symlink_metadata()?.is_dir() {
|
||||
return Err(Error("Bundle directory must not be a symlink".into()));
|
||||
@@ -247,23 +273,36 @@ pub fn inspect(path: &Path, runner: Option<&Path>) -> Result<Inspection> {
|
||||
}
|
||||
// Reject unlisted files or software hidden in a payload partition.
|
||||
for (index, root) in trees.iter().enumerate().skip(1) {
|
||||
let directory = if catalogue.format == 2 {
|
||||
"programs"
|
||||
} else {
|
||||
"bundles"
|
||||
};
|
||||
if fs::read_dir(root)?.count() != 1 {
|
||||
return Err(Error("Payload partitions may contain only bundles/".into()));
|
||||
return Err(Error(format!(
|
||||
"Payload partitions may contain only {directory}/"
|
||||
)));
|
||||
}
|
||||
let mut actual: Vec<_> = fs::read_dir(root.join("bundles"))?
|
||||
let mut actual: Vec<_> = fs::read_dir(root.join(directory))?
|
||||
.map(|e| e.map(|e| e.file_name()))
|
||||
.collect::<std::io::Result<_>>()?;
|
||||
let mut expected: Vec<_> = catalogue
|
||||
.software
|
||||
.iter()
|
||||
.filter(|s| usize::from(s.partition) == index + 1)
|
||||
.map(|s| std::ffi::OsString::from(format!("{}.tar.xz", s.id)))
|
||||
.map(|s| {
|
||||
std::ffi::OsString::from(if s.installed {
|
||||
s.id.clone()
|
||||
} else {
|
||||
format!("{}.tar.xz", s.id)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
actual.sort();
|
||||
expected.sort();
|
||||
if actual != expected {
|
||||
return Err(Error(
|
||||
"Payload archive inventory disagrees with metadata".into(),
|
||||
"Payload software inventory disagrees with metadata".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ pub fn cartridge(runner: Option<&Path>) -> Result<Value> {
|
||||
)));
|
||||
}
|
||||
Ok(
|
||||
json!({"xz":xz,"mkfs.erofs":mkfs,"fsck.erofs":fsck,"unprivileged_sandbox":"available","cross_compiler":"recipe-specific; use aarch64 output or architecture=any scripts"}),
|
||||
json!({"xz_legacy_reader":xz,"mkfs.erofs":mkfs,"fsck.erofs":fsck,"unprivileged_sandbox":"available","software_builder":"prepared Void xbps-src checkout with native XBPS tools; builds aarch64 source packages and installs runtime dependencies"}),
|
||||
)
|
||||
}
|
||||
pub fn emulator(runner: Option<&Path>) -> Result<Value> {
|
||||
|
||||
@@ -5,18 +5,20 @@ use std::{path::PathBuf, process::ExitCode};
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
version,
|
||||
about = "Build software bundles and metadata-first cartridge images on Linux"
|
||||
about = "Build Void source packages and ready-to-run cartridge images on Linux"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Optional wrapper that accepts TOOL followed by its arguments.
|
||||
#[arg(long, global = true)]
|
||||
image_tool_runner: Option<PathBuf>,
|
||||
#[command(flatten)]
|
||||
build: fds_workstation::software::BuildOptions,
|
||||
#[command(subcommand)]
|
||||
command: Action,
|
||||
}
|
||||
#[derive(Subcommand)]
|
||||
enum Action {
|
||||
/// Check xz, erofs-utils and the unprivileged filesystem inspection sandbox.
|
||||
/// Check EROFS tools, the inspection sandbox and legacy xz reader.
|
||||
Doctor,
|
||||
/// Build or package software on the workstation, never on the Pi.
|
||||
Software {
|
||||
@@ -25,7 +27,7 @@ enum Action {
|
||||
},
|
||||
/// Construct and verify a complete 1+m GPT cartridge image before burning.
|
||||
Create { recipe: PathBuf, output: PathBuf },
|
||||
/// Verify GPT, every filesystem, catalogue and xz tarball in a prepared image.
|
||||
/// Verify GPT, every filesystem, catalogue and installed program tree.
|
||||
Inspect { image: PathBuf },
|
||||
/// Verify a prepared image and save an image/target-bound write preview.
|
||||
Preview {
|
||||
@@ -45,11 +47,9 @@ enum Action {
|
||||
}
|
||||
#[derive(Subcommand)]
|
||||
enum Software {
|
||||
/// Execute an explicit trusted workstation build recipe, then package its output.
|
||||
/// Build a Void source template and install its complete runtime package tree.
|
||||
Build { recipe: PathBuf, output: PathBuf },
|
||||
/// Package an existing software tree without running its build command.
|
||||
Pack { recipe: PathBuf, output: PathBuf },
|
||||
/// Check a built software descriptor, archive digest and extracted contents.
|
||||
/// Check installed package metadata and every program/dependency file.
|
||||
Inspect { directory: PathBuf },
|
||||
}
|
||||
fn run() -> Result<()> {
|
||||
@@ -76,16 +76,18 @@ fn run() -> Result<()> {
|
||||
}
|
||||
Action::Software { command } => serde_json::to_value(match command {
|
||||
Software::Build { recipe, output } => {
|
||||
fds_workstation::software::build(&recipe, &output, true)?
|
||||
}
|
||||
Software::Pack { recipe, output } => {
|
||||
fds_workstation::software::build(&recipe, &output, false)?
|
||||
fds_workstation::software::build(&recipe, &output, &cli.build)?
|
||||
}
|
||||
Software::Inspect { directory } => fds_workstation::software::load(&directory)?,
|
||||
}),
|
||||
Action::Create { recipe, output } => serde_json::to_value(
|
||||
fds_workstation::cartridge::create(&recipe, &output, cli.image_tool_runner.as_deref())?,
|
||||
),
|
||||
Action::Create { recipe, output } => {
|
||||
serde_json::to_value(fds_workstation::cartridge::create(
|
||||
&recipe,
|
||||
&output,
|
||||
cli.image_tool_runner.as_deref(),
|
||||
&cli.build,
|
||||
)?)
|
||||
}
|
||||
Action::Inspect { image } => serde_json::to_value(fds_workstation::cartridge::inspect(
|
||||
&image,
|
||||
cli.image_tool_runner.as_deref(),
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
use crate::{Work, parent, workstation};
|
||||
//! Void source packages are built and installed on the workstation only.
|
||||
use crate::{Work, image_tool, parent, success, workstation};
|
||||
use fds_common::{Error, Result, manifest::identifier, read_text};
|
||||
use fds_software::{Software, archive};
|
||||
use fds_software::{Software, tree};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs::{self, File, OpenOptions},
|
||||
os::unix::fs::{OpenOptionsExt, PermissionsExt},
|
||||
fs::{self, File},
|
||||
os::unix::fs::PermissionsExt,
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
};
|
||||
|
||||
#[derive(clap::Args, Debug, Clone)]
|
||||
pub struct BuildOptions {
|
||||
/// Prepared Void source checkout used by xbps-src (not the workstation OS).
|
||||
#[arg(long, global = true, default_value = "vendor/void-packages")]
|
||||
pub void_packages: PathBuf,
|
||||
/// Optional rootless XBPS wrapper, for example this checkout's tools/in-void.
|
||||
#[arg(long, global = true)]
|
||||
pub xbps_tool_runner: Option<PathBuf>,
|
||||
/// Native XBPS executables for xbps-src, when they are not already in PATH.
|
||||
#[arg(long, global = true)]
|
||||
pub xbps_bin: Option<PathBuf>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Build {
|
||||
directory: PathBuf,
|
||||
command: Vec<String>,
|
||||
#[serde(default)]
|
||||
environment: BTreeMap<String, String>,
|
||||
struct Source {
|
||||
package: String,
|
||||
/// Omit to build an existing source package from the selected Void checkout.
|
||||
template: Option<PathBuf>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -25,97 +37,215 @@ struct Recipe {
|
||||
id: String,
|
||||
name: String,
|
||||
version: String,
|
||||
architecture: String,
|
||||
root: PathBuf,
|
||||
commands: BTreeMap<String, String>,
|
||||
build: Option<Build>,
|
||||
source: Source,
|
||||
}
|
||||
pub fn build(recipe: &Path, output: &Path, compile: bool) -> Result<Software> {
|
||||
fn xbps(options: &BuildOptions, program: &str) -> Command {
|
||||
let mut command = image_tool(options.xbps_tool_runner.as_deref(), "env");
|
||||
command.args(["XBPS_ARCH=aarch64", "XBPS_TARGET_ARCH=aarch64", program]);
|
||||
if let Some(directory) = &options.xbps_bin {
|
||||
let mut paths = vec![directory.clone()];
|
||||
paths.extend(std::env::split_paths(
|
||||
&std::env::var_os("PATH").unwrap_or_default(),
|
||||
));
|
||||
if let Ok(path) = std::env::join_paths(paths) {
|
||||
command.env("PATH", path);
|
||||
}
|
||||
}
|
||||
command
|
||||
}
|
||||
pub fn build(recipe: &Path, output: &Path, options: &BuildOptions) -> Result<Software> {
|
||||
workstation()?;
|
||||
if unsafe { libc::geteuid() } == 0 {
|
||||
return Err(Error(
|
||||
"Run software builds as an ordinary workstation user".into(),
|
||||
"Build Void software as an ordinary workstation user".into(),
|
||||
));
|
||||
}
|
||||
if output.try_exists()? {
|
||||
return Err(Error("Software output already exists".into()));
|
||||
}
|
||||
let input: Recipe = toml::from_str(&read_text(recipe, 65536)?)
|
||||
.map_err(|e| Error(format!("Invalid build recipe: {e}")))?;
|
||||
if input.format != 1 || !identifier(&input.id) {
|
||||
.map_err(|e| Error(format!("Invalid Void software recipe: {e}")))?;
|
||||
if input.format != 2
|
||||
|| !identifier(&input.id)
|
||||
|| !fds_software::xbps_identifier(&input.source.package)
|
||||
{
|
||||
return Err(Error(
|
||||
"Build recipe requires format 1 and a valid software id".into(),
|
||||
"Software recipe requires format 2, a software id and a Void source package name"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let base = parent(recipe)?;
|
||||
if compile {
|
||||
let build = input.build.ok_or_else(|| {
|
||||
Error("Build recipe lacks [build]; use software pack for an existing tree".into())
|
||||
})?;
|
||||
let program = build
|
||||
.command
|
||||
.first()
|
||||
.ok_or_else(|| Error("Build command is empty".into()))?;
|
||||
let directory = base.join(build.directory).canonicalize()?;
|
||||
crate::success(
|
||||
Command::new(program)
|
||||
.args(&build.command[1..])
|
||||
.current_dir(directory)
|
||||
.envs(build.environment)
|
||||
.env("FDS_TARGET_ARCH", &input.architecture)
|
||||
.stdin(Stdio::null()),
|
||||
let checkout = options.void_packages.canonicalize()?;
|
||||
if !checkout.join("xbps-src").is_file() {
|
||||
return Err(Error(
|
||||
"--void-packages must name a prepared Void source checkout".into(),
|
||||
));
|
||||
}
|
||||
let destination = checkout.join("srcpkgs").join(&input.source.package);
|
||||
if let Some(template) = input.source.template {
|
||||
let source = parent(recipe)?.join(template).canonicalize()?;
|
||||
if !source.join("template").is_file() {
|
||||
return Err(Error(
|
||||
"Source package directory must contain a Void template".into(),
|
||||
));
|
||||
}
|
||||
if source != destination {
|
||||
let tracked = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&checkout)
|
||||
.args(["ls-files", "--"])
|
||||
.arg(format!("srcpkgs/{}", input.source.package))
|
||||
.output()?;
|
||||
if !tracked.status.success() || !tracked.stdout.is_empty() {
|
||||
return Err(Error(
|
||||
"Custom source packages cannot replace tracked upstream Void files".into(),
|
||||
));
|
||||
}
|
||||
if destination.exists() {
|
||||
if !Command::new("diff")
|
||||
.args(["-qr", "--"])
|
||||
.arg(&source)
|
||||
.arg(&destination)
|
||||
.stdout(Stdio::null())
|
||||
.status()?
|
||||
.success()
|
||||
{
|
||||
return Err(Error(format!(
|
||||
"Stale generated source overlay {}; preserve local edits and remove that copy before rebuilding",
|
||||
destination.display()
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
tree::copy(&source, &destination)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !destination.join("template").is_file() {
|
||||
return Err(Error(format!(
|
||||
"Void source package {} has no template",
|
||||
input.source.package
|
||||
)));
|
||||
}
|
||||
// Source templates are trusted build code. Use normal xbps-src dependency
|
||||
// resolution and cross compilation, including its workstation build hooks.
|
||||
let mut source_build = Command::new(checkout.join("xbps-src"));
|
||||
source_build
|
||||
.args(["-f", "-a", "aarch64", "pkg", &input.source.package])
|
||||
.current_dir(&checkout)
|
||||
.env("XBPS_ARCH", std::env::consts::ARCH)
|
||||
.stdout(Stdio::from(std::io::stderr()));
|
||||
if let Some(directory) = &options.xbps_bin {
|
||||
let mut paths = vec![directory.canonicalize()?];
|
||||
paths.extend(std::env::split_paths(
|
||||
&std::env::var_os("PATH").unwrap_or_default(),
|
||||
));
|
||||
source_build.env(
|
||||
"PATH",
|
||||
std::env::join_paths(paths).map_err(|e| Error(e.to_string()))?,
|
||||
);
|
||||
}
|
||||
success(&mut source_build)?;
|
||||
let output_parent = parent(output)?;
|
||||
let work = Work::new(&output_parent)?;
|
||||
let staged = work.0.join("software");
|
||||
let root = staged.join("root");
|
||||
let config = work.0.join("config");
|
||||
let cache = work.0.join("cache");
|
||||
fs::create_dir_all(root.join("var/db/xbps/keys"))?;
|
||||
fs::create_dir(&config)?;
|
||||
fs::create_dir(&cache)?;
|
||||
for entry in fs::read_dir(checkout.join("common/repo-keys"))? {
|
||||
let entry = entry?;
|
||||
if entry.path().extension().is_some_and(|e| e == "plist") {
|
||||
fs::copy(
|
||||
entry.path(),
|
||||
root.join("var/db/xbps/keys").join(entry.file_name()),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
let mut install = xbps(options, "xbps-install");
|
||||
install
|
||||
.args(["-SyU", "--reproducible", "-i", "-C"])
|
||||
.arg(&config)
|
||||
.arg("-r")
|
||||
.arg(&root)
|
||||
.arg("-c")
|
||||
.arg(&cache)
|
||||
.args(["-R", "https://repo-default.voidlinux.org/current/aarch64"])
|
||||
.arg("-R")
|
||||
.arg(checkout.join("hostdir/binpkgs"))
|
||||
.arg(&input.source.package);
|
||||
// A supplied runner provides its own rootless XBPS environment. Otherwise
|
||||
// root exists only inside a user namespace with this staging area writable.
|
||||
if options.xbps_tool_runner.is_some() {
|
||||
success(install.stdout(Stdio::from(std::io::stderr())))?;
|
||||
} else {
|
||||
success(
|
||||
Command::new("bwrap")
|
||||
.args([
|
||||
"--unshare-user",
|
||||
"--uid",
|
||||
"0",
|
||||
"--gid",
|
||||
"0",
|
||||
"--ro-bind",
|
||||
"/",
|
||||
"/",
|
||||
"--dev",
|
||||
"/dev",
|
||||
"--proc",
|
||||
"/proc",
|
||||
"--bind",
|
||||
])
|
||||
.arg(&work.0)
|
||||
.arg(&work.0)
|
||||
.arg(install.get_program())
|
||||
.args(install.get_args())
|
||||
.envs(
|
||||
install
|
||||
.get_envs()
|
||||
.filter_map(|(key, value)| value.map(|value| (key, value))),
|
||||
)
|
||||
.stdout(Stdio::from(std::io::stderr())),
|
||||
)?;
|
||||
}
|
||||
let root = base.join(input.root).canonicalize()?;
|
||||
let output_parent = parent(output)?;
|
||||
if output_parent.starts_with(&root) {
|
||||
return Err(Error(
|
||||
"Software output must be outside its source tree".into(),
|
||||
));
|
||||
let list = xbps(options, "xbps-query")
|
||||
.arg("-r")
|
||||
.arg(&root)
|
||||
.arg("-l")
|
||||
.output()?;
|
||||
if !list.status.success() {
|
||||
return Err(Error("Cannot read installed XBPS package inventory".into()));
|
||||
}
|
||||
let work = Work::new(&output_parent)?;
|
||||
let bundle = work.0.join("bundle");
|
||||
fs::create_dir(&bundle)?;
|
||||
let archive_path = bundle.join(format!("{}.tar.xz", input.id));
|
||||
let encoded = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o644)
|
||||
.open(&archive_path)?;
|
||||
let mut child = Command::new("xz")
|
||||
.args(["--compress", "--stdout", "--threads=1", "-6"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::from(encoded))
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()?;
|
||||
let stats = archive::write_tar(&root, child.stdin.take().unwrap(), &input.architecture);
|
||||
if stats.is_err() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
let status = child.wait()?;
|
||||
let stats = stats?;
|
||||
if !status.success() {
|
||||
return Err(Error("XZ compression failed".into()));
|
||||
}
|
||||
let file = archive::open(&archive_path)?;
|
||||
let mut packages: Vec<_> = String::from_utf8_lossy(&list.stdout)
|
||||
.lines()
|
||||
.filter_map(|line| line.split_whitespace().nth(1).map(str::to_owned))
|
||||
.collect();
|
||||
packages.sort();
|
||||
packages.dedup();
|
||||
tree::relocate(&root)?;
|
||||
let inventory = tree::inspect(&root, "aarch64")?;
|
||||
let software = Software {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
version: input.version,
|
||||
architecture: input.architecture,
|
||||
architecture: "aarch64".into(),
|
||||
partition: 2,
|
||||
archive_bytes: file.metadata()?.len(),
|
||||
unpacked_bytes: stats.bytes,
|
||||
entries: stats.entries,
|
||||
sha256: archive::digest(&file)?,
|
||||
installed: true,
|
||||
packages,
|
||||
archive_bytes: 0,
|
||||
unpacked_bytes: inventory.bytes,
|
||||
entries: inventory.entries,
|
||||
sha256: inventory.sha256,
|
||||
commands: input.commands,
|
||||
};
|
||||
archive::verify(&file, &software, None)?;
|
||||
let descriptor = toml::to_string(&software).map_err(|e| Error(e.to_string()))?;
|
||||
fs::write(bundle.join("software.toml"), descriptor)?;
|
||||
fs::set_permissions(&bundle, fs::Permissions::from_mode(0o755))?;
|
||||
// renameat2(NO_REPLACE) publishes the complete directory without overwriting.
|
||||
let from = std::ffi::CString::new(bundle.as_os_str().as_encoded_bytes())
|
||||
tree::verify(&root, &software)?;
|
||||
fs::write(
|
||||
staged.join("software.toml"),
|
||||
toml::to_string(&software).map_err(|e| Error(e.to_string()))?,
|
||||
)?;
|
||||
fs::set_permissions(&staged, fs::Permissions::from_mode(0o755))?;
|
||||
let from = std::ffi::CString::new(staged.as_os_str().as_encoded_bytes())
|
||||
.map_err(|e| Error(e.to_string()))?;
|
||||
let to = std::ffi::CString::new(output.as_os_str().as_encoded_bytes())
|
||||
.map_err(|e| Error(e.to_string()))?;
|
||||
@@ -136,17 +266,14 @@ pub fn build(recipe: &Path, output: &Path, compile: bool) -> Result<Software> {
|
||||
}
|
||||
pub fn load(directory: &Path) -> Result<Software> {
|
||||
let path = directory.join("software.toml");
|
||||
let metadata = fs::symlink_metadata(&path)?;
|
||||
if !metadata.is_file() {
|
||||
if !path.symlink_metadata()?.is_file() {
|
||||
return Err(Error("Software descriptor must be a regular file".into()));
|
||||
}
|
||||
let software: Software = toml::from_str(&read_text(&path, 65536)?)
|
||||
.map_err(|e| Error(format!("Invalid software descriptor: {e}")))?;
|
||||
software.validate()?;
|
||||
archive::verify(
|
||||
&archive::open(&directory.join(format!("{}.tar.xz", software.id)))?,
|
||||
&software,
|
||||
None,
|
||||
)?;
|
||||
if !software.installed {
|
||||
return Err(Error("New cartridges require installed Void packages; rebuild this legacy bundle from its source template".into()));
|
||||
}
|
||||
tree::verify(&directory.join("root"), &software)?;
|
||||
Ok(software)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user