update docs

This commit is contained in:
2026-09-22 13:23:34 +08:00
parent 99bc3d15c5
commit 8a4788fca8
126 changed files with 7198 additions and 2425 deletions
+42 -2
View File
@@ -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 {
+1
View File
@@ -4,6 +4,7 @@ mod data_sessions;
mod media;
mod power;
mod profiles;
mod programs;
mod recovery;
mod server;
mod software;
+1 -1
View File
@@ -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(),
+88
View File
@@ -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(())
}
+166 -9
View File
@@ -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,
});
}
+53 -6
View File
@@ -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.
+4
View File
@@ -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" }
+6
View File
@@ -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);
}
+142
View File
@@ -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
);
}
}
+345
View File
@@ -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());
}
}
}
+18
View File
@@ -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,
}
}
}
+171
View File
@@ -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
View File
@@ -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;
+60 -4
View File
@@ -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());
}
}
+13
View File
@@ -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"
+859
View File
@@ -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();
}
}
+1 -1
View File
@@ -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" }
+2
View File
@@ -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(),
+2 -1
View File
@@ -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;
+361
View File
@@ -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, &copy_path).unwrap();
verify(&copy_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()])
}
+58 -19
View File
@@ -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(),
));
}
}
+1 -1
View File
@@ -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> {
+16 -14
View File
@@ -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(),
+211 -84
View File
@@ -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)
}