FDS/OS 1.0

This commit is contained in:
2026-09-21 22:29:23 +08:00
commit 99bc3d15c5
430 changed files with 34876 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "dasungd"
version = "0.1.0"
edition = "2024"
description = "DASUNG Paperlike USB keepalive, control socket and Linux display profile daemon"
license = "MIT"
[features]
vendored = ["rusb/vendored"]
[dependencies]
anyhow = "1"
clap.workspace = true
ctrlc = { version = "3", features = ["termination"] }
libc = "0.2"
rusb = "0.9"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
+27
View File
@@ -0,0 +1,27 @@
# Imported Dasung controller source
Source task: Fix Dasung monitor black screen
Task ID: `01a0b7d0-7020-71e0-bfec-13c9568b5872`
Imported from the local `dasungd` project on 2026-09-20.
The original task directory is not a build dependency. FDS owns the imported
source snapshot and subsequent adaptations. Original input hashes:
```text
cc97a9bf34829b213bf6c882729e0e272438fb60253ba4e248d6714c8325a637 Cargo.toml
8771f6322c399ab10bb321f341ca8f9b2a31e055ef72d8c5acd169f626fdd27a Cargo.lock
1126322e2cc8d165adc4c792eeb195717de2bcc7b39be1ce77959d78e87ef685 LICENSE
4fda0939b3ae291fc6ff5c2b57933dc4a00fb7d3e44bf4d4b1f42f855c1ac63a src/config.rs
55498d77a942f73f4c737b4f2efd4cc80dfc6985c93d942d1a21f77809774afc src/display.rs
be1d2956bec44dc396b37ba66016d333defff368bc62777573f1198aeb447f7f src/main.rs
5c3a1d0a6fa97a9b020433cc7a66482eb75ae2330cfd2b094aa347b29313e02d src/protocol.rs
53e412f7fac3be01cd3e03144ba7a6cdb44059321e820ecd1ab45be32916e53f src/transport.rs
b6c1e0a8d315d9cf5c2fb83784a177530bcc085c2d0724dc7478a9c12449e4f4 profiles/paperlike13k-37hz.edid
```
FDS changes: workspace integration, target filesystem paths, native s6 packaging,
and a portable simulator binary path. The original AMD/systemd installer and
workstation configuration are not part of the target package.
See [the FDS guide](../../docs/dasung.md) and the historical
[hardware validation record](VALIDATION.md).
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+16
View File
@@ -0,0 +1,16 @@
# dasungd in FDS/OS
The controller for the user's grayscale Dasung Paperlike 13K is maintained as an
FDS Rust workspace member. It provides USB keepalive, reconnect/watchdog handling,
configuration, and a local control socket.
Use the [FDS integration and usage guide](../../docs/dasung.md) for build commands,
base packaging, native s6 supervision, Pi display configuration, and validation
limits. From the repository root, run `make dasung` followed by `make dasung-test`.
These targets do not operate the workstation's live monitor.
[IMPORT.md](IMPORT.md) records the source task and original input hashes.
[VALIDATION.md](VALIDATION.md) is the original workstation acceptance record; its
references to the original README and systemd installation are historical. The
FDS target uses the native s6 integration documented in the guide above. Pi boot
and corrected physical cold-start recovery are not yet verified.
+29
View File
@@ -0,0 +1,29 @@
# Validation record — 2026-09-20
## Confirmed before installation
The user confirmed 3200×2400 at 36.9998735 Hz, 304.21 MHz, 2× scaling was **very stable** using the Python control helper. The full timing is in README.md. The monitor's SPI interface and native UART driver were unbound during that confirmation.
## Software and initial live handoff
- Six Rust unit tests pass: exact outgoing framing, fragmented/concatenated real replies, malformed stream recovery, parameter validation, profile checksum/identity/clock, bounded IPC and saved-setting round trip (some checks share test cases).
- `cargo clippy --locked --all-targets -- -D warnings` passes; release build succeeds.
- The pseudo-terminal integration test passes late attachment, keepalive, real response fragmentation, invalid command rejection, saved parameter replay, unplug/node replacement/replug, duplicate daemon exclusion, reply watchdog, process restart and forgetting saved settings.
- Systemd unit and udev rule syntax verification pass.
- Live Rust handoff received all expected queries: mode 1, contrast 4, front-light 2, brightness 40, temperature 0, protocol version 0x31. Mode 1 and contrast 4 writes were read back correctly and saved.
- Service restart and Hyprland reload retained the exact 304210 kHz timing and 2× scaling. The ASUS remained 2560×1440 at 144 Hz, scale 1. The diagnostic mpv window is closed.
- Service is enabled at boot. Current initramfs contains neither CH341 UART nor CH341 SPI modules; no bootloader or initramfs changes were required.
## Physical reconnect exposed a remaining cold-start issue
The first full power/reconnect test returned a dark picture. Video timing/scaling restored, and the UART was rediscovered at a new USB address. However, the kernel had bound `spi_ch341` to the second USB chip and replies stopped after the first two queries. Removing the driver and resetting that USB chip afterward did not recover replies.
Corrections now installed:
- Scoped udev autoload suppression for the monitor's hub/UART/SPI combination. A real synthetic add event left `spi_ch341` unloaded.
- The USB transport reserves the companion SPI interface without configuring it or sending SPI data, preventing a later-loaded driver from binding while the daemon owns it.
- Minimum 150 ms packet spacing and one-second startup query spacing, matching the proven Python helper; the initial Rust version could send adjacent keepalive/query packets.
- Reply timeouts reopen only control; they do not continually retrain an otherwise unchanged video signal.
- Status distinguishes an open transport (`connected`) from receipt of valid monitor frames (`responsive`).
**A clean physical power cycle with these corrections is still pending user confirmation. Persistence is installed, but cold-start picture recovery must not yet be described as verified.** No computer reboot has been performed.
Binary file not shown.
+103
View File
@@ -0,0 +1,103 @@
use crate::protocol::Parameter;
use anyhow::{Context, Result, ensure};
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub monitor_serial: String,
pub socket: PathBuf,
pub state_file: PathBuf,
pub transport: String,
pub serial_device: Option<PathBuf>,
pub usb_path: Option<String>,
pub require_companion: bool,
pub require_display: bool,
pub keepalive_ms: u64,
pub reconnect_ms: u64,
pub watchdog_ms: u64,
pub display: Display,
pub startup: BTreeMap<Parameter, u8>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Display {
pub enabled: bool,
pub edid: PathBuf,
// AMD's virtual hotplug API was verified on the actual machine. Other GPUs
// should use the portable firmware EDID mechanism or their KMS client.
pub hotplug: String,
}
impl Default for Display {
fn default() -> Self {
Self {
enabled: false,
edid: "/usr/lib/firmware/edid/dasung-paperlike13k-37hz.bin".into(),
hotplug: "none".into(),
}
}
}
impl Default for Config {
fn default() -> Self {
Self {
monitor_serial: "L56051794302".into(),
socket: "/run/dasungd/control.sock".into(),
state_file: "/run/dasungd/settings.json".into(),
transport: "usb".into(),
serial_device: None,
usb_path: None,
require_companion: true,
require_display: true,
keepalive_ms: 2000,
reconnect_ms: 2000,
watchdog_ms: 30000,
display: Display::default(),
startup: BTreeMap::new(),
}
}
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let config: Self = toml::from_str(
&fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?,
)?;
ensure!(
!config.monitor_serial.is_empty(),
"monitor_serial must identify the intended monitor"
);
ensure!(
(500..=5000).contains(&config.keepalive_ms),
"keepalive_ms must be 500..5000"
);
ensure!(
(500..=30000).contains(&config.reconnect_ms),
"reconnect_ms must be 500..30000"
);
ensure!(
config.watchdog_ms >= 20000,
"watchdog_ms must be at least 20000"
);
ensure!(
matches!(config.transport.as_str(), "usb" | "serial"),
"transport must be usb or serial"
);
ensure!(
config.transport != "serial" || config.serial_device.is_some(),
"serial transport needs serial_device"
);
ensure!(
matches!(config.display.hotplug.as_str(), "none" | "amdgpu"),
"display.hotplug must be none or amdgpu"
);
for (&parameter, &value) in &config.startup {
parameter.validate(value)?;
}
Ok(config)
}
}
+231
View File
@@ -0,0 +1,231 @@
use crate::config::Config;
use anyhow::{Context, Result, ensure};
use serde::Serialize;
use std::{collections::BTreeMap, fs, path::PathBuf, thread, time::Duration};
pub fn serial(edid: &[u8]) -> Option<String> {
if edid.len() < 128 {
return None;
}
for d in edid[54..126].as_chunks::<18>().0 {
if d[..5] == [0, 0, 0, 0xff, 0] {
return Some(String::from_utf8_lossy(&d[5..18]).trim().to_owned());
}
}
None
}
pub fn validate(edid: &[u8], identity: &str) -> Result<()> {
ensure!(
edid.len() >= 128 && edid.len().is_multiple_of(128),
"EDID must contain complete 128-byte blocks"
);
ensure!(
&edid[..8] == b"\x00\xff\xff\xff\xff\xff\xff\x00",
"invalid EDID header"
);
ensure!(
usize::from(edid[126]) + 1 == edid.len() / 128,
"EDID extension count mismatch"
);
ensure!(
edid.as_chunks::<128>()
.0
.iter()
.all(|b| b.iter().fold(0u8, |sum, v| sum.wrapping_add(*v)) == 0),
"EDID checksum failed"
);
ensure!(
serial(edid).as_deref() == Some(identity),
"EDID profile serial does not match configured monitor_serial"
);
Ok(())
}
#[derive(Clone, Debug, Serialize)]
pub struct Connector {
pub name: String,
pub path: PathBuf,
pub debug: PathBuf,
}
pub fn find(identity: &str) -> Result<Vec<Connector>> {
let mut found = Vec::new();
for entry in fs::read_dir("/sys/class/drm")? {
let path = entry?.path();
if fs::read_to_string(path.join("status"))
.unwrap_or_default()
.trim()
!= "connected"
{
continue;
}
if serial(&fs::read(path.join("edid")).unwrap_or_default()).as_deref() != Some(identity) {
continue;
}
let name = path.file_name().unwrap().to_string_lossy().into_owned();
let Some((card, connector)) = name.split_once('-') else {
continue;
};
let Some(index) = card.strip_prefix("card") else {
continue;
};
found.push(Connector {
debug: PathBuf::from(format!("/sys/kernel/debug/dri/{index}/{connector}")),
name,
path,
});
}
Ok(found)
}
fn redetect(c: &Connector, config: &Config) -> Result<()> {
if config.display.hotplug == "amdgpu" {
let trigger = c.debug.join("trigger_hotplug");
ensure!(
trigger.exists(),
"amdgpu trigger_hotplug absent on {}",
c.name
);
// The current kernel's parser requires newline-terminated numeric writes.
fs::write(&trigger, b"0\n")?;
thread::sleep(Duration::from_millis(500));
fs::write(&trigger, b"1\n")?;
}
Ok(())
}
pub struct Manager {
profile: Vec<u8>,
owned: BTreeMap<String, Connector>,
}
impl Manager {
pub fn recover_stale(&self, config: &Config) -> Result<()> {
if !config.display.enabled {
return Ok(());
}
// A previous process may have been killed without cleanup. Remove only
// this exact profile and reread the physical identity before trusting it.
// This prevents an old port override from impersonating a replacement
// monitor when the service restarts after an unattended cable swap.
for entry in fs::read_dir("/sys/class/drm")? {
let path = entry?.path();
let name = path.file_name().unwrap().to_string_lossy().into_owned();
let Some((card, connector)) = name.split_once('-') else {
continue;
};
let Some(index) = card.strip_prefix("card") else {
continue;
};
let debug = PathBuf::from(format!("/sys/kernel/debug/dri/{index}/{connector}"));
let p = debug.join("edid_override");
if fs::read(&p).unwrap_or_default() != self.profile {
continue;
}
fs::write(&p, b"reset")?;
let c = Connector { name, path, debug };
if fs::read_to_string(c.path.join("status"))
.unwrap_or_default()
.trim()
== "connected"
{
redetect(&c, config)?;
}
}
Ok(())
}
pub fn new(config: &Config) -> Result<Self> {
let profile = if config.display.enabled {
let b = fs::read(&config.display.edid).context("read display.edid")?;
validate(&b, &config.monitor_serial)?;
b
} else {
Vec::new()
};
Ok(Self {
profile,
owned: BTreeMap::new(),
})
}
pub fn tick(&mut self, config: &Config) -> Result<Vec<String>> {
if !config.display.enabled {
return Ok(find(&config.monitor_serial)?
.iter()
.map(|c| c.name.clone())
.collect());
}
// A connector override must not remain attached to a port after unplug.
let removed: Vec<_> = self
.owned
.iter()
.filter(|(_, c)| {
fs::read_to_string(c.path.join("status"))
.unwrap_or_default()
.trim()
!= "connected"
})
.map(|(n, _)| n.clone())
.collect();
for n in removed {
let c = self.owned.get(&n).unwrap();
self.clear(c)?;
self.owned.remove(&n);
}
let found = find(&config.monitor_serial)?;
ensure!(
found.len() <= 1,
"multiple connected outputs match monitor serial; connect video by one cable"
);
for c in &found {
let override_path = c.debug.join("edid_override");
let existing = fs::read(&override_path)
.context("read EDID override; mount debugfs and check permissions")?;
ensure!(
existing.is_empty() || existing == self.profile,
"another EDID override exists on {}; refusing to overwrite it",
c.name
);
self.owned.insert(c.name.clone(), c.clone());
if existing != self.profile {
fs::write(&override_path, &self.profile)?;
redetect(c, config)?;
eprintln!("{}: applied confirmed 304.21 MHz EDID", c.name);
}
}
Ok(found.iter().map(|c| c.name.clone()).collect())
}
fn clear(&self, c: &Connector) -> Result<()> {
let p = c.debug.join("edid_override");
if p.exists() && fs::read(&p)? == self.profile {
fs::write(p, b"reset")?;
}
Ok(())
}
pub fn cleanup(&mut self, config: &Config, reprobe: bool) {
for c in self.owned.values() {
if let Err(e) = self.clear(c) {
eprintln!("clear {}: {e:#}", c.name);
continue;
}
if reprobe && let Err(e) = redetect(c, config) {
eprintln!("redetect {}: {e:#}", c.name);
}
}
self.owned.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn profile_has_valid_identity_checksum_and_exact_clock() {
let b = include_bytes!("../profiles/paperlike13k-37hz.edid");
validate(b, "L56051794302").unwrap();
assert_eq!(u16::from_le_bytes([b[54], b[55]]), 30421);
let mut broken = b.to_vec();
broken[60] ^= 1;
assert!(validate(&broken, "L56051794302").is_err());
assert!(validate(b, "another monitor").is_err());
}
}
+573
View File
@@ -0,0 +1,573 @@
mod config;
mod display;
mod protocol;
mod transport;
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use config::Config;
use protocol::{Decoder, Parameter, packet};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::{
collections::{BTreeMap, VecDeque},
fs,
io::{BufRead, BufReader, Read, Write},
os::{
fd::AsRawFd,
unix::{
fs::PermissionsExt,
net::{UnixListener, UnixStream},
},
},
path::{Path, PathBuf},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
mpsc,
},
thread,
time::{Duration, Instant},
};
#[derive(Parser)]
#[command(version, about)]
struct Cli {
#[arg(long, global = true, default_value = "/etc/dasungd.toml")]
config: PathBuf,
#[arg(long, global = true)]
socket: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Internal udev matcher: recognize the monitor's SPI/UART hub combination.
#[command(hide = true)]
UdevMatchSpi { syspath: PathBuf },
/// Foreground daemon; a service manager may supervise it.
Daemon,
/// Validate configuration and the optional EDID without changing hardware.
Check,
/// Discover matching video/USB devices without claiming them.
Discover,
/// JSON status including cached hardware settings and connection health.
Status,
/// Ask the monitor to refresh all cached parameter values.
Query,
/// Send one full-refresh command.
Refresh,
/// Set a raw monitor parameter through the daemon's single USB owner.
Set {
#[arg(value_enum)]
parameter: Parameter,
value: u8,
/// Reapply this value after reconnection/restart.
#[arg(long)]
save: bool,
},
/// Forget a saved parameter without changing its current hardware value.
Forget {
#[arg(value_enum)]
parameter: Parameter,
},
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
enum Request {
Status,
Query,
Refresh,
Set {
parameter: Parameter,
value: u8,
#[serde(default)]
save: bool,
},
Forget {
parameter: Parameter,
},
}
type Work = (Request, mpsc::Sender<Value>);
#[derive(Default, Serialize)]
struct Status {
connected: bool,
responsive: bool,
transport: Option<String>,
reconnects: u64,
keepalives_sent: u64,
rx_frames: u64,
parameters: BTreeMap<String, u8>,
saved: BTreeMap<Parameter, u8>,
last_rx_ms_ago: Option<u64>,
usb_error: Option<String>,
displays: Vec<String>,
display_error: Option<String>,
}
fn reply_ok(message: &str) -> Value {
json!({"ok":true,"message":message})
}
fn reply_error(error: impl std::fmt::Display) -> Value {
json!({"ok":false,"error":error.to_string()})
}
fn read_request(stream: &UnixStream) -> Result<Request> {
stream.set_read_timeout(Some(Duration::from_millis(500)))?;
let mut reader = BufReader::new(stream);
let mut line = Vec::new();
// Bound local requests, including requests without a terminating newline.
std::io::Read::by_ref(&mut reader)
.take(4097)
.read_until(b'\n', &mut line)?;
if line.len() > 4096 || !line.ends_with(b"\n") {
bail!("request must be one JSON line of at most 4096 bytes");
}
Ok(serde_json::from_slice(&line)?)
}
fn control_server(
listener: UnixListener,
sender: mpsc::SyncSender<Work>,
running: Arc<AtomicBool>,
) {
while running.load(Ordering::Relaxed) {
match listener.accept() {
Ok((mut stream, _)) => {
let response = match read_request(&stream) {
Ok(request) => {
let (reply, recv) = mpsc::channel();
if sender.try_send((request, reply)).is_err() {
reply_error("daemon busy")
} else {
recv.recv_timeout(Duration::from_secs(4))
.unwrap_or_else(|_| reply_error("request timed out"))
}
}
Err(e) => reply_error(e),
};
let _ = stream.set_write_timeout(Some(Duration::from_millis(500)));
let _ = writeln!(stream, "{response}");
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(50))
}
Err(e) => {
eprintln!("control socket: {e}");
thread::sleep(Duration::from_millis(250));
}
}
}
}
fn save_settings(path: &Path, settings: &BTreeMap<Parameter, u8>) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let temporary = path.with_extension("tmp");
let mut file = fs::File::create(&temporary)?;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
file.write_all(serde_json::to_string_pretty(settings)?.as_bytes())?;
file.sync_all()?;
fs::rename(temporary, path)?;
Ok(())
}
fn query_all(queue: &mut VecDeque<(u8, u8)>) {
for parameter in [0x10, 0x13, 1, 2, 7, 9, 8] {
if !queue.contains(&(0x0a, parameter)) {
queue.push_back((0x0a, parameter));
}
}
}
fn daemon(config: Config) -> Result<()> {
// Lock a separate inode before replacing any stale socket. A second instance
// must never steal the active daemon's pathname or USB interface.
fs::create_dir_all(
config
.socket
.parent()
.context("socket needs a parent directory")?,
)?;
let lock = fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(config.socket.with_extension("lock"))?;
// SAFETY: flock receives a live fd and defined operation flags.
if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
bail!("another daemon owns this socket");
}
if config.socket.exists() {
fs::remove_file(&config.socket)?;
}
let listener = UnixListener::bind(&config.socket)?;
fs::set_permissions(&config.socket, fs::Permissions::from_mode(0o660))?;
listener.set_nonblocking(true)?;
let running = Arc::new(AtomicBool::new(true));
let signal_flag = running.clone();
ctrlc::set_handler(move || signal_flag.store(false, Ordering::Relaxed))?;
let mut saved = if config.state_file.exists() {
serde_json::from_slice::<BTreeMap<Parameter, u8>>(&fs::read(&config.state_file)?)?
} else {
BTreeMap::new()
};
for (&p, &v) in &saved {
p.validate(v)?;
}
let status = Arc::new(Mutex::new(Status {
saved: saved.clone(),
..Default::default()
}));
let mut manager = display::Manager::new(&config)?;
let ds = status.clone();
let dc = config.clone();
let dr = running.clone();
let invalidate = Arc::new(AtomicBool::new(false));
let di = invalidate.clone();
let display_thread = thread::spawn(move || {
let mut last_error = String::new();
// Do not apply any profile if stale-override recovery failed. Retrying
// keeps the daemon useful when debugfs appears later during boot.
let mut recovered = false;
while dr.load(Ordering::Relaxed) {
if !recovered {
match manager.recover_stale(&dc) {
Ok(()) => recovered = true,
Err(e) => {
let message = format!("stale EDID recovery: {e:#}");
if message != last_error {
eprintln!("{message}");
last_error = message.clone();
}
ds.lock().unwrap().display_error = Some(message);
thread::sleep(Duration::from_secs(1));
continue;
}
}
}
if di.swap(false, Ordering::Relaxed) {
manager.cleanup(&dc, true);
}
match manager.tick(&dc) {
Ok(displays) => {
let mut s = ds.lock().unwrap();
s.displays = displays;
s.display_error = None;
last_error.clear();
}
Err(e) => {
let message = format!("{e:#}");
if message != last_error {
eprintln!("display: {message}");
last_error = message.clone();
}
ds.lock().unwrap().display_error = Some(message);
}
}
thread::sleep(Duration::from_millis(250));
}
manager.cleanup(&dc, false);
});
let (sender, receiver) = mpsc::sync_channel::<Work>(16);
let sr = running.clone();
let server = thread::spawn(move || control_server(listener, sender, sr));
let mut port: Option<Box<dyn transport::Transport>> = None;
let mut decoder = Decoder::default();
let mut queue = VecDeque::new();
let mut reconnect = Instant::now();
let mut keepalive = Instant::now();
let mut query = Instant::now();
let mut control = Instant::now();
let mut last_rx = Instant::now();
let mut last_error = String::new();
eprintln!(
"dasungd {} ready; waiting for {}",
env!("CARGO_PKG_VERSION"),
config.monitor_serial
);
while running.load(Ordering::Relaxed) {
let now = Instant::now();
if port.is_none() && now >= reconnect {
let attempt = (|| -> Result<Box<dyn transport::Transport>> {
if config.require_display && display::find(&config.monitor_serial)?.is_empty() {
bail!("waiting for matching display EDID");
}
transport::open(&config)
})();
match attempt {
Ok(p) => {
eprintln!("connected {}", p.label());
let mut s = status.lock().unwrap();
s.connected = true;
s.responsive = false;
s.last_rx_ms_ago = None;
s.transport = Some(p.label().into());
s.reconnects += 1;
s.usb_error = None;
s.parameters.clear();
last_error.clear();
port = Some(p);
decoder = Decoder::default();
queue.clear();
query_all(&mut queue);
let mut startup = config.startup.clone();
startup.extend(saved.clone());
for (parameter, value) in startup {
queue.push_back((parameter.command(), value));
}
queue.push_back((3, 0));
last_rx = now;
keepalive = now + Duration::from_secs(1);
control = now;
query = now + Duration::from_secs(10);
}
Err(e) => {
let message = format!("{e:#}");
if message != last_error {
eprintln!("USB: {message}");
last_error = message.clone();
}
status.lock().unwrap().usb_error = Some(message);
reconnect = now + Duration::from_millis(config.reconnect_ms);
}
}
}
while let Ok((request, reply)) = receiver.try_recv() {
let result = (|| -> Result<Value> {
if matches!(request, Request::Status) {
return Ok(json!({"ok":true,"status":*status.lock().unwrap()}));
}
if let Request::Forget { parameter } = request {
let mut updated = saved.clone();
updated.remove(&parameter);
save_settings(&config.state_file, &updated)?;
saved = updated;
status.lock().unwrap().saved = saved.clone();
return Ok(reply_ok(
"saved setting removed; configured startup defaults still apply",
));
}
let p = port.as_mut().context("monitor is not connected")?;
match request {
Request::Query => {
query_all(&mut queue);
Ok(reply_ok(
"parameter queries queued; read status for replies",
))
}
Request::Refresh => {
p.write(&packet(3, 0))?;
Ok(reply_ok("refresh packet written"))
}
Request::Set {
parameter,
value,
save,
} => {
parameter.validate(value)?;
p.write(&packet(parameter.command(), value))?;
queue.push_front((0x0a, parameter.command()));
if save {
let mut updated = saved.clone();
updated.insert(parameter, value);
save_settings(&config.state_file, &updated)?;
saved = updated;
status.lock().unwrap().saved = saved.clone();
}
Ok(reply_ok(
"setting packet written; status reports the monitor's observed value",
))
}
_ => unreachable!(),
}
})();
let _ = reply.send(result.unwrap_or_else(reply_error));
}
if let Some(p) = port.as_mut() {
let mut reply_timeout = false;
let result = (|| -> Result<()> {
if now >= keepalive {
p.write(&packet(0x20, 1))?;
keepalive = Instant::now() + Duration::from_millis(config.keepalive_ms);
status.lock().unwrap().keepalives_sent += 1;
}
if now >= query {
if !queue.contains(&(0x0a, 2)) {
queue.push_back((0x0a, 2));
}
query = now + Duration::from_secs(10);
}
if now >= control
&& let Some((cmd, opt)) = queue.pop_front()
{
p.write(&packet(cmd, opt))?;
control = Instant::now() + Duration::from_secs(1);
}
let mut bytes = [0; 256];
let n = p.read(&mut bytes)?;
for frame in decoder.push(&bytes[..n]) {
let mut s = status.lock().unwrap();
s.rx_frames += 1;
s.responsive = true;
last_rx = Instant::now();
if let Some((parameter, value)) = frame.parameter() {
s.parameters.insert(format!("0x{parameter:02X}"), value);
}
}
let age = last_rx.elapsed();
{
let mut s = status.lock().unwrap();
s.last_rx_ms_ago = s.responsive.then_some(age.as_millis() as u64);
}
if age > Duration::from_millis(config.watchdog_ms) {
reply_timeout = true;
bail!(
"no valid monitor replies for {} seconds; reopening control channel",
age.as_secs()
);
}
Ok(())
})();
if let Err(e) = result {
eprintln!("control connection lost: {e:#}");
port = None;
queue.clear();
let mut s = status.lock().unwrap();
s.connected = false;
s.responsive = false;
s.last_rx_ms_ago = None;
s.transport = None;
s.usb_error = Some(format!("{e:#}"));
reconnect = Instant::now() + Duration::from_millis(config.reconnect_ms);
// A silent control MCU needs a UART reopen, not a video mode
// reset. Actual transport loss invalidates the port association.
if !reply_timeout {
invalidate.store(true, Ordering::Relaxed);
}
}
} else {
thread::sleep(Duration::from_millis(50));
}
}
if let Some(mut p) = port {
let _ = p.write(&packet(0x20, 0));
}
let _ = server.join();
let _ = display_thread.join();
let _ = fs::remove_file(&config.socket);
eprintln!("stopped; released control interface and owned runtime EDID overrides");
Ok(())
}
fn client(socket: &Path, request: Request) -> Result<()> {
let mut stream = UnixStream::connect(socket)
.with_context(|| format!("connect to {}; is dasungd running?", socket.display()))?;
stream.set_read_timeout(Some(Duration::from_secs(6)))?;
writeln!(stream, "{}", serde_json::to_string(&request)?)?;
let mut line = String::new();
BufReader::new(stream).read_line(&mut line)?;
let reply: Value = serde_json::from_str(&line)?;
println!("{}", serde_json::to_string_pretty(&reply)?);
if reply["ok"] != true {
bail!("daemon rejected the request");
}
Ok(())
}
fn run() -> Result<()> {
let cli = Cli::parse();
if let Command::UdevMatchSpi { syspath } = &cli.command {
if transport::is_monitor_spi(syspath) {
println!("yes");
return Ok(());
}
bail!("not a matching monitor SPI interface");
}
if matches!(
cli.command,
Command::Daemon | Command::Check | Command::Discover
) {
let mut config = Config::load(&cli.config)?;
if let Some(socket) = cli.socket {
config.socket = socket;
}
return match cli.command {
Command::Daemon => daemon(config),
Command::Check => {
display::Manager::new(&config)?;
println!("Configuration and EDID valid");
Ok(())
}
Command::Discover => {
println!(
"{}",
serde_json::to_string_pretty(
&json!({"displays":display::find(&config.monitor_serial)?,"usb":transport::usb_candidates(&config)?})
)?
);
Ok(())
}
_ => unreachable!(),
};
}
let socket = match cli.socket {
Some(socket) => socket,
None if cli.config.exists() => Config::load(&cli.config)?.socket,
None => "/run/dasungd/control.sock".into(),
};
let request = match cli.command {
Command::Status => Request::Status,
Command::Query => Request::Query,
Command::Refresh => Request::Refresh,
Command::Set {
parameter,
value,
save,
} => {
parameter.validate(value)?;
Request::Set {
parameter,
value,
save,
}
}
Command::Forget { parameter } => Request::Forget { parameter },
_ => unreachable!(),
};
client(&socket, request)
}
fn main() {
if let Err(e) = run() {
eprintln!("dasungd: {e:#}");
std::process::exit(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_or_oversized_ipc_is_rejected() {
let (a, mut b) = UnixStream::pair().unwrap();
b.write_all(b"{\"op\":\"set\",\"parameter\":\"firmware\",\"value\":3}\n")
.unwrap();
assert!(read_request(&a).is_err());
let (a, mut b) = UnixStream::pair().unwrap();
b.write_all(&vec![b'x'; 4097]).unwrap();
assert!(read_request(&a).is_err());
}
#[test]
fn saved_settings_survive_reload() {
let dir = std::env::temp_dir().join(format!("dasungd-test-{}", std::process::id()));
let p = dir.join("settings.json");
let settings = BTreeMap::from([(Parameter::Mode, 7)]);
save_settings(&p, &settings).unwrap();
let read: BTreeMap<Parameter, u8> = serde_json::from_slice(&fs::read(&p).unwrap()).unwrap();
assert_eq!(read, settings);
fs::remove_dir_all(dir).unwrap();
}
}
+131
View File
@@ -0,0 +1,131 @@
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
pub fn packet(command: u8, option: u8) -> Vec<u8> {
format!("5FF5{command:02X}{option:02X}000000000000A0FA").into_bytes()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Frame(pub Vec<u8>);
impl Frame {
pub fn parameter(&self) -> Option<(u8, u8)> {
match self.0.as_slice() {
[0xf0, 0x0a, parameter, value, ..] => Some((*parameter, *value)),
[command @ (1 | 2 | 7 | 8 | 9), value, ..] => Some((*command, *value)),
_ => None,
}
}
}
// USB reads can split frames, concatenate them, or contain unsolicited button events.
// The observed monitor replies have both 22- and 24-character framing.
#[derive(Default)]
pub struct Decoder(Vec<u8>);
impl Decoder {
pub fn push(&mut self, input: &[u8]) -> Vec<Frame> {
self.0.extend(input.iter().map(u8::to_ascii_uppercase));
let mut frames = Vec::new();
loop {
let Some(start) = self.0.windows(4).position(|v| v == b"5FF5") else {
if self.0.len() > 3 {
self.0.drain(..self.0.len() - 3);
}
break;
};
self.0.drain(..start);
let end = [22, 24]
.into_iter()
.find(|&n| self.0.len() >= n && &self.0[n - 4..n] == b"A0FA");
if let Some(n) = end {
let body = &self.0[4..n - 4];
if body.iter().all(u8::is_ascii_hexdigit) {
let bytes = body
.as_chunks::<2>()
.0
.iter()
.map(|p| u8::from_str_radix(std::str::from_utf8(p).unwrap(), 16).unwrap())
.collect();
frames.push(Frame(bytes));
}
self.0.drain(..n);
} else if self.0.len() >= 24 {
self.0.remove(0);
} else {
break;
}
}
frames
}
}
#[derive(
Debug, Clone, Copy, Serialize, Deserialize, clap::ValueEnum, PartialEq, Eq, PartialOrd, Ord,
)]
#[serde(rename_all = "snake_case")]
pub enum Parameter {
Mode,
Contrast,
FrontLight,
Brightness,
Temperature,
}
impl Parameter {
pub fn command(self) -> u8 {
match self {
Self::Contrast => 1,
Self::Mode => 2,
Self::FrontLight => 7,
Self::Temperature => 8,
Self::Brightness => 9,
}
}
pub fn validate(self, value: u8) -> Result<()> {
let valid = match self {
Self::Mode => (1..=8).contains(&value),
Self::Contrast => (1..=9).contains(&value),
Self::FrontLight => value <= 2,
Self::Brightness | Self::Temperature => value <= 100,
};
if !valid {
bail!("value {value} outside the allowed raw range for {self:?}");
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn framing_and_fragmented_real_responses() {
assert_eq!(packet(0x20, 1), b"5FF52001000000000000A0FA");
let mut d = Decoder::default();
assert!(d.push(b"junk5FF5F00").is_empty());
let frames = d.push(b"A020100000000A0FA5FF50207000000000000A0FA");
assert_eq!(
frames
.iter()
.filter_map(Frame::parameter)
.collect::<Vec<_>>(),
[(2, 1), (2, 7)]
);
}
#[test]
fn resynchronizes_after_bad_frame_and_bounds_noise() {
let mut d = Decoder::default();
assert!(d.push(&vec![b'X'; 50000]).is_empty());
assert!(d.0.len() <= 3);
let f = d.push(b"5FF5ZZ02000000000000A0FA5FF5F00A020300000000A0FA");
assert_eq!(f.len(), 1);
assert_eq!(f[0].parameter(), Some((2, 3)));
}
#[test]
fn reject_invalid_settings() {
assert!(Parameter::Mode.validate(0).is_err());
assert!(Parameter::Mode.validate(7).is_ok());
assert!(Parameter::FrontLight.validate(3).is_err());
}
}
+306
View File
@@ -0,0 +1,306 @@
use crate::config::Config;
use anyhow::{Context, Result, bail, ensure};
use rusb::{DeviceHandle, GlobalContext};
use std::{
fs::{self, File, OpenOptions},
io::{Read, Write},
os::{fd::AsRawFd, unix::fs::OpenOptionsExt},
path::Path,
time::{Duration, Instant},
};
pub trait Transport: Send {
fn write(&mut self, bytes: &[u8]) -> Result<()>;
fn read(&mut self, bytes: &mut [u8]) -> Result<usize>;
fn label(&self) -> &str;
}
struct Usb {
handle: DeviceHandle<GlobalContext>,
// Claiming this interface sends no SPI data and prevents a driver loaded
// later for another adapter from probing the monitor's internal bridge.
_spi_guard: Option<DeviceHandle<GlobalContext>>,
last_write: Option<Instant>,
name: String,
}
impl Transport for Usb {
fn write(&mut self, bytes: &[u8]) -> Result<()> {
pace(self.last_write);
let n = self
.handle
.write_bulk(0x02, bytes, Duration::from_millis(500))?;
ensure!(n == bytes.len(), "partial USB write: {n}/{}", bytes.len());
self.last_write = Some(Instant::now());
Ok(())
}
fn read(&mut self, bytes: &mut [u8]) -> Result<usize> {
match self
.handle
.read_bulk(0x82, bytes, Duration::from_millis(50))
{
Ok(n) => Ok(n),
Err(rusb::Error::Timeout) => Ok(0),
Err(e) => Err(e.into()),
}
}
fn label(&self) -> &str {
&self.name
}
}
fn pace(last_write: Option<Instant>) {
// Match the proven helper's inter-command gap. In particular, a keepalive
// and query must not reach the MCU back-to-back at cold startup.
if let Some(last) = last_write {
let gap = Duration::from_millis(150);
if last.elapsed() < gap {
std::thread::sleep(gap.saturating_sub(last.elapsed()));
}
}
}
fn matches_id(path: &Path, vendor: &str, product: &str) -> bool {
fs::read_to_string(path.join("idVendor"))
.unwrap_or_default()
.trim()
== vendor
&& fs::read_to_string(path.join("idProduct"))
.unwrap_or_default()
.trim()
== product
}
pub fn is_monitor_spi(path: &Path) -> bool {
let path = if path.starts_with("/sys") {
path.to_path_buf()
} else {
Path::new("/sys").join(path.strip_prefix("/").unwrap_or(path))
};
let Ok(interface) = path.canonicalize() else {
return false;
};
let Some(device) = interface.parent() else {
return false;
};
let Some(hub) = device.parent() else {
return false;
};
matches_id(device, "1a86", "5512")
&& matches_id(hub, "1a40", "0101")
&& fs::read_dir(hub).is_ok_and(|entries| {
entries
.filter_map(Result::ok)
.any(|e| matches_id(&e.path(), "1a86", "7523"))
})
}
pub fn usb_candidates(config: &Config) -> Result<Vec<(u8, u8, String)>> {
let mut candidates = Vec::new();
for entry in fs::read_dir("/sys/bus/usb/devices")? {
let path = entry?.path();
if !matches_id(&path, "1a86", "7523") {
continue;
}
let name = path.file_name().unwrap().to_string_lossy().into_owned();
if config
.usb_path
.as_ref()
.is_some_and(|wanted| wanted != &name)
{
continue;
}
if config.require_companion {
let actual = path.canonicalize()?;
let Some(parent) = actual.parent() else {
continue;
};
// The monitor contains an SPI bridge and UART under the same hub.
// This avoids claiming an unrelated generic CH340 serial adapter.
let sibling = fs::read_dir(parent)?
.filter_map(Result::ok)
.any(|p| matches_id(&p.path(), "1a86", "5512"));
if !sibling {
continue;
}
}
let bus = fs::read_to_string(path.join("busnum"))?.trim().parse()?;
let address = fs::read_to_string(path.join("devnum"))?.trim().parse()?;
candidates.push((bus, address, name));
}
Ok(candidates)
}
fn usb_open(config: &Config) -> Result<Box<dyn Transport>> {
let candidates = usb_candidates(config)?;
ensure!(
candidates.len() == 1,
"expected one matching monitor UART; found {} (set usb_path to disambiguate)",
candidates.len()
);
let (bus, address, name) = &candidates[0];
let devices = rusb::devices()?;
let spi_guard = if config.require_companion {
let uart_path = Path::new("/sys/bus/usb/devices")
.join(name)
.canonicalize()?;
let hub = uart_path.parent().context("UART hub absent")?;
let path = fs::read_dir(hub)?
.filter_map(Result::ok)
.map(|e| e.path())
.find(|p| matches_id(p, "1a86", "5512"))
.context("SPI companion disappeared")?;
let spi_bus: u8 = fs::read_to_string(path.join("busnum"))?.trim().parse()?;
let spi_address: u8 = fs::read_to_string(path.join("devnum"))?.trim().parse()?;
let dev = devices
.iter()
.find(|d| d.bus_number() == spi_bus && d.address() == spi_address)
.context("SPI companion disappeared from USB enumeration")?;
let descriptor = dev.device_descriptor()?;
ensure!(
descriptor.vendor_id() == 0x1a86 && descriptor.product_id() == 0x5512,
"SPI companion identity changed"
);
let guard = dev.open()?;
ensure!(
!guard.kernel_driver_active(0)?,
"monitor SPI interface has a kernel driver; install the scoped udev rule, unbind/unload spi_ch341 when unused, and power-cycle the monitor"
);
guard.claim_interface(0)?;
Some(guard)
} else {
None
};
let device = devices
.iter()
.find(|d| d.bus_number() == *bus && d.address() == *address)
.context("UART disappeared during enumeration")?;
let descriptor = device.device_descriptor()?;
ensure!(
descriptor.vendor_id() == 0x1a86 && descriptor.product_id() == 0x7523,
"USB device changed during enumeration"
);
let handle = device.open()?;
handle.set_auto_detach_kernel_driver(true)?;
if handle.active_configuration()? != 1 {
handle.set_active_configuration(1)?;
}
handle.claim_interface(0)?;
// Rebinding the kernel ch341 driver is unreliable on the affected host.
// Physical reconnection restores normal kernel probing after daemon exit.
handle.set_auto_detach_kernel_driver(false)?;
let cfg = device.active_config_descriptor()?;
let iface = cfg
.interfaces()
.find(|i| i.number() == 0)
.context("missing CH340 interface 0")?;
let endpoints: Vec<_> = iface
.descriptors()
.flat_map(|i| {
i.endpoint_descriptors()
.map(|e| e.address())
.collect::<Vec<_>>()
})
.collect();
ensure!(
endpoints.contains(&0x02) && endpoints.contains(&0x82),
"unexpected UART endpoints"
);
// Same 115200 8N1 sequence used by the confirmed Python helper. No SPI I/O.
for (request, value, index) in [
(0xa1, 0, 0),
(0x9a, 0x1312, 0xcc03),
(0x9a, 0x2518, 0x00c3),
(0xa4, 0x00ff, 0),
] {
handle.write_control(0x40, request, value, index, &[], Duration::from_secs(1))?;
}
Ok(Box::new(Usb {
handle,
_spi_guard: spi_guard,
last_write: None,
name: format!("usb:{name} ({bus:03}/{address:03})"),
}))
}
struct Serial {
file: File,
name: String,
last_write: Option<Instant>,
}
impl Drop for Serial {
fn drop(&mut self) {
// SAFETY: release only the exclusive flag on our still-open tty.
unsafe {
libc::ioctl(self.file.as_raw_fd(), libc::TIOCNXCL);
}
}
}
impl Transport for Serial {
fn write(&mut self, bytes: &[u8]) -> Result<()> {
pace(self.last_write);
self.file.write_all(bytes)?;
self.last_write = Some(Instant::now());
Ok(())
}
fn read(&mut self, bytes: &mut [u8]) -> Result<usize> {
let mut poll = libc::pollfd {
fd: self.file.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
// SAFETY: poll points to one initialized pollfd for a live descriptor.
let n = unsafe { libc::poll(&mut poll, 1, 50) };
if n < 0 {
return Err(std::io::Error::last_os_error().into());
}
if poll.revents & (libc::POLLHUP | libc::POLLERR | libc::POLLNVAL) != 0 {
bail!("serial device disconnected");
}
if n == 0 {
return Ok(0);
}
Ok(self.file.read(bytes)?)
}
fn label(&self) -> &str {
&self.name
}
}
pub fn open(config: &Config) -> Result<Box<dyn Transport>> {
if config.transport == "usb" {
return usb_open(config);
}
let path = config
.serial_device
.as_ref()
.context("missing serial_device")?;
let file = OpenOptions::new()
.read(true)
.write(true)
.custom_flags(libc::O_NOCTTY | libc::O_NONBLOCK)
.open(path)?;
let fd = file.as_raw_fd();
// SAFETY: termios is initialized by tcgetattr; all operations use a live fd.
unsafe {
let mut attrs = std::mem::zeroed();
if libc::tcgetattr(fd, &mut attrs) != 0 {
return Err(std::io::Error::last_os_error().into());
}
libc::cfmakeraw(&mut attrs);
libc::cfsetispeed(&mut attrs, libc::B115200);
libc::cfsetospeed(&mut attrs, libc::B115200);
attrs.c_cflag = (attrs.c_cflag
& !(libc::CRTSCTS | libc::CSTOPB | libc::PARENB | libc::CSIZE))
| libc::CS8
| libc::CLOCAL
| libc::CREAD;
if libc::tcsetattr(fd, libc::TCSANOW, &attrs) != 0 || libc::ioctl(fd, libc::TIOCEXCL) != 0 {
return Err(std::io::Error::last_os_error().into());
}
}
Ok(Box::new(Serial {
file,
last_write: None,
name: format!("serial:{}", path.display()),
}))
}
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Exercise the real daemon/IPC/reconnect/persistence using a fake serial monitor.
No root access, physical display changes, USB claims or Python runtime dependency
in the shipped daemon. Run after cargo build --release.
"""
import errno
import json
import os
from pathlib import Path
import pty
import select
import signal
import socket
import subprocess
import tempfile
import threading
import time
ROOT = Path(__file__).resolve().parents[1]
BINARY = Path(os.environ.get("DASUNGD_BIN", ROOT.parents[1] / "target/x86_64-unknown-linux-gnu/release/dasungd"))
class Monitor:
def __init__(self, link):
self.master, slave = pty.openpty()
self.values = {1: 4, 2: 1, 7: 2, 8: 0, 9: 40, 0x10: 0x31, 0x13: 2}
self.keepalives = 0
self.running = True
self.respond = True
link.unlink(missing_ok=True)
link.symlink_to(os.ttyname(slave))
os.close(slave)
self.thread = threading.Thread(target=self.loop)
self.thread.start()
def loop(self):
buffer = b""
while self.running:
try:
if not select.select([self.master], [], [], 0.1)[0]:
continue
data = os.read(self.master, 4096)
buffer += data
while b"5FF5" in buffer and len(buffer) >= 24:
buffer = buffer[buffer.index(b"5FF5"):]
if len(buffer) < 24:
break
frame, buffer = buffer[:24], buffer[24:]
if frame[-4:] != b"A0FA":
continue
cmd, opt = int(frame[4:6], 16), int(frame[6:8], 16)
if cmd == 0x20:
self.keepalives += opt == 1
elif cmd == 0x0A and self.respond:
reply = f"5FF5F00A{opt:02X}{self.values.get(opt,0):02X}000000A0FA".encode()
# Fragment actual 22-byte query responses across reads.
os.write(self.master, reply[:9])
time.sleep(0.005)
os.write(self.master, reply[9:])
elif cmd in self.values:
self.values[cmd] = opt
if self.respond:
os.write(self.master, frame)
except OSError as exc:
if exc.errno in (errno.EIO, errno.EBADF):
time.sleep(0.05)
else:
raise
def close(self):
self.running = False
self.thread.join(timeout=2)
os.close(self.master)
def wait_for(predicate, timeout=12):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
value = predicate()
if value:
return value
except (FileNotFoundError, ConnectionRefusedError, json.JSONDecodeError):
pass
time.sleep(0.1)
raise AssertionError("condition did not become true")
with tempfile.TemporaryDirectory(prefix="dasungd-smoke-") as directory:
d = Path(directory)
sock = d / "control.sock"
link = d / "uart"
cfg = d / "config.toml"
cfg.write_text(f'''monitor_serial = "TEST-NOT-A-PHYSICAL-MONITOR"
socket = "{sock}"
state_file = "{d / 'state.json'}"
transport = "serial"
serial_device = "{link}"
require_display = false
keepalive_ms = 500
reconnect_ms = 500
watchdog_ms = 20000
[display]
enabled = false
''')
def request(value):
with socket.socket(socket.AF_UNIX) as connection:
connection.settimeout(5)
connection.connect(str(sock))
connection.sendall(json.dumps(value).encode() + b"\n")
with connection.makefile("r") as reader:
return json.loads(reader.readline())
def status():
return request({"op": "status"})["status"]
log = (d / "daemon.log").open("w+")
process = None
monitor = None
try:
# Start with the monitor absent, then plug it in.
process = subprocess.Popen([str(BINARY), "--config", str(cfg), "daemon"], stderr=log)
wait_for(lambda: sock.exists())
assert not status()["connected"]
monitor = Monitor(link)
wait_for(lambda: status()["parameters"].get("0x02") == 1)
wait_for(lambda: monitor.keepalives >= 2)
assert not request({"op": "set", "parameter": "mode", "value": 255})["ok"]
assert request({"op": "set", "parameter": "contrast", "value": 5, "save": True})["ok"]
wait_for(lambda: monitor.values[1] == 5)
# USB/serial unplug, node replacement, automatic reinitialization.
monitor.close()
monitor = None
wait_for(lambda: not status()["connected"])
monitor = Monitor(link)
wait_for(lambda: monitor.values[1] == 5)
assert status()["reconnects"] >= 2
wait_for(lambda: monitor.keepalives >= 2)
# Only one daemon can own a given socket; the first remains reachable.
duplicate = subprocess.run([str(BINARY), "--config", str(cfg), "daemon"], capture_output=True)
assert duplicate.returncode != 0
assert status()["connected"]
# A control board that stops replying gets reopened by the watchdog.
old_reconnects = status()["reconnects"]
monitor.respond = False
wait_for(lambda: status()["reconnects"] > old_reconnects, timeout=25)
monitor.respond = True
wait_for(lambda: status()["parameters"].get("0x02") == 1)
# Restart the process and verify saved settings survive.
process.send_signal(signal.SIGTERM)
assert process.wait(timeout=5) == 0
monitor.values[1] = 4
process = subprocess.Popen([str(BINARY), "--config", str(cfg), "daemon"], stderr=log)
wait_for(lambda: monitor.values[1] == 5)
assert request({"op": "forget", "parameter": "contrast"})["ok"]
assert json.loads((d / "state.json").read_text()) == {}
print("PASS: late attach, keepalive, fragmented replies, IPC validation, saved settings, unplug/replug, duplicate exclusion, reply watchdog, restart and forget")
finally:
if process is not None and process.poll() is None:
process.terminate()
process.wait(timeout=5)
if monitor is not None:
monitor.close()
log.flush()
log.seek(0)
print(log.read())
View File
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "fds-boottrace"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "Measured FDS boot events and regression reports"
[dependencies]
clap.workspace = true
fds-common = { path = "../fds-common" }
serde_json = "1"
libc = "0.2"
+232
View File
@@ -0,0 +1,232 @@
use clap::{CommandFactory, Parser, Subcommand};
use fds_common::{
Error, Result, read_text,
trace::{self, Point, Report},
};
use std::{
fs,
os::unix::fs::PermissionsExt,
path::{Path, PathBuf},
process::ExitCode,
};
fn root() -> Result<()> {
if unsafe { libc::geteuid() } != 0 {
return Err(Error("This boot event requires root".into()));
}
Ok(())
}
fn clock_floor() -> Result<()> {
root()?;
let epoch: i64 = read_text(Path::new("/usr/share/fds/build-epoch"), 32)?
.trim()
.parse()
.map_err(|_| Error("Invalid image clock floor".into()))?;
if !(1..=4_102_444_800).contains(&epoch) {
return Err(Error(
"Image clock floor is outside the supported range".into(),
));
}
let mut before: libc::timespec = unsafe { std::mem::zeroed() };
if unsafe { libc::clock_gettime(libc::CLOCK_REALTIME, &mut before) } < 0 {
return Err(std::io::Error::last_os_error().into());
}
let advance = before.tv_sec < epoch;
if advance {
let floor = libc::timespec {
tv_sec: epoch,
tv_nsec: 0,
};
if unsafe { libc::clock_settime(libc::CLOCK_REALTIME, &floor) } < 0 {
return Err(std::io::Error::last_os_error().into());
}
}
fs::create_dir_all("/run/fds")?;
let status = serde_json::json!({"format":1, "source":if advance {"image_floor"} else {"retained_kernel_clock"},
"image_epoch":epoch, "previous_unix_seconds":before.tv_sec, "minimum_unix_seconds":before.tv_sec.max(epoch)});
fs::write(
"/run/fds/clock.json",
serde_json::to_vec_pretty(&status).map_err(|e| Error(e.to_string()))?,
)?;
Ok(())
}
fn adopt() -> Result<()> {
root()?;
let instant = trace::now()?;
let directory = Path::new(trace::RUNTIME);
fs::create_dir_all(directory.join("console"))?;
fs::set_permissions(directory, fs::Permissions::from_mode(0o755))?;
fs::set_permissions(directory.join("console"), fs::Permissions::from_mode(0o755))?;
let path = std::ffi::CString::new(directory.join("console").to_str().unwrap()).unwrap();
if unsafe { libc::chown(path.as_ptr(), 1000, 1000) } < 0 {
return Err(std::io::Error::last_os_error().into());
}
for point in Point::ALL {
let source = Path::new(trace::EARLY).join(format!("{}.json", point.name()));
if source.exists() {
fs::rename(&source, directory.join(source.file_name().unwrap())).or_else(|error| {
if error.raw_os_error() != Some(libc::EXDEV) {
return Err(error);
}
fs::copy(&source, directory.join(source.file_name().unwrap()))?;
fs::remove_file(source)
})?;
}
}
trace::save(directory, Point::S6Start, instant)?;
// Preserve a valid kernel/RTC clock. A machine without an RTC must still
// create files newer than immutable image inputs; no time server is awaited.
clock_floor()
}
#[derive(Parser)]
#[command(
version,
about = "Record and compare measured boot events",
after_help = "Measurements use Linux CLOCK_BOOTTIME, excluding firmware and power-on."
)]
struct Cli {
#[command(subcommand)]
command: Option<Action>,
}
#[derive(Subcommand)]
enum Action {
/// Import stage0 events and record native s6 startup (root only).
Adopt,
/// Advance an older clock to the image timestamp (root only).
ClockFloor,
/// Record a named boot event.
Mark {
#[arg(value_parser = Point::parse)]
event: Point,
},
/// Show this boot's events and durations.
Report {
#[arg(long)]
json: bool,
},
/// Compare reports; a regression over 100 ms requires an explanation.
Compare {
baseline: PathBuf,
current: PathBuf,
#[arg(long, value_parser = explanation)]
explain: Option<String>,
},
}
fn explanation(value: &str) -> std::result::Result<String, String> {
if value.trim().is_empty() {
Err("An explanation must not be blank".into())
} else {
Ok(value.into())
}
}
fn run() -> Result<()> {
match Cli::parse().command {
None => {
Cli::command().print_help()?;
println!();
}
Some(Action::Adopt) => adopt()?,
Some(Action::ClockFloor) => clock_floor()?,
Some(Action::Mark { event: point }) => {
let directory = if point == Point::ConsoleReady {
let uid = unsafe { libc::geteuid() };
if uid != 0 && uid != 1000 {
return Err(Error("Console readiness requires the FDS user".into()));
}
Path::new(trace::RUNTIME).join("console")
} else {
root()?;
Path::new(trace::RUNTIME).to_owned()
};
trace::save(&directory, point, trace::now()?)?;
}
Some(Action::Report { json }) => trace::load(Path::new(trace::RUNTIME))?.print(json)?,
Some(Action::Compare {
baseline,
current,
explain,
}) => compare(&baseline, &current, explain.as_deref())?,
}
Ok(())
}
fn compare(baseline: &Path, current: &Path, explanation: Option<&str>) -> Result<()> {
let load = |path| -> Result<Report> {
serde_json::from_str(&read_text(path, 65536)?).map_err(|e| Error(e.to_string()))
};
let (before, after) = (load(baseline)?, load(current)?);
if before.format != 1
|| after.format != 1
|| before.clock != after.clock
|| before.platform != after.platform
{
return Err(Error(
"Compare reports from the same platform and clock".into(),
));
}
let elapsed = |report: &Report| {
report
.durations_ns
.get("kernel-to-console")
.copied()
.ok_or_else(|| Error("Console readiness was not recorded".into()))
};
let delta = i128::from(elapsed(&after)?) - i128::from(elapsed(&before)?);
println!(
"KERNEL TO CONSOLE CHANGE: {:+.3} ms",
delta as f64 / 1_000_000.0
);
if let Some(reason) = explanation {
println!("EXPLANATION: {reason}");
}
if delta > 100_000_000 && explanation.is_none() {
return Err(Error(
"Regression exceeds 100 ms; fix it or record --explain REASON".into(),
));
}
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("fds-boottrace: {error}");
ExitCode::from(2)
}
}
}
#[cfg(test)]
mod cli_tests {
use super::*;
use clap::{CommandFactory, Parser};
#[test]
fn typed_command_contract() {
Cli::command().debug_assert();
assert!(Cli::try_parse_from(["fds-boottrace", "mark", "console-ready"]).is_ok());
assert!(Cli::try_parse_from(["fds-boottrace", "mark", "unknown"]).is_err());
assert!(Cli::try_parse_from(["fds-boottrace", "report", "--json"]).is_ok());
assert!(
Cli::try_parse_from([
"fds-boottrace",
"compare",
"before",
"after",
"--explain",
"measured change"
])
.is_ok()
);
assert!(
Cli::try_parse_from([
"fds-boottrace",
"compare",
"before",
"after",
"--explain",
" "
])
.is_err()
);
assert!(Cli::try_parse_from(["fds-boottrace", "clock-floor", "--json"]).is_err());
}
}
View File
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "fds-burn"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "Verified cartridge images and protected FDS media writes"
[dependencies]
clap.workspace = true
fds-common = { path = "../fds-common" }
libc = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "=0.10.9"
+215
View File
@@ -0,0 +1,215 @@
//! Typed command grammar shared by `fds burn`, `fds format`, and `fds-burn`.
use clap::{Args, Subcommand};
use fds_common::Bay;
use std::path::PathBuf;
#[derive(Debug, Subcommand)]
pub enum BurnCommand {
/// Write a prepared SYSTEM image after confirmation.
System(ImageArgs),
/// Write a prepared PROGRAM image after confirmation.
Program(ImageArgs),
/// Write IMAGE BAY, or create DATA with BAY [--label NAME] [--size-mib N].
Data(DataWrite),
/// Write IMAGE BAY, or create ENVIRONMENT with BAY [--profile NAME].
Environment(EnvironmentWrite),
/// Show a media operation's current state.
Status { id: String },
/// Wait for a media operation to finish.
Wait { id: String },
/// Confirm the exact phrase returned by a write preview.
Confirm { id: String, confirmation: String },
/// Cancel a media operation.
Cancel { id: String },
}
#[derive(Debug, Args)]
pub struct ImageArgs {
pub image: PathBuf,
#[arg(value_parser = crate::client::bay)]
pub bay: Bay,
}
#[derive(Debug, Args)]
pub struct DataWrite {
/// Prepared image path, or a bay number to create a new DATA filesystem.
#[arg(value_name = "IMAGE_OR_BAY")]
pub source: PathBuf,
#[arg(value_parser = crate::client::bay, conflicts_with_all = ["label", "id", "size_mib"])]
pub bay: Option<Bay>,
#[command(flatten)]
pub options: DataOptions,
}
#[derive(Debug, Args)]
pub struct EnvironmentWrite {
/// Prepared image path, or a bay number to create a new ENVIRONMENT.
#[arg(value_name = "IMAGE_OR_BAY")]
pub source: PathBuf,
#[arg(value_parser = crate::client::bay, conflicts_with_all = ["label", "id", "profile"])]
pub bay: Option<Bay>,
#[command(flatten)]
pub options: EnvironmentOptions,
}
#[derive(Debug, Default, Args)]
pub struct MetadataOptions {
/// Human-readable cartridge name.
#[arg(long, value_name = "NAME")]
pub label: Option<String>,
/// Cartridge identifier (generated when omitted).
#[arg(long)]
pub id: Option<String>,
}
#[derive(Debug, Args)]
pub struct DataOptions {
#[command(flatten)]
pub metadata: MetadataOptions,
/// Filesystem size in MiB (otherwise fill the target, leaving GPT space).
#[arg(long, value_name = "N")]
pub size_mib: Option<u64>,
}
#[derive(Debug, Args)]
pub struct EnvironmentOptions {
#[command(flatten)]
pub metadata: MetadataOptions,
/// Activation profile (defaults to windowmaker).
#[arg(long, value_name = "NAME")]
pub profile: Option<String>,
}
#[derive(Debug, Args)]
pub struct DataFormat {
#[arg(value_name = "BAY", value_parser = crate::client::bay)]
pub target: Bay,
#[command(flatten)]
pub options: DataOptions,
}
#[derive(Debug, Args)]
pub struct EnvironmentFormat {
#[arg(value_name = "BAY", value_parser = crate::client::bay)]
pub target: Bay,
#[command(flatten)]
pub options: EnvironmentOptions,
}
#[derive(Debug, Subcommand)]
pub enum FormatCommand {
/// Create DATA and preview a confirmed cartridge write.
Data(DataFormat),
/// Create an ENVIRONMENT descriptor and preview its write.
Environment(EnvironmentFormat),
/// Package an application directory containing bin/ and preview its write.
Program {
#[arg(value_name = "APP_DIRECTORY")]
source: PathBuf,
#[arg(value_name = "BAY", value_parser = crate::client::bay)]
target: Bay,
#[command(flatten)]
metadata: MetadataOptions,
},
/// Package a prepared SYSTEM root and preview its write.
System {
#[arg(value_name = "ROOT_DIRECTORY")]
source: PathBuf,
#[arg(value_name = "BAY", value_parser = crate::client::bay)]
target: Bay,
},
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{CommandFactory, Parser};
#[derive(Debug, Parser)]
struct Burn {
#[command(subcommand)]
command: BurnCommand,
}
#[derive(Debug, Parser)]
struct Format {
#[command(subcommand)]
command: FormatCommand,
}
#[test]
fn prepared_images_and_format_shorthand_have_distinct_options() {
Burn::command().debug_assert();
Format::command().debug_assert();
let parsed = Burn::try_parse_from([
"burn",
"data",
"BAY2",
"--label",
"My data",
"--id",
"user.data",
"--size-mib",
"64",
])
.unwrap();
let BurnCommand::Data(args) = parsed.command else {
panic!("DATA")
};
assert!(args.bay.is_none());
assert_eq!(args.options.size_mib, Some(64));
assert_eq!(args.options.metadata.label.as_deref(), Some("My data"));
for class in ["data", "environment", "system", "program"] {
assert!(Burn::try_parse_from(["burn", class, "card.img", "12"]).is_ok());
assert!(Burn::try_parse_from(["burn", class, "card.img", "13"]).is_err());
assert!(
Burn::try_parse_from(["burn", class, "card.img", "1", "--label", "name"]).is_err()
);
}
assert!(Burn::try_parse_from(["burn", "confirm", "id", "phrase with spaces"]).is_ok());
assert!(Burn::try_parse_from(["burn", "confirm", "id"]).is_err());
assert!(Burn::try_parse_from(["burn", "data", "1", "--size-mib", "bad"]).is_err());
assert!(
Burn::try_parse_from(["burn", "data", "1", "--label", "one", "--label", "two"])
.is_err()
);
}
#[test]
fn format_rejects_unknown_duplicate_and_inapplicable_options() {
for arguments in [
vec![
"format",
"data",
"BAY12",
"--label",
"DATA",
"--id",
"test.data",
],
vec![
"format",
"environment",
"2",
"--profile",
"windowmaker",
"--label",
"GUI",
],
vec!["format", "program", "app", "3", "--label", "Editor"],
vec!["format", "system", "root", "4"],
] {
assert!(Format::try_parse_from(&arguments).is_ok(), "{arguments:?}");
}
for arguments in [
vec!["format", "data", "1", "--profile", "windowmaker"],
vec!["format", "data", "1", "--size-mib", "-1"],
vec!["format", "data", "1", "--label", "a", "--label", "b"],
vec!["format", "data", "1", "--force"],
vec!["format", "environment", "1", "--size-mib", "32"],
vec!["format", "program", "app", "1", "--profile", "cli"],
vec!["format", "system", "root", "1", "--label", "SYS"],
vec!["format", "system", "root"],
] {
assert!(Format::try_parse_from(&arguments).is_err(), "{arguments:?}");
}
}
}
+326
View File
@@ -0,0 +1,326 @@
use crate::{
cli::{BurnCommand, DataFormat, EnvironmentFormat, FormatCommand, MetadataOptions},
create, image,
};
use fds_common::{
Bay, Error, Result,
control::{self, MediaJob, Request},
manifest::{Activation, Cartridge, Class, Manifest, Media},
};
use std::{
fs,
io::{self, IsTerminal, Write},
path::Path,
process::{Command, Stdio},
};
fn job(request: Request) -> Result<MediaJob> {
control::request(&request)?
.media_job
.ok_or_else(|| Error("Missing media operation response".into()))
}
pub fn bay(value: &str) -> Result<Bay> {
value.strip_prefix("BAY").unwrap_or(value).parse()
}
fn print(job: &MediaJob, json: bool) -> Result<()> {
if json {
println!(
"{}",
serde_json::to_string_pretty(job).map_err(|e| Error(e.to_string()))?
);
return Ok(());
}
println!(
"BAY {} {} {} bytes\nOPERATION {} {}",
job.bay,
job.model,
job.target_bytes,
job.id,
job.phase.to_uppercase().replace('_', " ")
);
if let Some(bytes) = job.image_bytes {
println!("IMAGE {} {bytes} bytes", job.image_class.label());
}
if let Some(hash) = &job.image_sha256 {
println!("SHA256 {hash}");
}
if let Some(serial) = &job.serial {
println!("SERIAL {serial}");
}
println!("INSERTION {}", job.diskseq);
if let Some(error) = &job.error {
println!("ERROR {error}");
}
if job.phase == "complete" {
println!("VERIFIED — SAFE TO REMOVE");
}
Ok(())
}
fn wait(mut current: MediaJob, until_ready: bool) -> Result<MediaJob> {
while !current.finished() && !(until_ready && current.phase == "awaiting_confirmation") {
current = job(Request::MediaStatus {
id: current.id.clone(),
after_sequence: Some(current.sequence),
})?;
}
Ok(current)
}
fn result(current: MediaJob, json: bool) -> Result<()> {
print(&current, json)?;
if let Some(error) = current.error {
return Err(Error(error));
}
Ok(())
}
pub fn burn(command: BurnCommand, json: bool) -> Result<()> {
match command {
BurnCommand::Status { id } => result(
job(Request::MediaStatus {
id,
after_sequence: None,
})?,
json,
),
BurnCommand::Wait { id } => result(
wait(
job(Request::MediaStatus {
id,
after_sequence: None,
})?,
false,
)?,
json,
),
BurnCommand::Confirm { id, confirmation } => result(
wait(job(Request::MediaConfirm { id, confirmation })?, false)?,
json,
),
BurnCommand::Cancel { id } => result(wait(job(Request::MediaCancel { id })?, false)?, json),
BurnCommand::System(args) => prepare(Class::System, &args.image, args.bay, json),
BurnCommand::Program(args) => prepare(Class::Program, &args.image, args.bay, json),
BurnCommand::Data(args) => match args.bay {
Some(target) => prepare(Class::Data, &args.source, target, json),
None => format(
FormatCommand::Data(DataFormat {
target: format_target(&args.source)?,
options: args.options,
}),
json,
),
},
BurnCommand::Environment(args) => match args.bay {
Some(target) => prepare(Class::Environment, &args.source, target, json),
None => format(
FormatCommand::Environment(EnvironmentFormat {
target: format_target(&args.source)?,
options: args.options,
}),
json,
),
},
}
}
fn format_target(source: &Path) -> Result<Bay> {
source.to_str().and_then(|value| bay(value).ok()).ok_or_else(||
Error("Supply IMAGE BAY to write an image, or BAY with format options to create a cartridge".into()))
}
pub fn prepare(class: Class, path: &Path, target: Bay, json: bool) -> Result<()> {
let image = fs::canonicalize(path)?
.to_str()
.ok_or_else(|| Error("Image path must be UTF-8".into()))?
.to_owned();
let current = wait(
job(Request::MediaPrepare {
bay: target,
image,
class,
})?,
true,
)?;
print(&current, json)?;
if let Some(error) = &current.error {
return Err(Error(error.clone()));
}
if current.phase != "awaiting_confirmation" {
return Err(Error("Media operation did not reach confirmation".into()));
}
let phrase = current
.confirmation
.as_deref()
.ok_or_else(|| Error("Missing confirmation phrase".into()))?;
if json {
return Ok(());
}
println!("This erases the entire selected cartridge.\nType exactly: {phrase}");
if !io::stdin().is_terminal() {
println!(
"To proceed: fds burn confirm {} '{phrase}'\nTo cancel: fds burn cancel {}",
current.id, current.id
);
return Ok(());
}
print!("> ");
io::stdout().flush()?;
let mut reply = String::new();
io::stdin().read_line(&mut reply)?;
if reply.trim_end() != phrase {
let _ = job(Request::MediaCancel { id: current.id });
return Err(Error("Cancelled before writing".into()));
}
result(
wait(
job(Request::MediaConfirm {
id: current.id,
confirmation: phrase.into(),
})?,
false,
)?,
false,
)
}
pub fn inspect_bay(target: Bay, json: bool) -> Result<()> {
let disk = control::request(&Request::Disk { bay: target })?
.disk
.ok_or_else(|| Error("Missing disk inspection".into()))?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&disk).map_err(|e| Error(e.to_string()))?
);
} else {
println!(
"BAY {} {}\nCAPACITY {} bytes\nSECTOR {} bytes\nINSERTION {}",
disk.bay, disk.model, disk.bytes, disk.sector_bytes, disk.diskseq
);
if let Some(serial) = disk.serial {
println!("SERIAL {serial}");
}
println!(
"{}",
disk.protected.map_or_else(
|| "AVAILABLE FOR CONFIRMED WRITE".into(),
|reason| format!("PROTECTED: {reason}")
)
);
}
Ok(())
}
/// Prepare a user-owned filesystem tree; never run source scripts or use shell
/// interpolation. Explicit confirmation follows creation and privileged preview.
pub fn format(command: FormatCommand, json: bool) -> Result<()> {
let (class, source, target, metadata, profile, size) = match command {
FormatCommand::Data(args) => (
Class::Data,
None,
args.target,
args.options.metadata,
None,
args.options.size_mib,
),
FormatCommand::Environment(args) => (
Class::Environment,
None,
args.target,
args.options.metadata,
args.options.profile,
None,
),
FormatCommand::Program {
source,
target,
metadata,
} => (Class::Program, Some(source), target, metadata, None, None),
FormatCommand::System { source, target } => (
Class::System,
Some(source),
target,
MetadataOptions::default(),
None,
None,
),
};
let disk = control::request(&Request::Disk { bay: target })?
.disk
.ok_or_else(|| Error("Missing target geometry".into()))?;
if let Some(reason) = disk.protected {
return Err(Error(reason));
}
let label = metadata
.label
.unwrap_or_else(|| format!("FDS {}", class.label()));
let profile = profile.unwrap_or_else(|| "windowmaker".into());
let id = match metadata.id {
Some(id) => id,
None => format!(
"fds.{}.{}",
class.label().to_ascii_lowercase(),
image::hex(&image::random_id()?)
),
};
let manifest = Manifest {
format: 1,
cartridge: Cartridge {
id,
name: label,
class,
version: fds_common::VERSION.into(),
},
media: Media {
writable: class == Class::Data,
},
activation: if class == Class::Environment {
Some(Activation { profile })
} else {
None
},
};
let text = manifest.to_toml()?;
let parent = std::env::current_dir()?;
let work = create::Work::new(&parent)?;
let tree = work.0.join("source");
fs::create_dir(&tree)?;
if class != Class::System {
fs::create_dir(tree.join("FDS"))?;
fs::write(tree.join("FDS/CARTRIDGE.TOML"), text)?;
}
if class == Class::Program {
let source = fs::canonicalize(source.as_ref().unwrap())?;
if !source.is_dir() || !source.join("bin").is_dir() {
return Err(Error(
"PROGRAM input must contain bin, with optional lib and share".into(),
));
}
let status = Command::new("/usr/bin/cp")
.args(["-a", "--no-preserve=ownership", "--"])
.arg(source)
.arg(tree.join("app"))
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()?;
if !status.success() {
return Err(Error("Copying PROGRAM input failed".into()));
}
}
let source = if class == Class::System {
source.as_ref().unwrap().as_path()
} else {
tree.as_path()
};
let size = if class == Class::Data {
Some(
size.unwrap_or(
(disk.bytes / (1024 * 1024))
.checked_sub(2)
.filter(|mib| *mib >= 32)
.ok_or_else(|| {
Error("DATA target must hold at least a 32 MiB filesystem plus GPT".into())
})?,
),
)
} else {
None
};
let output = work.0.join("cartridge.img");
create::create(class, source, &output, size)?;
prepare(class, &output, target, json)
}
+231
View File
@@ -0,0 +1,231 @@
//! Filesystem utilities run only against private regular staging files. Creating
//! an image does not open a disk; writing a cartridge has a separate confirmation.
use crate::image::{self, Image, Layout};
use fds_common::{
Error, Result,
manifest::{Class, Manifest},
};
use std::{
fs::{self, File, OpenOptions},
io::{Read, Write},
os::{
fd::{AsRawFd, BorrowedFd, FromRawFd},
unix::fs::{OpenOptionsExt, PermissionsExt},
},
path::{Path, PathBuf},
process::{Command, Stdio},
};
pub(crate) struct Work(pub PathBuf);
impl Work {
pub(crate) fn new(parent: &Path) -> Result<Self> {
let path = parent.join(format!(".fds-image-{}", image::hex(&image::random_id()?)));
let mut builder = fs::DirBuilder::new();
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700).create(&path)?;
Ok(Self(path))
}
}
impl Drop for Work {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn command(program: &str, args: &[&str]) -> Result<()> {
let result = Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::from(
unsafe { BorrowedFd::borrow_raw(2) }.try_clone_to_owned()?,
))
.stderr(Stdio::inherit())
.status()
.map_err(|e| Error(format!("Run {program}: {e}")))?;
if !result.success() {
return Err(Error(format!("{program} failed: {result}")));
}
Ok(())
}
fn text(path: &Path) -> Result<&str> {
path.to_str()
.ok_or_else(|| Error("Image paths must be UTF-8".into()))
}
pub fn class(value: &str) -> Result<Class> {
match value {
"system" => Ok(Class::System),
"data" => Ok(Class::Data),
"program" => Ok(Class::Program),
"environment" => Ok(Class::Environment),
_ => Err(Error(
"Expected system, data, program or environment".into(),
)),
}
}
pub fn tree_manifest(tree: &Path) -> Result<Manifest> {
let root = OpenOptions::new()
.read(true)
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
.open(tree)?;
let dir = unsafe {
libc::openat(
root.as_raw_fd(),
c"FDS".as_ptr(),
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
)
};
if dir < 0 {
return Err(std::io::Error::last_os_error().into());
}
let dir = unsafe { File::from_raw_fd(dir) };
let fd = unsafe {
libc::openat(
dir.as_raw_fd(),
c"CARTRIDGE.TOML".as_ptr(),
libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC,
)
};
if fd < 0 {
return Err(std::io::Error::last_os_error().into());
}
let file = unsafe { File::from_raw_fd(fd) };
if !file.metadata()?.is_file() {
return Err(Error("CARTRIDGE.TOML must be a regular file".into()));
}
let mut contents = String::new();
file.take(fds_common::MAX_CONFIG_BYTES + 1)
.read_to_string(&mut contents)?;
Manifest::parse(&contents)
}
pub fn create(class: Class, tree: &Path, output: &Path, size_mib: Option<u64>) -> Result<Image> {
if class == Class::Program && Path::new("/usr/share/fds/image-profile").exists() {
return Err(Error("Build software cartridges on a Linux workstation with fds-cartridge software build and fds-cartridge create; no software build runs on the Pi".into()));
}
let tree = fs::canonicalize(tree)?;
if !tree.is_dir() {
return Err(Error("Image source must be a directory".into()));
}
let manifest = tree_manifest(&tree)?;
if manifest.cartridge.class != class {
return Err(Error(
"Requested class does not match CARTRIDGE.TOML".into(),
));
}
match class {
Class::System => {
for name in [
"sbin/init",
"usr/bin/fds",
"usr/bin/fds-cartridged",
"usr/bin/dasungd",
] {
if !tree.join(name).is_file() {
return Err(Error(format!("SYSTEM source lacks {name}")));
}
}
}
Class::Program if !tree.join("app/bin").is_dir() => {
return Err(Error("PROGRAM source needs app/bin".into()));
}
Class::Environment if size_mib.is_some() => {
return Err(Error(
"ENVIRONMENT size is determined by its contents".into(),
));
}
_ => (),
}
let parent = fs::canonicalize(
output
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new(".")),
)?;
// A staging file inside SOURCE could be consumed by its own image builder.
if parent.starts_with(&tree) {
return Err(Error(
"Image output must be outside its source directory".into(),
));
}
let work = Work::new(&parent)?;
let payload = work.0.join("filesystem.img");
let payload_arg = text(&payload)?;
let source_arg = text(&tree)?;
let label = image::label(class)?;
if class == Class::Data {
let bytes = size_mib
.unwrap_or(128)
.checked_mul(1024 * 1024)
.filter(|v| *v >= 32 * 1024 * 1024 && *v <= 1024 * 1024 * 1024 * 1024)
.ok_or_else(|| Error("DATA filesystem size must be 32..1048576 MiB".into()))?;
OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o600)
.open(&payload)?
.set_len(bytes)?;
// Explicit root ownership supports both ordinary-user and recovery builds.
// Lazy initialization is disabled: all construction happens before boot.
command(
"/usr/bin/mkfs.ext4",
&[
"-q",
"-F",
"-m",
"0",
"-L",
label,
"-E",
"root_owner=1000:1000,lazy_itable_init=0,lazy_journal_init=0",
"-d",
source_arg,
payload_arg,
],
)?;
command("/usr/bin/e2fsck", &["-f", "-n", payload_arg])?;
} else {
if size_mib.is_some() {
return Err(Error("--size-mib applies only to DATA filesystems".into()));
}
command(
"/usr/bin/mkfs.erofs",
&[
"--quiet",
"-b4096",
"-T0",
"--mkfs-time",
"-L",
label,
payload_arg,
source_arg,
],
)?;
command("/usr/bin/fsck.erofs", &["--extract", payload_arg])?;
}
let mut payload = File::open(&payload)?;
let layout = Layout::new(
class,
payload.metadata()?.len(),
None,
image::random_id()?,
image::random_id()?,
)?;
let staged = work.0.join("cartridge.img");
let mut disk = OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.mode(0o600)
.open(&staged)?;
layout.write(&disk)?;
use std::io::{Seek, SeekFrom};
disk.seek(SeekFrom::Start(image::FIRST_LBA * image::SECTOR))?;
std::io::copy(&mut payload, &mut disk)?;
disk.flush()?;
disk.sync_all()?;
let mut info = image::inspect(&disk, layout.bytes)?;
info.sha256 = Some(image::digest(&disk, layout.bytes, |_| Ok(()))?);
// Atomic no-replace publication: never truncate an existing path or follow
// a symlink. Staging is on the same filesystem as the final image.
fs::set_permissions(&staged, fs::Permissions::from_mode(0o644))?;
fs::hard_link(&staged, output)?;
File::open(parent)?.sync_all()?;
Ok(info)
}
+526
View File
@@ -0,0 +1,526 @@
//! Whole-disk selection and conservative protection checks. A bay maps to a
//! kernel USB path. Names are used only to open the verified kernel identity.
use fds_common::{Error, Result, read_text};
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, BTreeSet},
fs::{self, File, OpenOptions},
os::{
fd::AsRawFd,
unix::fs::{FileExt, FileTypeExt, MetadataExt, OpenOptionsExt},
},
path::{Path, PathBuf},
};
const BLKGETSIZE64: libc::c_ulong = 0x80081272;
const BLKSSZGET: libc::c_ulong = 0x1268;
const BLKROGET: libc::c_ulong = 0x125e;
const BLKGETDISKSEQ: libc::c_ulong = 0x80081280;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Disk {
pub path: PathBuf,
pub sysfs_path: PathBuf,
pub major: u32,
pub minor: u32,
pub diskseq: u64,
pub bytes: u64,
pub sector_bytes: u32,
pub model: String,
pub serial: Option<String>,
}
fn bad(message: impl Into<String>) -> Error {
Error(message.into())
}
fn number(path: &Path) -> Result<u64> {
read_text(path, 128)?
.trim()
.parse()
.map_err(|_| bad(format!("Invalid kernel block number: {}", path.display())))
}
fn fields(path: &Path) -> Result<BTreeMap<String, String>> {
let mut fields = BTreeMap::new();
for line in read_text(path, 16384)?.lines() {
if let Some((k, v)) = line.split_once('=') {
if fields.insert(k.into(), v.into()).is_some() {
return Err(bad("Duplicate block uevent field"));
}
}
}
Ok(fields)
}
fn dev(path: &Path) -> Result<(u32, u32)> {
let value = read_text(&path.join("dev"), 128)?;
let (major, minor) = value
.trim()
.split_once(':')
.ok_or_else(|| bad("Invalid kernel device number"))?;
Ok((
major.parse().map_err(|_| bad("Invalid device major"))?,
minor.parse().map_err(|_| bad("Invalid device minor"))?,
))
}
fn text_field(path: &Path) -> Result<String> {
match read_text(path, 4096) {
Ok(s) => Ok(s
.trim()
.chars()
.filter(|c| !c.is_control())
.take(128)
.collect()),
Err(_) if !path.exists() => Ok(String::new()),
Err(e) => Err(e),
}
}
pub fn select(sysfs: &Path, usb: &Path) -> Result<Disk> {
let usb = fs::canonicalize(usb)?;
if !usb.starts_with(fs::canonicalize(sysfs.join("devices"))?) {
return Err(bad("USB identity is outside kernel devices"));
}
let mut matches = Vec::new();
for entry in fs::read_dir(sysfs.join("class/block"))? {
let entry = entry?.path();
let path = match fs::canonicalize(&entry) {
Ok(p) => p,
Err(_) if !entry.exists() => continue,
Err(e) => return Err(e.into()),
};
if !path.starts_with(&usb) || path.join("partition").exists() {
continue;
}
let values = fields(&path.join("uevent"))?;
if values.get("DEVTYPE").map(String::as_str) != Some("disk") {
continue;
}
let name = values
.get("DEVNAME")
.ok_or_else(|| bad("Missing disk name"))?;
if name.is_empty()
|| !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"_-".contains(&b))
{
return Err(bad("Unsafe disk name"));
}
let (major, minor) = dev(&path)?;
let bytes = number(&path.join("size"))?
.checked_mul(512)
.ok_or_else(|| bad("Disk size overflow"))?;
let sector_bytes = number(&path.join("queue/logical_block_size"))?
.try_into()
.map_err(|_| bad("Invalid sector size"))?;
let serial = text_field(&usb.join("serial"))?;
matches.push(Disk {
path: Path::new("/dev").join(name),
sysfs_path: path.clone(),
major,
minor,
diskseq: number(&path.join("diskseq"))?,
bytes,
sector_bytes,
model: text_field(&path.join("device/model"))?,
serial: if serial.is_empty() {
None
} else {
Some(serial)
},
});
}
if matches.len() != 1 {
return Err(bad("Bay must contain exactly one whole USB disk"));
}
Ok(matches.remove(0))
}
/// Inspect both existing GPTs directly as well as using kernel partitions. A
/// freshly enumerated disk can appear before all partition uevents arrive.
fn protect_existing_gpt(file: &File, bytes: u64) -> Result<()> {
use crate::image::{u32le, u64le};
if bytes < 1024 || bytes % 512 != 0 {
return Err(bad("Invalid target capacity"));
}
for offset in [512, bytes - 512] {
let mut header = [0u8; 512];
file.read_exact_at(&mut header, offset)?;
if &header[..8] != b"EFI PART" {
continue;
}
let count = u32le(&header, 80) as usize;
let stride = u32le(&header, 84) as usize;
if count == 0 || count > 4096 || !(128..=1024).contains(&stride) || stride % 128 != 0 {
return Err(bad("Cannot safely inspect existing GPT entry geometry"));
}
let start = u64le(&header, 72)
.checked_mul(512)
.ok_or_else(|| bad("Existing GPT table offset overflow"))?;
let length = count * stride;
if start < 1024
|| start
.checked_add(length as u64)
.is_none_or(|end| end > bytes - 512)
{
return Err(bad("Existing GPT table extends outside the target"));
}
let mut entries = vec![0; length];
file.read_exact_at(&mut entries, start)?;
for entry in entries.chunks_exact(stride) {
let name: Vec<u16> = entry[56..128]
.chunks_exact(2)
.map(|c| u16::from_le_bytes(c.try_into().unwrap()))
.take_while(|c| *c != 0)
.collect();
let name = String::from_utf16_lossy(&name);
if ["FDS_BOOT", "FDS_RECOVERY", "FDS_INTERNAL"].contains(&name.as_str()) {
return Err(bad(format!(
"Protected internal partition in existing GPT: {name}"
)));
}
}
}
Ok(())
}
impl Disk {
pub fn select_current(usb: &Path) -> Result<Self> {
select(Path::new("/sys"), usb)
}
pub fn key(&self) -> String {
format!(
"{}:{}:{}:{}:{}",
self.sysfs_path.display(),
self.major,
self.minor,
self.diskseq,
self.bytes
)
}
pub fn present(&self) -> bool {
self.present_at(Path::new("/sys"))
}
fn present_at(&self, sysfs: &Path) -> bool {
fs::canonicalize(
sysfs
.join("dev/block")
.join(format!("{}:{}", self.major, self.minor)),
)
.is_ok_and(|p| {
p == self.sysfs_path
&& number(&p.join("diskseq")).is_ok_and(|s| s == self.diskseq)
&& number(&p.join("size")).is_ok_and(|s| s.checked_mul(512) == Some(self.bytes))
})
}
/// Refuse all mounted children, swap, holders, or reserved internal labels.
/// This is re-run after exclusive open and immediately before a confirmed write.
pub fn protect(&self, sysfs: &Path, proc: &Path) -> Result<()> {
if !self.present_at(sysfs) {
return Err(bad(
"Cartridge identity changed; inspect and confirm it again",
));
}
if self.sector_bytes != 512 {
return Err(bad(
"Only 512-byte logical sectors are supported for cartridge writes",
));
}
if number(&self.sysfs_path.join("ro"))? != 0 {
return Err(bad("Disk is write protected"));
}
let mut children = BTreeSet::new();
let mut paths = BTreeSet::new();
for entry in fs::read_dir(sysfs.join("class/block"))? {
let entry = entry?.path();
let path = match fs::canonicalize(&entry) {
Ok(p) => p,
Err(_) if !entry.exists() => continue,
Err(e) => return Err(e.into()),
};
if path != self.sysfs_path && !path.starts_with(&self.sysfs_path) {
continue;
}
children.insert(dev(&path)?);
let values = fields(&path.join("uevent"))?;
if let Some(label) = values.get("PARTNAME") {
if ["FDS_BOOT", "FDS_RECOVERY", "FDS_INTERNAL"].contains(&label.as_str()) {
return Err(bad(format!("Protected internal partition: {label}")));
}
}
if let Some(name) = values.get("DEVNAME") {
paths.insert(format!("/dev/{name}"));
}
if fs::read_dir(path.join("holders"))?
.next()
.transpose()?
.is_some()
{
return Err(bad(
"Disk or partition has a kernel holder (RAID, encryption or device mapper)",
));
}
}
if !children.contains(&(self.major, self.minor)) {
return Err(bad("Target vanished during protection check"));
}
for line in read_text(&proc.join("self/mountinfo"), 4 * 1024 * 1024)?.lines() {
let parts: Vec<_> = line.split_whitespace().collect();
if parts.len() < 6 {
return Err(bad("Malformed mount table; refusing to write"));
}
let (major, minor) = parts[2]
.split_once(':')
.ok_or_else(|| bad("Invalid mount device"))?;
let id = (
major.parse().map_err(|_| bad("Invalid mount major"))?,
minor.parse().map_err(|_| bad("Invalid mount minor"))?,
);
if children.contains(&id) {
return Err(bad(format!(
"Mounted disk or partition at {}; eject it before writing",
parts[4]
)));
}
}
// Swap on a block path is excluded directly. Swap files necessarily live
// on a mounted filesystem, already excluded above.
for line in read_text(&proc.join("swaps"), 1024 * 1024)?.lines().skip(1) {
let path = line
.split_whitespace()
.next()
.ok_or_else(|| bad("Invalid swap table"))?;
if paths.contains(path)
|| fs::metadata(path).is_ok_and(|m| {
m.file_type().is_block_device()
&& children.contains(&(libc::major(m.rdev()), libc::minor(m.rdev())))
})
{
return Err(bad("Disk or partition is active swap"));
}
}
Ok(())
}
pub fn open_exclusive(&self) -> Result<File> {
self.protect(Path::new("/sys"), Path::new("/proc"))?;
let file = OpenOptions::new()
.read(true)
.write(true)
.custom_flags(libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK)
.open(&self.path)?;
let metadata = file.metadata()?;
if !metadata.file_type().is_block_device()
|| libc::major(metadata.rdev()) != self.major
|| libc::minor(metadata.rdev()) != self.minor
{
return Err(bad("Opened target does not match the selected disk"));
}
let mut bytes = 0u64;
let mut sector = 0u32;
let mut ro = 0u32;
let mut diskseq = 0u64;
for (request, pointer) in [
(
BLKGETSIZE64,
(&mut bytes as *mut u64).cast::<libc::c_void>(),
),
(BLKSSZGET, (&mut sector as *mut u32).cast()),
(BLKROGET, (&mut ro as *mut u32).cast()),
(BLKGETDISKSEQ, (&mut diskseq as *mut u64).cast()),
] {
if unsafe { libc::ioctl(file.as_raw_fd(), request as _, pointer) } < 0 {
return Err(std::io::Error::last_os_error().into());
}
}
if bytes != self.bytes || sector != self.sector_bytes || ro != 0 || diskseq != self.diskseq
{
return Err(bad(
"Opened disk changed size, sector format, write protection or insertion identity",
));
}
protect_existing_gpt(&file, bytes)?;
self.protect(Path::new("/sys"), Path::new("/proc"))?;
Ok(file)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::symlink;
struct Fixture {
root: PathBuf,
sys: PathBuf,
proc: PathBuf,
usb: PathBuf,
disk: PathBuf,
}
impl Fixture {
fn new() -> Self {
let root = std::env::temp_dir().join(format!(
"fds-disk-test-{}",
crate::image::hex(&crate::image::random_id().unwrap())
));
let sys = root.join("sys");
let proc = root.join("proc");
let usb = sys.join("devices/platform/usb2/2-1");
let disk = usb.join("host/target/block/sdz");
for path in [
disk.join("queue"),
disk.join("device"),
disk.join("holders"),
sys.join("class/block"),
sys.join("dev/block"),
proc.join("self"),
] {
fs::create_dir_all(path).unwrap();
}
for (path, text) in [
(disk.join("uevent"), "DEVTYPE=disk\nDEVNAME=sdz\n"),
(disk.join("dev"), "8:240\n"),
(disk.join("diskseq"), "12\n"),
(disk.join("size"), "262144\n"),
(disk.join("ro"), "0\n"),
(disk.join("queue/logical_block_size"), "512\n"),
(disk.join("device/model"), "Test disk\n"),
(
proc.join("self/mountinfo"),
"1 0 0:1 / / rw - tmpfs none rw\n",
),
(proc.join("swaps"), "Filename Type Size Used Priority\n"),
] {
fs::write(path, text).unwrap();
}
symlink(&disk, sys.join("class/block/sdz")).unwrap();
symlink(&disk, sys.join("dev/block/8:240")).unwrap();
Self {
root,
sys,
proc,
usb,
disk,
}
}
fn partition(&self, label: &str) {
let p = self.disk.join("sdz1");
fs::create_dir_all(p.join("holders")).unwrap();
fs::write(p.join("partition"), "1\n").unwrap();
fs::write(p.join("dev"), "8:241\n").unwrap();
fs::write(
p.join("uevent"),
format!("DEVTYPE=partition\nDEVNAME=sdz1\nPARTNAME={label}\n"),
)
.unwrap();
symlink(&p, self.sys.join("class/block/sdz1")).unwrap();
}
}
impl Drop for Fixture {
fn drop(&mut self) {
fs::remove_dir_all(&self.root).unwrap();
}
}
#[test]
fn internal_labels_are_protected_without_partition_uevents() {
use std::os::fd::FromRawFd;
let fd = unsafe { libc::memfd_create(c"fds-existing-gpt".as_ptr(), libc::MFD_CLOEXEC) };
assert!(fd >= 0);
let file = unsafe { File::from_raw_fd(fd) };
let layout = crate::image::Layout::new(
fds_common::manifest::Class::Data,
1024 * 1024,
None,
[1; 16],
[2; 16],
)
.unwrap();
layout.write(&file).unwrap();
protect_existing_gpt(&file, layout.bytes).unwrap();
let mut name = [0u8; 72];
for (i, c) in "FDS_INTERNAL".encode_utf16().enumerate() {
name[2 * i..2 * i + 2].copy_from_slice(&c.to_le_bytes());
}
// Protection is conservative even when the old table CRC is damaged.
file.write_all_at(&name, layout.bytes - layout.tail.len() as u64 + 56)
.unwrap();
file.write_all_at(&[0; 512], 512).unwrap();
assert!(
protect_existing_gpt(&file, layout.bytes)
.unwrap_err()
.to_string()
.contains("FDS_INTERNAL")
);
}
#[test]
fn blank_media_selects_by_usb_and_replacement_invalidates_identity() {
let f = Fixture::new();
let disk = select(&f.sys, &f.usb).unwrap();
assert_eq!(disk.bytes, 128 * 1024 * 1024);
disk.protect(&f.sys, &f.proc).unwrap();
fs::write(f.disk.join("diskseq"), "13\n").unwrap();
assert!(
disk.protect(&f.sys, &f.proc)
.unwrap_err()
.to_string()
.contains("identity changed")
);
}
#[test]
fn internal_partitions_remain_protected_while_unmounted() {
for label in ["FDS_BOOT", "FDS_RECOVERY", "FDS_INTERNAL"] {
let f = Fixture::new();
f.partition(label);
let disk = select(&f.sys, &f.usb).unwrap();
assert!(
disk.protect(&f.sys, &f.proc)
.unwrap_err()
.to_string()
.contains("Protected internal")
);
}
}
#[test]
fn mounts_swap_and_holders_block_writes() {
let f = Fixture::new();
f.partition("FDS_SYSTEM");
let disk = select(&f.sys, &f.usb).unwrap();
disk.protect(&f.sys, &f.proc).unwrap();
fs::write(
f.proc.join("self/mountinfo"),
"1 0 8:241 / / ro - erofs /dev/sdz1 ro\n",
)
.unwrap();
assert!(
disk.protect(&f.sys, &f.proc)
.unwrap_err()
.to_string()
.contains("Mounted")
);
fs::write(f.proc.join("self/mountinfo"), "").unwrap();
fs::write(
f.proc.join("swaps"),
"Filename Type Size Used Priority\n/dev/sdz1 partition 1 0 -1\n",
)
.unwrap();
assert!(
disk.protect(&f.sys, &f.proc)
.unwrap_err()
.to_string()
.contains("swap")
);
fs::write(f.proc.join("swaps"), "Filename Type Size Used Priority\n").unwrap();
fs::write(f.disk.join("sdz1/holders/dm-0"), "").unwrap();
assert!(
disk.protect(&f.sys, &f.proc)
.unwrap_err()
.to_string()
.contains("holder")
);
}
#[test]
fn sector_size_and_readonly_are_checked() {
let f = Fixture::new();
let mut disk = select(&f.sys, &f.usb).unwrap();
disk.sector_bytes = 4096;
assert!(disk.protect(&f.sys, &f.proc).is_err());
disk.sector_bytes = 512;
fs::write(f.disk.join("ro"), "1\n").unwrap();
assert!(
disk.protect(&f.sys, &f.proc)
.unwrap_err()
.to_string()
.contains("write protected")
);
}
}
+538
View File
@@ -0,0 +1,538 @@
//! Bounded FDS GPT images: legacy single-filesystem cartridges and metadata-first
//! software cartridges, with both table/header CRCs and exact backup agreement.
use fds_common::{Error, Result, manifest::Class};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{fs::File, io::Read, os::unix::fs::FileExt};
pub const SECTOR: u64 = 512;
pub const TABLE_BYTES: usize = 128 * 128;
pub const FIRST_LBA: u64 = 2048;
pub const LINUX_TYPE: [u8; 16] = [
0xaf, 0x3d, 0xc6, 0x0f, 0x83, 0x84, 0x72, 0x47, 0x8e, 0x79, 0x3d, 0x69, 0xd8, 0x47, 0x7d, 0xe4,
];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Partition {
pub number: u8,
pub name: String,
pub start: u64,
pub bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Image {
pub bytes: u64,
pub partition_start: u64,
pub partition_bytes: u64,
pub class: Class,
pub filesystem: String,
pub disk_uuid: String,
pub sha256: Option<String>,
pub partitions: Vec<Partition>,
}
fn bad(message: &str) -> Error {
Error(format!("Invalid cartridge image: {message}"))
}
pub fn u32le(data: &[u8], at: usize) -> u32 {
u32::from_le_bytes(data[at..at + 4].try_into().unwrap())
}
pub fn u64le(data: &[u8], at: usize) -> u64 {
u64::from_le_bytes(data[at..at + 8].try_into().unwrap())
}
pub fn put32(data: &mut [u8], at: usize, value: u32) {
data[at..at + 4].copy_from_slice(&value.to_le_bytes());
}
pub fn put64(data: &mut [u8], at: usize, value: u64) {
data[at..at + 8].copy_from_slice(&value.to_le_bytes());
}
/// IEEE CRC-32 for GPT's small fixed tables; no cryptographic role.
pub fn crc32(data: &[u8]) -> u32 {
let mut crc = !0u32;
for byte in data {
crc ^= *byte as u32;
for _ in 0..8 {
crc = (crc >> 1) ^ (0xedb88320 & 0u32.wrapping_sub(crc & 1));
}
}
!crc
}
pub fn hex(data: &[u8]) -> String {
data.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn digest(
file: &File,
bytes: u64,
mut progress: impl FnMut(u64) -> Result<()>,
) -> Result<String> {
let mut hash = Sha256::new();
let mut buffer = vec![0; 1024 * 1024];
let mut offset = 0;
while offset < bytes {
let n = buffer.len().min((bytes - offset) as usize);
file.read_exact_at(&mut buffer[..n], offset)?;
hash.update(&buffer[..n]);
offset += n as u64;
if offset % (64 * 1024 * 1024) == 0 || offset == bytes {
progress(offset)?;
}
}
Ok(hex(&hash.finalize()))
}
pub fn random_id() -> Result<[u8; 16]> {
let mut id = [0; 16];
File::open("/dev/urandom")?.read_exact(&mut id)?;
// GPT stores the first UUID fields little endian.
id[7] = (id[7] & 15) | 0x40;
id[8] = (id[8] & 63) | 0x80;
Ok(id)
}
fn uuid(id: &[u8]) -> String {
format!(
"{:08x}-{:04x}-{:04x}-{}-{}",
u32le(id, 0),
u16::from_le_bytes(id[4..6].try_into().unwrap()),
u16::from_le_bytes(id[6..8].try_into().unwrap()),
hex(&id[8..10]),
hex(&id[10..])
)
}
pub fn label(class: Class) -> Result<&'static str> {
match class {
Class::System => Ok("FDS_SYSTEM"),
Class::Data => Ok("FDS_DATA"),
Class::Program => Ok("FDS_PROGRAM"),
Class::Environment => Ok("FDS_ENVIRONMENT"),
_ => Err(bad(
"only SYSTEM, DATA, PROGRAM and ENVIRONMENT are writable cartridge classes",
)),
}
}
fn header(file: &File, lba: u64, sectors: u64) -> Result<[u8; 512]> {
let mut data = [0; 512];
file.read_exact_at(&mut data, lba * SECTOR)?;
if &data[..8] != b"EFI PART"
|| u32le(&data, 8) != 0x10000
|| u32le(&data, 12) != 92
|| u32le(&data, 20) != 0
|| data[92..].iter().any(|b| *b != 0)
{
return Err(bad("unsupported or malformed GPT header"));
}
let recorded = u32le(&data, 16);
let mut checked = data;
put32(&mut checked, 16, 0);
if crc32(&checked[..92]) != recorded {
return Err(bad("GPT header CRC mismatch"));
}
if u64le(&data, 24) != lba
|| u64le(&data, 32) != if lba == 1 { sectors - 1 } else { 1 }
|| u64le(&data, 40) != 34
|| u64le(&data, 48) != sectors - 34
|| data[56..72].iter().all(|b| *b == 0)
|| u64le(&data, 72) != if lba == 1 { 2 } else { sectors - 33 }
|| u32le(&data, 80) != 128
|| u32le(&data, 84) != 128
{
return Err(bad("GPT geometry, UUID or entry format is invalid"));
}
Ok(data)
}
pub fn inspect(file: &File, bytes: u64) -> Result<Image> {
if bytes % SECTOR != 0 || bytes < (FIRST_LBA + 2048 + 33) * SECTOR {
return Err(bad("image is too small or is not sector aligned"));
}
let sectors = bytes / SECTOR;
let mut mbr = [0; 512];
file.read_exact_at(&mut mbr, 0)?;
if mbr[510..] != [0x55, 0xaa]
|| mbr[446] != 0
|| mbr[450] != 0xee
|| u32le(&mbr, 454) != 1
|| u32le(&mbr, 458) != (sectors - 1).min(u32::MAX as u64) as u32
|| mbr[462..510].iter().any(|b| *b != 0)
{
return Err(bad(
"missing protective MBR or unsupported hybrid partitions",
));
}
let main = header(file, 1, sectors)?;
let backup = header(file, sectors - 1, sectors)?;
if main[40..72] != backup[40..72] || main[80..92] != backup[80..92] {
return Err(bad("primary and backup GPT disagree"));
}
let mut table = vec![0; TABLE_BYTES];
let mut mirror = vec![0; TABLE_BYTES];
file.read_exact_at(&mut table, 2 * SECTOR)?;
file.read_exact_at(&mut mirror, (sectors - 33) * SECTOR)?;
if table != mirror || crc32(&table) != u32le(&main, 88) {
return Err(bad("GPT table CRC or backup mismatch"));
}
let mut partitions = Vec::new();
let mut ids = std::collections::BTreeSet::new();
let mut next = FIRST_LBA;
for (index, entry) in table.chunks_exact(128).enumerate() {
if entry[..16].iter().all(|b| *b == 0) {
if entry.iter().any(|b| *b != 0) {
return Err(bad("nonempty unused GPT entry"));
}
continue;
}
if index != partitions.len() || partitions.len() == 33 {
return Err(bad("partition entries must be consecutive and at most 33"));
}
if entry[..16] != LINUX_TYPE
|| entry[16..32].iter().all(|b| *b == 0)
|| !ids.insert(entry[16..32].to_vec())
|| u64le(entry, 48) != 0
{
return Err(bad(
"unsupported partition type, duplicate UUID or attributes",
));
}
let first = u64le(entry, 32);
let last = u64le(entry, 40);
if first < next
|| first > sectors - 34
|| first % 2048 != 0
|| last < first
|| last > sectors - 34
|| (last - first + 1) < 2048
|| (last - first + 1) % 2048 != 0
|| (index == 0 && first != FIRST_LBA)
{
return Err(bad(
"partition overlaps another partition or GPT, or is not MiB aligned",
));
}
next = last + 1;
let units: Vec<u16> = entry[56..128]
.chunks_exact(2)
.map(|c| u16::from_le_bytes(c.try_into().unwrap()))
.collect();
let end = units
.iter()
.position(|u| *u == 0)
.ok_or_else(|| bad("partition name has no terminator"))?;
if units[end..].iter().any(|u| *u != 0) {
return Err(bad("partition name contains trailing data"));
}
let name =
String::from_utf16(&units[..end]).map_err(|_| bad("invalid UTF-16 partition name"))?;
partitions.push(Partition {
number: (index + 1) as u8,
name,
start: first * SECTOR,
bytes: (last - first + 1) * SECTOR,
});
}
let first = partitions
.first()
.ok_or_else(|| bad("no cartridge partition"))?;
let software = first.name == "FDS_METADATA";
let class = if software {
if partitions.len() < 2
|| partitions
.iter()
.skip(1)
.any(|p| p.name != format!("FDS_PAYLOAD{:02}", p.number))
{
return Err(bad(
"software requires metadata followed by consecutive payload partitions",
));
}
Class::Program
} else {
if partitions.len() != 1 {
return Err(bad("legacy cartridges require exactly one partition"));
}
match first.name.as_str() {
"FDS_SYSTEM" => Class::System,
"FDS_DATA" => Class::Data,
"FDS_PROGRAM" => Class::Program,
"FDS_ENVIRONMENT" => Class::Environment,
_ => return Err(bad("unrecognized cartridge partition name")),
}
};
let filesystem = if class == Class::Data {
"ext4"
} else {
"erofs"
};
for part in &partitions {
let mut superblock = [0; 1024];
file.read_exact_at(&mut superblock, part.start + 1024)?;
if filesystem == "ext4" {
if superblock[56..58] != [0x53, 0xef] {
return Err(bad("DATA needs ext4"));
}
} else if superblock[..4] != [0xe2, 0xe1, 0xf5, 0xe0] {
return Err(bad("every read-only partition needs EROFS"));
}
}
Ok(Image {
bytes,
partition_start: first.start,
partition_bytes: first.bytes,
class,
filesystem: filesystem.into(),
disk_uuid: uuid(&main[56..72]),
sha256: None,
partitions,
})
}
/// Fixed GPT metadata for bounded filesystem payloads. Callers write only new
/// regular image files; privileged block writes are a separate confirmed step.
pub struct Layout {
pub head: Vec<u8>,
pub tail: Vec<u8>,
pub bytes: u64,
pub partition_bytes: u64,
pub partitions: Vec<Partition>,
}
impl Layout {
pub fn new(
class: Class,
payload_bytes: u64,
total: Option<u64>,
disk_id: [u8; 16],
part_id: [u8; 16],
) -> Result<Self> {
Self::from_parts(
&[(label(class)?.into(), payload_bytes, part_id)],
total,
disk_id,
)
}
/// Metadata is first; the remaining entries contain xz software bundles.
pub fn software(payloads: &[u64], disk_id: [u8; 16], ids: &[[u8; 16]]) -> Result<Self> {
if !(2..=33).contains(&payloads.len()) || ids.len() != payloads.len() {
return Err(bad(
"software images require one metadata and 1..32 payload partitions",
));
}
let parts: Vec<_> = payloads
.iter()
.enumerate()
.map(|(i, bytes)| {
(
if i == 0 {
"FDS_METADATA".into()
} else {
format!("FDS_PAYLOAD{:02}", i + 1)
},
*bytes,
ids[i],
)
})
.collect();
Self::from_parts(&parts, None, disk_id)
}
fn from_parts(
parts: &[(String, u64, [u8; 16])],
total: Option<u64>,
disk_id: [u8; 16],
) -> Result<Self> {
let mut table = vec![0; TABLE_BYTES];
let mut partitions = Vec::new();
let mut offset = FIRST_LBA * SECTOR;
let mut ids = std::collections::BTreeSet::new();
if disk_id == [0; 16] {
return Err(bad("zero disk UUID"));
}
for (i, (name, payload_bytes, part_id)) in parts.iter().enumerate() {
let allocated = payload_bytes
.checked_add(1024 * 1024 - 1)
.ok_or_else(|| bad("payload too large"))?
/ (1024 * 1024)
* (1024 * 1024);
if allocated == 0 || *part_id == [0; 16] || !ids.insert(*part_id) {
return Err(bad("empty payload or invalid partition UUID"));
}
let end = offset
.checked_add(allocated)
.ok_or_else(|| bad("image size overflow"))?;
let entry = &mut table[i * 128..(i + 1) * 128];
entry[..16].copy_from_slice(&LINUX_TYPE);
entry[16..32].copy_from_slice(part_id);
put64(entry, 32, offset / SECTOR);
put64(entry, 40, end / SECTOR - 1);
for (j, unit) in name.encode_utf16().enumerate() {
entry[56 + 2 * j..58 + 2 * j].copy_from_slice(&unit.to_le_bytes());
}
partitions.push(Partition {
number: (i + 1) as u8,
name: name.clone(),
start: offset,
bytes: allocated,
});
offset = end;
}
let minimum = offset
.checked_add(33 * SECTOR)
.ok_or_else(|| bad("image size overflow"))?;
let rounded = minimum
.checked_add(1024 * 1024 - 1)
.ok_or_else(|| bad("image size overflow"))?
/ (1024 * 1024)
* (1024 * 1024);
let bytes = total.unwrap_or(rounded);
if bytes < minimum || bytes % SECTOR != 0 {
return Err(bad("invalid output size"));
}
let sectors = bytes / SECTOR;
let table_crc = crc32(&table);
let make_header = |lba, alternate, entries| {
let mut h = [0u8; 512];
h[..8].copy_from_slice(b"EFI PART");
put32(&mut h, 8, 0x10000);
put32(&mut h, 12, 92);
put64(&mut h, 24, lba);
put64(&mut h, 32, alternate);
put64(&mut h, 40, 34);
put64(&mut h, 48, sectors - 34);
h[56..72].copy_from_slice(&disk_id);
put64(&mut h, 72, entries);
put32(&mut h, 80, 128);
put32(&mut h, 84, 128);
put32(&mut h, 88, table_crc);
let crc = crc32(&h[..92]);
put32(&mut h, 16, crc);
h
};
let mut head = vec![0; 1024 + TABLE_BYTES];
head[447..450].copy_from_slice(&[0, 2, 0]);
head[450] = 0xee;
head[451..454].fill(255);
put32(&mut head, 454, 1);
put32(&mut head, 458, (sectors - 1).min(u32::MAX as u64) as u32);
head[510..512].copy_from_slice(&[0x55, 0xaa]);
head[512..1024].copy_from_slice(&make_header(1, sectors - 1, 2));
head[1024..].copy_from_slice(&table);
let mut tail = table;
tail.extend_from_slice(&make_header(sectors - 1, 1, sectors - 33));
Ok(Self {
head,
tail,
bytes,
partition_bytes: partitions[0].bytes,
partitions,
})
}
pub fn write(&self, output: &File) -> Result<()> {
if !output.metadata()?.is_file() {
return Err(bad("image output must be a regular file"));
}
output.set_len(self.bytes)?;
output.write_all_at(&self.head, 0)?;
output.write_all_at(&self.tail, self.bytes - self.tail.len() as u64)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::fd::FromRawFd;
fn example(class: Class) -> File {
let name = c"fds-gpt-test";
let fd = unsafe { libc::memfd_create(name.as_ptr(), libc::MFD_CLOEXEC) };
assert!(fd >= 0);
let f = unsafe { File::from_raw_fd(fd) };
Layout::new(class, 1024 * 1024, None, [1; 16], [2; 16])
.unwrap()
.write(&f)
.unwrap();
if class == Class::Data {
f.write_all_at(&[0x53, 0xef], FIRST_LBA * SECTOR + 1080)
.unwrap();
} else {
f.write_all_at(&[0xe2, 0xe1, 0xf5, 0xe0], FIRST_LBA * SECTOR + 1024)
.unwrap();
}
f
}
#[test]
fn known_hash_and_crc() {
assert_eq!(crc32(b"123456789"), 0xcbf43926);
assert_eq!(
hex(&Sha256::digest(b"abc")),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn four_classes_roundtrip_and_corruption_is_rejected() {
for class in [
Class::System,
Class::Data,
Class::Program,
Class::Environment,
] {
let f = example(class);
let bytes = f.metadata().unwrap().len();
assert_eq!(inspect(&f, bytes).unwrap().class, class);
for offset in [
510,
512,
1024,
bytes - 512,
bytes - 1024,
FIRST_LBA * SECTOR + if class == Class::Data { 1080 } else { 1024 },
] {
let mut old = [0];
f.read_exact_at(&mut old, offset).unwrap();
f.write_all_at(&[old[0] ^ 0x80], offset).unwrap();
assert!(
inspect(&f, bytes).is_err(),
"accepted corrupt offset {offset}"
);
f.write_all_at(&old, offset).unwrap();
}
}
}
#[test]
fn untrusted_lengths_are_bounded() {
let f = example(Class::Data);
for bytes in [0, 511, 512, 1024, 1024 * 1024, u64::MAX] {
assert!(inspect(&f, bytes).is_err());
}
assert!(Layout::new(Class::Data, u64::MAX, None, [1; 16], [2; 16]).is_err());
assert!(Layout::new(Class::Data, 1024 * 1024, Some(1024), [1; 16], [2; 16]).is_err());
}
#[test]
fn metadata_first_software_gpt_roundtrips_and_rejects_overlap() {
let file = example(Class::Program);
let layout =
Layout::software(&[4096, 8192, 4096], [9; 16], &[[1; 16], [2; 16], [3; 16]]).unwrap();
layout.write(&file).unwrap();
for part in &layout.partitions {
file.write_all_at(&[0xe2, 0xe1, 0xf5, 0xe0], part.start + 1024)
.unwrap();
}
let parsed = inspect(&file, layout.bytes).unwrap();
assert_eq!(parsed.partitions, layout.partitions);
assert_eq!(parsed.class, Class::Program);
// Keep both CRCs and both copies valid: geometry must reject the overlap.
let mut head = layout.head.clone();
put64(&mut head[1024..], 128 + 32, FIRST_LBA);
let table_crc = crc32(&head[1024..]);
put32(&mut head[512..1024], 88, table_crc);
put32(&mut head[512..1024], 16, 0);
let header_crc = crc32(&head[512..604]);
put32(&mut head[512..1024], 16, header_crc);
let mut tail = layout.tail.clone();
tail[..TABLE_BYTES].copy_from_slice(&head[1024..]);
put32(&mut tail[TABLE_BYTES..], 88, table_crc);
put32(&mut tail[TABLE_BYTES..], 16, 0);
let crc = crc32(&tail[TABLE_BYTES..TABLE_BYTES + 92]);
put32(&mut tail[TABLE_BYTES..], 16, crc);
file.write_all_at(&head, 0).unwrap();
file.write_all_at(&tail, layout.bytes - tail.len() as u64)
.unwrap();
assert!(
inspect(&file, layout.bytes)
.unwrap_err()
.to_string()
.contains("overlaps")
);
assert!(Layout::software(&[4096], [9; 16], &[[1; 16]]).is_err());
assert!(Layout::software(&[4096, 4096], [9; 16], &[[1; 16], [1; 16]]).is_err());
}
}
+9
View File
@@ -0,0 +1,9 @@
//! Shared image validation and media identity checks. Device paths come from
//! kernel topology; public commands select a physical bay, never /dev/sdX.
pub mod cli;
pub mod client;
pub mod create;
pub mod device;
pub mod image;
pub mod worker;
pub mod write;
+132
View File
@@ -0,0 +1,132 @@
use clap::{CommandFactory, Parser, Subcommand};
use fds_burn::{cli::BurnCommand, create, image};
use fds_common::{Error, Result, manifest::Class};
use std::{fs::OpenOptions, os::unix::fs::OpenOptionsExt, path::PathBuf, process::ExitCode};
#[derive(Parser)]
#[command(
version,
about = "Create cartridge images and preview confirmed media writes",
after_help = "Creation writes a new regular file. Writes require confirmation tied to the selected insertion and image hash."
)]
struct Cli {
#[command(subcommand)]
command: Option<Action>,
}
#[derive(Subcommand)]
enum Action {
/// Validate a regular cartridge image and report its SHA-256 digest.
Inspect { image: PathBuf },
/// Create a new image from a tree containing FDS/CARTRIDGE.TOML.
Create {
/// Cartridge class: system, data, program, or environment.
#[arg(value_parser = create::class)]
class: Class,
source_directory: PathBuf,
output: PathBuf,
/// DATA filesystem size in MiB.
#[arg(long, value_name = "N")]
size_mib: Option<u64>,
},
#[command(flatten)]
Burn(BurnCommand),
#[command(long_flag = "worker", hide = true)]
Worker,
}
fn run() -> Result<()> {
match Cli::parse().command {
None => {
Cli::command().print_help()?;
println!();
Ok(())
}
Some(Action::Worker) => fds_burn::worker::run(),
Some(Action::Inspect { image: path }) => {
let file = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK)
.open(path)?;
let metadata = file.metadata()?;
if !metadata.is_file() {
return Err(Error(
"Image inspection requires a regular file; use bay inspection for devices"
.into(),
));
}
let mut info = image::inspect(&file, metadata.len())?;
info.sha256 = Some(image::digest(&file, info.bytes, |_| Ok(()))?);
println!(
"{}",
serde_json::to_string_pretty(&info).map_err(|e| Error(e.to_string()))?
);
Ok(())
}
Some(Action::Create {
class,
source_directory,
output,
size_mib,
}) => {
let info = create::create(class, &source_directory, &output, size_mib)?;
println!(
"{}",
serde_json::to_string_pretty(&info).map_err(|e| Error(e.to_string()))?
);
Ok(())
}
Some(Action::Burn(command)) => fds_burn::client::burn(command, false),
}
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("fds-burn: {e}");
ExitCode::from(2)
}
}
}
#[cfg(test)]
mod cli_tests {
use super::*;
use clap::{CommandFactory, Parser};
#[test]
fn typed_command_contract() {
Cli::command().debug_assert();
assert!(matches!(
Cli::try_parse_from(["fds-burn", "--worker"])
.unwrap()
.command,
Some(Action::Worker)
));
assert!(Cli::try_parse_from(["fds-burn", "--worker", "data", "1"]).is_err());
assert!(
Cli::try_parse_from([
"fds-burn",
"create",
"data",
"source",
"output",
"--size-mib",
"32"
])
.is_ok()
);
assert!(
Cli::try_parse_from(["fds-burn", "create", "unknown", "source", "output"]).is_err()
);
assert!(
Cli::try_parse_from([
"fds-burn",
"create",
"data",
"source",
"output",
"--size-mib",
"bad"
])
.is_err()
);
}
}
+435
View File
@@ -0,0 +1,435 @@
//! Root worker for one prepared media operation. All source-file access is
//! checked using the IPC caller's effective identity before privileges return.
use crate::{create, device::Disk, image, write};
use fds_common::{
Error, Result,
control::{LIMIT, MediaJob},
manifest::Class,
};
use serde::{Deserialize, Serialize};
use std::{
ffi::CString,
fs::{self, File, OpenOptions},
io::{self, BufRead, Read, Write},
os::{
fd::{AsRawFd, BorrowedFd},
unix::{
fs::{MetadataExt, OpenOptionsExt},
process::CommandExt,
},
},
path::Path,
process::{Command, Stdio},
time::{Duration, Instant},
};
#[derive(Serialize, Deserialize)]
pub struct Preparation {
pub disk: Disk,
pub image_path: String,
pub uid: u32,
pub job: MediaJob,
}
fn check(value: i32, action: &str) -> Result<()> {
if value < 0 {
Err(Error(format!("{action}: {}", io::Error::last_os_error())))
} else {
Ok(())
}
}
fn c(s: &str) -> Result<CString> {
CString::new(s).map_err(|_| Error("NUL in path".into()))
}
fn report_error(error: &str) -> String {
// Parser diagnostics may quote an entire untrusted manifest. Keep the IPC
// record bounded and prevent terminal controls from reaching CLI output.
let mut result = String::new();
for ch in error.chars() {
let ch = if ch.is_control() { ' ' } else { ch };
if result.len() + ch.len_utf8() > 2048 {
result.push_str(" [truncated]");
break;
}
result.push(ch);
}
result
}
fn emit(job: &mut MediaJob, phase: &str, progress: u64) -> Result<()> {
job.sequence += 1;
job.phase = phase.into();
job.progress_bytes = progress;
let mut stdout = io::stdout().lock();
serde_json::to_writer(&mut stdout, job).map_err(|e| Error(e.to_string()))?;
stdout.write_all(b"\n")?;
stdout.flush()?;
Ok(())
}
fn source(path: &str, uid: u32) -> Result<File> {
if ![0, 1000].contains(&uid) || !path.starts_with('/') {
return Err(Error("Invalid image owner or path".into()));
}
check(
unsafe { libc::setgroups(0, std::ptr::null()) },
"clear worker groups",
)?;
check(unsafe { libc::setegid(uid) }, "select image group")?;
check(unsafe { libc::seteuid(uid) }, "select image owner")?;
let opened = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC)
.open(path);
check(unsafe { libc::seteuid(0) }, "restore worker identity")?;
check(unsafe { libc::setegid(0) }, "restore worker group")?;
let file = opened?;
if !file.metadata()?.is_file() {
return Err(Error("Source image must be a regular file".into()));
}
Ok(file)
}
#[repr(C)]
struct LoopInfo {
device: u64,
inode: u64,
rdevice: u64,
offset: u64,
sizelimit: u64,
number: u32,
encrypt_type: u32,
key_size: u32,
flags: u32,
file_name: [u8; 64],
crypt_name: [u8; 64],
key: [u8; 32],
init: [u64; 2],
}
#[repr(C)]
struct LoopConfig {
fd: u32,
block_size: u32,
info: LoopInfo,
reserved: [u64; 8],
}
fn loop_image(source: &File, info: &image::Image, id: &str) -> Result<File> {
let control = OpenOptions::new()
.read(true)
.write(true)
.open("/dev/loop-control")
.map_err(|e| Error(format!("open loop-control: {e}")))?;
// LOOP_CONFIGURE is atomic. A competing loop user causes EBUSY, never an
// accidental configuration change to another user's loop device.
for _ in 0..8 {
let number = unsafe { libc::ioctl(control.as_raw_fd(), 0x4c82 as libc::Ioctl) };
check(number, "allocate image loop device")?;
let file = OpenOptions::new()
.read(true)
.custom_flags(libc::O_CLOEXEC)
.open(format!("/dev/loop{number}"))
.map_err(|e| Error(format!("open loop{number}: {e}")))?;
let mut config: LoopConfig = unsafe { std::mem::zeroed() };
config.fd = source.as_raw_fd() as u32;
config.block_size = 512;
config.info.offset = info.partition_start;
config.info.sizelimit = info.partition_bytes;
config.info.flags = 1 | 4; // READ_ONLY | AUTOCLEAR
let result = unsafe { libc::ioctl(file.as_raw_fd(), 0x4c0a as libc::Ioctl, &config) };
if result >= 0 {
// Give the unprivileged checker an already-open private inode for
// this loop device. Reopening /proc/self/fd/3 still checks inode
// permissions; changing /dev/loopN permissions would expose it.
// /run intentionally has nodev. Use a root-private directory
// on devtmpfs, then unlink its device inode after opening it.
let private = create::Work::new(Path::new("/dev"))?;
let path = private
.0
.join(format!("burn-{id}.device"))
.display()
.to_string();
check(
unsafe {
libc::mknod(
c(&path)?.as_ptr(),
libc::S_IFBLK | 0o400,
file.metadata()?.rdev(),
)
},
"create private inspection handle",
)?;
let opened = (|| -> Result<File> {
check(
unsafe { libc::chown(c(&path)?.as_ptr(), 1000, 1000) },
"set inspection handle owner",
)?;
Ok(OpenOptions::new()
.read(true)
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW)
.open(&path)
.map_err(|e| Error(format!("open private loop handle: {e}")))?)
})();
fs::remove_file(path)?;
return opened;
}
if io::Error::last_os_error().raw_os_error() != Some(libc::EBUSY) {
check(result, "configure read-only image loop")?;
}
}
Err(Error("Image loop devices remained busy".into()))
}
fn check_filesystem(device: &File, info: &image::Image) -> Result<()> {
let (program, args): (&str, &[&str]) = if info.class == Class::Data {
("/usr/bin/e2fsck", &["-f", "-n"])
} else {
("/usr/bin/fsck.erofs", &["--extract"])
};
let fd = device.as_raw_fd();
let parent = unsafe { libc::getpid() };
let mut command = Command::new(program);
command
.args(args)
.arg("/proc/self/fd/3")
.stdin(Stdio::null())
.stdout(Stdio::from(
unsafe { BorrowedFd::borrow_raw(2) }.try_clone_to_owned()?,
))
.stderr(Stdio::inherit())
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("LC_ALL", "C");
unsafe {
command.pre_exec(move || {
if libc::dup2(fd, 3) < 0 || libc::fcntl(3, libc::F_SETFD, 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
{
return Err(io::Error::last_os_error());
}
if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0
|| libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) < 0
{
return Err(io::Error::last_os_error());
}
if libc::getppid() != parent {
return Err(io::Error::other("Media worker exited"));
}
Ok(())
});
}
let status = command
.status()
.map_err(|e| Error(format!("start filesystem checker: {e}")))?;
if !status.success() {
return Err(Error(format!(
"Image filesystem verification failed: {status}"
)));
}
Ok(())
}
fn verify_manifest(device: &File, info: &image::Image, id: &str) -> Result<()> {
check_filesystem(device, info)?;
let path = format!("/run/fds/probe/burn-{id}");
fs::create_dir(&path).map_err(|e| Error(format!("create image probe directory: {e}")))?;
let source = format!("/proc/self/fd/{}", device.as_raw_fd());
let options = if info.class == Class::Data {
Some(c("noload")?)
} else {
None
};
let mounted = check(
unsafe {
libc::mount(
c(&source)?.as_ptr(),
c(&path)?.as_ptr(),
c(&info.filesystem)?.as_ptr(),
libc::MS_RDONLY | libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC,
options
.as_ref()
.map_or(std::ptr::null(), |s| s.as_ptr().cast()),
)
},
"mount image for metadata validation",
);
if let Err(e) = mounted {
let _ = fs::remove_dir(&path);
return Err(e);
}
let parsed = create::tree_manifest(Path::new(&path));
let unmounted = check(
unsafe { libc::umount2(c(&path)?.as_ptr(), 0) },
"unmount inspected image",
);
let _ = fs::remove_dir(&path);
unmounted?;
let manifest = parsed?;
if manifest.cartridge.class != info.class {
return Err(Error(
"Image metadata disagrees with its partition class".into(),
));
}
Ok(())
}
fn cancelled(input: &mut impl BufRead) -> Result<()> {
let mut fd = libc::pollfd {
fd: 0,
events: libc::POLLIN,
revents: 0,
};
check(
unsafe { libc::poll(&mut fd, 1, 0) },
"check media cancellation",
)?;
if fd.revents != 0 {
let mut line = String::new();
input.read_line(&mut line)?;
return Err(Error(
"Media operation cancelled; partial media is not SAFE".into(),
));
}
Ok(())
}
fn perform(preparation: &mut Preparation, input: &mut impl BufRead) -> Result<()> {
let Preparation {
disk,
image_path,
uid,
job,
} = preparation;
check(
unsafe { libc::unshare(libc::CLONE_NEWNS) },
"isolate image inspection mounts",
)?;
check(
unsafe {
libc::mount(
std::ptr::null(),
c("/")?.as_ptr(),
std::ptr::null(),
libc::MS_PRIVATE | libc::MS_REC,
std::ptr::null(),
)
},
"isolate mount propagation",
)?;
let target = disk.open_exclusive()?;
let source = source(image_path, *uid)?;
let mut info = image::inspect(&source, source.metadata()?.len())?;
if info.class != job.image_class {
return Err(Error(
"Image class does not match the requested operation".into(),
));
}
if info.bytes > disk.bytes {
return Err(Error("Image is larger than the selected cartridge".into()));
}
job.image_bytes = Some(info.bytes);
emit(job, "inspecting", 0)?;
info.sha256 = Some(image::digest(&source, info.bytes, |n| {
cancelled(input)?;
emit(job, "inspecting", n)
})?);
let image_device = loop_image(&source, &info, &job.id)?;
verify_manifest(&image_device, &info, &job.id)?;
drop(image_device);
job.image_sha256 = info.sha256.clone();
job.confirmation = Some(format!("ERASE BAY{} {}", job.bay, job.id));
emit(job, "awaiting_confirmation", 0)?;
// Readiness and confirmation are event-driven; expiration is a deadline.
let deadline = Instant::now() + Duration::from_secs(300);
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(Error("Confirmation expired; target untouched".into()));
}
let mut fd = libc::pollfd {
fd: 0,
events: libc::POLLIN,
revents: 0,
};
let result = unsafe {
libc::poll(
&mut fd,
1,
remaining.as_millis().min(i32::MAX as u128) as i32,
)
};
if result < 0 && io::Error::last_os_error().kind() == io::ErrorKind::Interrupted {
continue;
}
check(result, "wait for media confirmation")?;
if result == 0 {
continue;
}
let mut line = String::new();
input.take(LIMIT as u64).read_line(&mut line)?;
if line.trim_end() != job.confirmation.as_deref().unwrap_or("") {
return Err(Error("Media operation cancelled before writing".into()));
}
break;
}
job.confirmation = None;
disk.protect(Path::new("/sys"), Path::new("/proc"))?;
write::transfer(&source, &target, &info, disk.bytes, |phase, n| {
if !disk.present() {
return Err(Error(
"Cartridge removed during write; no SAFE status issued".into(),
));
}
cancelled(input)?;
emit(job, phase, n)
})?;
check(
unsafe { libc::ioctl(target.as_raw_fd(), 0x125f as libc::Ioctl) },
"reread verified partition table",
)?;
drop(target);
emit(job, "complete", info.bytes)?;
Ok(())
}
pub fn run() -> Result<()> {
if unsafe { libc::geteuid() } != 0 {
return Err(Error("Internal media worker requires root".into()));
}
let mut input = io::BufReader::with_capacity(1, io::stdin());
let mut line = String::new();
(&mut input).take((LIMIT + 1) as u64).read_line(&mut line)?;
if line.len() > LIMIT {
return Err(Error("Oversized media preparation".into()));
}
let mut preparation: Preparation =
serde_json::from_str(&line).map_err(|e| Error(e.to_string()))?;
if preparation.job.id.len() != 32 || !preparation.job.id.bytes().all(|b| b.is_ascii_hexdigit())
{
return Err(Error("Invalid media operation identifier".into()));
}
if let Err(e) = perform(&mut preparation, &mut input) {
preparation.job.error = Some(report_error(&e.to_string()));
preparation.job.confirmation = None;
emit(&mut preparation.job, "failed", 0)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn untrusted_diagnostics_remain_bounded_and_printable() {
let malformed = format!(
"format = 1\n[cartridge]\nname = \"{}",
"x".repeat(60 * 1024)
);
let error = fds_common::manifest::Manifest::parse(&malformed).unwrap_err();
let reported = report_error(&error.to_string());
assert!(reported.starts_with("Invalid cartridge manifest:"));
assert!(reported.len() <= 2060);
assert!(!reported.chars().any(char::is_control));
assert_eq!(report_error("a\x1b[2J\r\nb\0"), "a [2J b ");
let multibyte = report_error(&"\u{1f642}".repeat(1000));
assert!(multibyte.len() <= 2060);
assert!(multibyte.ends_with(" [truncated]"));
}
#[test]
fn loop_ioctl_layout_matches_linux_uapi() {
assert_eq!(std::mem::size_of::<LoopInfo>(), 232);
assert_eq!(std::mem::size_of::<LoopConfig>(), 304);
}
}
+277
View File
@@ -0,0 +1,277 @@
//! Byte-for-byte verified image transfer. GPT backup metadata is relocated to
//! the end of a larger target; filesystem contents are never silently resized.
//! The caller must own an exclusively opened, identity-checked and confirmed disk.
use crate::image::{self, Image};
use fds_common::{Error, Result};
use sha2::{Digest, Sha256};
use std::{
fs::File,
os::{
fd::AsRawFd,
unix::fs::{FileExt, FileTypeExt},
},
};
fn error(s: &str) -> Error {
Error(s.into())
}
fn apply(buffer: &mut [u8], offset: u64, patch: &[u8], at: u64) {
let start = offset.max(at);
let end = (offset + buffer.len() as u64).min(at + patch.len() as u64);
if start < end {
buffer[(start - offset) as usize..(end - offset) as usize]
.copy_from_slice(&patch[(start - at) as usize..(end - at) as usize]);
}
}
struct Patches {
head: Vec<u8>,
tail: Vec<u8>,
old_tail: Vec<u8>,
new_bytes: u64,
old_bytes: u64,
}
impl Patches {
fn new(source: &File, image: &Image, target_bytes: u64) -> Result<Self> {
if target_bytes < image.bytes || target_bytes % 512 != 0 {
return Err(error(
"Target is smaller than the image or is not sector aligned",
));
}
let mut head = vec![0; 1024 + image::TABLE_BYTES];
source.read_exact_at(&mut head, 0)?;
let mut tail = vec![0; 512 + image::TABLE_BYTES];
let tail_offset = image.bytes - tail.len() as u64;
source.read_exact_at(&mut tail, tail_offset)?;
let sectors = target_bytes / 512;
image::put32(&mut head, 458, (sectors - 1).min(u32::MAX as u64) as u32);
for (h, lba, backup, entries) in [
(&mut head[512..1024], 1, sectors - 1, 2),
(
&mut tail[image::TABLE_BYTES..],
sectors - 1,
1,
sectors - 33,
),
] {
image::put64(h, 24, lba);
image::put64(h, 32, backup);
image::put64(h, 48, sectors - 34);
image::put64(h, 72, entries);
image::put32(h, 16, 0);
let crc = image::crc32(&h[..92]);
image::put32(h, 16, crc);
}
Ok(Self {
head,
tail,
old_tail: vec![0; 512 + image::TABLE_BYTES],
new_bytes: target_bytes,
old_bytes: image.bytes,
})
}
fn overlay(&self, buffer: &mut [u8], offset: u64) {
apply(buffer, offset, &self.head, 0);
if self.new_bytes != self.old_bytes {
apply(
buffer,
offset,
&self.old_tail,
self.old_bytes - self.old_tail.len() as u64,
);
}
apply(
buffer,
offset,
&self.tail,
self.new_bytes - self.tail.len() as u64,
);
}
}
pub fn transfer(
source: &File,
target: &File,
approved: &Image,
target_bytes: u64,
mut progress: impl FnMut(&str, u64) -> Result<()>,
) -> Result<()> {
let expected = approved
.sha256
.as_ref()
.filter(|s| s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit()))
.ok_or_else(|| error("Write requires a previously approved SHA-256"))?;
if !source.metadata()?.is_file() || source.metadata()?.len() != approved.bytes {
return Err(error("Source image changed type or size"));
}
let mut observed = image::inspect(source, approved.bytes)?;
observed.sha256 = approved.sha256.clone();
if &observed != approved {
return Err(error("Source image geometry changed after inspection"));
}
progress("checking", 0)?;
if image::digest(source, approved.bytes, |n| progress("checking", n))? != *expected {
return Err(error(
"Source image changed after confirmation; target untouched",
));
}
let patches = Patches::new(source, approved, target_bytes)?;
let mut original = vec![0; 1024 * 1024];
let mut output = vec![0; original.len()];
let mut hash = Sha256::new();
let mut offset = 0;
progress("writing", 0)?;
while offset < approved.bytes {
let n = original.len().min((approved.bytes - offset) as usize);
source.read_exact_at(&mut original[..n], offset)?;
hash.update(&original[..n]);
output[..n].copy_from_slice(&original[..n]);
patches.overlay(&mut output[..n], offset);
target.write_all_at(&output[..n], offset)?;
offset += n as u64;
if offset % (64 * 1024 * 1024) == 0 || offset == approved.bytes {
progress("writing", offset)?;
}
}
// This also handles a target only one sector larger than the source, where
// the old and new backup tables overlap.
target.write_all_at(&patches.tail, target_bytes - patches.tail.len() as u64)?;
target.sync_all()?;
if image::hex(&hash.finalize()) != *expected {
return Err(error(
"Source changed during transfer; cartridge is incomplete, not SAFE",
));
}
let metadata = target.metadata()?;
if metadata.file_type().is_block_device() {
// Flush and invalidate the block cache so readback reaches the device.
if unsafe { libc::ioctl(target.as_raw_fd(), 0x1261 as libc::Ioctl) } < 0 {
return Err(std::io::Error::last_os_error().into());
}
} else if metadata.is_file() {
let result =
unsafe { libc::posix_fadvise(target.as_raw_fd(), 0, 0, libc::POSIX_FADV_DONTNEED) };
if result != 0 {
return Err(std::io::Error::from_raw_os_error(result).into());
}
} else {
return Err(error("Unexpected target file type"));
}
progress("verifying", 0)?;
let mut hash = Sha256::new();
let mut offset = 0;
while offset < approved.bytes {
let n = original.len().min((approved.bytes - offset) as usize);
source.read_exact_at(&mut original[..n], offset)?;
hash.update(&original[..n]);
patches.overlay(&mut original[..n], offset);
target.read_exact_at(&mut output[..n], offset)?;
if original[..n] != output[..n] {
return Err(error("Cartridge readback mismatch; no SAFE status issued"));
}
offset += n as u64;
if offset % (64 * 1024 * 1024) == 0 || offset == approved.bytes {
progress("verifying", offset)?;
}
}
let mut backup = vec![0; patches.tail.len()];
let backup_offset = target_bytes - backup.len() as u64;
target.read_exact_at(&mut backup, backup_offset)?;
if backup != patches.tail || image::hex(&hash.finalize()) != *expected {
return Err(error(
"Image or backup GPT changed during verification; no SAFE status issued",
));
}
let target_info = image::inspect(target, target_bytes)?;
if target_info.class != approved.class
|| target_info.partition_start != approved.partition_start
|| target_info.partition_bytes != approved.partition_bytes
|| target_info.partitions != approved.partitions
|| target_info.disk_uuid != approved.disk_uuid
{
return Err(error("Written cartridge geometry failed verification"));
}
target.sync_all()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use fds_common::manifest::Class;
use std::os::fd::FromRawFd;
fn memory() -> File {
let fd = unsafe { libc::memfd_create(c"fds-write-test".as_ptr(), libc::MFD_CLOEXEC) };
assert!(fd >= 0);
unsafe { File::from_raw_fd(fd) }
}
fn source() -> (File, Image) {
let f = memory();
let l = image::Layout::new(Class::Program, 1024 * 1024, None, [1; 16], [2; 16]).unwrap();
l.write(&f).unwrap();
f.write_all_at(&[0xe2, 0xe1, 0xf5, 0xe0], image::FIRST_LBA * 512 + 1024)
.unwrap();
f.write_all_at(
b"Approved application contents",
image::FIRST_LBA * 512 + 8192,
)
.unwrap();
let mut i = image::inspect(&f, l.bytes).unwrap();
i.sha256 = Some(image::digest(&f, l.bytes, |_| Ok(())).unwrap());
(f, i)
}
#[test]
fn exact_and_larger_targets_keep_verified_payload_and_valid_backup() {
for extra in [0, 512, 16 * 1024, 1024 * 1024] {
let (source, i) = source();
let target = memory();
target.set_len(i.bytes + extra).unwrap();
transfer(&source, &target, &i, i.bytes + extra, |_, _| Ok(())).unwrap();
let parsed = image::inspect(&target, i.bytes + extra).unwrap();
assert_eq!(parsed.partition_bytes, i.partition_bytes);
if extra == 0 {
assert_eq!(
image::digest(&target, i.bytes, |_| Ok(())).unwrap(),
i.sha256.unwrap()
);
}
}
}
#[test]
fn changed_source_is_rejected_before_writing() {
let (source, i) = source();
let target = memory();
target.set_len(i.bytes).unwrap();
target.write_all_at(b"UNCHANGED", 0).unwrap();
source
.write_all_at(b"x", image::FIRST_LBA * 512 + 8192)
.unwrap();
assert!(transfer(&source, &target, &i, i.bytes, |_, _| Ok(())).is_err());
let mut marker = [0; 9];
target.read_exact_at(&mut marker, 0).unwrap();
assert_eq!(&marker, b"UNCHANGED");
}
#[test]
fn faults_during_write_and_readback_never_succeed() {
let (source, i) = source();
let target = memory();
target.set_len(i.bytes).unwrap();
let result = transfer(&source, &target, &i, i.bytes, |phase, n| {
if phase == "verifying" && n == 0 {
target.write_all_at(b"wrong", image::FIRST_LBA * 512 + 8192)?;
}
Ok(())
});
assert!(
result
.unwrap_err()
.to_string()
.contains("readback mismatch")
);
let result = transfer(&source, &target, &i, i.bytes, |phase, _| {
if phase == "writing" {
Err(error("Cancelled"))
} else {
Ok(())
}
});
assert!(result.is_err());
}
}
View File
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "fds-cartridged"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "FDS event-driven cartridge and physical bay manager"
[dependencies]
clap.workspace = true
fds-common = { path = "../fds-common" }
fds-software = { path = "../fds-software" }
fds-burn = { path = "../fds-burn" }
serde = { version = "1", features = ["derive"] }
libc = "0.2"
serde_json = "1"
[[bin]]
name = "fds-cartridged"
path = "src/main.rs"
[[bin]]
name = "fds-profile"
path = "src/profile-main.rs"
+358
View File
@@ -0,0 +1,358 @@
//! One background writer, with root-owned operation records retained until the
//! physical insertion changes. Restart never turns an interrupted write SAFE.
use crate::media::checked;
use fds_burn::{device::Disk, image, worker::Preparation};
use fds_common::{
Bay, Error, Result,
control::{LIMIT, MediaJob},
manifest::Class,
};
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeMap,
fs,
io::{self, Read, Write},
os::{
fd::AsRawFd,
unix::{fs::PermissionsExt, process::CommandExt},
},
path::Path,
process::{Child, ChildStdout, Command, Stdio},
};
const DIRECTORY: &str = "/run/fds/burn";
#[derive(Serialize, Deserialize)]
struct Record {
disk: Disk,
job: MediaJob,
owner: u32,
}
struct Worker {
child: Child,
output: ChildStdout,
input: Vec<u8>,
bay: Bay,
confirmed: bool,
cancelled: bool,
}
#[derive(Default)]
pub struct Manager {
records: BTreeMap<Bay, Record>,
active: Option<Worker>,
}
impl Manager {
pub fn active(&self) -> bool {
self.active.is_some()
}
pub fn load() -> Result<Self> {
fs::create_dir_all(DIRECTORY)?;
fs::set_permissions(DIRECTORY, fs::Permissions::from_mode(0o700))?;
let mut this = Self::default();
for n in 1..=12 {
let bay = Bay::try_from(n)?;
let path = format!("{DIRECTORY}/{bay}.json");
if Path::new(&path).exists() {
let mut record: Record =
serde_json::from_str(&fds_common::read_text(Path::new(&path), LIMIT as u64)?)
.map_err(|e| Error(format!("Invalid burn recovery record: {e}")))?;
if record.job.bay != bay {
return Err(Error("Burn recovery bay mismatch".into()));
}
if !record.job.finished() {
record.job.phase = "failed".into();
record.job.sequence += 1;
record.job.confirmation = None;
record.job.error = Some(
"Media service restarted during an operation; cartridge is not SAFE".into(),
);
}
this.records.insert(bay, record);
this.save(bay)?;
}
}
Ok(this)
}
fn save(&self, bay: Bay) -> Result<()> {
let path = format!("{DIRECTORY}/{bay}.json");
fs::write(
format!("{path}.next"),
serde_json::to_vec(&self.records[&bay]).map_err(|e| Error(e.to_string()))?,
)?;
fs::rename(format!("{path}.next"), path)?;
Ok(())
}
pub fn reserved(&self, bay: Bay) -> Option<&MediaJob> {
self.records
.get(&bay)
.filter(|r| r.disk.present())
.map(|r| &r.job)
}
pub fn status(&self, id: &str) -> Result<MediaJob> {
self.records
.values()
.find(|r| r.job.id == id)
.map(|r| r.job.clone())
.ok_or_else(|| Error("Unknown media operation".into()))
}
pub fn begin(
&mut self,
bay: Bay,
usb: &Path,
path: &str,
class: Class,
uid: u32,
) -> Result<MediaJob> {
if self.active.is_some() {
return Err(Error(
"Another media operation is active; finish or cancel it first".into(),
));
}
image::label(class)?;
if !path.starts_with('/') || path.len() > 4096 || path.contains('\0') {
return Err(Error(
"Image path must be an absolute path of at most 4096 bytes".into(),
));
}
let disk = Disk::select_current(usb)?;
disk.protect(Path::new("/sys"), Path::new("/proc"))?;
let job = MediaJob {
id: image::hex(&image::random_id()?),
bay,
sequence: 0,
phase: "inspecting".into(),
diskseq: disk.diskseq,
target_bytes: disk.bytes,
model: disk.model.clone(),
serial: disk.serial.clone(),
image_class: class,
image_bytes: None,
image_sha256: None,
progress_bytes: 0,
confirmation: None,
error: None,
};
let preparation = Preparation {
disk: disk.clone(),
image_path: path.into(),
uid,
job: job.clone(),
};
self.records.insert(
bay,
Record {
disk,
job: job.clone(),
owner: uid,
},
);
self.save(bay)?;
let parent = unsafe { libc::getpid() };
let mut command = Command::new("/usr/bin/fds-burn");
command
.arg("--worker")
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("LC_ALL", "C")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
unsafe {
command.pre_exec(move || {
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
|| libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) < 0
{
return Err(io::Error::last_os_error());
}
if libc::getppid() != parent {
return Err(io::Error::other("Cartridge service exited"));
}
Ok(())
});
}
let started = (|| -> Result<Worker> {
let mut child = command.spawn()?;
let output = child.stdout.take().unwrap();
let setup = (|| -> Result<()> {
checked(
unsafe { libc::fcntl(output.as_raw_fd(), libc::F_SETFL, libc::O_NONBLOCK) },
"make burn status asynchronous",
)?;
let mut data =
serde_json::to_vec(&preparation).map_err(|e| Error(e.to_string()))?;
data.push(b'\n');
child.stdin.as_mut().unwrap().write_all(&data)?;
Ok(())
})();
if let Err(e) = setup {
let _ = child.kill();
let _ = child.wait();
return Err(e);
}
Ok(Worker {
child,
output,
input: Vec::new(),
bay,
confirmed: false,
cancelled: false,
})
})();
match started {
Ok(worker) => self.active = Some(worker),
Err(e) => {
let record = self.records.get_mut(&bay).unwrap();
record.job.phase = "failed".into();
record.job.error = Some(e.to_string());
record.job.sequence += 1;
self.save(bay)?;
return Err(e);
}
}
Ok(job)
}
pub fn descriptor(&self) -> i32 {
self.active.as_ref().map_or(-1, |w| w.output.as_raw_fd())
}
pub fn poll(&mut self) -> Result<()> {
let Some(worker) = self.active.as_mut() else {
return Ok(());
};
let bay = worker.bay;
let mut changed = false;
loop {
let mut chunk = [0; 8192];
match worker.output.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
worker.input.extend_from_slice(&chunk[..n]);
if worker.input.len() > LIMIT {
return Err(Error("Media worker status exceeded protocol limit".into()));
}
while let Some(end) = worker.input.iter().position(|b| *b == b'\n') {
let report: MediaJob = serde_json::from_slice(&worker.input[..end])
.map_err(|e| Error(format!("Invalid media worker status: {e}")))?;
worker.input.drain(..=end);
let record = self.records.get_mut(&bay).unwrap();
if report.id != record.job.id
|| report.bay != bay
|| report.diskseq != record.disk.diskseq
|| report.sequence <= record.job.sequence
{
return Err(Error("Media worker identity or sequence changed".into()));
}
record.job = report;
changed = true;
}
}
Err(e) if e.kind() == io::ErrorKind::WouldBlock => break,
Err(e) => return Err(e.into()),
}
}
let exited = worker.child.try_wait()?;
if let Some(status) = exited {
// The child can write its last report between EAGAIN and waitpid.
// Once it has exited, drain that final pipe data before deciding
// whether completion was reported.
worker.output.read_to_end(&mut worker.input)?;
while let Some(end) = worker.input.iter().position(|b| *b == b'\n') {
let report: MediaJob = serde_json::from_slice(&worker.input[..end])
.map_err(|e| Error(e.to_string()))?;
worker.input.drain(..=end);
let record = self.records.get_mut(&bay).unwrap();
if report.id != record.job.id
|| report.bay != bay
|| report.diskseq != record.disk.diskseq
|| report.sequence <= record.job.sequence
{
return Err(Error(
"Final media worker identity or sequence changed".into(),
));
}
record.job = report;
changed = true;
}
let record = self.records.get_mut(&bay).unwrap();
if !status.success() || !record.job.finished() {
record.job.phase = "failed".into();
record.job.sequence += 1;
record.job.confirmation = None;
record.job.error = Some(format!(
"Media worker exited without verified completion ({status}); no SAFE status issued"
));
changed = true;
}
self.active = None;
}
if changed {
self.save(bay)?;
}
Ok(())
}
fn control(&mut self, id: &str, uid: u32, line: &str, confirm: bool) -> Result<MediaJob> {
let job = self.status(id)?;
let record = &self.records[&job.bay];
if uid != 0 && uid != record.owner {
return Err(Error(
"Only the operation's owner may confirm or cancel it".into(),
));
}
if confirm
&& (job.phase != "awaiting_confirmation" || job.confirmation.as_deref() != Some(line))
{
return Err(Error(
"Confirmation must exactly match this cartridge and image".into(),
));
}
let worker = self
.active
.as_mut()
.filter(|w| w.bay == job.bay)
.ok_or_else(|| Error("Media operation is no longer active".into()))?;
if job.finished() {
return Err(Error("Media operation already finished".into()));
}
if (confirm && worker.confirmed) || worker.cancelled {
return Err(Error("Media control command was already sent".into()));
}
if confirm {
worker.confirmed = true;
} else {
worker.cancelled = true;
}
let input = worker
.child
.stdin
.as_mut()
.ok_or_else(|| Error("Media worker input closed".into()))?;
input.write_all(line.as_bytes())?;
input.write_all(b"\n")?;
Ok(job)
}
pub fn confirm(&mut self, id: &str, uid: u32, phrase: &str) -> Result<MediaJob> {
self.control(id, uid, phrase, true)
}
pub fn cancel(&mut self, id: &str, uid: u32) -> Result<MediaJob> {
self.control(id, uid, "CANCEL", false)
}
pub fn stop(&mut self) -> Result<()> {
if let Some(worker) = self.active.as_mut() {
worker.child.stdin.take();
}
while self.active.is_some() {
self.poll()?;
if self.active.is_some() {
let mut fd = libc::pollfd {
fd: self.descriptor(),
events: libc::POLLIN,
revents: 0,
};
let result = unsafe { libc::poll(&mut fd, 1, 1000) };
if result < 0 && io::Error::last_os_error().kind() != io::ErrorKind::Interrupted {
return Err(io::Error::last_os_error().into());
}
}
}
Ok(())
}
}
+257
View File
@@ -0,0 +1,257 @@
//! Managed jobs enter a root-owned cgroup before losing privileges or executing.
//! Descendants inherit membership; they cannot escape by double-forking.
use crate::media::{c, checked};
use fds_common::{Bay, Error, Result, read_text};
use std::{
fs::{self, File, OpenOptions},
io::{self, Read, Seek, SeekFrom},
os::{
fd::{AsRawFd, FromRawFd, OwnedFd},
unix::{
fs::{OpenOptionsExt, PermissionsExt},
process::CommandExt,
},
},
path::{Path, PathBuf},
process::{Child, Command, Stdio},
time::{Duration, Instant},
};
const ROOT: &str = "/sys/fs/cgroup/fds";
pub fn prepare() -> Result<()> {
let mut stat: libc::statfs = unsafe { std::mem::zeroed() };
checked(
unsafe { libc::statfs(c("/sys/fs/cgroup")?.as_ptr(), &mut stat) },
"inspect cgroup filesystem",
)?;
if stat.f_type as u64 == 0x6265_6572 {
checked(
unsafe {
libc::mount(
c("none")?.as_ptr(),
c("/sys/fs/cgroup")?.as_ptr(),
c("cgroup2")?.as_ptr(),
libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC,
std::ptr::null(),
)
},
"mount managed process hierarchy",
)?;
} else if stat.f_type as u64 != 0x6367_7270 {
return Err(Error(
"Managed programs require the cgroup v2 filesystem".into(),
));
}
fs::create_dir_all(ROOT)?;
fs::set_permissions(ROOT, fs::Permissions::from_mode(0o755))?;
fs::write("/sys/fs/cgroup/cgroup.subtree_control", b"+pids")?;
fs::write(Path::new(ROOT).join("cgroup.subtree_control"), b"+pids")?;
Ok(())
}
fn directory(name: &str) -> PathBuf {
Path::new(ROOT).join(name)
}
pub fn enter_service(name: &str) -> Result<()> {
if unsafe { libc::geteuid() } != 0 || !fds_common::manifest::identifier(name) {
return Err(Error("Invalid privileged service group".into()));
}
let path = directory(name);
fs::create_dir_all(&path)?;
fs::write(path.join("pids.max"), b"256")?;
fs::write(path.join("cgroup.procs"), b"0")?;
Ok(())
}
pub fn start(
bay: Bay,
arguments: &[String],
working: &str,
environment: &[(String, String)],
) -> Result<Child> {
start_group(&format!("bay{bay}"), arguments, working, environment)
}
pub fn start_group(
name: &str,
arguments: &[String],
working: &str,
environment: &[(String, String)],
) -> Result<Child> {
if !fds_common::manifest::identifier(name) {
return Err(Error("Invalid process group".into()));
}
if arguments.is_empty()
|| !arguments[0].starts_with('/')
|| arguments.len() > 128
|| arguments.iter().any(|a| a.contains('\0'))
{
return Err(Error(
"Managed run requires an absolute executable path and at most 128 arguments".into(),
));
}
let path = directory(name);
fs::create_dir_all(&path)?;
fs::write(path.join("pids.max"), b"256")?;
let group = OpenOptions::new()
.write(true)
.custom_flags(libc::O_CLOEXEC)
.open(path.join("cgroup.procs"))?;
let mut command = Command::new(&arguments[0]);
command
.args(&arguments[1..])
.current_dir(working)
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("HOME", "/home/fds")
.env("USER", "fds")
.env("LOGNAME", "fds")
.env("LANG", "en_US.UTF-8")
.env("SHELL", "/bin/bash")
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
command.envs(environment.iter().cloned());
// 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 {
command.pre_exec(move || {
if libc::write(group.as_raw_fd(), b"0".as_ptr().cast(), 1) != 1 {
return Err(io::Error::last_os_error());
}
if libc::setsid() < 0 {
return Err(io::Error::last_os_error());
}
if libc::setgroups(0, std::ptr::null()) < 0
|| libc::setgid(1000) < 0
|| libc::setuid(1000) < 0
{
return Err(io::Error::last_os_error());
}
if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 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 {
return Err(io::Error::last_os_error());
}
Ok(())
});
}
command.spawn().map_err(Into::into)
}
pub fn count(bay: Bay) -> Result<usize> {
let path = directory(&format!("bay{bay}")).join("cgroup.procs");
if !path.exists() {
return Ok(0);
}
Ok(read_text(&path, 1024 * 1024)?.lines().count())
}
fn populated(events: &mut File) -> Result<bool> {
events.seek(SeekFrom::Start(0))?;
let mut text = String::new();
events.take(4096).read_to_string(&mut text)?;
if text.lines().any(|s| s == "populated 0") {
Ok(false)
} else if text.lines().any(|s| s == "populated 1") {
Ok(true)
} else {
Err(Error("Invalid cgroup event state".into()))
}
}
fn wait_empty(events: &mut File, limit: Duration) -> Result<bool> {
let deadline = Instant::now() + limit;
loop {
if !populated(events)? {
return Ok(true);
}
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Ok(false);
}
let mut descriptor = libc::pollfd {
fd: events.as_raw_fd(),
events: libc::POLLPRI | libc::POLLERR,
revents: 0,
};
let result = unsafe {
libc::poll(
&mut descriptor,
1,
remaining.as_millis().max(1).min(i32::MAX as u128) as i32,
)
};
if result < 0 && io::Error::last_os_error().kind() == io::ErrorKind::Interrupted {
continue;
}
checked(result, "wait for cartridge consumers")?;
}
}
pub fn stop(bay: Bay) -> Result<()> {
stop_group(&format!("bay{bay}"))
}
pub fn stop_group(name: &str) -> Result<()> {
if !fds_common::manifest::identifier(name) {
return Err(Error("Invalid process group".into()));
}
let path = directory(name);
if !path.exists() {
return Ok(());
}
let mut events = File::open(path.join("cgroup.events"))?;
if !populated(&mut events)? {
return Ok(());
}
let membership = format!("0::/fds/{name}");
for value in read_text(&path.join("cgroup.procs"), 1024 * 1024)?.lines() {
let pid: i32 = value
.parse()
.map_err(|_| Error("Invalid consumer process ID".into()))?;
let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) } as libc::c_int;
if fd < 0 {
if io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) {
continue;
}
checked(fd, "open consumer process handle")?;
}
let fd = unsafe { OwnedFd::from_raw_fd(fd) };
// Verify membership after opening the stable handle. A recycled PID in
// another cgroup must never receive a signal intended for a consumer.
let current = match read_text(
&Path::new("/proc").join(pid.to_string()).join("cgroup"),
16384,
) {
Ok(s) => s,
Err(_) => continue,
};
if current.lines().any(|s| s == membership) {
let sent = unsafe {
libc::syscall(
libc::SYS_pidfd_send_signal,
fd.as_raw_fd(),
libc::SIGTERM,
std::ptr::null::<libc::siginfo_t>(),
0,
)
} as libc::c_int;
if sent < 0 && io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) {
checked(sent, "stop cartridge consumer")?;
}
}
}
if !wait_empty(&mut events, Duration::from_secs(1))? {
// The kernel kills the entire cgroup atomically, including forks made
// after the TERM snapshot. This is an exit deadline, not a fixed wait.
fs::write(path.join("cgroup.kill"), b"1")?;
if !wait_empty(&mut events, Duration::from_secs(2))? {
return Err(Error(
"Cartridge consumers did not exit; media remains mounted".into(),
));
}
}
Ok(())
}
pub fn stop_all() -> Result<()> {
for n in 1..=12 {
stop(Bay::try_from(n)?)?;
}
Ok(())
}
+87
View File
@@ -0,0 +1,87 @@
//! A DATA session outlives the daemon process. Losing its writeback descriptor
//! must never turn an earlier I/O error into a fresh, apparently healthy session.
use fds_common::{Bay, Error, Result, read_text};
use serde::{Deserialize, Serialize};
use std::{fs, io, os::unix::fs::PermissionsExt, path::Path};
const DIRECTORY: &str = "/run/fds/data-sessions";
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Record {
key: String,
fault: Option<String>,
}
impl Record {
fn fault_for(&self, key: &str) -> Option<String> {
(self.key == key).then(|| {
self.fault.clone().unwrap_or_else(|| {
"DATA service was interrupted before verified unmount; recover this cartridge before writable use".into()
})
})
}
}
pub fn prepare() -> Result<()> {
fs::create_dir_all(DIRECTORY)?;
fs::set_permissions(DIRECTORY, fs::Permissions::from_mode(0o700))?;
Ok(())
}
fn path(bay: Bay) -> String {
format!("{DIRECTORY}/{bay}.json")
}
pub fn begin(bay: Bay, key: &str) -> Result<()> {
save(bay, key, None)
}
pub fn save(bay: Bay, key: &str, fault: Option<&str>) -> Result<()> {
let path = path(bay);
let record = Record {
key: key.into(),
fault: fault.map(Into::into),
};
fs::write(
format!("{path}.next"),
serde_json::to_vec(&record).map_err(|e| Error(e.to_string()))?,
)?;
fs::rename(format!("{path}.next"), path)?;
Ok(())
}
pub fn recovered_fault(bay: Bay, key: &str) -> Result<Option<String>> {
let path = path(bay);
if !Path::new(&path).try_exists()? {
return Ok(None);
}
let record: Record = serde_json::from_str(&read_text(Path::new(&path), 16 * 1024)?)
.map_err(|e| Error(format!("Invalid DATA session record: {e}")))?;
Ok(record.fault_for(key))
}
pub fn clear(bay: Bay) -> Result<()> {
match fs::remove_file(path(bay)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn interrupted_sessions_and_faults_follow_only_the_same_insertion() {
let mut record = Record {
key: "usb/2:8:1:91".into(),
fault: None,
};
assert!(
record
.fault_for(&record.key)
.unwrap()
.contains("interrupted")
);
assert!(record.fault_for("usb/2:8:1:92").is_none());
record.fault = Some("flush failed: I/O error".into());
let decoded: Record =
serde_json::from_slice(&serde_json::to_vec(&record).unwrap()).unwrap();
assert_eq!(
decoded.fault_for(&record.key).as_deref(),
Some("flush failed: I/O error")
);
}
}
+66
View File
@@ -0,0 +1,66 @@
mod burning;
mod consumers;
mod data_sessions;
mod media;
mod power;
mod profiles;
mod recovery;
mod server;
mod software;
use clap::Parser;
use fds_common::{Error, Result, topology};
use std::{path::PathBuf, process::ExitCode};
#[derive(Parser)]
#[command(
version,
about = "Run the root cartridge service or inspect USB topology"
)]
struct Cli {
/// Notify s6 when the service is ready.
#[arg(long, conflicts_with = "topology")]
notify: bool,
/// Inspect topology without starting the service or mounting devices.
#[arg(long, value_name = "SYSFS_ROOT")]
topology: Option<PathBuf>,
}
fn run() -> Result<()> {
let cli = Cli::parse();
if let Some(sys) = cli.topology {
println!(
"{}",
serde_json::to_string_pretty(&topology::devices(&sys)?)
.map_err(|e| Error(e.to_string()))?
);
Ok(())
} else {
server::run(cli.notify)
}
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("fds-cartridged: {e}");
ExitCode::FAILURE
}
}
}
#[cfg(test)]
mod cli_tests {
use super::*;
use clap::{CommandFactory, Parser};
#[test]
fn typed_command_contract() {
Cli::command().debug_assert();
assert!(!Cli::try_parse_from(["fds-cartridged"]).unwrap().notify);
assert!(
Cli::try_parse_from(["fds-cartridged", "--notify"])
.unwrap()
.notify
);
assert!(Cli::try_parse_from(["fds-cartridged", "--topology", "/sys"]).is_ok());
assert!(Cli::try_parse_from(["fds-cartridged", "--notify", "--topology", "/sys"]).is_err());
}
}
+548
View File
@@ -0,0 +1,548 @@
use crate::data_sessions;
use fds_common::{
Bay, Error, Result,
manifest::{Class, Manifest},
read_text,
sysfs::{self, BlockPartition},
};
use std::{
ffi::CString,
fs::{self, File, OpenOptions},
io::{self, Read},
os::{
fd::{AsRawFd, FromRawFd},
unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt},
},
path::Path,
};
pub fn checked(value: libc::c_int, action: &str) -> Result<()> {
if value < 0 {
Err(Error(format!("{action}: {}", io::Error::last_os_error())))
} else {
Ok(())
}
}
pub fn c(value: &str) -> Result<CString> {
CString::new(value).map_err(|_| Error("NUL in syscall argument".into()))
}
pub fn unmount(path: &str, removed: bool) -> Result<()> {
// Detach is restricted to confirmed surprise-removal cleanup, never safe eject.
checked(
unsafe {
libc::umount2(
c(path)?.as_ptr(),
if removed { libc::MNT_DETACH } else { 0 },
)
},
"unmount cartridge",
)
}
pub fn manifest(root: &str) -> Result<Manifest> {
Manifest::parse(&metadata(root, "CARTRIDGE.TOML")?)
}
pub fn metadata(root: &str, name: &str) -> Result<String> {
// Each component is opened relative to an existing directory descriptor;
// a cartridge cannot redirect privileged metadata reads through symlinks.
let root = OpenOptions::new()
.read(true)
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
.open(root)?;
let dir = unsafe {
libc::openat(
root.as_raw_fd(),
c("FDS")?.as_ptr(),
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
)
};
checked(dir, "open FDS metadata directory")?;
let dir = unsafe { File::from_raw_fd(dir) };
let fd = unsafe {
libc::openat(
dir.as_raw_fd(),
c(name)?.as_ptr(),
libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK,
)
};
checked(fd, "open cartridge manifest")?;
let file = unsafe { File::from_raw_fd(fd) };
if !file.metadata()?.is_file() {
return Err(Error("Cartridge manifest is not a regular file".into()));
}
let mut text = String::new();
file.take(fds_common::MAX_CONFIG_BYTES + 1)
.read_to_string(&mut text)?;
if text.len() as u64 > fds_common::MAX_CONFIG_BYTES {
return Err(Error("Cartridge metadata exceeds 64 KiB".into()));
}
Ok(text)
}
pub struct Mounted {
pub bay: Bay,
pub key: String,
pub path: String,
pub manifest: Manifest,
pub protected: bool,
pub source: File,
pub writable: bool,
pub sync_handle: Option<File>,
pub fault: Option<String>,
pub partition: BlockPartition,
pub software: Option<crate::software::Mounted>,
}
impl Mounted {
pub fn present(&self) -> bool {
key(&self.partition).is_ok_and(|key| key == self.key)
}
pub fn activate_data(&mut self) -> Result<()> {
if self.manifest.cartridge.class != Class::Data || self.protected {
return Err(Error("This is not a DATA cartridge".into()));
}
if let Some(fault) = &self.fault {
return Err(Error(format!("DATA is quarantined: {fault}")));
}
if self.writable {
return Ok(());
}
self.ensure_exclusive_mount()?;
data_sessions::begin(self.bay, &self.key)?;
if let Err(error) = unmount(&self.path, false) {
data_sessions::clear(self.bay)?;
return Err(error);
}
let result = checked(
unsafe {
libc::mount(
c(&format!("/proc/self/fd/{}", self.source.as_raw_fd()))?.as_ptr(),
c("/data")?.as_ptr(),
c("ext4")?.as_ptr(),
libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC,
std::ptr::null(),
)
},
"mount writable DATA",
);
if let Err(error) = result {
// Restore read-only visibility when activation fails. If restoration
// also fails, retain a fault and never report this insertion SAFE.
let restored = checked(
unsafe {
libc::mount(
c(&format!("/proc/self/fd/{}", self.source.as_raw_fd()))?.as_ptr(),
c(&self.path)?.as_ptr(),
c("ext4")?.as_ptr(),
libc::MS_RDONLY | libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC,
c("noload")?.as_ptr().cast(),
)
},
"restore read-only DATA",
);
if let Err(restore_error) = restored {
self.record_fault(&restore_error)?;
} else {
data_sessions::clear(self.bay)?;
}
return Err(error);
}
self.path = "/data".into();
self.writable = true;
// Retain a filesystem descriptor from activation to observe writeback
// errors across the session. A block-device descriptor cannot do this.
match File::open(&self.path) {
Ok(handle) => self.sync_handle = Some(handle),
Err(error) => {
let error = Error(format!("Open DATA writeback handle: {error}"));
self.record_fault(&error)?;
return Err(error);
}
}
Ok(())
}
fn record_fault(&mut self, error: &Error) -> Result<()> {
self.fault = Some(error.to_string());
data_sessions::save(self.bay, &self.key, self.fault.as_deref())
}
fn data_readonly(&self, readonly: bool) -> io::Result<()> {
let result = unsafe {
libc::mount(
std::ptr::null(),
c(&self.path).map_err(io::Error::other)?.as_ptr(),
std::ptr::null(),
libc::MS_REMOUNT
| libc::MS_NOSUID
| libc::MS_NODEV
| libc::MS_NOEXEC
| if readonly { libc::MS_RDONLY } else { 0 },
std::ptr::null(),
)
};
if result < 0 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
pub fn program(
&mut self,
arguments: &[String],
) -> Result<(Vec<String>, Vec<(String, String)>)> {
if self.manifest.cartridge.class != Class::Program
|| self.fault.is_some()
|| !self.present()
{
return Err(Error("No healthy PROGRAM cartridge in this bay".into()));
}
self.ensure_exclusive_mount()?;
if let Some(software) = &mut self.software {
return software.program(arguments);
}
let name = arguments
.first()
.ok_or_else(|| Error("Specify an executable name from app/bin".into()))?;
if !fds_common::manifest::identifier(name) {
return Err(Error(
"PROGRAM executable must be a simple name from app/bin".into(),
));
}
let app = fs::canonicalize(Path::new(&self.path).join("app"))?;
let executable = fs::canonicalize(app.join("bin").join(name))?;
if !app.starts_with(&self.path) || !executable.starts_with(&app) || !executable.is_file() {
return Err(Error("PROGRAM executable escapes its app directory".into()));
}
self.ensure_exclusive_mount()?;
checked(
unsafe {
libc::mount(
std::ptr::null(),
c(&self.path)?.as_ptr(),
std::ptr::null(),
libc::MS_REMOUNT | libc::MS_RDONLY | libc::MS_NODEV | libc::MS_NOSUID,
std::ptr::null(),
)
},
"enable read-only PROGRAM execution",
)?;
let mut args = arguments.to_vec();
args[0] = executable.to_string_lossy().into_owned();
let env = vec![
("FDS_APP".into(), app.display().to_string()),
(
"PATH".into(),
format!("{}/bin:/usr/bin:/bin", app.display()),
),
(
"LD_LIBRARY_PATH".into(),
app.join("lib").display().to_string(),
),
(
"XDG_DATA_DIRS".into(),
format!("{}/share:/usr/share", app.display()),
),
("DISPLAY".into(), ":0".into()),
("XAUTHORITY".into(), "/run/fds/x11/authority".into()),
];
Ok((args, env))
}
pub fn eject(&mut self) -> Result<()> {
if let Some(fault) = &self.fault {
return Err(Error(format!(
"DATA I/O fault: {fault}; no SAFE status issued"
)));
}
self.ensure_exclusive_mount()?;
if self.writable {
let handle = self
.sync_handle
.as_ref()
.ok_or_else(|| Error("Missing DATA writeback handle".into()))?;
if let Err(error) = checked(
unsafe { libc::syncfs(handle.as_raw_fd()) },
"flush DATA filesystem",
) {
self.record_fault(&error)?;
return Err(error);
}
// Stop new writers before releasing the descriptor that tracks
// writeback errors. A busy writer leaves this descriptor open.
// MS_REMOUNT without MS_BIND makes the filesystem itself read-only.
if let Err(error) = self.data_readonly(true) {
let busy = error.raw_os_error() == Some(libc::EBUSY);
let error = Error(format!("Make DATA read-only before unmount: {error}"));
if !busy {
self.record_fault(&error)?;
}
return Err(error);
}
if let Err(error) = checked(
unsafe { libc::syncfs(handle.as_raw_fd()) },
"verify DATA writeback after read-only transition",
) {
self.record_fault(&error)?;
return Err(error);
}
// Our descriptor itself makes the mount busy. Close it only after
// syncfs succeeds, immediately before the ordinary unmount.
self.sync_handle.take();
}
if let Some(software) = &mut self.software {
software.release(false)?;
}
match unmount(&self.path, false) {
Ok(()) => {
if self.writable {
data_sessions::clear(self.bay)?;
}
Ok(())
}
Err(error) => {
if self.writable {
// Still read-only: establish the new error cursor before
// restoring writes, so no writeback failure is skipped.
let restored = File::open(&self.path).and_then(|handle| {
self.sync_handle = Some(handle);
self.data_readonly(false)
});
if let Err(restore_error) = restored {
let restore_error =
Error(format!("Restore DATA after busy unmount: {restore_error}"));
self.record_fault(&restore_error)?;
return Err(restore_error);
}
}
Err(error)
}
}
}
pub fn removed(&mut self) -> Result<()> {
if let Some(software) = &mut self.software {
software.release(true)?;
}
self.sync_handle.take();
unmount(&self.path, true)
}
pub fn ensure_exclusive_mount(&self) -> Result<()> {
if let Some(software) = &self.software {
software.exclusive()?;
}
let dev = self.source.metadata()?.rdev();
let identity = format!("{}:{}", libc::major(dev), libc::minor(dev));
let table = read_text(Path::new("/proc/self/mountinfo"), 4 * 1024 * 1024)?;
let matching: Vec<_> = table
.lines()
.filter(|line| line.split_whitespace().nth(2) == Some(identity.as_str()))
.collect();
if matching.len() != 1 || matching[0].split_whitespace().nth(4) != Some(self.path.as_str())
{
return Err(Error(
"Cartridge has additional or unexpected mounts; close them before eject".into(),
));
}
Ok(())
}
}
pub fn partitions_for(usb: &Path) -> Result<Vec<BlockPartition>> {
Ok(sysfs::partitions(Path::new("/sys"))?
.into_iter()
.filter(|part| {
let name = Path::new(&part.device).file_name().unwrap();
fs::canonicalize(Path::new("/sys/class/block").join(name))
.is_ok_and(|path| path.starts_with(usb))
})
.collect())
}
pub fn key(part: &BlockPartition) -> Result<String> {
let path = fs::canonicalize(
Path::new("/sys/dev/block").join(format!("{}:{}", part.major, part.minor)),
)?;
let disk = path
.parent()
.ok_or_else(|| Error("Missing parent disk".into()))?;
let sequence = read_text(&disk.join("diskseq"), 64)?;
Ok(format!(
"{}:{}:{}:{}",
path.display(),
part.major,
part.minor,
sequence.trim()
))
}
/// Partition-table rereads can briefly remove every partition without removing
/// its physical disk. Preserve an eject record until that insertion is gone.
pub fn key_disk_present(key: &str) -> Result<bool> {
let fields: Vec<_> = key.rsplitn(4, ':').collect();
if fields.len() != 4 || fields[..3].iter().any(|s| s.parse::<u64>().is_err()) {
return Err(Error("Invalid saved cartridge insertion identity".into()));
}
let partition = Path::new(fields[3]);
let parent = partition
.parent()
.ok_or_else(|| Error("Missing saved disk path".into()))?;
if !parent.starts_with("/sys/devices") {
return Err(Error(
"Saved disk identity is outside kernel devices".into(),
));
}
let sequence = match read_text(&parent.join("diskseq"), 64) {
Ok(sequence) => sequence,
Err(_) if !parent.exists() => return Ok(false),
Err(error) => return Err(error),
};
Ok(sequence.trim() == fields[0])
}
pub fn mount(bay: Bay, part: &BlockPartition, key: String) -> Result<Mounted> {
let fault = if part.partition_name == "FDS_DATA" {
data_sessions::recovered_fault(bay, &key)?
} else {
None
};
let source = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC)
.open(&part.device)?;
let st = source.metadata()?;
if !st.file_type().is_block_device()
|| libc::major(st.rdev()) != part.major
|| libc::minor(st.rdev()) != part.minor
|| self::key(part)? != key
{
return Err(Error("Device changed during inspection".into()));
}
let root = fs::metadata("/")?;
let protected = root.dev() == st.rdev();
let expected = match part.partition_name.as_str() {
"FDS_SYSTEM" => Class::System,
"FDS_DATA" => Class::Data,
"FDS_PROGRAM" | "FDS_METADATA" => Class::Program,
"FDS_ENVIRONMENT" => Class::Environment,
"FDS_UTILITY" => Class::Utility,
_ => return Err(Error("Unrecognized cartridge partition name".into())),
};
let published = format!("/run/fds/media/{bay}");
let path = if protected {
"/".into()
} else {
format!("/run/fds/probe/{bay}")
};
if !protected {
fs::create_dir_all(&path)?;
let kind = if expected == Class::Data {
"ext4"
} else {
"erofs"
};
let data = c(if expected == Class::Data {
"noload"
} else {
""
})?;
checked(
unsafe {
libc::mount(
c(&format!("/proc/self/fd/{}", source.as_raw_fd()))?.as_ptr(),
c(&path)?.as_ptr(),
c(kind)?.as_ptr(),
libc::MS_RDONLY | libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC,
data.as_ptr().cast(),
)
},
"mount cartridge read-only",
)?;
}
let result = manifest(&path).and_then(|value| {
if value.cartridge.class != expected {
return Err(Error(
"Manifest class disagrees with GPT partition name".into(),
));
}
Ok(value)
});
match result {
Ok(manifest) => {
let published = if expected == Class::Program {
let destination = format!("/run/fds/apps/{}", manifest.cartridge.id);
let mounts = read_text(Path::new("/proc/self/mountinfo"), 4 * 1024 * 1024)?;
if mounts
.lines()
.any(|l| l.split_whitespace().nth(4) == Some(destination.as_str()))
{
unmount(&path, false)?;
return Err(Error("A PROGRAM with this id is already mounted".into()));
}
destination
} else {
published
};
let path = if protected {
path
} else {
fs::create_dir_all(&published)?;
let moved = checked(
unsafe {
libc::mount(
c(&path)?.as_ptr(),
c(&published)?.as_ptr(),
std::ptr::null(),
libc::MS_MOVE,
std::ptr::null(),
)
},
"publish validated cartridge",
);
if let Err(error) = moved {
unmount(&path, false)?;
return Err(error);
}
published
};
Ok(Mounted {
bay,
key,
path,
manifest,
protected,
source,
writable: false,
sync_handle: None,
fault,
partition: part.clone(),
software: None,
})
}
Err(error) => {
if !protected {
unmount(&path, false)?;
}
Err(error)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::symlink;
#[test]
fn metadata_symlinks_and_special_files_are_rejected() {
let root = std::env::temp_dir().join(format!("fds-manifest-{}", std::process::id()));
fs::create_dir_all(root.join("FDS")).unwrap();
let metadata = root.join("FDS/CARTRIDGE.TOML");
fs::write(
&metadata,
include_str!("../../../tests/fixtures/manifests/windowmaker.toml"),
)
.unwrap();
assert!(manifest(root.to_str().unwrap()).is_ok());
fs::remove_file(&metadata).unwrap();
symlink("/etc/passwd", &metadata).unwrap();
assert!(manifest(root.to_str().unwrap()).is_err());
fs::remove_file(&metadata).unwrap();
let name = c(metadata.to_str().unwrap()).unwrap();
assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0);
assert!(manifest(root.to_str().unwrap()).is_err());
fs::remove_file(&metadata).unwrap();
fs::remove_dir(root.join("FDS")).unwrap();
symlink("/etc", root.join("FDS")).unwrap();
assert!(manifest(root.to_str().unwrap()).is_err());
fs::remove_dir_all(root).unwrap();
}
}
+139
View File
@@ -0,0 +1,139 @@
//! Shutdown preparation is persistent for this boot. A failed step leaves new
//! operations frozen; only an explicit retry or resume changes that decision.
use fds_common::{
Error, Result,
control::{PowerEvent, PowerState},
trace,
};
use std::{
fs, io,
os::unix::{fs::PermissionsExt, process::CommandExt},
path::Path,
process::Command,
};
pub const DIRECTORY: &str = "/run/fds/power";
pub const RECORD: &str = "/run/fds/power/state.json";
pub const NATIVE: &str = "/run/fds/power/native-pending";
pub struct Manager {
pub state: PowerState,
}
impl Manager {
pub fn load() -> Result<Self> {
fs::create_dir_all(DIRECTORY)?;
fs::set_permissions(DIRECTORY, fs::Permissions::from_mode(0o700))?;
let state = if Path::new(RECORD).try_exists()? {
serde_json::from_str(&fds_common::read_text(Path::new(RECORD), 32 * 1024)?)
.map_err(|e| Error(format!("Invalid shutdown recovery record: {e}")))?
} else {
PowerState::default()
};
let mut this = Self { state };
this.state.native_pending = Path::new(NATIVE).try_exists()?;
if this.state.phase != "idle" && this.state.phase != "prepared" {
this.failed(&Error(
"Cartridge service restarted during shutdown preparation; retry required".into(),
))?;
} else if this.state.native_pending && this.state.phase == "idle" {
this.failed(&Error(
"Native shutdown is waiting for DATA preparation".into(),
))?;
}
Ok(this)
}
pub fn frozen(&self) -> bool {
self.state.phase != "idle"
}
fn save(&self) -> Result<()> {
fs::write(
format!("{RECORD}.next"),
serde_json::to_vec(&self.state).map_err(|e| Error(e.to_string()))?,
)?;
fs::rename(format!("{RECORD}.next"), RECORD)?;
Ok(())
}
pub fn phase(&mut self, phase: &str) -> Result<()> {
self.state.phase = phase.into();
self.state.events.push(PowerEvent {
phase: phase.into(),
at_ns: trace::now()?,
});
if self.state.events.len() > 64 {
self.state.events.remove(0);
}
self.save()?;
eprintln!("FDS shutdown: {phase}");
Ok(())
}
pub fn begin(&mut self, action: Option<&str>, native: bool) -> Result<()> {
if native {
self.native_started()?;
}
if !self.state.native_pending {
self.state.action = action.map(Into::into);
}
self.state.error = None;
self.phase("frozen")
}
pub fn native_started(&mut self) -> Result<()> {
fs::write(NATIVE, b"native shutdown requested\n")?;
self.state.native_pending = true;
self.save()
}
pub fn failed(&mut self, error: &Error) -> Result<()> {
self.state.error = Some(
error
.to_string()
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.take(2048)
.collect(),
);
self.phase("blocked")
}
pub fn resume(&mut self) -> Result<()> {
if self.state.native_pending || Path::new(NATIVE).try_exists()? {
return Err(Error("Native shutdown has already started and cannot be cancelled; resolve the reported problem and retry fds poweroff".into()));
}
self.state = PowerState::default();
self.save()
}
pub fn commit(&mut self, reboot: bool) -> Result<()> {
if self.state.phase != "prepared" {
return Err(Error("Shutdown preparation is incomplete".into()));
}
if self.state.native_pending {
return Ok(());
}
self.state.native_pending = true;
self.save()?;
let mut command = Command::new("/usr/bin/s6-linux-init-shutdown");
command
.args([if reboot { "-r" } else { "-p" }, "-t", "0", "now"])
.env_clear()
.env("PATH", "/usr/bin:/bin");
unsafe {
command.pre_exec(|| {
let mut mask = std::mem::zeroed();
libc::sigemptyset(&mut mask);
if libc::sigprocmask(libc::SIG_SETMASK, &mask, std::ptr::null_mut()) < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
});
}
let result = command.status().map_err(Error::from).and_then(|status| {
if status.success() {
Ok(())
} else {
Err(Error(format!("Native shutdown request failed: {status}")))
}
});
if let Err(error) = result {
self.state.native_pending = false;
self.failed(&error)?;
return Err(error);
}
Ok(())
}
}
+328
View File
@@ -0,0 +1,328 @@
//! Profile client and privileged s6 helpers. No cartridge supplies root commands.
#[allow(dead_code)]
mod consumers;
#[allow(dead_code)]
mod data_sessions;
#[allow(dead_code)]
mod media;
#[allow(dead_code)]
mod software;
mod x11;
use fds_common::{
Error, Result,
control::{self, Request},
trace,
};
use media::{c, checked};
use std::{
fs::{self, OpenOptions},
io::{Read, Write},
os::{
fd::{AsRawFd, FromRawFd, OwnedFd},
unix::{
fs::{OpenOptionsExt, PermissionsExt},
process::CommandExt,
},
},
path::Path,
process::{Command, ExitCode},
time::{Duration, Instant},
};
fn root() -> Result<()> {
if unsafe { libc::geteuid() } != 0 {
return Err(Error("This s6 service helper requires root".into()));
}
Ok(())
}
fn xserver() -> Result<()> {
root()?;
fs::create_dir_all("/run/fds/x11")?;
checked(
unsafe { libc::chown(c("/run/fds/x11")?.as_ptr(), 0, 1000) },
"set X authority directory group",
)?;
fs::set_permissions("/run/fds/x11", fs::Permissions::from_mode(0o750))?;
let mut cookie = [0u8; 16];
fs::File::open("/dev/urandom")?.read_exact(&mut cookie)?;
let mut bytes = vec![0xff, 0xff]; // FamilyWild: Unix socket access still requires the secret cookie.
for field in [&b""[..], &b"0"[..], &b"MIT-MAGIC-COOKIE-1"[..], &cookie[..]] {
bytes.extend_from_slice(&(field.len() as u16).to_be_bytes());
bytes.extend_from_slice(field);
}
let temporary = "/run/fds/x11/authority.next";
match fs::remove_file(temporary) {
Ok(()) => (),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
Err(e) => return Err(e.into()),
}
let mut authority = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o640)
.custom_flags(libc::O_NOFOLLOW)
.open(temporary)?;
authority.write_all(&bytes)?;
checked(
unsafe { libc::fchown(authority.as_raw_fd(), 0, 1000) },
"set X authority group",
)?;
drop(authority);
fs::rename(temporary, "/run/fds/x11/authority")?;
let backend = fs::read_to_string("/etc/fds/xserver").unwrap_or_else(|_| "xorg".into());
let mut command = match backend.trim() {
"xorg" => {
let mut c = Command::new("/usr/libexec/Xorg");
c.args([":0", "vt2", "-logfile", "/run/log/xserver/Xorg.0.log"]);
c
}
"xvfb" => {
let mut c = Command::new("/usr/bin/Xvfb");
c.args([":0", "-screen", "0", "1600x1200x24"]);
c
}
_ => return Err(Error("Unknown X server backend".into())),
};
command.args([
"-displayfd",
"3",
"-nolisten",
"tcp",
"-noreset",
"-auth",
"/run/fds/x11/authority",
"-dpi",
"120",
"-extension",
"Composite",
]);
Err(command.exec().into())
}
fn session() -> Result<()> {
root()?;
checked(
unsafe { libc::fcntl(3, libc::F_GETFD) },
"require s6 readiness descriptor",
)?;
checked(
unsafe { libc::fcntl(3, libc::F_SETFD, libc::FD_CLOEXEC) },
"protect readiness descriptor",
)?;
consumers::prepare()?;
consumers::stop_group("desktop")?;
let _ = fs::remove_file("/run/fds/desktop-ready-ns");
let env = vec![
("DISPLAY".into(), ":0".into()),
("XAUTHORITY".into(), "/run/fds/x11/authority".into()),
];
let mut mask: libc::sigset_t = unsafe { std::mem::zeroed() };
unsafe {
libc::sigemptyset(&mut mask);
for sig in [libc::SIGTERM, libc::SIGINT, libc::SIGCHLD] {
libc::sigaddset(&mut mask, sig);
}
}
checked(
unsafe { libc::sigprocmask(libc::SIG_BLOCK, &mask, std::ptr::null_mut()) },
"block session signals",
)?;
let fd = unsafe { libc::signalfd(-1, &mask, libc::SFD_CLOEXEC | libc::SFD_NONBLOCK) };
checked(fd, "observe session processes")?;
let signals = unsafe { OwnedFd::from_raw_fd(fd) };
let mut observer = Some(x11::Observer::connect()?);
let mut wm = consumers::start_group(
"desktop",
&["/usr/libexec/fds/windowmaker-session".into()],
"/home/fds",
&env,
)?;
let deadline = Instant::now() + Duration::from_secs(15);
let mut ready = false;
let mut terminal = None;
let result = (|| -> Result<()> {
loop {
if wm.try_wait()?.is_some() {
return Err(Error("WindowMaker exited".into()));
}
if !ready && Instant::now() >= deadline {
return Err(Error("WindowMaker readiness deadline expired".into()));
}
let mut fds = [
libc::pollfd {
fd: signals.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
},
libc::pollfd {
fd: observer.as_ref().map_or(-1, AsRawFd::as_raw_fd),
events: libc::POLLIN,
revents: 0,
},
];
let timeout = if ready {
-1
} else {
deadline
.saturating_duration_since(Instant::now())
.as_millis()
.min(i32::MAX as u128) as i32
};
let count = unsafe { libc::poll(fds.as_mut_ptr(), 2, timeout) };
if count < 0
&& std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted
{
continue;
}
checked(count, "wait for desktop events")?;
if fds[0].revents != 0 {
let mut info: libc::signalfd_siginfo = unsafe { std::mem::zeroed() };
while unsafe {
libc::read(
signals.as_raw_fd(),
(&mut info as *mut libc::signalfd_siginfo).cast(),
std::mem::size_of_val(&info),
)
} > 0
{
if info.ssi_signo == libc::SIGTERM as u32
|| info.ssi_signo == libc::SIGINT as u32
{
return Ok(());
}
}
if let Some(child) = &mut terminal {
let _: Option<std::process::ExitStatus> = std::process::Child::try_wait(child)?;
}
}
if !ready && fds[1].revents != 0 && observer.as_mut().unwrap().event()? {
let now = trace::now()?;
trace::save(Path::new(trace::RUNTIME), trace::Point::DesktopReady, now)?;
fs::write("/run/fds/desktop-ready-ns", format!("{now}\n"))?;
terminal = Some(consumers::start_group(
"desktop",
&["/usr/libexec/fds/terminal".into()],
"/home/fds",
&env,
)?);
checked(
unsafe { libc::write(3, b"\n".as_ptr().cast(), 1) } as i32,
"notify desktop readiness",
)?;
unsafe {
libc::close(3);
}
observer.take();
ready = true;
}
}
})();
consumers::stop_group("desktop")?;
let _ = wm.wait();
if let Some(mut child) = terminal {
let _ = child.wait();
}
let _ = fs::remove_file("/run/fds/desktop-ready-ns");
result
}
#[derive(clap::Parser)]
#[command(
version,
about = "Control the desktop and network profile",
after_help = "Examples: fds-profile activate windowmaker; fds-profile deactivate"
)]
struct Cli {
#[command(subcommand)]
command: Option<Action>,
}
#[derive(clap::Subcommand)]
enum Action {
/// Show desktop and network state (the default).
Status,
/// Activate a profile.
Activate {
#[arg(value_parser = ["windowmaker", "cli"])]
name: String,
},
/// Return to the console.
Deactivate,
#[command(long_flag = "xserver", hide = true)]
Xserver,
#[command(long_flag = "network", hide = true)]
Network,
#[command(long_flag = "cleanup-network", hide = true)]
CleanupNetwork,
#[command(long_flag = "session", hide = true)]
Session,
#[command(long_flag = "cleanup-session", hide = true)]
CleanupSession,
}
fn run() -> Result<()> {
use clap::Parser;
let request = match Cli::parse().command.unwrap_or(Action::Status) {
Action::Xserver => return xserver(),
Action::Network => {
root()?;
consumers::enter_service("network")?;
return Err(Command::new("/usr/libexec/fds/network-run").exec().into());
}
Action::CleanupNetwork => {
root()?;
return consumers::stop_group("network");
}
Action::Session => return session(),
Action::CleanupSession => {
root()?;
consumers::stop_group("desktop")?;
let _ = fs::remove_file("/run/fds/desktop-ready-ns");
return Ok(());
}
Action::Activate { name } => Request::Profile { profile: name },
Action::Deactivate => Request::Profile {
profile: "cli".into(),
},
Action::Status => Request::Profiles,
};
let response = control::request(&request)?;
println!(
"{}",
serde_json::to_string_pretty(&response.profiles).map_err(|e| Error(e.to_string()))?
);
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("fds-profile: {e}");
ExitCode::FAILURE
}
}
}
#[cfg(test)]
mod cli_tests {
use super::*;
use clap::{CommandFactory, Parser};
#[test]
fn typed_command_contract() {
Cli::command().debug_assert();
assert!(
Cli::try_parse_from(["fds-profile"])
.unwrap()
.command
.is_none()
);
for flag in [
"--xserver",
"--network",
"--cleanup-network",
"--session",
"--cleanup-session",
] {
assert!(Cli::try_parse_from(["fds-profile", flag]).is_ok());
assert!(Cli::try_parse_from(["fds-profile", flag, "activate", "cli"]).is_err());
}
assert!(Cli::try_parse_from(["fds-profile", "activate", "windowmaker"]).is_ok());
assert!(Cli::try_parse_from(["fds-profile", "activate"]).is_err());
}
}
+288
View File
@@ -0,0 +1,288 @@
//! Only SYSTEM-owned, named service bundles may be activated by media.
use crate::{consumers, media::Mounted};
use fds_common::{
Bay, Error, Result, control::ProfileState, manifest::Class, topology::UsbDevice, trace,
};
use std::{
collections::{BTreeMap, BTreeSet},
fs,
os::unix::process::CommandExt,
path::Path,
process::{Child, Command},
};
fn begin_change(service: &str, up: bool) -> Result<Child> {
let mut command = Command::new("/usr/bin/s6-rc");
command.args([
"-b",
"-l",
"/run/s6-rc",
"-t",
"20000",
if up { "-u" } else { "-d" },
"change",
service,
]);
unsafe {
command.pre_exec(|| {
let mut mask = std::mem::zeroed();
libc::sigemptyset(&mut mask);
if libc::sigprocmask(libc::SIG_SETMASK, &mask, std::ptr::null_mut()) < 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
command.spawn().map_err(Into::into)
}
pub fn change(service: &str, up: bool) -> Result<()> {
if begin_change(service, up)?.wait()?.success() {
Ok(())
} else {
Err(Error(format!(
"Service transition failed: {service}; inspect /run/log/{service}"
)))
}
}
#[derive(Default)]
pub struct Manager {
automatic: bool,
desktop: bool,
pending: Option<Child>,
owner: Option<(Bay, String)>,
attempted: BTreeSet<String>,
pub manual_network: bool,
network_disabled: bool,
network: Vec<String>,
network_attempted: Vec<String>,
activation: Option<u64>,
error: Option<String>,
}
impl Manager {
pub fn new(automatic: bool) -> Self {
Self {
automatic,
..Self::default()
}
}
pub fn desktop(&mut self, name: &str, mounts: &BTreeMap<Bay, Mounted>) -> Result<()> {
match name {
"windowmaker" => {
self.owner = None;
self.start_desktop()
}
"cli" => {
self.attempted
.extend(mounts.values().map(|m| m.key.clone()));
self.stop_desktop()
}
_ => Err(Error(
"Unknown profile; available profiles: cli, windowmaker".into(),
)),
}
}
fn start_desktop(&mut self) -> Result<()> {
if self.desktop {
return Ok(());
}
if !console_ready() {
return Err(Error("The console is not ready yet".into()));
}
let instant = trace::now()?;
self.activation = Some(instant);
self.error = None;
match fs::remove_file("/run/fds/desktop-ready-ns") {
Ok(()) => (),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
Err(e) => return Err(e.into()),
}
self.pending = Some(begin_change("desktop", true)?);
self.desktop = true;
Ok(())
}
pub fn poll(&mut self) -> Result<()> {
let status = if let Some(child) = &mut self.pending {
child.try_wait()?
} else {
None
};
if let Some(status) = status {
self.pending.take();
if !status.success() {
self.desktop = false;
self.owner = None;
self.error = Some(
"Desktop startup failed; inspect /run/log/xserver and /run/log/desktop".into(),
);
change("desktop", false)?;
}
}
Ok(())
}
pub fn stop_desktop(&mut self) -> Result<()> {
if let Some(mut child) = self.pending.take() {
let _ = child.wait()?;
}
// s6-rc also tears down a partly started bundle after an activation error.
change("desktop", false)?;
self.desktop = false;
self.owner = None;
Ok(())
}
pub fn before_eject(
&mut self,
bay: Bay,
data: bool,
mounts: &BTreeMap<Bay, Mounted>,
) -> Result<()> {
if data && self.desktop || self.owner.as_ref().is_some_and(|(b, _)| *b == bay) {
self.desktop("cli", mounts)?;
}
Ok(())
}
pub fn network(&mut self, enabled: bool) -> Result<()> {
let interfaces = if enabled {
interfaces(None)?
} else {
Vec::new()
};
if enabled && interfaces.is_empty() {
return Err(Error("No Ethernet interface is available".into()));
}
self.set_network(interfaces)?;
self.manual_network = enabled;
self.network_disabled = !enabled;
Ok(())
}
fn set_network(&mut self, interfaces: Vec<String>) -> Result<()> {
if self.network == interfaces && !interfaces.is_empty() {
return Ok(());
}
change("network", false)?;
consumers::stop_group("network")?;
self.network.clear();
if interfaces.is_empty() {
return Ok(());
}
fs::create_dir_all("/run/fds/network")?;
fs::write("/run/fds/network/interfaces", interfaces.join("\n") + "\n")?;
change("network", true)?;
self.network = interfaces;
Ok(())
}
pub fn reconcile(
&mut self,
mounts: &BTreeMap<Bay, Mounted>,
devices: &[(Bay, UsbDevice)],
) -> Result<()> {
self.poll()?;
let keys: BTreeSet<_> = mounts.values().map(|m| m.key.clone()).collect();
self.attempted.retain(|key| keys.contains(key));
if let Some((bay, key)) = &self.owner {
if !mounts.get(bay).is_some_and(|m| &m.key == key) {
self.stop_desktop()?;
}
}
if self.automatic && !self.desktop && console_ready() {
let candidates: Vec<_> = mounts
.iter()
.filter(|(_, m)| {
m.manifest.cartridge.class == Class::Environment
&& m.fault.is_none()
&& m.manifest
.activation
.as_ref()
.is_some_and(|a| a.profile == "windowmaker")
})
.collect();
if candidates.len() == 1 {
let (bay, mount) = candidates[0];
if self.attempted.insert(mount.key.clone()) {
match self.start_desktop() {
Ok(()) => self.owner = Some((*bay, mount.key.clone())),
Err(error) => {
eprintln!("ENVIRONMENT activation: {error}");
self.error = Some(error.to_string());
}
}
}
}
}
let usb_paths: Vec<_> = devices.iter().map(|(_, d)| d.path.as_path()).collect();
let automatic = interfaces(Some(&usb_paths))?;
if automatic.is_empty() {
self.network_disabled = false;
}
let wanted = if self.manual_network {
interfaces(None)?
} else if self.network_disabled || !self.automatic {
Vec::new()
} else {
automatic
};
if wanted != self.network_attempted {
self.network_attempted = wanted.clone();
if let Err(error) = self.set_network(wanted) {
eprintln!("Network activation: {error}");
self.error = Some(error.to_string());
}
}
Ok(())
}
pub fn status(&self) -> ProfileState {
let ready = fs::read_to_string("/run/fds/desktop-ready-ns")
.ok()
.and_then(|s| s.trim().parse().ok());
ProfileState {
desktop: if self.desktop { "windowmaker" } else { "cli" }.into(),
environment_bay: self.owner.as_ref().map(|(b, _)| *b),
network: self.network.clone(),
manual_network: self.manual_network,
activation_ns: self.activation,
ready_ns: if self.desktop { ready } else { None },
error: self.error.clone(),
}
}
pub fn shutdown(&mut self) -> Result<()> {
self.stop_desktop()?;
change("network", false)?;
consumers::stop_group("network")?;
self.network.clear();
Ok(())
}
}
pub fn console_ready() -> bool {
Path::new(trace::RUNTIME)
.join("console/console-ready.json")
.is_file()
}
fn interfaces(usb_paths: Option<&[&Path]>) -> Result<Vec<String>> {
let mut names = Vec::new();
for entry in fs::read_dir("/sys/class/net")? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if name == "lo"
|| name.len() > 15
|| !name
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"_.-".contains(&c))
{
continue;
}
if fs::read_to_string(path.join("type")).is_ok_and(|s| s.trim() == "1")
&& !path.join("wireless").exists()
{
if let Some(parents) = usb_paths {
if !fs::canonicalize(&path)
.is_ok_and(|p| parents.iter().any(|parent| p.starts_with(parent)))
{
continue;
}
}
names.push(name);
}
}
names.sort();
Ok(names)
}
+291
View File
@@ -0,0 +1,291 @@
//! Explicit, local recovery maintenance. Normal boot never runs a checker.
use crate::media::{self, checked};
use fds_burn::device::Disk;
use fds_common::{
Bay, Error, Result,
control::{BayDisk, RecoveryReport},
manifest::Class,
read_text,
sysfs::BlockPartition,
};
use std::{
fs::{self, File, OpenOptions},
os::{
fd::AsRawFd,
unix::{
fs::{FileTypeExt, MetadataExt, OpenOptionsExt, PermissionsExt},
process::CommandExt,
},
},
path::Path,
process::{Command, Stdio},
};
pub struct Selection {
bay: Bay,
disk: Disk,
partition: BlockPartition,
pub key: String,
confirmation: String,
log: String,
}
// Linux UAPI include/uapi/linux/loop.h. LOOP_CONFIGURE atomically binds the
// already verified partition descriptor and enables automatic cleanup.
#[repr(C)]
struct LoopInfo {
device: u64,
inode: u64,
rdevice: u64,
offset: u64,
size_limit: u64,
number: u32,
encrypt_type: u32,
encrypt_key_size: u32,
flags: u32,
file_name: [u8; 64],
crypt_name: [u8; 64],
encrypt_key: [u8; 32],
init: [u64; 2],
}
#[repr(C)]
struct LoopConfig {
fd: u32,
block_size: u32,
info: LoopInfo,
reserved: [u64; 8],
}
const _: () = assert!(std::mem::size_of::<LoopInfo>() == 232);
const _: () = assert!(std::mem::size_of::<LoopConfig>() == 304);
fn checker_device(partition: &File, repair: bool) -> Result<File> {
let control = OpenOptions::new()
.read(true)
.write(true)
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW)
.open("/dev/loop-control")?;
for _ in 0..8 {
let number = unsafe { libc::ioctl(control.as_raw_fd(), 0x4c82u32 as _) };
checked(number, "allocate recovery loop device")?;
let device = OpenOptions::new()
.read(true)
.write(true)
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW)
.open(format!("/dev/loop{number}"))?;
let metadata = device.metadata()?;
if !metadata.file_type().is_block_device()
|| libc::major(metadata.rdev()) != 7
|| libc::minor(metadata.rdev()) != number as u32
{
return Err(Error("Unexpected recovery loop device identity".into()));
}
let mut config: LoopConfig = unsafe { std::mem::zeroed() };
config.fd = partition.as_raw_fd() as u32;
config.info.flags = 4 | if repair { 0 } else { 1 }; // AUTOCLEAR, READ_ONLY
let result = unsafe { libc::ioctl(device.as_raw_fd(), 0x4c0au32 as _, &config) };
if result == 0 {
return Ok(device);
}
if std::io::Error::last_os_error().raw_os_error() != Some(libc::EBUSY) {
checked(result, "bind verified DATA partition for recovery")?;
}
// Retry an actual allocation race, without a time-based delay.
}
Err(Error(
"Recovery loop allocation repeatedly raced with another user".into(),
))
}
pub fn select(bay: Bay, usb: &Path) -> Result<Selection> {
let disk = Disk::select_current(usb)?;
let mut parts = media::partitions_for(usb)?;
if parts.len() != 1 || parts[0].partition_name != "FDS_DATA" {
return Err(Error(
"Recovery requires one FDS_DATA partition; SYSTEM and internal storage are excluded"
.into(),
));
}
let partition = parts.remove(0);
let key = media::key(&partition)?;
let confirmation = format!(
"REPAIR BAY{bay} DISK{} BOOT{}",
disk.diskseq,
fds_common::trace::boot_id()?
);
Ok(Selection {
bay,
disk,
partition,
key,
confirmation,
log: format!(
"/run/fds/recovery/data-{bay}-{}.log",
fds_common::trace::now()?
),
})
}
impl Selection {
pub fn confirm(&self, repair: bool, confirmation: Option<&str>) -> Result<()> {
if repair && confirmation != Some(self.confirmation.as_str())
|| !repair && confirmation.is_some()
{
return Err(Error(
"Recovery confirmation does not match this bay, insertion and boot".into(),
));
}
Ok(())
}
pub fn report(&self, checked: bool, repaired: bool) -> RecoveryReport {
RecoveryReport {
disk: BayDisk {
bay: self.bay,
diskseq: self.disk.diskseq,
bytes: self.disk.bytes,
sector_bytes: self.disk.sector_bytes,
model: self.disk.model.clone(),
serial: self.disk.serial.clone(),
protected: None,
},
confirmation: if checked {
None
} else {
Some(self.confirmation.clone())
},
checked,
repaired,
log: checked.then(|| self.log.clone()),
}
}
pub fn run(&self, repair: bool) -> Result<()> {
let result = self.check_filesystem(repair);
result.map_err(|error| {
Error(format!(
"DATA recovery failed: {error}; no SAFE status issued. Log: {}",
self.log
))
})
}
fn check_filesystem(&self, repair: bool) -> Result<()> {
// The whole-disk reservation excludes mounts and other FDS writers.
// A child inherits it so daemon death cannot release it before fsck exits.
let disk = self.disk.open_exclusive()?;
let image = fds_burn::image::inspect(&disk, self.disk.bytes)?;
if image.class != Class::Data || image.filesystem != "ext4" {
return Err(Error(
"Recovery accepts only a valid single-partition DATA GPT with ext4".into(),
));
}
let part = OpenOptions::new()
.read(true)
.write(repair)
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(&self.partition.device)?;
let metadata = part.metadata()?;
let mut diskseq = 0u64;
checked(
unsafe { libc::ioctl(part.as_raw_fd(), 0x80081280u32 as _, &mut diskseq) },
"verify DATA insertion",
)?;
let sysfs = fs::canonicalize(
Path::new("/sys/dev/block")
.join(format!("{}:{}", self.partition.major, self.partition.minor)),
)?;
let number = |name: &str| -> Result<u64> {
read_text(&sysfs.join(name), 128)?
.trim()
.parse()
.map_err(|_| Error("Invalid partition geometry".into()))
};
if !metadata.file_type().is_block_device()
|| libc::major(metadata.rdev()) != self.partition.major
|| libc::minor(metadata.rdev()) != self.partition.minor
|| diskseq != self.disk.diskseq
|| media::key(&self.partition)? != self.key
|| number("start")?.checked_mul(512) != Some(image.partition_start)
|| number("size")?.checked_mul(512) != Some(image.partition_bytes)
{
return Err(Error("DATA identity or partition geometry changed".into()));
}
// e2fsck claims its device exclusively too. Give it an automatically
// removed loop view while retaining the physical whole-disk claim;
// releasing that claim would allow an unrelated mount during repairs.
let checker = checker_device(&part, repair)?;
fs::create_dir_all("/run/fds/recovery")?;
fs::set_permissions("/run/fds/recovery", fs::Permissions::from_mode(0o700))?;
let log = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW)
.open(&self.log)?;
let run = |fix: bool| -> Result<i32> {
let parent = unsafe { libc::getpid() };
let disk_fd = disk.as_raw_fd();
let part_fd = checker.as_raw_fd();
let mut command = Command::new("/usr/bin/e2fsck");
command
.args(["-f", if fix { "-p" } else { "-n" }])
.arg(format!("/proc/self/fd/{part_fd}"))
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("LC_ALL", "C")
.stdin(Stdio::null())
.stdout(log.try_clone()?)
.stderr(log.try_clone()?);
unsafe {
command.pre_exec(move || {
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) < 0
|| libc::getppid() != parent
{
return Err(std::io::Error::other("Recovery parent disappeared"));
}
let mut mask = std::mem::zeroed();
libc::sigemptyset(&mut mask);
let limit = libc::rlimit {
rlim_cur: 16 * 1024 * 1024,
rlim_max: 16 * 1024 * 1024,
};
if libc::sigprocmask(libc::SIG_SETMASK, &mask, std::ptr::null_mut()) < 0
|| libc::setrlimit(libc::RLIMIT_FSIZE, &limit) < 0
|| libc::fcntl(disk_fd, libc::F_SETFD, 0) < 0
|| libc::fcntl(part_fd, libc::F_SETFD, 0) < 0
{
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
command
.status()?
.code()
.ok_or_else(|| Error("Filesystem checker was terminated".into()))
};
let status = run(repair)?;
if status != 0 && !(repair && status == 1) {
return Err(Error(format!(
"e2fsck returned {status}; unresolved errors require review"
)));
}
if repair {
checker.sync_all()?;
part.sync_all()?;
checked(
unsafe { libc::ioctl(checker.as_raw_fd(), 0x1261u32 as _) },
"invalidate recovery loop cache",
)?;
checked(
unsafe { libc::ioctl(part.as_raw_fd(), 0x1261u32 as _) },
"flush and invalidate DATA block cache",
)?;
let verified = run(false)?;
if verified != 0 {
return Err(Error(format!(
"Post-repair read-only check returned {verified}"
)));
}
}
if !self.disk.present() || media::key(&self.partition)? != self.key {
return Err(Error("DATA disappeared during recovery".into()));
}
Ok(())
}
}
File diff suppressed because it is too large Load Diff
+318
View File
@@ -0,0 +1,318 @@
//! Metadata-first software media. Building is exclusively a workstation operation.
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 std::{
collections::{BTreeMap, BTreeSet},
fs::{self, File, OpenOptions},
os::{
fd::AsRawFd,
unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt, PermissionsExt},
},
path::{Path, PathBuf},
};
struct Payload {
path: String,
source: File,
partition: BlockPartition,
key: String,
}
pub struct Mounted {
pub catalogue: Catalogue,
root: String,
payloads: BTreeMap<u8, Payload>,
caches: BTreeMap<String, String>,
}
fn mount(source: &str, path: &str, kind: &str, flags: libc::c_ulong, options: &str) -> Result<()> {
checked(
unsafe {
libc::mount(
c(source)?.as_ptr(),
c(path)?.as_ptr(),
c(kind)?.as_ptr(),
flags,
c(options)?.as_ptr().cast(),
)
},
"mount software storage",
)
}
fn exclusive(source: &File, paths: &[&str]) -> Result<()> {
let meta = source.metadata()?;
let dev = if meta.file_type().is_block_device() {
meta.rdev()
} else {
meta.dev()
};
let identity = format!("{}:{}", libc::major(dev), libc::minor(dev));
let mounts = read_text(Path::new("/proc/self/mountinfo"), 4 * 1024 * 1024)?;
let found: BTreeSet<_> = mounts
.lines()
.filter(|line| line.split_whitespace().nth(2) == Some(identity.as_str()))
.filter_map(|line| line.split_whitespace().nth(4))
.collect();
if found != paths.iter().copied().collect() {
return Err(Error(
"Software storage has additional or unexpected mounts; close them before eject".into(),
));
}
Ok(())
}
impl Mounted {
pub fn open(bay: Bay, metadata: &str, usb: &Path, parts: &[BlockPartition]) -> Result<Self> {
let catalogue = Catalogue::parse(&media::metadata(metadata, "SOFTWARE.TOML")?)?;
let disk = Disk::select_current(usb)?;
let source = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC)
.open(&disk.path)?;
let stat = source.metadata()?;
if !stat.file_type().is_block_device()
|| libc::major(stat.rdev()) != disk.major
|| libc::minor(stat.rdev()) != disk.minor
{
return Err(Error("Software disk identity changed".into()));
}
let layout = image::inspect(&source, disk.bytes)?;
if layout.partitions.first().map(|p| p.name.as_str()) != Some("FDS_METADATA")
|| layout.partitions.len() != catalogue.partition_count()
|| parts.len() != layout.partitions.len()
{
return Err(Error(
"Software catalogue does not match the complete GPT layout".into(),
));
}
let disk_path = fs::canonicalize(format!("/sys/dev/block/{}:{}", disk.major, disk.minor))?;
let root = format!("/run/fds/software/{bay}");
fs::create_dir_all(&root)?;
fs::set_permissions(&root, fs::Permissions::from_mode(0o755))?;
let mut result = Self {
catalogue,
root,
payloads: BTreeMap::new(),
caches: BTreeMap::new(),
};
for spec in &layout.partitions {
let part = parts
.iter()
.find(|p| p.partition_name == spec.name)
.ok_or_else(|| Error("Missing software partition".into()))?;
let sys = fs::canonicalize(format!("/sys/dev/block/{}:{}", part.major, part.minor))?;
let number = |name: &str| -> Result<u64> {
read_text(&sys.join(name), 64)?
.trim()
.parse()
.map_err(|_| Error("Invalid kernel partition geometry".into()))
};
if sys.parent() != Some(disk_path.as_path())
|| number("partition")? != u64::from(spec.number)
|| number("start")? != spec.start / 512
|| number("size")? != spec.bytes / 512
{
return Err(Error(
"Kernel partition geometry disagrees with verified GPT".into(),
));
}
if spec.number == 1 {
continue;
}
let key = media::key(part)?;
let source = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC)
.open(&part.device)?;
let stat = source.metadata()?;
if !stat.file_type().is_block_device()
|| libc::major(stat.rdev()) != part.major
|| libc::minor(stat.rdev()) != part.minor
|| media::key(part)? != key
{
return Err(Error("Software payload changed during inspection".into()));
}
let path = format!("{}/payload{:02}", result.root, spec.number);
fs::create_dir_all(&path)?;
mount(
&format!("/proc/self/fd/{}", source.as_raw_fd()),
&path,
"erofs",
libc::MS_RDONLY | libc::MS_NOEXEC | libc::MS_NOSUID | libc::MS_NODEV,
"",
)?;
result.payloads.insert(
spec.number,
Payload {
path: path.clone(),
source,
partition: part.clone(),
key,
},
);
let bundles = Path::new(&path).join("bundles");
if !fs::symlink_metadata(&bundles)?.is_dir() {
return Err(Error("Payload bundles must be a real directory".into()));
}
let expected: BTreeSet<_> = result
.catalogue
.software
.iter()
.filter(|s| s.partition == spec.number)
.map(|s| 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 {
return Err(Error(
"Payload archive inventory disagrees with catalogue".into(),
));
}
for software in result
.catalogue
.software
.iter()
.filter(|s| s.partition == spec.number)
{
if archive::open(&Path::new(&path).join(software.archive_path()))?
.metadata()?
.len()
!= software.archive_bytes
{
return Err(Error(
"Software archive size disagrees with catalogue".into(),
));
}
}
}
if Disk::select_current(usb)? != disk {
return Err(Error("Software disk changed during validation".into()));
}
result.exclusive()?;
Ok(result)
}
pub fn exclusive(&self) -> Result<()> {
for payload in self.payloads.values() {
exclusive(&payload.source, &[&payload.path])?;
}
for path in self.caches.values() {
exclusive(&File::open(path)?, &[path])?;
}
Ok(())
}
pub fn program(
&mut self,
arguments: &[String],
) -> Result<(Vec<String>, Vec<(String, String)>)> {
let (id, command) = arguments
.first()
.and_then(|s| s.split_once(':'))
.ok_or_else(|| {
Error("Use SOFTWARE-ID:COMMAND; fds bay BAY lists available commands".into())
})?;
let software = self
.catalogue
.software
.iter()
.find(|s| s.id == id)
.ok_or_else(|| Error("Unknown software id".into()))?;
let executable = software
.commands
.get(command)
.ok_or_else(|| Error("Unknown software command".into()))?;
let payload = self
.payloads
.get(&software.partition)
.ok_or_else(|| Error("Software is being ejected; remove and reinsert it".into()))?;
if media::key(&payload.partition)? != payload.key {
return Err(Error("Software payload was removed".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.
let capacity = software
.unpacked_bytes
.checked_add(u64::from(software.entries) * 4096 + 1024 * 1024)
.ok_or_else(|| Error("Runtime cache size overflow".into()))?;
if capacity > 256 * 1024 * 1024 {
return Err(Error("Software exceeds the 256 MiB runtime cache limit; use a smaller workstation bundle".into()));
}
let path = format!("{}/cache-{}", self.root, id);
fs::create_dir_all(&path)?;
mount(
"tmpfs",
&path,
"tmpfs",
libc::MS_NOSUID | libc::MS_NODEV,
&format!(
"mode=0700,size={capacity},nr_inodes={}",
software.entries + 1024
),
)?;
self.caches.insert(id.to_owned(), path.clone());
let result = (|| {
archive::verify(
&archive::open(&PathBuf::from(&payload.path).join(software.archive_path()))?,
software,
Some(Path::new(&path)),
)?;
fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?;
mount(
"tmpfs",
&path,
"tmpfs",
libc::MS_REMOUNT | libc::MS_RDONLY | libc::MS_NOSUID | libc::MS_NODEV,
"",
)?;
Ok(())
})();
if let Err(error) = result {
media::unmount(&path, false)?;
self.caches.remove(id);
fs::remove_dir(&path)?;
return Err(error);
}
}
let root = &self.caches[id];
let mut args = arguments.to_vec();
args[0] = format!("{root}/{executable}");
Ok((
args,
vec![
("FDS_APP".into(), root.clone()),
("PATH".into(), format!("{root}/bin:/usr/bin:/bin")),
("LD_LIBRARY_PATH".into(), format!("{root}/lib")),
("XDG_DATA_DIRS".into(), format!("{root}/share:/usr/share")),
("DISPLAY".into(), ":0".into()),
("XAUTHORITY".into(), "/run/fds/x11/authority".into()),
],
))
}
pub fn release(&mut self, removed: bool) -> Result<()> {
if !removed {
self.exclusive()?;
}
while let Some((id, path)) = self.caches.last_key_value() {
media::unmount(path, removed)?;
let id = id.clone();
self.caches.remove(&id);
}
while let Some((number, payload)) = self.payloads.last_key_value() {
media::unmount(&payload.path, removed)?;
let number = *number;
self.payloads.remove(&number);
}
if Path::new(&self.root).exists() {
fs::remove_dir_all(&self.root)?;
}
Ok(())
}
}
impl Drop for Mounted {
fn drop(&mut self) {
if !self.payloads.is_empty() || !self.caches.is_empty() {
if let Err(error) = self.release(false) {
eprintln!("Software cleanup: {error}");
}
}
}
}
+194
View File
@@ -0,0 +1,194 @@
//! Minimal authenticated X11 readiness observer. Subscribe before the snapshot,
//! including when the EWMH atom has not been created yet. No Xlib dependency.
//! Wire formats: X Window System Protocol, Appendix B (connection and requests).
use fds_common::{Error, Result};
use std::{
fs,
io::{Read, Write},
os::{
fd::{AsRawFd, RawFd},
unix::net::UnixStream,
},
time::Duration,
};
fn u16le(data: &[u8], at: usize) -> Result<u16> {
Ok(u16::from_le_bytes(
data.get(at..at + 2)
.ok_or_else(|| Error("Short X11 packet".into()))?
.try_into()
.unwrap(),
))
}
fn u32le(data: &[u8], at: usize) -> Result<u32> {
Ok(u32::from_le_bytes(
data.get(at..at + 4)
.ok_or_else(|| Error("Short X11 packet".into()))?
.try_into()
.unwrap(),
))
}
fn root_window(data: &[u8]) -> Result<u32> {
if data.len() < 32 || data[20] == 0 {
return Err(Error("X11 server has no screen".into()));
}
let start = 32 + (usize::from(u16le(data, 16)?) + 3) / 4 * 4 + usize::from(data[21]) * 8;
u32le(data, start)
}
struct Packet {
header: [u8; 32],
body: Vec<u8>,
}
pub struct Observer {
stream: UnixStream,
root: u32,
atom: u32,
sequence: u16,
}
impl AsRawFd for Observer {
fn as_raw_fd(&self) -> RawFd {
self.stream.as_raw_fd()
}
}
impl Observer {
pub fn connect() -> Result<Self> {
let authority = fs::read("/run/fds/x11/authority")?;
if authority.len() != 45 || &authority[9..27] != b"MIT-MAGIC-COOKIE-1" {
return Err(Error("Invalid FDS X authority record".into()));
}
let mut stream = UnixStream::connect("/tmp/.X11-unix/X0")?;
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
stream.set_write_timeout(Some(Duration::from_secs(2)))?;
let mut setup = vec![b'l', 0, 11, 0, 0, 0, 18, 0, 16, 0, 0, 0];
setup.extend_from_slice(b"MIT-MAGIC-COOKIE-1");
setup.extend_from_slice(&[0, 0]);
setup.extend_from_slice(&authority[29..]);
stream.write_all(&setup)?;
let mut header = [0u8; 8];
stream.read_exact(&mut header)?;
if header[0] != 1 || u16le(&header, 2)? != 11 {
return Err(Error("X11 authentication/setup failed".into()));
}
let mut body = vec![0; usize::from(u16le(&header, 6)?) * 4];
stream.read_exact(&mut body)?;
let root = root_window(&body)?;
let mut observer = Self {
stream,
root,
atom: 0,
sequence: 0,
};
// Event selection and InternAtom are ordered on this same connection.
// Its reply proves the subscription is installed before WindowMaker starts.
observer.send(
2,
0,
&[
root.to_le_bytes(),
(1u32 << 11).to_le_bytes(),
(1u32 << 22).to_le_bytes(),
]
.concat(),
)?;
let name = b"_NET_SUPPORTING_WM_CHECK";
let mut data = Vec::from((name.len() as u16).to_le_bytes());
data.extend_from_slice(&[0, 0]);
data.extend_from_slice(name);
data.resize((data.len() + 3) / 4 * 4, 0);
observer.send(16, 0, &data)?;
let packet = observer.reply()?;
observer.atom = u32le(&packet.header, 8)?;
if observer.atom == 0 {
return Err(Error("X11 could not allocate the readiness atom".into()));
}
Ok(observer)
}
fn send(&mut self, opcode: u8, detail: u8, body: &[u8]) -> Result<()> {
let mut data = vec![opcode, detail];
data.extend_from_slice(&((body.len() / 4 + 1) as u16).to_le_bytes());
data.extend_from_slice(body);
self.stream.write_all(&data)?;
self.sequence = self.sequence.wrapping_add(1);
Ok(())
}
fn packet(&mut self) -> Result<Packet> {
let mut header = [0u8; 32];
self.stream.read_exact(&mut header)?;
if header[0] == 0 {
return Err(Error(format!(
"X11 request failed with error {}",
header[1]
)));
}
let length = if header[0] == 1 || header[0] & 0x7f == 35 {
u32le(&header, 4)? as usize * 4
} else {
0
};
if length > 65536 {
return Err(Error("X11 reply exceeds readiness protocol limit".into()));
}
let mut body = vec![0; length];
self.stream.read_exact(&mut body)?;
Ok(Packet { header, body })
}
fn reply(&mut self) -> Result<Packet> {
for _ in 0..1024 {
let packet = self.packet()?;
if packet.header[0] == 1 {
if u16le(&packet.header, 2)? != self.sequence {
return Err(Error("Unexpected X11 reply sequence".into()));
}
return Ok(packet);
}
}
Err(Error(
"Excessive X11 events while waiting for a reply".into(),
))
}
pub fn ready(&mut self) -> Result<bool> {
self.send(
20,
0,
&[
self.root.to_le_bytes(),
self.atom.to_le_bytes(),
0u32.to_le_bytes(),
0u32.to_le_bytes(),
1u32.to_le_bytes(),
]
.concat(),
)?;
let packet = self.reply()?;
Ok(packet.header[1] == 32
&& u32le(&packet.header, 8)? == 33
&& u32le(&packet.header, 16)? == 1
&& packet.body.len() == 4
&& u32le(&packet.body, 0)? != 0)
}
pub fn event(&mut self) -> Result<bool> {
let packet = self.packet()?;
if packet.header[0] & 0x7f == 28
&& u32le(&packet.header, 4)? == self.root
&& u32le(&packet.header, 8)? == self.atom
{
self.ready()
} else {
Ok(false)
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn setup_offsets_account_for_vendor_padding_and_pixmap_formats() {
let mut data = vec![0; 56];
data[16] = 3;
data[20] = 1;
data[21] = 2;
data[52..56].copy_from_slice(&0x1234u32.to_le_bytes());
assert_eq!(super::root_window(&data).unwrap(), 0x1234);
assert!(super::root_window(&data[..55]).is_err());
assert!(super::root_window(&[]).is_err());
}
}
View File
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "fds-cli"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "FDS system and cartridge command interface"
[[bin]]
name = "fds"
path = "src/main.rs"
[dependencies]
clap.workspace = true
fds-burn = { path = "../fds-burn" }
fds-common = { path = "../fds-common" }
serde_json = "1"
libc = "0.2"
+409
View File
@@ -0,0 +1,409 @@
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
use fds_burn::cli::{BurnCommand, FormatCommand};
use fds_common::Bay;
use std::path::PathBuf;
#[derive(Debug, Parser)]
#[command(multicall = true)]
pub struct Cli {
#[command(subcommand)]
pub applet: Applet,
}
#[derive(Debug, Subcommand)]
pub enum Applet {
#[command(version, about = "FDS/OS system and cartridge control")]
Fds(FdsArgs),
/// Inspect a bay, image, or cartridge manifest.
#[command(version)]
FdsInspect {
#[arg(long, global = true)]
json: bool,
#[command(flatten)]
args: InspectArgs,
},
/// Unmount a cartridge before removal.
#[command(version)]
FdsEject {
#[arg(long, global = true)]
json: bool,
#[command(flatten)]
args: BayArgs,
},
/// Prepare native shutdown or inspect its status.
#[command(version)]
FdsPower {
#[arg(long, global = true)]
json: bool,
#[command(flatten)]
args: PowerArgs,
},
}
impl Cli {
pub fn into_command(self) -> (Option<Action>, bool) {
match self.applet {
Applet::Fds(args) => (args.command, args.json),
Applet::FdsInspect { json, args } => (Some(Action::Inspect(args)), json),
Applet::FdsEject { json, args } => (Some(Action::Eject(args)), json),
Applet::FdsPower { json, args } => (Some(Action::Power(args)), json),
}
}
}
#[derive(Debug, Args)]
pub struct FdsArgs {
/// Emit machine-readable JSON where supported.
#[arg(long, global = true)]
pub json: bool,
#[command(subcommand)]
pub command: Option<Action>,
}
#[derive(Debug, Subcommand)]
pub enum Action {
/// Show the system and tool identity.
Info,
/// Show all twelve bays.
Bays,
/// Show one bay and its cartridge state.
#[command(visible_alias = "cartridge")]
Bay(BayArgs),
/// Unmount a cartridge before removal.
Eject(BayArgs),
/// Select writable DATA.
Data {
#[command(subcommand)]
command: DataCommand,
},
/// Start a managed DATA job or PROGRAM executable.
Run {
#[arg(value_parser = fds_burn::client::bay)]
bay: Bay,
/// Executable and its arguments; separate them from FDS options with --.
#[arg(last = true, required = true, num_args = 1.., value_name = "EXECUTABLE_AND_ARGS")]
arguments: Vec<String>,
},
/// Show desktop and network state.
Profiles,
/// Activate a desktop or return to the console.
Profile {
#[command(subcommand)]
command: ProfileCommand,
},
/// Enable or stop DHCP networking.
Network {
#[arg(value_enum)]
state: Switch,
},
/// Refresh cartridge inventory.
Rescan,
/// Show USB paths for bay calibration.
Topology,
/// Inspect a bay, image, or manifest file.
Inspect(InspectArgs),
/// Preview and confirm a cartridge write, or manage an existing operation.
Burn {
#[command(subcommand)]
command: BurnCommand,
},
/// Create a cartridge and preview a confirmed write.
Format {
#[command(subcommand)]
command: FormatCommand,
},
/// Manage persistent machine settings and saved diagnostics.
Machine {
#[command(subcommand)]
command: Option<MachineCommand>,
},
/// Check DATA or confirm conservative repair from the root recovery console.
Recovery {
#[command(subcommand)]
command: Option<RecoveryCommand>,
},
/// Verify DATA and request native shutdown.
Poweroff,
/// Verify DATA and request a reboot.
Reboot,
/// Show, resume, or request native shutdown preparation.
Power(PowerArgs),
/// Show measured boot events.
BootProfile,
/// Show the tool version.
Version,
}
#[derive(Debug, Args)]
pub struct BayArgs {
#[arg(value_parser = fds_burn::client::bay)]
pub bay: Bay,
}
#[derive(Debug, Subcommand)]
pub enum DataCommand {
/// Select a DATA cartridge for /data.
Use(BayArgs),
}
#[derive(Debug, Subcommand)]
pub enum ProfileCommand {
/// Activate a profile.
Activate {
#[arg(value_parser = ["windowmaker", "cli"])]
name: String,
},
/// Return to the console.
Deactivate,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum Switch {
On,
Off,
}
#[derive(Debug)]
pub struct InspectArgs {
pub target: Option<PathBuf>,
pub command: Option<InspectCommand>,
}
#[derive(Debug, Args)]
#[command(subcommand_negates_reqs = true)]
struct InspectParser {
/// A bay number (1–12 or BAY1–BAY12), or a manifest file path.
#[arg(required = true, value_name = "BAY_OR_MANIFEST")]
target: Option<PathBuf>,
#[command(subcommand)]
command: Option<InspectCommand>,
}
impl TryFrom<InspectParser> for InspectArgs {
type Error = clap::Error;
fn try_from(parsed: InspectParser) -> Result<Self, Self::Error> {
// Clap's args_conflicts_with_subcommands also conflicts with global
// --json. Check only these two typed alternatives during conversion.
if parsed.target.is_some() && parsed.command.is_some() {
return Err(clap::Error::raw(
clap::error::ErrorKind::ArgumentConflict,
"Choose a bay/manifest target or the image subcommand, not both",
));
}
Ok(Self {
target: parsed.target,
command: parsed.command,
})
}
}
impl clap::FromArgMatches for InspectArgs {
fn from_arg_matches(matches: &clap::ArgMatches) -> Result<Self, clap::Error> {
InspectParser::from_arg_matches(matches)?.try_into()
}
fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> {
let mut parsed = InspectParser {
target: self.target.clone(),
command: self.command.clone(),
};
parsed.update_from_arg_matches(matches)?;
*self = parsed.try_into()?;
Ok(())
}
}
impl Args for InspectArgs {
fn augment_args(command: clap::Command) -> clap::Command {
InspectParser::augment_args(command)
}
fn augment_args_for_update(command: clap::Command) -> clap::Command {
InspectParser::augment_args_for_update(command)
}
}
#[derive(Clone, Debug, Subcommand)]
pub enum InspectCommand {
/// Validate a regular cartridge image and calculate its SHA-256 digest.
Image { image: PathBuf },
}
#[derive(Debug, Args)]
pub struct PowerArgs {
#[command(subcommand)]
pub command: Option<PowerCommand>,
}
#[derive(Debug, Subcommand)]
pub enum PowerCommand {
/// Show preparation status or its blocking error (the default).
Status,
/// Resume operations before native shutdown starts.
Resume,
/// Verify DATA and request native shutdown.
Poweroff,
/// Verify DATA and request a reboot.
Reboot,
#[command(long_flag = "shutdown-hook", hide = true)]
ShutdownHook,
#[command(long_flag = "hold-shutdown", hide = true)]
HoldShutdown,
#[command(long_flag = "record-final", hide = true)]
RecordFinal {
#[arg(value_parser = ["failed"])]
outcome: Option<String>,
},
}
#[derive(Debug, Subcommand)]
pub enum RecoveryCommand {
/// Unmount and check DATA without repairs.
Check(BayArgs),
/// Preview repair, or confirm the exact token returned by the preview.
Repair {
#[arg(value_parser = fds_burn::client::bay)]
bay: Bay,
#[arg(long, value_name = "TOKEN")]
confirm: Option<String>,
},
}
#[derive(Debug, Subcommand)]
pub enum MachineCommand {
/// Show the active machine identity and settings source.
Status,
/// Export this boot's active settings into a new directory.
Export { new_directory: PathBuf },
/// Validate machine.toml, bays.toml and hardware-catalog.toml.
Validate { directory: PathBuf },
/// Validate and pack settings into a new JSON file.
Pack {
directory: PathBuf,
new_json_file: PathBuf,
},
/// Install settings for the next boot (root recovery console only).
Install { directory: PathBuf },
/// Save an explicit diagnostic of up to 16 MiB (root only).
Store { name: String, file: PathBuf },
/// Retrieve a saved diagnostic into a new file (root only).
Fetch { name: String, new_file: PathBuf },
#[command(long_flag = "load", hide = true)]
Load,
}
pub fn help(subcommand: Option<&str>) -> std::io::Result<()> {
let mut root = Cli::command();
root.build();
let fds = root.find_subcommand_mut("fds").expect("fds applet");
let command = match subcommand {
Some(name) => fds.find_subcommand_mut(name).expect("known subcommand"),
None => fds,
};
command.print_help()?;
println!();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_tree_and_installed_aliases() {
Cli::command().debug_assert();
for arguments in [
vec!["fds", "--json", "inspect", "BAY12"],
vec!["/usr/bin/fds-inspect", "BAY12", "--json"],
vec!["fds-inspect", "image", "card.img"],
vec!["fds-eject", "--json", "12"],
vec!["fds-power", "status", "--json"],
vec!["fds", "cartridge", "12"],
vec![
"fds",
"recovery",
"repair",
"BAY1",
"--confirm",
"exact phrase",
],
vec!["fds", "machine", "pack", "settings", "new.json"],
vec!["fds", "machine", "--load"],
vec!["fds", "power", "--shutdown-hook"],
vec!["fds-power", "--hold-shutdown"],
vec!["fds-power", "--record-final", "failed"],
] {
assert!(Cli::try_parse_from(&arguments).is_ok(), "{arguments:?}");
}
for name in ["fds", "fds-inspect", "fds-eject", "fds-power"] {
let error = Cli::try_parse_from([name, "--help"]).unwrap_err();
assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp);
assert!(error.to_string().contains(&format!("Usage: {name}")));
}
}
#[test]
fn run_preserves_every_child_argument_after_separator() {
let (command, json) = Cli::try_parse_from([
"fds", "run", "BAY2", "--", "/bin/app", "--json", "--help", "-x", "", "--", "a b",
])
.unwrap()
.into_command();
assert!(!json);
let Some(Action::Run { bay, arguments }) = command else {
panic!("run command")
};
assert_eq!(bay, "2".parse::<Bay>().unwrap());
assert_eq!(
arguments,
["/bin/app", "--json", "--help", "-x", "", "--", "a b"]
);
let (_, json) = Cli::try_parse_from(["fds", "run", "--json", "2", "--", "/bin/app"])
.unwrap()
.into_command();
assert!(json);
}
#[test]
fn inspect_image_accepts_global_options_in_every_position() {
for arguments in [
vec!["fds", "--json", "inspect", "image", "card.img"],
vec!["fds", "inspect", "--json", "image", "card.img"],
vec!["fds", "inspect", "image", "card.img", "--json"],
vec!["fds-inspect", "--json", "image", "card.img"],
vec!["fds-inspect", "image", "--json", "card.img"],
] {
let (command, json) = Cli::try_parse_from(&arguments).unwrap().into_command();
assert!(json);
assert!(matches!(
command,
Some(Action::Inspect(InspectArgs {
command: Some(InspectCommand::Image { .. }),
target: None,
}))
));
}
}
#[test]
fn rejects_incomplete_or_conflicting_requests_before_execution() {
for arguments in [
vec!["fds", "eject", "0"],
vec!["fds-eject", "13"],
vec!["fds", "run", "1", "/bin/app"],
vec!["fds", "run", "1", "--"],
vec!["fds", "inspect", "image"],
vec!["fds-inspect", "1", "image", "card.img"],
vec!["fds", "recovery", "check", "1", "--confirm", "token"],
vec!["fds", "recovery", "repair", "1", "--confirm"],
vec![
"fds",
"recovery",
"repair",
"1",
"--confirm",
"one",
"--confirm",
"two",
],
vec!["fds", "power", "--shutdown-hook", "--record-final"],
vec!["fds", "power", "--record-final", "success"],
vec!["fds", "machine", "--load", "settings"],
vec!["fds", "network", "maybe"],
vec!["fds", "profile", "activate", "unknown"],
vec!["fds", "unknown"],
] {
assert!(Cli::try_parse_from(&arguments).is_err(), "{arguments:?}");
}
}
}
+510
View File
@@ -0,0 +1,510 @@
//! Internal NVMe access is explicit and isolated in this process's mount namespace.
use fds_common::{
Error, Result,
machine::{self, Config},
read_text, sysfs,
};
use std::{
ffi::CString,
fs::{self, File, OpenOptions},
io::{self, Read, Write},
os::{
fd::AsRawFd,
unix::fs::{FileExt, FileTypeExt, MetadataExt, OpenOptionsExt, PermissionsExt},
},
path::{Path, PathBuf},
};
const RUNTIME: &str = "/run/fds/machine";
const MOUNT: &str = "/run/fds/machine/internal";
fn c(text: &str) -> Result<CString> {
CString::new(text).map_err(|_| Error("NUL in mount argument".into()))
}
fn checked(value: libc::c_int, what: &str) -> Result<()> {
if value < 0 {
Err(Error(format!("{what}: {}", io::Error::last_os_error())))
} else {
Ok(())
}
}
fn root() -> Result<()> {
if unsafe { libc::geteuid() } != 0 {
return Err(Error("Machine storage operations require root".into()));
}
Ok(())
}
fn prepare() -> Result<File> {
root()?;
fs::create_dir_all(RUNTIME)?;
fs::set_permissions(RUNTIME, fs::Permissions::from_mode(0o755))?;
let lock = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(format!("{RUNTIME}/lock"))?;
checked(
unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) },
"Another machine storage operation is active",
)?;
Ok(lock)
}
fn parent(part: &sysfs::BlockPartition) -> Result<PathBuf> {
let path = fs::canonicalize(format!("/sys/dev/block/{}:{}", part.major, part.minor))?;
path.parent()
.map(Path::to_path_buf)
.ok_or_else(|| Error("Partition has no parent disk".into()))
}
fn select() -> Result<(sysfs::BlockPartition, PathBuf, String)> {
let parts = sysfs::partitions(Path::new("/sys"))?;
let mut candidates = Vec::new();
for part in parts.iter().filter(|p| p.partition_name == "FDS_INTERNAL") {
let disk = parent(part)?;
let buses: Vec<_> = disk
.ancestors()
.filter_map(|path| fs::read_link(path.join("subsystem")).ok())
.filter_map(|path| path.file_name().map(|s| s.to_owned()))
.collect();
if !buses.iter().any(|b| b == "nvme") || buses.iter().any(|b| b == "usb") {
continue;
}
if read_text(&disk.join("removable"), 32)?.trim() != "0" {
continue;
}
let siblings: Vec<_> = parts
.iter()
.filter(|p| parent(p).ok().as_ref() == Some(&disk))
.collect();
if siblings.len() != 3 {
return Err(Error(
"Internal NVMe must contain exactly FDS_BOOT, FDS_RECOVERY and FDS_INTERNAL".into(),
));
}
for (number, label) in [(1, "FDS_BOOT"), (2, "FDS_RECOVERY"), (3, "FDS_INTERNAL")] {
let matches: Vec<_> = siblings
.iter()
.filter(|p| p.partition_name == label)
.collect();
if matches.len() != 1
|| read_text(
&PathBuf::from(format!(
"/sys/dev/block/{}:{}/partition",
matches[0].major, matches[0].minor
)),
32,
)?
.trim()
!= number.to_string()
{
return Err(Error("Invalid internal NVMe partition layout".into()));
}
}
let sequence = read_text(&disk.join("diskseq"), 32)?.trim().to_owned();
sequence
.parse::<u64>()
.map_err(|_| Error("Invalid internal disk sequence".into()))?;
candidates.push((part.clone(), disk, sequence));
}
match candidates.len() {
0 => Err(Error(
"No complete internal NVMe layout found; image defaults remain available".into(),
)),
1 => Ok(candidates.pop().unwrap()),
_ => Err(Error(
"Multiple internal NVMe layouts found; refusing to choose one".into(),
)),
}
}
struct Internal {
file: File,
disk: PathBuf,
sequence: String,
mounted: bool,
}
impl Internal {
fn open(writable: bool) -> Result<Self> {
let (part, disk, sequence) = select()?;
let mounts = read_text(Path::new("/proc/self/mountinfo"), 4 * 1024 * 1024)?;
if mounts.lines().any(|line| {
line.split_whitespace().nth(2) == Some(&format!("{}:{}", part.major, part.minor))
}) {
return Err(Error(
"Internal settings are already mounted; unmount them before continuing".into(),
));
}
let file = OpenOptions::new()
.read(true)
.write(writable)
.custom_flags(libc::O_NOFOLLOW)
.open(&part.device)?;
let meta = file.metadata()?;
if !meta.file_type().is_block_device()
|| libc::major(meta.rdev()) != part.major
|| libc::minor(meta.rdev()) != part.minor
{
return Err(Error("Internal partition identity changed".into()));
}
let mut header = [0u8; 1024];
file.read_exact_at(&mut header, 1024)?;
let word = |at| u16::from_le_bytes([header[at], header[at + 1]]);
let features = u32::from_le_bytes(header[96..100].try_into().unwrap());
if word(56) != 0xef53
|| word(58) != 1
|| features & 4 != 0
|| &header[120..136] != b"FDS_INTERNAL\0\0\0\0"
{
return Err(Error("Internal ext4 is unclean, damaged or incorrectly labeled; offline filesystem maintenance is required".into()));
}
checked(
unsafe { libc::unshare(libc::CLONE_NEWNS) },
"Isolate internal storage mounts",
)?;
checked(
unsafe {
libc::mount(
std::ptr::null(),
c("/")?.as_ptr(),
std::ptr::null(),
libc::MS_REC | libc::MS_PRIVATE,
std::ptr::null(),
)
},
"Make storage mount private",
)?;
fs::create_dir_all(MOUNT)?;
fs::set_permissions(MOUNT, fs::Permissions::from_mode(0o700))?;
let mut internal = Self {
file,
disk,
sequence,
mounted: false,
};
internal.identity()?;
let options = c(if writable {
"errors=remount-ro"
} else {
"noload"
})?;
checked(
unsafe {
libc::mount(
c(&format!("/proc/self/fd/{}", internal.file.as_raw_fd()))?.as_ptr(),
c(MOUNT)?.as_ptr(),
c("ext4")?.as_ptr(),
libc::MS_NOSUID
| libc::MS_NODEV
| libc::MS_NOEXEC
| if writable { 0 } else { libc::MS_RDONLY },
options.as_ptr().cast(),
)
},
"Mount internal settings",
)?;
internal.mounted = true;
internal.identity()?;
Ok(internal)
}
fn identity(&self) -> Result<()> {
if read_text(&self.disk.join("diskseq"), 32)?.trim() != self.sequence {
return Err(Error("Internal NVMe changed during the operation".into()));
}
Ok(())
}
fn close(mut self, writable: bool) -> Result<()> {
self.identity()?;
if writable {
let directory = File::open(MOUNT)?;
checked(
unsafe { libc::syncfs(directory.as_raw_fd()) },
"Flush internal settings filesystem",
)?;
}
checked(
unsafe { libc::umount(c(MOUNT)?.as_ptr()) },
"Unmount internal settings",
)?;
self.mounted = false;
if writable {
self.file.sync_all()?;
}
Ok(())
}
}
impl Drop for Internal {
fn drop(&mut self) {
if self.mounted {
// No lazy unmount. The private namespace is also destroyed on exit.
if let Ok(path) = c(MOUNT) {
unsafe {
libc::umount(path.as_ptr());
}
}
}
}
}
fn atomic(path: &Path, bytes: &[u8], mode: u32) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| Error("Missing parent directory".into()))?;
let temporary = path.with_extension(format!("next-{}", std::process::id()));
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(mode)
.custom_flags(libc::O_NOFOLLOW)
.open(&temporary)?;
file.write_all(bytes)?;
file.sync_all()?;
fs::rename(&temporary, path)?;
File::open(parent)?.sync_all()?;
Ok(())
}
fn emulator_config() -> Result<Option<Config>> {
if !read_text(Path::new("/proc/cmdline"), 65536)?
.split_whitespace()
.any(|s| s == "fds.emulator=1")
{
return Ok(None);
}
let compatible = fs::read("/proc/device-tree/compatible")?;
if !compatible
.split(|b| *b == 0)
.any(|s| s == b"linux,dummy-virt")
{
return Err(Error(
"fds.emulator=1 requires the QEMU virt machine".into(),
));
}
let controller = fs::canonicalize("/sys/bus/pci/devices/0000:00:05.0")?;
if read_text(&controller.join("vendor"), 64)?.trim() != "0x1b36"
|| read_text(&controller.join("device"), 64)?.trim() != "0x000d"
{
return Err(Error(
"Emulator requires QEMU xHCI at PCI address 00:05.0".into(),
));
}
let identity = controller
.strip_prefix("/sys/devices")
.map_err(|_| Error("Unexpected emulator controller path".into()))?
.to_string_lossy();
let mut config = Config::fallback()?;
config.name = "FDS QEMU workstation emulator".into();
config.bays.clear();
for protocol in ["usb2", "usb3"] {
config.bays.push_str(&format!(
"[{protocol}]\nhub = \"{identity}:{protocol}\"\n[{protocol}.ports]\n"
));
for n in 1..=12 {
config.bays.push_str(&format!("{n} = {n}\n"));
}
}
config.validate()?;
Ok(Some(config))
}
pub fn load() -> Result<()> {
let _lock = prepare()?;
if Path::new(machine::SNAPSHOT).exists() && Path::new(machine::STATUS).exists() {
return Ok(());
}
let attempt = || -> Result<(Config, String)> {
let internal = Internal::open(false)?;
let config = Config::parse(&machine::trusted_text(
Path::new(&format!("{MOUNT}/config/machine.json")),
machine::MAX_BUNDLE,
)?)?;
let sequence = internal.sequence.clone();
internal.close(false)?;
Ok((config, sequence))
};
let emulated = emulator_config()?;
let (config, status) = if let Some(config) = emulated {
let status = serde_json::json!({"source":"qemu_emulator", "name":config.name, "disk_sequence":null, "error":null});
(config, status)
} else {
match attempt() {
Ok((config, sequence)) => {
let status = serde_json::json!({"source":"internal_nvme", "name":config.name, "disk_sequence":sequence, "error":null});
(config, status)
}
Err(error) => {
eprintln!("FDS machine settings: {error}");
let config = Config::fallback()?;
let status = serde_json::json!({"source":"image_defaults", "name":config.name, "disk_sequence":null, "error":error.to_string()});
(config, status)
}
}
};
atomic(
Path::new(machine::STATUS),
&serde_json::to_vec_pretty(&status).map_err(|e| Error(e.to_string()))?,
0o644,
)?;
atomic(Path::new(machine::SNAPSHOT), &config.json()?, 0o644)?;
Ok(())
}
fn install(directory: &Path) -> Result<()> {
root()?;
if !fds_common::recovery_mode()? {
return Err(Error("Installing machine settings requires the recovery console; changes take effect at the next boot".into()));
}
let config = Config::directory(directory)?;
let _lock = prepare()?;
let internal = Internal::open(true)?;
let current = PathBuf::from(format!("{MOUNT}/config/machine.json"));
// Validate ownership and preserve the previous bytes, even when its JSON is
// damaged: recovery must be able to replace invalid settings with valid ones.
let previous = machine::trusted_text(&current, machine::MAX_BUNDLE)?;
atomic(
&current.with_file_name("previous.json"),
previous.as_bytes(),
0o600,
)?;
atomic(&current, &config.json()?, 0o600)?;
internal.close(true)?;
println!(
"Saved machine settings for {}. Reboot to activate them; the current bay mapping is unchanged.",
config.name
);
Ok(())
}
fn diagnostic_name(name: &str) -> Result<()> {
if name.is_empty()
|| name.len() > 80
|| name.starts_with('.')
|| !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b))
{
return Err(Error("Diagnostic name must be 1..80 letters, digits, dots, hyphens or underscores and must not start with a dot".into()));
}
Ok(())
}
fn store(name: &str, input: &Path) -> Result<()> {
root()?;
diagnostic_name(name)?;
let file = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(input)?;
if !file.metadata()?.is_file() {
return Err(Error("Diagnostic input must be a regular file".into()));
}
let mut bytes = Vec::new();
file.take(16 * 1024 * 1024 + 1).read_to_end(&mut bytes)?;
if bytes.len() > 16 * 1024 * 1024 {
return Err(Error("Diagnostic input exceeds 16 MiB".into()));
}
let _lock = prepare()?;
let internal = Internal::open(true)?;
let directory = PathBuf::from(format!("{MOUNT}/diagnostics"));
let meta = fs::symlink_metadata(&directory)?;
if !meta.is_dir() || meta.uid() != 0 || meta.mode() & 0o077 != 0 {
return Err(Error("Untrusted diagnostics directory".into()));
}
let output = directory.join(name);
if output.symlink_metadata().is_ok() {
return Err(Error("Diagnostic already exists; choose a new name".into()));
}
atomic(&output, &bytes, 0o600)?;
internal.close(true)?;
println!("Saved diagnostics/{name} on internal NVMe; storage is flushed and unmounted.");
Ok(())
}
pub fn run(command: crate::cli::MachineCommand, json: bool) -> Result<()> {
use crate::cli::MachineCommand;
match command {
MachineCommand::Status => {
let text = read_text(Path::new(machine::STATUS), 16384)?;
if json {
println!("{text}");
} else {
let status: serde_json::Value =
serde_json::from_str(&text).map_err(|e| Error(e.to_string()))?;
println!(
"MACHINE {}\nSETTINGS {}",
status["name"].as_str().unwrap_or("unknown"),
status["source"].as_str().unwrap_or("unknown")
);
if let Some(error) = status["error"].as_str() {
println!("DETAIL {error}");
}
}
}
MachineCommand::Validate { directory } => {
let config = Config::directory(&directory)?;
println!("Valid machine settings: {}", config.name);
}
MachineCommand::Pack {
directory,
new_json_file: output,
} => {
let bytes = Config::directory(&directory)?.json()?;
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&output)?;
file.write_all(&bytes)?;
file.sync_all()?;
println!("Validated machine settings: {}", output.display());
}
MachineCommand::Load => load()?,
MachineCommand::Install { directory } => install(&directory)?,
MachineCommand::Store { name, file: input } => store(&name, &input)?,
MachineCommand::Export {
new_directory: directory,
} => {
let config = Config::active()?;
let path = &directory;
fs::create_dir(path)?;
fs::write(
path.join("machine.toml"),
format!(
"format = 1\nname = {}\n",
serde_json::to_string(&config.name).map_err(|e| Error(e.to_string()))?
),
)?;
fs::write(path.join("bays.toml"), config.bays)?;
fs::write(path.join("hardware-catalog.toml"), config.hardware_catalog)?;
println!(
"Exported this boot's machine settings to {}",
directory.display()
);
}
MachineCommand::Fetch {
name,
new_file: output,
} => {
diagnostic_name(&name)?;
let _lock = prepare()?;
let internal = Internal::open(false)?;
let path = format!("{MOUNT}/diagnostics/{name}");
let input = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(path)?;
let meta = input.metadata()?;
if !meta.is_file() || meta.uid() != 0 || meta.mode() & 0o022 != 0 {
return Err(Error("Untrusted diagnostic file".into()));
}
let mut bytes = Vec::new();
input.take(16 * 1024 * 1024 + 1).read_to_end(&mut bytes)?;
if bytes.len() > 16 * 1024 * 1024 {
return Err(Error("Diagnostic exceeds 16 MiB".into()));
}
internal.close(false)?;
let mut output = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(output)?;
output.write_all(&bytes)?;
output.sync_all()?;
println!("Retrieved diagnostics/{name}");
}
}
Ok(())
}
+271
View File
@@ -0,0 +1,271 @@
mod cli;
mod machine;
mod power;
mod recovery;
use clap::Parser;
use cli::{
Action, DataCommand, InspectCommand, PowerCommand, ProfileCommand, RecoveryCommand, Switch,
};
use fds_common::{
Error, Result, VERSION,
control::{self, Request},
manifest::Manifest,
read_text, trace,
};
use std::{path::Path, process::ExitCode};
fn run() -> Result<()> {
let (command, json) = cli::Cli::parse().into_command();
let Some(command) = command else {
return Ok(cli::help(None)?);
};
match command {
Action::Machine {
command: Some(command),
} => machine::run(command, json)?,
Action::Machine { command: None } => cli::help(Some("machine"))?,
Action::Recovery { command: None } => cli::help(Some("recovery"))?,
Action::Recovery {
command: Some(RecoveryCommand::Check(args)),
} => recovery::run(args.bay, false, None, json)?,
Action::Recovery {
command: Some(RecoveryCommand::Repair { bay, confirm }),
} => recovery::run(bay, true, confirm, json)?,
Action::Poweroff => power::client(Request::Poweroff, json)?,
Action::Reboot => power::client(Request::Reboot, json)?,
Action::Power(args) => match args.command.unwrap_or(PowerCommand::Status) {
PowerCommand::Poweroff => power::client(Request::Poweroff, json)?,
PowerCommand::Reboot => power::client(Request::Reboot, json)?,
PowerCommand::Status => power::client(Request::PowerStatus, json)?,
PowerCommand::Resume => power::client(Request::PowerResume, json)?,
PowerCommand::ShutdownHook => power::shutdown_hook()?,
PowerCommand::HoldShutdown => power::hold_shutdown()?,
PowerCommand::RecordFinal { outcome } => power::record_final(outcome.is_some())?,
},
Action::Burn { command } => fds_burn::client::burn(command, json)?,
Action::Format { command } => fds_burn::client::format(command, json)?,
Action::Inspect(args) => {
if let Some(InspectCommand::Image { image: path }) = args.command {
let file = std::fs::File::open(path)?;
if !file.metadata()?.is_file() {
return Err(Error("Image inspection requires a regular file".into()));
}
let mut info = fds_burn::image::inspect(&file, file.metadata()?.len())?;
info.sha256 = Some(fds_burn::image::digest(&file, info.bytes, |_| Ok(()))?);
println!(
"{}",
serde_json::to_string_pretty(&info).map_err(|e| Error(e.to_string()))?
);
} else {
let path = args
.target
.expect("Clap requires a target or image subcommand");
if let Some(bay) = path
.to_str()
.and_then(|value| fds_burn::client::bay(value).ok())
{
fds_burn::client::inspect_bay(bay, json)?;
} else {
inspect_manifest(&path, json)?;
}
}
}
Action::Profiles => cartridge(Request::Profiles, json)?,
Action::Profile { command } => cartridge(
Request::Profile {
profile: match command {
ProfileCommand::Activate { name } => name,
ProfileCommand::Deactivate => "cli".into(),
},
},
json,
)?,
Action::Network { state } => cartridge(
Request::Network {
enabled: matches!(state, Switch::On),
},
json,
)?,
Action::Bays => cartridge(Request::Bays, json)?,
Action::Bay(args) => cartridge(Request::Bay { bay: args.bay }, json)?,
Action::Eject(args) => cartridge(Request::Eject { bay: args.bay }, json)?,
Action::Rescan => cartridge(Request::Rescan, json)?,
Action::Data {
command: DataCommand::Use(args),
} => cartridge(Request::DataUse { bay: args.bay }, json)?,
Action::Run { bay, arguments } => cartridge(Request::Run { bay, arguments }, json)?,
Action::Topology => cartridge(Request::Topology, json)?,
Action::Version => println!("fds {VERSION}"),
Action::BootProfile => trace::load(Path::new(trace::RUNTIME))?.print(json)?,
Action::Info => info(json)?,
}
Ok(())
}
fn info(json: bool) -> Result<()> {
let kernel = read_text(Path::new("/proc/sys/kernel/osrelease"), 4096)?
.trim()
.to_owned();
let pid1 = std::fs::read_link("/proc/1/exe")
.map(|p| p.display().to_string())
.unwrap_or_else(|_| {
read_text(Path::new("/proc/1/comm"), 4096)
.map(|s| s.trim().to_owned())
.unwrap_or_else(|_| "unavailable".into())
});
let target = if cfg!(all(
target_arch = "aarch64",
target_env = "musl",
target_feature = "crt-static"
)) {
"aarch64 static-musl"
} else {
"host test build"
};
if json {
println!(
"{}",
serde_json::json!({"fds_version": VERSION, "target": target, "kernel": kernel, "pid1": pid1})
);
} else {
println!("FDS/OS {VERSION}\nTOOLS {target}\nKERNEL {kernel}\nINIT {pid1}");
}
Ok(())
}
fn inspect_manifest(path: &Path, json: bool) -> Result<()> {
let manifest = Manifest::load(path)?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&manifest).map_err(|e| Error(e.to_string()))?
);
} else {
println!(
"{}\n{} {}\nID {}\nMEDIA {}",
manifest.cartridge.class.label(),
manifest.cartridge.name,
manifest.cartridge.version,
manifest.cartridge.id,
if manifest.media.writable {
"WRITABLE"
} else {
"READ-ONLY"
}
);
if let Some(activation) = manifest.activation {
println!("PROFILE {}", activation.profile);
}
}
Ok(())
}
fn cartridge(request: Request, json: bool) -> Result<()> {
let debug = matches!(request, Request::Topology);
let response = control::request(&request)?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&response).map_err(|e| Error(e.to_string()))?
);
} else {
if let Some(pid) = response.started_pid {
println!("STARTED {pid} Output: /run/log/cartridged/current");
}
if let Some(p) = response.profiles {
println!(
"PROFILES cli, windowmaker\nDESKTOP {} {}\nNETWORK {}",
p.desktop,
if p.desktop == "windowmaker" {
if p.ready_ns.is_some() {
"READY"
} else {
"STARTING"
}
} else {
""
},
if p.network.is_empty() {
"OFF".into()
} else {
p.network.join(", ")
}
);
if let Some(bay) = p.environment_bay {
println!("ENVIRONMENT BAY {bay}");
}
if let (Some(start), Some(end)) = (p.activation_ns, p.ready_ns) {
println!(
"ACTIVATION TO READY {:.3} ms",
end.saturating_sub(start) as f64 / 1_000_000.0
);
}
if let Some(error) = p.error {
println!("ERROR {error}");
}
return Ok(());
}
for bay in response.bays {
println!(
"BAY {} {}{}",
bay.bay,
bay.state.to_uppercase().replace('_', " "),
bay.name
.as_ref()
.map(|n| format!(" {n}"))
.unwrap_or_default()
);
if let Some(detail) = bay.detail {
println!(" {detail}");
}
if let Some(manifest) = bay.manifest {
println!(
" {} {} {}",
manifest.cartridge.class.label(),
manifest.cartridge.id,
manifest.cartridge.version
);
}
if let Some(catalogue) = bay.software {
for software in catalogue.software {
println!(
" SOFTWARE {} {} (partition {})",
software.id, software.version, software.partition
);
for command in software.commands.keys() {
println!(" fds run {} -- {}:{}", bay.bay, software.id, command);
}
}
}
if let Some(mount) = bay.mount {
println!(" MOUNT {mount}");
}
if bay.consumers > 0 {
println!(" MANAGED PROCESSES {}", bay.consumers);
}
if debug {
for device in bay.devices {
println!(
" {} USB {}:{}",
device.topology, device.vendor, device.product
);
}
}
}
if debug {
for device in response.unmapped {
println!(
"UNMAPPED {} USB {}:{}",
device.topology, device.vendor, device.product
);
}
}
}
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("fds: {error}");
ExitCode::from(2)
}
}
}
+134
View File
@@ -0,0 +1,134 @@
use fds_common::{
Error, Result,
control::{self, PowerEvent, PowerState, Request},
trace,
};
use std::{
ffi::CString,
fs, io,
os::{
fd::{AsRawFd, FromRawFd, OwnedFd},
unix::fs::PermissionsExt,
},
path::Path,
};
const DIRECTORY: &str = "/run/fds/power";
const RECORD: &str = "/run/fds/power/state.json";
pub fn client(request: Request, json: bool) -> Result<()> {
let power = control::request(&request)?
.power
.ok_or_else(|| Error("Missing shutdown state".into()))?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&power).map_err(|e| Error(e.to_string()))?
);
} else {
println!("POWER STATE: {}", power.phase);
if let Some(action) = &power.action {
println!("ACTION: {action}");
}
if power.native_pending {
println!("Native s6 shutdown has been requested.");
}
if let Some(error) = &power.error {
println!(
"BLOCKED: {error}\nResolve the problem and retry, or use fds power resume before native shutdown starts."
);
}
}
Ok(())
}
fn root() -> Result<()> {
if unsafe { libc::geteuid() } != 0 {
Err(Error("This native shutdown helper requires root".into()))
} else {
Ok(())
}
}
pub fn hold_shutdown() -> Result<()> {
root()?;
// Last-resort fail-closed path used only if the state watcher itself fails.
// A blocked signal wait consumes no CPU and does not advance native init.
let mut mask = unsafe { std::mem::zeroed() };
unsafe {
libc::sigfillset(&mut mask);
if libc::sigprocmask(libc::SIG_BLOCK, &mask, std::ptr::null_mut()) < 0 {
return Err(io::Error::last_os_error().into());
}
loop {
libc::pause();
}
}
}
fn state() -> Result<PowerState> {
serde_json::from_str(&fds_common::read_text(Path::new(RECORD), 32 * 1024)?)
.map_err(|e| Error(format!("Invalid shutdown record: {e}")))
}
pub fn shutdown_hook() -> Result<()> {
root()?;
fs::create_dir_all(DIRECTORY)?;
fs::set_permissions(DIRECTORY, fs::Permissions::from_mode(0o700))?;
let fd = unsafe { libc::inotify_init1(libc::IN_CLOEXEC) };
if fd < 0 {
return Err(io::Error::last_os_error().into());
}
let fd = unsafe { OwnedFd::from_raw_fd(fd) };
let path = CString::new(DIRECTORY).unwrap();
if unsafe {
libc::inotify_add_watch(
fd.as_raw_fd(),
path.as_ptr(),
libc::IN_CLOSE_WRITE | libc::IN_MOVED_TO,
)
} < 0
{
return Err(io::Error::last_os_error().into());
}
// Record the irrevocable native request even if the cartridge daemon is
// currently restarting. Its next startup must keep operations frozen.
fs::write(
Path::new(DIRECTORY).join("native-pending"),
b"native shutdown requested\n",
)?;
if let Err(error) = control::request(&Request::PowerPrepare) {
eprintln!(
"Native shutdown is waiting: {error}. Resolve the problem and retry fds poweroff; DATA is not declared SAFE."
);
}
loop {
if state().is_ok_and(|s| s.phase == "prepared" && s.native_pending) {
return Ok(());
}
let mut bytes = [0u8; 4096];
let count = unsafe { libc::read(fd.as_raw_fd(), bytes.as_mut_ptr().cast(), bytes.len()) };
if count < 0 && io::Error::last_os_error().kind() == io::ErrorKind::Interrupted {
continue;
}
if count <= 0 {
return Err(Error("Shutdown state watcher failed".into()));
}
}
}
pub fn record_final(failed: bool) -> Result<()> {
root()?;
let mut record = state()?;
if record.phase != "prepared" {
return Err(Error("Shutdown preparation was not completed".into()));
}
record.events.push(PowerEvent {
phase: if failed {
"service_stop_failed"
} else {
"services_stopped"
}
.into(),
at_ns: trace::now()?,
});
println!(
"FDS_SHUTDOWN_FINAL {}",
serde_json::to_string(&record).map_err(|e| Error(e.to_string()))?
);
Ok(())
}
+47
View File
@@ -0,0 +1,47 @@
use fds_common::{
Bay, Error, Result,
control::{self, Request},
};
pub fn run(bay: Bay, repair: bool, confirmation: Option<String>, json: bool) -> Result<()> {
let response = control::request(&Request::RecoveryData {
bay,
repair,
confirmation,
})?;
let report = response
.recovery
.ok_or_else(|| Error("Missing recovery response".into()))?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&report).map_err(|e| Error(e.to_string()))?
);
} else {
println!(
"BAY {} {} {} bytes\nSERIAL {}",
report.disk.bay,
report.disk.model,
report.disk.bytes,
report.disk.serial.as_deref().unwrap_or("unavailable")
);
if let Some(token) = report.confirmation {
println!(
"Repair preview: no filesystem changes made.\nAfter reviewing this device, run:\nfds recovery repair {bay} --confirm '{token}'"
);
} else {
println!(
"{}\nSAFE TO REMOVE",
if report.repaired {
"DATA REPAIRED AND VERIFIED"
} else {
"DATA CHECK PASSED"
}
);
}
if let Some(log) = report.log {
println!("LOG {log}");
}
}
Ok(())
}
View File
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "fds-common"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "Shared bounded configuration and Linux discovery for FDS tools"
[dependencies]
serde = { version = "1", features = ["derive"] }
toml = "0.8"
serde_json = "1"
libc = "0.2"
+84
View File
@@ -0,0 +1,84 @@
use crate::{Error, Result};
use serde::Serialize;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum BootMode {
#[default]
Normal,
Recovery,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct BootOptions {
pub mode: BootMode,
pub debug: bool,
pub emulator: bool,
}
impl BootOptions {
pub fn parse(command_line: &str) -> Result<Self> {
if command_line.len() > 65536 || command_line.contains('\0') {
return Err(Error("Invalid kernel command line".into()));
}
let mut options = Self::default();
let mut seen = std::collections::BTreeSet::new();
for word in command_line
.split_ascii_whitespace()
.filter(|w| w.starts_with("fds."))
{
let (key, value) = word
.split_once('=')
.ok_or_else(|| Error(format!("Expected key=value: {word}")))?;
if !seen.insert(key) {
return Err(Error(format!("Duplicate boot option: {key}")));
}
match (key, value) {
("fds.boot", "normal") => options.mode = BootMode::Normal,
("fds.boot", "recovery") => options.mode = BootMode::Recovery,
("fds.debug", "0") => options.debug = false,
("fds.debug", "1") => options.debug = true,
("fds.emulator", "0") => options.emulator = false,
("fds.emulator", "1") => options.emulator = true,
_ => return Err(Error(format!("Unknown or invalid boot option: {word}"))),
}
}
Ok(options)
}
pub fn root_label(self) -> &'static str {
match self.mode {
BootMode::Normal => "FDS_SYSTEM",
BootMode::Recovery => "FDS_RECOVERY",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recovery_is_explicit_and_options_fail_closed() {
assert_eq!(
BootOptions::parse("console=ttyAMA0 ro")
.unwrap()
.root_label(),
"FDS_SYSTEM"
);
assert_eq!(
BootOptions::parse("quiet fds.boot=recovery fds.debug=1")
.unwrap()
.root_label(),
"FDS_RECOVERY"
);
assert!(BootOptions::parse("fds.emulator=1").unwrap().emulator);
assert!(!BootOptions::parse("fds.emulator=0").unwrap().emulator);
for bad in [
"fds.emulator=2",
"fds.emulator=1 fds.emulator=0",
"fds.boot=anything",
"fds.debug=2",
"fds.boot=normal fds.boot=recovery",
"fds.boot",
"fds.execute=/bin/sh",
] {
assert!(BootOptions::parse(bad).is_err(), "{bad}");
}
}
}
+233
View File
@@ -0,0 +1,233 @@
//! Bounded, versioned local control protocol. No client-supplied device paths.
use crate::{Bay, Error, Result, manifest::Manifest, topology::UsbDevice};
use serde::{Deserialize, Serialize};
use std::{
io::{Read, Write},
os::unix::net::UnixStream,
time::Duration,
};
pub const SOCKET: &str = "/run/fds/control.sock";
pub const LIMIT: usize = 256 * 1024;
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "command", rename_all = "snake_case", deny_unknown_fields)]
pub enum Request {
Poweroff,
Reboot,
PowerStatus,
PowerResume,
PowerPrepare,
RecoveryData {
bay: Bay,
repair: bool,
confirmation: Option<String>,
},
Bays,
Profiles,
Profile {
profile: String,
},
Network {
enabled: bool,
},
Bay {
bay: Bay,
},
Disk {
bay: Bay,
},
Topology,
Rescan,
Eject {
bay: Bay,
},
DataUse {
bay: Bay,
},
Run {
bay: Bay,
arguments: Vec<String>,
},
MediaPrepare {
bay: Bay,
image: String,
class: crate::manifest::Class,
},
MediaStatus {
id: String,
after_sequence: Option<u64>,
},
MediaConfirm {
id: String,
confirmation: String,
},
MediaCancel {
id: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaJob {
pub id: String,
pub bay: Bay,
pub sequence: u64,
pub phase: String,
pub diskseq: u64,
pub target_bytes: u64,
pub model: String,
pub serial: Option<String>,
pub image_class: crate::manifest::Class,
pub image_bytes: Option<u64>,
pub image_sha256: Option<String>,
pub progress_bytes: u64,
pub confirmation: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BayDisk {
pub bay: Bay,
pub diskseq: u64,
pub bytes: u64,
pub sector_bytes: u32,
pub model: String,
pub serial: Option<String>,
pub protected: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecoveryReport {
pub disk: BayDisk,
pub confirmation: Option<String>,
pub checked: bool,
pub repaired: bool,
pub log: Option<String>,
}
impl MediaJob {
pub fn finished(&self) -> bool {
matches!(self.phase.as_str(), "complete" | "failed" | "cancelled")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BayState {
pub bay: Bay,
pub state: String,
pub name: Option<String>,
pub detail: Option<String>,
pub devices: Vec<UsbDevice>,
pub manifest: Option<Manifest>,
pub mount: Option<String>,
pub consumers: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub software: Option<crate::software::Catalogue>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProfileState {
pub desktop: String,
pub environment_bay: Option<Bay>,
pub network: Vec<String>,
pub manual_network: bool,
pub activation_ns: Option<u64>,
pub ready_ns: Option<u64>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PowerEvent {
pub phase: String,
pub at_ns: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PowerState {
pub phase: String,
pub action: Option<String>,
pub native_pending: bool,
pub error: Option<String>,
pub events: Vec<PowerEvent>,
}
impl Default for PowerState {
fn default() -> Self {
Self {
phase: "idle".into(),
action: None,
native_pending: false,
error: None,
events: Vec::new(),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Response {
pub format: u32,
pub error: Option<String>,
pub bays: Vec<BayState>,
pub unmapped: Vec<UsbDevice>,
pub started_pid: Option<u32>,
#[serde(default)]
pub profiles: Option<ProfileState>,
#[serde(default)]
pub media_job: Option<MediaJob>,
#[serde(default)]
pub disk: Option<BayDisk>,
#[serde(default)]
pub power: Option<PowerState>,
#[serde(default)]
pub recovery: Option<RecoveryReport>,
}
impl Response {
pub fn failure(error: impl ToString) -> Self {
Self {
format: 1,
error: Some(error.to_string()),
bays: Vec::new(),
unmapped: Vec::new(),
started_pid: None,
profiles: None,
media_job: None,
disk: None,
power: None,
recovery: None,
}
}
}
pub fn request(request: &Request) -> Result<Response> {
let mut stream = UnixStream::connect(SOCKET)
.map_err(|e| Error(format!("Cartridge service unavailable: {e}")))?;
// Flushing real removable storage can legitimately outlast a status query.
// A timeout remains an error, never a substituted SAFE result.
stream.set_read_timeout(Some(Duration::from_secs(
if matches!(request, Request::RecoveryData { .. }) {
1800
} else if matches!(
request,
Request::Run { .. }
| Request::Eject { .. }
| Request::Poweroff
| Request::Reboot
| Request::PowerPrepare
| Request::Profile { .. }
| Request::Network { .. }
| Request::MediaStatus { .. }
) {
120
} else {
10
},
)))?;
stream.set_write_timeout(Some(Duration::from_secs(10)))?;
let mut data = serde_json::to_vec(request).map_err(|e| Error(e.to_string()))?;
if data.len() + 1 > LIMIT {
return Err(Error("Cartridge request exceeds protocol limit".into()));
}
data.push(b'\n');
stream.write_all(&data)?;
let mut bytes = Vec::new();
stream.take((LIMIT + 1) as u64).read_to_end(&mut bytes)?;
if bytes.len() > LIMIT {
return Err(Error("Cartridge response exceeds protocol limit".into()));
}
let response: Response = serde_json::from_slice(&bytes)
.map_err(|e| Error(format!("Invalid cartridge response: {e}")))?;
if response.format != 1 {
return Err(Error("Unsupported cartridge protocol".into()));
}
if let Some(error) = &response.error {
return Err(Error(error.clone()));
}
Ok(response)
}
+102
View File
@@ -0,0 +1,102 @@
//! Shared data contracts. Cartridge contents are data, never startup commands.
pub mod boot;
pub mod control;
pub mod machine;
pub mod manifest;
pub mod software;
pub mod sysfs;
pub mod topology;
pub mod trace;
use std::{fmt, fs::File, io::Read, path::Path, str::FromStr};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const MAX_CONFIG_BYTES: u64 = 64 * 1024;
pub type Result<T> = std::result::Result<T, Error>;
/// The profile marker is part of the immutable root, never supplied by media.
pub fn recovery_mode() -> Result<bool> {
Ok(read_text(Path::new("/usr/share/fds/image-profile"), 64)?.trim() == "recovery")
}
#[derive(Debug)]
pub struct Error(pub String);
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for Error {}
impl From<std::io::Error> for Error {
fn from(value: std::io::Error) -> Self {
Self(value.to_string())
}
}
/// Read at most the limit plus a sentinel byte, including on special files.
pub fn read_text(path: &Path, limit: u64) -> Result<String> {
let file = File::open(path).map_err(|e| Error(format!("{}: {e}", path.display())))?;
let mut bytes = Vec::new();
file.take(limit + 1).read_to_end(&mut bytes)?;
if bytes.len() as u64 > limit {
return Err(Error(format!("{} exceeds {limit} bytes", path.display())));
}
String::from_utf8(bytes).map_err(|_| Error(format!("{} is not UTF-8", path.display())))
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
#[serde(try_from = "u8", into = "u8")]
pub struct Bay(u8);
impl Bay {
pub fn number(self) -> u8 {
self.0
}
}
impl TryFrom<u8> for Bay {
type Error = Error;
fn try_from(value: u8) -> Result<Self> {
if (1..=12).contains(&value) {
Ok(Self(value))
} else {
Err(Error("Bay must be between 1 and 12".into()))
}
}
}
impl From<Bay> for u8 {
fn from(value: Bay) -> Self {
value.0
}
}
impl FromStr for Bay {
type Err = Error;
fn from_str(value: &str) -> Result<Self> {
if value.is_empty() || value.len() > 2 || !value.bytes().all(|c| c.is_ascii_digit()) {
return Err(Error("Bay must be a number from 01 to 12".into()));
}
Self::try_from(
value
.parse::<u8>()
.map_err(|_| Error("Invalid bay".into()))?,
)
}
}
impl fmt::Display for Bay {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:02}", self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bay_identifiers_are_bounded() {
assert_eq!("02".parse::<Bay>().unwrap().number(), 2);
assert_eq!(Bay::try_from(12).unwrap().to_string(), "12");
for invalid in ["", "0", "13", "-1", "+2", " 2", "002", "a"] {
assert!(invalid.parse::<Bay>().is_err(), "{invalid}");
}
}
}
+217
View File
@@ -0,0 +1,217 @@
//! A single atomic settings document; TOML contents remain declarative data.
use crate::{
Error, MAX_CONFIG_BYTES, Result, read_text,
topology::{BayMap, Catalog},
};
use serde::{Deserialize, Serialize};
use std::{
fs::{self, File, OpenOptions},
io::Read,
os::unix::fs::{MetadataExt, OpenOptionsExt},
path::Path,
};
pub const SNAPSHOT: &str = "/run/fds/machine/config.json";
pub const STATUS: &str = "/run/fds/machine/status.json";
pub const MAX_BUNDLE: u64 = 256 * 1024;
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub format: u8,
pub name: String,
pub bays: String,
pub hardware_catalog: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Identity {
format: u8,
name: String,
}
impl Config {
pub fn validate(&self) -> Result<()> {
if self.format != 1
|| self.name.is_empty()
|| self.name.len() > 128
|| self.name.chars().any(char::is_control)
|| self.bays.len() as u64 > MAX_CONFIG_BYTES
|| self.hardware_catalog.len() as u64 > MAX_CONFIG_BYTES
{
return Err(Error(
"Invalid machine settings format, name or size".into(),
));
}
BayMap::parse(&self.bays)?;
Catalog::parse(&self.hardware_catalog)?;
// JSON escaping can expand two individually valid TOML inputs beyond
// the loader's bundle limit. Reject that before an installation writes.
self.json()?;
Ok(())
}
pub fn parse(text: &str) -> Result<Self> {
if text.len() as u64 > MAX_BUNDLE {
return Err(Error("Machine settings are too large".into()));
}
let result: Self = serde_json::from_str(text)
.map_err(|e| Error(format!("Invalid machine settings: {e}")))?;
result.validate()?;
Ok(result)
}
pub fn directory(path: &Path) -> Result<Self> {
let identity: Identity = toml::from_str(&source_text(&path.join("machine.toml"), 4096)?)
.map_err(|e| Error(format!("Invalid machine identity: {e}")))?;
let result = Self {
format: identity.format,
name: identity.name,
bays: source_text(&path.join("bays.toml"), MAX_CONFIG_BYTES)?,
hardware_catalog: source_text(&path.join("hardware-catalog.toml"), MAX_CONFIG_BYTES)?,
};
result.validate()?;
Ok(result)
}
pub fn fallback() -> Result<Self> {
let result = Self {
format: 1,
name: "FDS image defaults".into(),
bays: read_text(Path::new("/etc/fds/bays.toml"), MAX_CONFIG_BYTES)?,
hardware_catalog: read_text(
Path::new("/etc/fds/hardware-catalog.toml"),
MAX_CONFIG_BYTES,
)?,
};
result.validate()?;
Ok(result)
}
pub fn active() -> Result<Self> {
if Path::new(SNAPSHOT).exists() {
Self::parse(&trusted_text(Path::new(SNAPSHOT), MAX_BUNDLE)?)
} else {
Self::fallback()
}
}
pub fn json(&self) -> Result<Vec<u8>> {
let mut bytes = serde_json::to_vec_pretty(self).map_err(|e| Error(e.to_string()))?;
bytes.push(b'\n');
if bytes.len() as u64 > MAX_BUNDLE {
return Err(Error("Encoded machine settings are too large".into()));
}
Ok(bytes)
}
}
fn source_text(path: &Path, limit: u64) -> Result<String> {
let file = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(path)?;
if !file.metadata()?.is_file() {
return Err(Error(
"Machine settings inputs must be regular files".into(),
));
}
let mut text = String::new();
file.take(limit + 1).read_to_string(&mut text)?;
if text.len() as u64 > limit {
return Err(Error("Machine settings exceed the size limit".into()));
}
Ok(text)
}
/// Reject symlinks, FIFOs, device nodes, and unprivileged-writable settings.
pub fn trusted_text(path: &Path, limit: u64) -> Result<String> {
for parent in path.ancestors().skip(1) {
let meta = fs::symlink_metadata(parent)?;
if !meta.is_dir() || meta.uid() != 0 || meta.mode() & 0o022 != 0 {
return Err(Error(format!(
"Untrusted machine settings directory: {}",
parent.display()
)));
}
}
let file: File = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(path)?;
let meta = file.metadata()?;
if !meta.is_file() || meta.uid() != 0 || meta.mode() & 0o022 != 0 {
return Err(Error(
"Machine settings must be a root-owned regular file without group/other write access"
.into(),
));
}
let mut text = String::new();
file.take(limit + 1).read_to_string(&mut text)?;
if text.len() as u64 > limit {
return Err(Error("Machine settings exceed the size limit".into()));
}
Ok(text)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_settings_reject_symlinks_fifos_and_oversized_files() {
let path = std::env::temp_dir().join(format!("fds-machine-source-{}", std::process::id()));
fs::create_dir(&path).unwrap();
fs::write(path.join("ordinary"), "hello").unwrap();
std::os::unix::fs::symlink("ordinary", path.join("link")).unwrap();
let fifo = std::ffi::CString::new(path.join("fifo").to_str().unwrap()).unwrap();
assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0);
assert!(source_text(&path.join("link"), 10).is_err());
assert!(source_text(&path.join("fifo"), 10).is_err());
assert!(source_text(&path.join("ordinary"), 4).is_err());
assert_eq!(source_text(&path.join("ordinary"), 5).unwrap(), "hello");
fs::remove_dir_all(path).unwrap();
}
#[test]
fn bundle_reuses_strict_topology_and_catalog_contracts() {
let good = Config {
format: 1,
name: "FP-85".into(),
bays: "".into(),
hardware_catalog: "device=[]".into(),
};
assert!(Config::parse(&String::from_utf8(good.json().unwrap()).unwrap()).is_ok());
for bad in [
Config { format: 2, ..good },
Config {
format: 1,
name: "bad\nname".into(),
bays: "".into(),
hardware_catalog: "".into(),
},
] {
assert!(bad.validate().is_err());
}
assert!(
Config::parse(
r#"{"format":1,"name":"FP-85","bays":"","hardware_catalog":"execute='sh'"}"#
)
.is_err()
);
assert!(
Config::parse(
r#"{"format":1,"name":"FP-85","bays":"","hardware_catalog":"","command":"sh"}"#
)
.is_err()
);
}
#[test]
fn escaped_toml_cannot_produce_an_unloadable_bundle() {
// Backslashes in TOML comments are legal, but JSON doubles each byte.
let comment = format!("#{}", "\\".repeat(MAX_CONFIG_BYTES as usize - 1));
let config = Config {
format: 1,
name: "FP-85".into(),
bays: comment.clone(),
hardware_catalog: comment,
};
assert!(BayMap::parse(&config.bays).is_ok());
assert!(Catalog::parse(&config.hardware_catalog).is_ok());
assert!(config.validate().is_err());
assert!(config.json().is_err());
}
}
+168
View File
@@ -0,0 +1,168 @@
use crate::{Error, MAX_CONFIG_BYTES, Result, read_text};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Class {
System,
Data,
Program,
Environment,
Hardware,
Utility,
}
impl Class {
pub fn label(self) -> &'static str {
match self {
Self::System => "SYSTEM",
Self::Data => "DATA",
Self::Program => "PROGRAM",
Self::Environment => "ENVIRONMENT",
Self::Hardware => "HARDWARE",
Self::Utility => "UTILITY",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Cartridge {
pub id: String,
pub name: String,
pub class: Class,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Media {
pub writable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Activation {
pub profile: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub format: u32,
pub cartridge: Cartridge,
pub media: Media,
#[serde(skip_serializing_if = "Option::is_none")]
pub activation: Option<Activation>,
}
pub fn identifier(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 64
&& value.as_bytes()[0].is_ascii_alphanumeric()
&& value
.bytes()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || b"._-".contains(&c))
&& !value.contains("..")
}
fn display_text(value: &str, limit: usize) -> bool {
!value.trim().is_empty() && value.len() <= limit && !value.chars().any(char::is_control)
}
impl Manifest {
pub fn to_toml(&self) -> Result<String> {
self.validate()?;
toml::to_string(self).map_err(|e| Error(e.to_string()))
}
pub fn load(path: &Path) -> Result<Self> {
Self::parse(&read_text(path, MAX_CONFIG_BYTES)?)
}
pub fn parse(input: &str) -> Result<Self> {
if input.len() as u64 > MAX_CONFIG_BYTES {
return Err(Error("Cartridge manifest exceeds 64 KiB".into()));
}
let value: Self =
toml::from_str(input).map_err(|e| Error(format!("Invalid cartridge manifest: {e}")))?;
value.validate()?;
Ok(value)
}
pub fn validate(&self) -> Result<()> {
if self.format != 1 {
return Err(Error("Unsupported cartridge format; expected 1".into()));
}
if !identifier(&self.cartridge.id) {
return Err(Error("Invalid cartridge id".into()));
}
if !display_text(&self.cartridge.name, 128) || !display_text(&self.cartridge.version, 32) {
return Err(Error(
"Cartridge name/version is empty, too long, or contains control characters".into(),
));
}
if self.cartridge.class == Class::Data && !self.media.writable {
return Err(Error("DATA requires writable media".into()));
}
if matches!(
self.cartridge.class,
Class::System | Class::Program | Class::Environment
) && self.media.writable
{
return Err(Error(
"SYSTEM, PROGRAM and ENVIRONMENT media must be read-only".into(),
));
}
match (&self.activation, self.cartridge.class) {
(Some(value), Class::Environment) if identifier(&value.profile) => (),
(None, Class::Environment) => {
return Err(Error("ENVIRONMENT requires an activation profile".into()));
}
(Some(_), _) => {
return Err(Error(
"Only ENVIRONMENT permits a valid declarative activation profile".into(),
));
}
(None, _) => (),
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
const VALID: &str = include_str!("../../../tests/fixtures/manifests/windowmaker.toml");
#[test]
fn master_plan_manifest_roundtrips() {
let value = Manifest::parse(VALID).unwrap();
assert_eq!(value.cartridge.class, Class::Environment);
assert_eq!(value.activation.as_ref().unwrap().profile, "windowmaker");
assert_eq!(
Manifest::parse(&toml::to_string(&value).unwrap())
.unwrap()
.cartridge
.id,
value.cartridge.id
);
}
#[test]
fn untrusted_manifest_cannot_inject_actions_or_paths() {
for invalid in [
VALID.replace("format = 1", "format = 2"),
VALID.replace("fds.windowmaker", "../../etc"),
VALID.replace("profile = \"windowmaker\"", "run = \"/FDS/autorun.sh\""),
VALID.replace("writable = false", "writable = true"),
VALID.replace("WINDOW SYSTEM", "WINDOW\\nSYSTEM"),
VALID.replace("profile = \"windowmaker\"", "profile = \"/bin/sh\""),
format!("{VALID}\n[autorun]\ncommand = 'id'\n"),
] {
assert!(Manifest::parse(&invalid).is_err(), "{invalid}");
}
assert!(Manifest::parse(&" ".repeat(65 * 1024)).is_err());
}
#[test]
fn data_and_profile_classes_are_not_interchangeable() {
assert!(Manifest::parse(&VALID.replace("environment", "data")).is_err());
let data = VALID
.split("[activation]")
.next()
.unwrap()
.replace("environment", "data")
.replace("writable = false", "writable = true");
assert!(Manifest::parse(&data).is_ok());
}
}
+184
View File
@@ -0,0 +1,184 @@
//! Software metadata is descriptive. Bundles never contain privileged build hooks.
use crate::{Error, Result, manifest::identifier};
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, BTreeSet},
path::{Component, Path},
};
pub const MAX_ARCHIVE: u64 = 512 * 1024 * 1024;
pub const MAX_UNPACKED: u64 = 1024 * 1024 * 1024;
pub const MAX_ENTRIES: u32 = 65_536;
pub const MAX_CATALOGUE: usize = 64 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Software {
pub id: String,
pub name: String,
pub version: String,
pub architecture: String,
/// GPT partition number, starting at 2 after FDS_METADATA.
pub partition: u8,
pub archive_bytes: u64,
pub unpacked_bytes: u64,
pub entries: u32,
pub sha256: String,
/// Command names mapped to relative regular executables inside the bundle.
pub commands: BTreeMap<String, String>,
}
impl Software {
pub fn archive_path(&self) -> String {
format!("bundles/{}.tar.xz", self.id)
}
pub fn validate(&self) -> Result<()> {
let display = |s: &str, max: usize| {
!s.trim().is_empty() && s.len() <= max && !s.chars().any(char::is_control)
};
if !identifier(&self.id)
|| !display(&self.name, 128)
|| !display(&self.version, 32)
|| !matches!(self.architecture.as_str(), "aarch64" | "any")
|| !(2..=33).contains(&self.partition)
|| !(1..=MAX_ARCHIVE).contains(&self.archive_bytes)
|| self.unpacked_bytes > MAX_UNPACKED
|| !(1..=MAX_ENTRIES).contains(&self.entries)
|| self.sha256.len() != 64
|| !self
.sha256
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|| self.commands.is_empty()
|| self.commands.len() > 64
|| self
.commands
.iter()
.any(|(name, path)| !identifier(name) || !relative(path))
{
return Err(Error(
"Invalid software identity, architecture, partition, digest, limits or commands"
.into(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Catalogue {
pub format: u32,
pub software: Vec<Software>,
}
impl Catalogue {
pub fn parse(text: &str) -> Result<Self> {
if text.len() > MAX_CATALOGUE {
return Err(Error("Software catalogue exceeds 64 KiB".into()));
}
let value: Self =
toml::from_str(text).map_err(|e| Error(format!("Invalid software catalogue: {e}")))?;
value.validate()?;
Ok(value)
}
pub fn validate(&self) -> Result<()> {
if self.format != 1 || self.software.is_empty() || self.software.len() > 128 {
return Err(Error(
"Software catalogue requires format 1 and 1..128 software entries".into(),
));
}
let mut ids = BTreeSet::new();
let mut partitions = BTreeSet::new();
for software in &self.software {
software.validate()?;
if !ids.insert(&software.id) {
return Err(Error("Duplicate software id".into()));
}
partitions.insert(software.partition);
}
if partitions
.iter()
.copied()
.ne(2..=partitions.len() as u8 + 1)
{
return Err(Error(
"Every payload partition must be described, consecutively from partition 2".into(),
));
}
Ok(())
}
pub fn partition_count(&self) -> usize {
self.software.iter().map(|s| s.partition).max().unwrap_or(1) as usize
}
pub fn to_toml(&self) -> Result<String> {
self.validate()?;
let result = toml::to_string(self).map_err(|e| Error(e.to_string()))?;
if result.len() > MAX_CATALOGUE {
return Err(Error("Software catalogue exceeds 64 KiB".into()));
}
Ok(result)
}
}
pub fn relative(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 1024
&& !value.chars().any(char::is_control)
&& !value.contains('\\')
&& !value
.split('/')
.any(|s| s.is_empty() || s == "." || s == "..")
&& Path::new(value)
.components()
.all(|c| matches!(c, Component::Normal(_)))
}
#[cfg(test)]
mod tests {
use super::*;
fn software(id: &str, partition: u8) -> Software {
Software {
id: id.into(),
name: id.into(),
version: "1".into(),
architecture: "aarch64".into(),
partition,
archive_bytes: 100,
unpacked_bytes: 200,
entries: 1,
sha256: "a".repeat(64),
commands: [("hello".into(), "bin/hello".into())].into(),
}
}
#[test]
fn multiple_programs_can_share_or_span_payload_partitions() {
let c = Catalogue {
format: 1,
software: vec![software("one", 2), software("two", 2), software("three", 3)],
};
assert_eq!(Catalogue::parse(&c.to_toml().unwrap()).unwrap(), c);
assert_eq!(c.partition_count(), 3);
let mut bad = c.clone();
bad.software[2].partition = 4;
assert!(bad.validate().is_err());
let mut bad = c.clone();
bad.software[2].id = "one".into();
assert!(bad.validate().is_err());
let mut bad = c.clone();
bad.software[0].architecture = "x86_64".into();
assert!(bad.validate().is_err());
let mut bad = c.clone();
bad.software[0]
.commands
.insert("escape".into(), "../bin/sh".into());
assert!(bad.validate().is_err());
assert!(Catalogue::parse(&(c.to_toml().unwrap() + "\n[autorun]\ncommand='sh'\n")).is_err());
}
#[test]
fn paths_are_strictly_relative() {
for bad in [
"", "/bin/sh", "../x", "a/../x", "a//x", "./x", "a/", "a\\b", "a\nb",
] {
assert!(!relative(bad), "{bad:?}");
}
assert!(relative("share/document with spaces.txt"));
}
}
+115
View File
@@ -0,0 +1,115 @@
use crate::{Error, Result, read_text};
use serde::Serialize;
use std::{collections::BTreeMap, fs, path::Path};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BlockPartition {
pub device: String,
pub major: u32,
pub minor: u32,
pub partition_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "state", content = "devices", rename_all = "snake_case")]
pub enum Selection {
Missing,
Unique(BlockPartition),
Ambiguous(Vec<BlockPartition>),
}
/// Read kernel-reported partition identities, never disk enumeration order.
pub fn partitions(sysfs: &Path) -> Result<Vec<BlockPartition>> {
let mut result = Vec::new();
for entry in fs::read_dir(sysfs.join("class/block"))? {
let path = entry?.path();
let input = match read_text(&path.join("uevent"), 16384) {
Ok(input) => input,
Err(_) if !path.exists() => continue, // Removed during the scan; the next event retries it.
Err(error) => return Err(error),
};
let mut fields = BTreeMap::new();
for line in input.lines() {
if let Some((key, value)) = line.split_once('=') {
if fields.insert(key, value).is_some() {
return Err(Error(format!("Duplicate sysfs field: {key}")));
}
}
}
if fields.get("DEVTYPE") != Some(&"partition") {
continue;
}
let Some(name) = fields.get("PARTNAME") else {
continue;
};
let device = fields
.get("DEVNAME")
.ok_or_else(|| Error("Missing sysfs DEVNAME".into()))?;
if device.is_empty()
|| !device
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"_-".contains(&c))
{
return Err(Error("Unsafe sysfs device name".into()));
}
let number = |key| -> Result<u32> {
fields
.get(key)
.ok_or_else(|| Error(format!("Missing sysfs {key}")))?
.parse()
.map_err(|_| Error(format!("Invalid sysfs {key}")))
};
result.push(BlockPartition {
device: format!("/dev/{device}"),
major: number("MAJOR")?,
minor: number("MINOR")?,
partition_name: name.to_string(),
});
}
result.sort_by_key(|device| (device.major, device.minor));
for pair in result.windows(2) {
if (pair[0].major, pair[0].minor) == (pair[1].major, pair[1].minor) {
return Err(Error("Duplicate block device identity".into()));
}
}
Ok(result)
}
pub fn select(devices: &[BlockPartition], label: &str) -> Selection {
let mut candidates: Vec<_> = devices
.iter()
.filter(|d| d.partition_name == label)
.cloned()
.collect();
match candidates.len() {
0 => Selection::Missing,
1 => Selection::Unique(candidates.pop().unwrap()),
_ => Selection::Ambiguous(candidates),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selection_never_chooses_the_first_of_multiple_systems() {
let a = BlockPartition {
device: "/dev/sdz1".into(),
major: 8,
minor: 241,
partition_name: "FDS_SYSTEM".into(),
};
let b = BlockPartition {
device: "/dev/nvme0n1p2".into(),
major: 259,
minor: 2,
..a.clone()
};
assert_eq!(select(&[], "FDS_SYSTEM"), Selection::Missing);
assert_eq!(
select(&[a.clone()], "FDS_SYSTEM"),
Selection::Unique(a.clone())
);
assert!(matches!(
select(&[b, a], "FDS_SYSTEM"),
Selection::Ambiguous(_)
));
}
}
+322
View File
@@ -0,0 +1,322 @@
//! Controller/port identity independent of USB bus numbers and block names.
use crate::{Bay, Error, MAX_CONFIG_BYTES, Result, read_text};
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::{Path, PathBuf},
};
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Hub {
pub hub: String,
pub ports: BTreeMap<String, Bay>,
}
#[derive(Debug, Default)]
pub struct BayMap(pub BTreeMap<String, Bay>);
impl BayMap {
pub fn parse(text: &str) -> Result<Self> {
let groups: BTreeMap<String, Hub> =
toml::from_str(text).map_err(|e| Error(format!("Invalid bay map: {e}")))?;
let mut map = BTreeMap::new();
for group in groups.values() {
if group.hub.is_empty()
|| group.hub.len() > 512
|| group.hub.chars().any(char::is_control)
|| !group.hub.contains(":usb")
|| group.hub.contains("..")
|| group.hub.ends_with('/')
{
return Err(Error("Invalid stable hub identity".into()));
}
let mut seen = BTreeSet::new();
for (port, bay) in &group.ports {
if port
.parse::<u8>()
.ok()
.filter(|n| *n > 0)
.map(|n| n.to_string())
.as_ref()
!= Some(port)
|| !seen.insert(*bay)
{
return Err(Error("Invalid port or duplicate bay within a hub".into()));
}
let key = format!("{}/{}", group.hub, port);
if map.insert(key, *bay).is_some() {
return Err(Error("Duplicate physical port mapping".into()));
}
}
}
for key in map.keys() {
if map
.keys()
.any(|other| other != key && other.starts_with(&format!("{key}/")))
{
return Err(Error("Bay mappings overlap a parent and child port".into()));
}
}
Ok(Self(map))
}
pub fn load(path: &Path) -> Result<Self> {
Self::parse(&read_text(path, MAX_CONFIG_BYTES)?)
}
pub fn bay(&self, identity: &str) -> Option<Bay> {
self.0
.iter()
.find(|(key, _)| identity == key.as_str() || identity.starts_with(&format!("{key}/")))
.map(|(_, bay)| *bay)
}
pub fn configured(&self, bay: Bay) -> bool {
self.0.values().any(|b| *b == bay)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsbDevice {
pub topology: String,
pub vendor: String,
pub product: String,
pub serial: Option<String>,
pub class: String,
pub interfaces: Vec<String>,
#[serde(skip)]
pub path: PathBuf,
}
fn attribute(path: &Path, name: &str) -> Result<String> {
let text = read_text(&path.join(name), 4096)?.trim().to_owned();
if text.chars().any(char::is_control) {
return Err(Error(format!("USB {name} contains control characters")));
}
Ok(text)
}
fn hex(text: &str, len: usize) -> bool {
text.len() == len && text.bytes().all(|b| b.is_ascii_hexdigit())
}
pub fn devices(sys: &Path) -> Result<Vec<UsbDevice>> {
let base = sys.join("devices").canonicalize()?;
let mut devices = Vec::new();
let entries = match fs::read_dir(sys.join("bus/usb/devices")) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(devices),
Err(error) => return Err(error.into()),
};
for entry in entries {
let path = entry?.path();
// Root hubs and interface directories are not cartridge devices.
if !path.join("idVendor").exists()
|| path
.file_name()
.unwrap()
.to_string_lossy()
.starts_with("usb")
{
continue;
}
let inspect = || -> Result<UsbDevice> {
let path = path.canonicalize()?;
let root = path
.ancestors()
.find(|p| {
p.file_name().is_some_and(|n| {
let n = n.to_string_lossy();
n.starts_with("usb") && n[3..].bytes().all(|c| c.is_ascii_digit())
})
})
.ok_or_else(|| Error("USB device has no root hub".into()))?;
let controller = root
.parent()
.unwrap()
.strip_prefix(&base)
.map_err(|_| Error("USB path escapes sysfs".into()))?;
let version = attribute(root, "version")?;
let protocol = if version.starts_with('3') {
"usb3"
} else if version.starts_with('2') || version.starts_with('1') {
"usb2"
} else {
return Err(Error("Unknown USB root protocol".into()));
};
let chain = attribute(&path, "devpath")?;
if chain.split('.').any(|p| {
p.parse::<u8>()
.ok()
.filter(|n| *n > 0)
.map(|n| n.to_string())
.as_deref()
!= Some(p)
}) {
return Err(Error("Invalid USB port chain".into()));
}
let vendor = attribute(&path, "idVendor")?.to_ascii_lowercase();
let product = attribute(&path, "idProduct")?.to_ascii_lowercase();
let class = attribute(&path, "bDeviceClass")?.to_ascii_lowercase();
if !hex(&vendor, 4) || !hex(&product, 4) || !hex(&class, 2) {
return Err(Error("Invalid USB descriptor identity".into()));
}
let mut interfaces = Vec::new();
for child in fs::read_dir(&path)? {
let child = child?.path();
if child.join("bInterfaceClass").exists() {
let value = attribute(&child, "bInterfaceClass")?.to_ascii_lowercase();
if !hex(&value, 2) {
return Err(Error("Invalid USB interface class".into()));
}
interfaces.push(value);
}
}
interfaces.sort();
interfaces.dedup();
Ok(UsbDevice {
topology: format!(
"{}:{protocol}/{}",
controller.display(),
chain.replace('.', "/")
),
vendor,
product,
class,
interfaces,
serial: if path.join("serial").exists() {
// USB strings are data. JSON escapes control characters;
// a device's serial string must not stop global discovery.
Some(read_text(&path.join("serial"), 4096)?.trim().to_owned())
} else {
None
},
path,
})
};
match inspect() {
Ok(device) => devices.push(device),
Err(_) if !path.exists() => (),
Err(error) => eprintln!("Incomplete USB device {}: {error}", path.display()),
}
}
devices.sort_by(|a, b| a.topology.cmp(&b.topology));
Ok(devices)
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Hardware {
pub name: String,
pub vendor: String,
pub product: String,
pub serial: Option<String>,
pub class: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Catalog {
#[serde(default)]
pub device: Vec<Hardware>,
}
impl Catalog {
pub fn load(path: &Path) -> Result<Self> {
Self::parse(&read_text(path, MAX_CONFIG_BYTES)?)
}
pub fn parse(text: &str) -> Result<Self> {
let value: Self = toml::from_str(text).map_err(|e| Error(e.to_string()))?;
for item in &value.device {
if item.name.is_empty()
|| item.name.len() > 128
|| item.name.chars().any(char::is_control)
|| !hex(&item.vendor, 4)
|| !hex(&item.product, 4)
|| item.class.as_ref().is_some_and(|c| !hex(c, 2))
{
return Err(Error("Invalid hardware catalog entry".into()));
}
}
Ok(value)
}
pub fn identify(&self, usb: &UsbDevice) -> Result<Option<String>> {
let matches: Vec<_> = self
.device
.iter()
.filter(|h| {
h.vendor.eq_ignore_ascii_case(&usb.vendor)
&& h.product.eq_ignore_ascii_case(&usb.product)
&& h.serial
.as_ref()
.is_none_or(|s| Some(s) == usb.serial.as_ref())
&& h.class.as_ref().is_none_or(|c| {
c.eq_ignore_ascii_case(&usb.class)
|| usb.interfaces.iter().any(|i| c.eq_ignore_ascii_case(i))
})
})
.collect();
if matches.len() > 1 {
return Err(Error("Ambiguous hardware catalog entries".into()));
}
Ok(matches.first().map(|h| h.name.clone()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn topology_aliases_are_explicit_and_overlaps_rejected() {
let map = BayMap::parse("[front]\nhub='pci/controller:usb2/1'\n[front.ports]\n1=1\n2=2\n[fast]\nhub='pci/controller:usb3/1'\n[fast.ports]\n1=1\n").unwrap();
assert_eq!(map.bay("pci/controller:usb3/1/1").unwrap().number(), 1);
assert_eq!(map.bay("pci/controller:usb2/1/2/3").unwrap().number(), 2);
assert!(map.bay("pci/controller:usb2/1/12").is_none());
assert!(BayMap::parse("[a]\nhub='x:usb2'\n[a.ports]\n1=1\n2=1").is_err());
assert!(BayMap::parse("[a]\nhub='x:usb2'\n[a.ports]\n1=13").is_err());
assert!(
BayMap::parse("[a]\nhub='x:usb2'\n[a.ports]\n1=1\n[b]\nhub='x:usb2/1'\n[b.ports]\n2=2")
.is_err()
);
}
}
#[cfg(test)]
mod fixture_tests {
use super::*;
use std::os::unix::fs::symlink;
#[test]
fn missing_usb_subsystem_is_an_empty_inventory() {
let sys = std::env::temp_dir().join(format!("fds-no-usb-{}", std::process::id()));
fs::create_dir_all(sys.join("devices")).unwrap();
assert!(devices(&sys).unwrap().is_empty());
fs::remove_dir_all(sys).unwrap();
}
#[test]
fn enumeration_numbers_do_not_change_physical_identity() {
let base = std::env::temp_dir().join(format!("fds-topology-{}", std::process::id()));
fs::create_dir_all(&base).unwrap();
for bus in [1, 7] {
let sys = base.join(bus.to_string());
let root = sys.join(format!("devices/platform/controller/usb{bus}"));
let usb = root.join(format!("{bus}-2.4"));
let interface = usb.join(format!("{bus}-2.4:1.0"));
fs::create_dir_all(&interface).unwrap();
fs::create_dir_all(sys.join("bus/usb/devices")).unwrap();
fs::write(root.join("version"), " 2.00\n").unwrap();
for (name, value) in [
("idVendor", "1234"),
("idProduct", "abcd"),
("bDeviceClass", "00"),
("devpath", "2.4"),
("serial", "UNIT-1"),
] {
fs::write(usb.join(name), value).unwrap();
}
fs::write(interface.join("bInterfaceClass"), "08").unwrap();
symlink(&usb, sys.join(format!("bus/usb/devices/{bus}-2.4"))).unwrap();
// Removal can leave a device directory visible after attributes
// vanish. One incomplete entry must not hide the healthy device.
let partial = root.join(format!("{bus}-5"));
fs::create_dir_all(&partial).unwrap();
fs::write(partial.join("idVendor"), "1234").unwrap();
symlink(&partial, sys.join(format!("bus/usb/devices/{bus}-5"))).unwrap();
let result = devices(&sys).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].topology, "platform/controller:usb2/2/4");
assert_eq!(result[0].interfaces, ["08"]);
}
fs::remove_dir_all(base).unwrap();
}
}
+308
View File
@@ -0,0 +1,308 @@
//! Boot observations use the kernel's monotonic boot clock, never wall-clock time.
use crate::{Error, Result, read_text};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, fs, io::Write, os::unix::fs::OpenOptionsExt, path::Path};
pub const EARLY: &str = "/dev/fds-early/boot-trace";
pub const RUNTIME: &str = "/run/fds/boot-trace";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Point {
Stage0Start,
SystemFound,
RootMounted,
RootSwitch,
S6Start,
ConsoleReady,
DesktopReady,
}
impl Point {
pub const ALL: [Self; 7] = [
Self::Stage0Start,
Self::SystemFound,
Self::RootMounted,
Self::RootSwitch,
Self::S6Start,
Self::ConsoleReady,
Self::DesktopReady,
];
pub fn name(self) -> &'static str {
match self {
Self::Stage0Start => "stage0-start",
Self::SystemFound => "system-found",
Self::RootMounted => "root-mounted",
Self::RootSwitch => "root-switch",
Self::S6Start => "s6-start",
Self::ConsoleReady => "console-ready",
Self::DesktopReady => "desktop-ready",
}
}
pub fn parse(name: &str) -> Result<Self> {
Self::ALL
.into_iter()
.find(|point| point.name() == name)
.ok_or_else(|| Error("Unknown boot event".into()))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Event {
pub format: u32,
pub boot_id: String,
pub point: Point,
pub boot_ns: u64,
}
pub fn now() -> Result<u64> {
let mut clock: libc::timespec = unsafe { std::mem::zeroed() };
if unsafe { libc::clock_gettime(libc::CLOCK_BOOTTIME, &mut clock) } < 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(clock.tv_sec as u64 * 1_000_000_000 + clock.tv_nsec as u64)
}
pub fn boot_id() -> Result<String> {
Ok(
read_text(Path::new("/proc/sys/kernel/random/boot_id"), 128)?
.trim()
.into(),
)
}
pub fn save(directory: &Path, point: Point, boot_ns: u64) -> Result<()> {
let event = Event {
format: 1,
boot_id: boot_id()?,
point,
boot_ns,
};
fs::create_dir_all(directory)?;
let target = directory.join(format!("{}.json", point.name()));
let existing = || -> Result<()> {
if !fs::symlink_metadata(&target)?.is_file() {
return Err(Error("Boot trace record is not a regular file".into()));
}
let recorded: Event =
serde_json::from_str(&read_text(&target, 4096)?).map_err(|e| Error(e.to_string()))?;
if recorded.format != 1 || recorded.point != point || recorded.boot_id != event.boot_id {
return Err(Error(
"Existing event belongs to a different boot or point".into(),
));
}
Ok(())
};
// A restarted console must not replace the first readiness observation.
if target.try_exists()? {
return existing();
}
let temporary = directory.join(format!(".{}-{}.tmp", point.name(), std::process::id()));
let mut stream = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o644)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
.open(&temporary)?;
let result = (|| -> Result<()> {
serde_json::to_writer(&mut stream, &event).map_err(|e| Error(e.to_string()))?;
stream.write_all(b"\n")?;
stream.flush()?;
match fs::hard_link(&temporary, &target) {
Ok(()) => (),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => existing()?,
Err(error) => return Err(error.into()),
}
Ok(())
})();
let _ = fs::remove_file(temporary);
result
}
pub fn mark_early(point: Point, instant: u64) {
if let Err(error) = save(Path::new(EARLY), point, instant) {
eprintln!("Boot trace unavailable: {error}");
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Report {
pub format: u32,
pub clock: String,
pub boot_id: String,
pub platform: String,
pub kernel: String,
pub events_ns: BTreeMap<String, u64>,
pub durations_ns: BTreeMap<String, u64>,
pub missing_events: Vec<String>,
}
impl Report {
pub fn from_events(events: &[Event], platform: String, kernel: String) -> Result<Self> {
let mut report = Self {
format: 1,
clock: "Linux CLOCK_BOOTTIME".into(),
boot_id: events
.first()
.map(|e| e.boot_id.clone())
.unwrap_or_default(),
platform,
kernel,
events_ns: BTreeMap::new(),
durations_ns: BTreeMap::new(),
missing_events: Vec::new(),
};
for event in events {
if event.format != 1 || event.boot_id != report.boot_id || event.boot_id.is_empty() {
return Err(Error("Incompatible boot trace records".into()));
}
if report
.events_ns
.insert(event.point.name().into(), event.boot_ns)
.is_some()
{
return Err(Error("Duplicate boot event".into()));
}
}
let mut previous = 0;
for point in Point::ALL {
if let Some(&instant) = report.events_ns.get(point.name()) {
if instant < previous {
return Err(Error("Boot events are out of order".into()));
}
previous = instant;
} else {
report.missing_events.push(point.name().into());
}
}
for (label, start, end) in [
("kernel-to-console", None, Point::ConsoleReady),
("kernel-to-desktop", None, Point::DesktopReady),
("kernel-to-stage0", None, Point::Stage0Start),
(
"system-discovery",
Some(Point::Stage0Start),
Point::SystemFound,
),
("root-mount", Some(Point::SystemFound), Point::RootMounted),
("root-handoff", Some(Point::RootMounted), Point::S6Start),
("s6-to-console", Some(Point::S6Start), Point::ConsoleReady),
] {
let begin = match start {
None => Some(0),
Some(point) => report.events_ns.get(point.name()).copied(),
};
if let (Some(begin), Some(&finish)) = (begin, report.events_ns.get(end.name())) {
report.durations_ns.insert(label.into(), finish - begin);
}
}
Ok(report)
}
pub fn print(&self, json: bool) -> Result<()> {
if json {
println!(
"{}",
serde_json::to_string_pretty(self).map_err(|e| Error(e.to_string()))?
);
} else {
println!(
"FDS BOOT PROFILE\nPLATFORM {}\nCLOCK {}",
self.platform, self.clock
);
for (name, ns) in &self.durations_ns {
println!("{name:22} {:8.3} ms", *ns as f64 / 1_000_000.0);
}
if !self.missing_events.is_empty() {
println!("NOT RECORDED {}", self.missing_events.join(", "));
}
println!("Power-on and firmware time are outside this clock.");
}
Ok(())
}
}
pub fn load(directory: &Path) -> Result<Report> {
let mut events = Vec::new();
for point in Point::ALL {
let folder = if point == Point::ConsoleReady {
directory.join("console")
} else {
directory.to_owned()
};
let path = folder.join(format!("{}.json", point.name()));
if !path.exists() {
continue;
}
let event: Event =
serde_json::from_str(&read_text(&path, 4096)?).map_err(|e| Error(e.to_string()))?;
if event.point != point {
return Err(Error(
"Boot event filename does not match its record".into(),
));
}
events.push(event);
}
if events.is_empty() {
return Err(Error("No boot trace recorded".into()));
}
let platform = read_text(Path::new("/proc/device-tree/model"), 4096)
.unwrap_or_else(|_| "unknown platform".into())
.trim_matches('\0')
.trim()
.to_owned();
let kernel = read_text(Path::new("/proc/sys/kernel/osrelease"), 4096)?
.trim()
.to_owned();
Report::from_events(&events, platform, kernel)
}
#[cfg(test)]
mod tests {
use super::*;
fn event(point: Point, time: u64) -> Event {
Event {
format: 1,
boot_id: "fixture".into(),
point,
boot_ns: time,
}
}
#[test]
fn durations_are_measured_and_missing_events_are_not_zeroes() {
let result = Report::from_events(
&[event(Point::S6Start, 700), event(Point::ConsoleReady, 900)],
"VM fixture".into(),
"test".into(),
)
.unwrap();
assert_eq!(result.durations_ns["s6-to-console"], 200);
assert_eq!(result.durations_ns["kernel-to-console"], 900);
assert!(!result.durations_ns.contains_key("system-discovery"));
}
#[test]
fn mixed_boots_duplicates_and_backwards_events_are_rejected() {
let start = event(Point::Stage0Start, 100);
let mut finish = event(Point::ConsoleReady, 50);
assert!(
Report::from_events(&[start.clone(), finish.clone()], "VM".into(), "test".into())
.is_err()
);
finish.boot_ns = 200;
finish.boot_id = "different".into();
assert!(Report::from_events(&[start.clone(), finish], "VM".into(), "test".into()).is_err());
assert!(Report::from_events(&[start.clone(), start], "VM".into(), "test".into()).is_err());
}
#[test]
fn restarting_the_console_preserves_the_first_ready_time() {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let directory =
std::env::temp_dir().join(format!("fds-trace-{}-{unique}", std::process::id()));
save(&directory, Point::ConsoleReady, 100).unwrap();
save(&directory, Point::ConsoleReady, 200).unwrap();
let record: Event = serde_json::from_str(
&fs::read_to_string(directory.join("console-ready.json")).unwrap(),
)
.unwrap();
assert_eq!(record.boot_ns, 100);
fs::remove_dir_all(directory).unwrap();
}
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "fds-release"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "FDS release manifest signing and explicit-key verification"
[dependencies]
clap.workspace = true
fds-common = { path = "../fds-common" }
ed25519-dalek = { version = "=3.0.0", default-features = false, features = ["zeroize"] }
zeroize = { version = "1", features = ["alloc"] }
libc = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
+402
View File
@@ -0,0 +1,402 @@
//! Detached Ed25519 signatures bind bounded manifests and streamed artifact hashes.
//! The verifier's trust anchor is always an explicit public-key file.
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use fds_common::{Error, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
collections::BTreeSet,
ffi::CString,
fs::{File, OpenOptions},
io::{self, Read, Write},
os::{
fd::{AsRawFd, FromRawFd},
unix::fs::{MetadataExt, OpenOptionsExt},
},
path::{Path, PathBuf},
};
use zeroize::Zeroizing;
pub const MANIFEST: &str = "manifest.json";
pub const SIGNATURE: &str = "manifest.sig";
pub const CONTEXT: &[u8] = b"FDS/OS release manifest v1\0";
const MAX_MANIFEST: u64 = 1024 * 1024;
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Artifact {
pub name: String,
pub bytes: u64,
pub sha256: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub format: u8,
pub version: String,
pub source_epoch: u64,
pub source_sha256: String,
pub void_commit: String,
pub hardware_validation: String,
pub files: Vec<Artifact>,
}
fn hex_valid(value: &str, length: usize) -> bool {
value.len() == length
&& value
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
pub fn filename(value: &str) -> Result<()> {
if value.is_empty()
|| value.len() > 128
|| value.starts_with('.')
|| !value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b))
{
return Err(Error(
"Release artifact names must be simple filenames without paths or leading dots".into(),
));
}
Ok(())
}
impl Manifest {
pub fn validate(&self) -> Result<()> {
if self.format != 1
|| self.version != fds_common::VERSION
|| self.source_epoch == 0
|| !hex_valid(&self.source_sha256, 64)
|| !hex_valid(&self.void_commit, 40)
|| self.hardware_validation != "deferred"
|| self.files.is_empty()
|| self.files.len() > 64
{
return Err(Error("Unsupported or invalid FDS release manifest".into()));
}
let mut names = BTreeSet::new();
for artifact in &self.files {
filename(&artifact.name)?;
if [MANIFEST, SIGNATURE].contains(&artifact.name.as_str())
|| !names.insert(&artifact.name)
|| artifact.bytes == 0
|| !hex_valid(&artifact.sha256, 64)
{
return Err(Error(
"Invalid, reserved or duplicate release artifact".into(),
));
}
}
Ok(())
}
}
pub fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn unhex<const N: usize>(text: &str) -> Result<[u8; N]> {
if !hex_valid(text, N * 2) {
return Err(Error("Invalid lowercase hexadecimal encoding".into()));
}
let mut bytes = [0; N];
for (byte, pair) in bytes.iter_mut().zip(text.as_bytes().chunks_exact(2)) {
*byte = u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16)
.map_err(|e| Error(e.to_string()))?;
}
Ok(bytes)
}
fn regular(file: File) -> Result<File> {
if !file.metadata()?.is_file() {
return Err(Error("Release inputs must be regular files".into()));
}
Ok(file)
}
fn open(path: &Path) -> Result<File> {
regular(
OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(path)?,
)
}
fn bounded(mut file: File, limit: u64) -> Result<Vec<u8>> {
if file.metadata()?.len() > limit {
return Err(Error("Release input exceeds its size limit".into()));
}
let mut bytes = Vec::new();
(&mut file).take(limit + 1).read_to_end(&mut bytes)?;
if bytes.len() as u64 > limit {
return Err(Error("Release input exceeds its size limit".into()));
}
Ok(bytes)
}
struct Directory {
fd: File,
}
impl Directory {
fn open(path: &Path) -> Result<Self> {
Ok(Self {
fd: OpenOptions::new()
.read(true)
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW)
.open(path)?,
})
}
fn file(&self, name: &str) -> Result<File> {
filename(name)?;
let name = CString::new(name).map_err(|_| Error("NUL in filename".into()))?;
let fd = unsafe {
libc::openat(
self.fd.as_raw_fd(),
name.as_ptr(),
libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC,
)
};
if fd < 0 {
return Err(io::Error::last_os_error().into());
}
regular(unsafe { File::from_raw_fd(fd) })
}
fn create(&self, name: &str, bytes: &[u8]) -> Result<()> {
filename(name)?;
let name = CString::new(name).map_err(|_| Error("NUL in filename".into()))?;
let fd = unsafe {
libc::openat(
self.fd.as_raw_fd(),
name.as_ptr(),
libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC,
0o644,
)
};
if fd < 0 {
return Err(io::Error::last_os_error().into());
}
let mut file = unsafe { File::from_raw_fd(fd) };
file.write_all(bytes)?;
file.sync_all()?;
self.fd.sync_all()?;
Ok(())
}
fn manifest(&self) -> Result<(Vec<u8>, Manifest)> {
let bytes = bounded(self.file(MANIFEST)?, MAX_MANIFEST)?;
let manifest: Manifest = serde_json::from_slice(&bytes)
.map_err(|e| Error(format!("Invalid release manifest: {e}")))?;
manifest.validate()?;
Ok((bytes, manifest))
}
fn verify_files(&self, manifest: &Manifest) -> Result<()> {
for artifact in &manifest.files {
let mut file = self.file(&artifact.name)?;
let before = file.metadata()?;
if before.len() != artifact.bytes {
return Err(Error(format!("Artifact size mismatch: {}", artifact.name)));
}
let mut hash = Sha256::new();
let mut buffer = vec![0u8; 1024 * 1024];
let mut remaining = artifact.bytes;
while remaining > 0 {
let size = (remaining as usize).min(buffer.len());
file.read_exact(&mut buffer[..size])?;
hash.update(&buffer[..size]);
remaining -= size as u64;
}
let after = file.metadata()?;
if after.len() != before.len()
|| after.mtime() != before.mtime()
|| after.mtime_nsec() != before.mtime_nsec()
|| after.ctime() != before.ctime()
|| after.ctime_nsec() != before.ctime_nsec()
|| hex(&hash.finalize()) != artifact.sha256
{
return Err(Error(format!(
"Artifact changed or SHA-256 mismatch: {}",
artifact.name
)));
}
}
Ok(())
}
}
fn message(manifest: &[u8]) -> Vec<u8> {
let mut bytes = Vec::with_capacity(CONTEXT.len() + manifest.len());
bytes.extend_from_slice(CONTEXT);
bytes.extend_from_slice(manifest);
bytes
}
fn secret(path: &Path) -> Result<SigningKey> {
let mut file = open(path)?;
let meta = file.metadata()?;
if meta.uid() != unsafe { libc::geteuid() } || meta.mode() & 0o077 != 0 || meta.len() != 32 {
return Err(Error(
"Signing key must be an owner-only 32-byte file belonging to the current user".into(),
));
}
let mut bytes = Zeroizing::new([0u8; 32]);
file.read_exact(&mut *bytes)?;
Ok(SigningKey::from_bytes(&bytes))
}
fn public(path: &Path) -> Result<VerifyingKey> {
let bytes = bounded(open(path)?, 128)?;
let text = std::str::from_utf8(&bytes)
.map_err(|_| Error("Public key must be lowercase hex followed by one newline".into()))?;
let key = unhex::<32>(
text.strip_suffix('\n')
.ok_or_else(|| Error("Public key needs a final newline".into()))?,
)?;
let key =
VerifyingKey::from_bytes(&key).map_err(|e| Error(format!("Invalid public key: {e}")))?;
if key.is_weak() {
return Err(Error("Weak release public keys are rejected".into()));
}
Ok(key)
}
fn write_new(path: &Path, bytes: &[u8], mode: u32) -> Result<()> {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(mode)
.custom_flags(libc::O_NOFOLLOW)
.open(path)?;
file.write_all(bytes)?;
file.sync_all()?;
File::open(
path.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new(".")),
)?
.sync_all()?;
Ok(())
}
pub fn public_key(private: &Path, output: &Path) -> Result<String> {
let key = secret(private)?.verifying_key();
write_new(
output,
format!("{}\n", hex(key.as_bytes())).as_bytes(),
0o644,
)?;
Ok(hex(&Sha256::digest(key.as_bytes())))
}
pub fn keygen(prefix: &Path) -> Result<String> {
let mut private_name = prefix.as_os_str().to_os_string();
private_name.push(".key");
let mut public_name = prefix.as_os_str().to_os_string();
public_name.push(".pub");
let (private, public) = (PathBuf::from(private_name), PathBuf::from(public_name));
if private.symlink_metadata().is_ok() || public.symlink_metadata().is_ok() {
return Err(Error(
"Key output already exists; keys are never overwritten".into(),
));
}
let mut seed = Zeroizing::new([0u8; 32]);
let mut offset = 0;
while offset < seed.len() {
let count =
unsafe { libc::getrandom(seed[offset..].as_mut_ptr().cast(), seed.len() - offset, 0) };
if count < 0 {
let error = io::Error::last_os_error();
if error.kind() == io::ErrorKind::Interrupted {
continue;
}
return Err(error.into());
}
if count == 0 {
return Err(Error("Kernel randomness returned no bytes".into()));
}
offset += count as usize;
}
write_new(&private, &*seed, 0o600)?;
public_key(&private, &public)
}
pub fn sign(directory: &Path, private: &Path) -> Result<String> {
let directory = Directory::open(directory)?;
let (bytes, manifest) = directory.manifest()?;
directory.verify_files(&manifest)?;
let key = secret(private)?;
let signature: Signature = key.sign(&message(&bytes));
directory.create(
SIGNATURE,
format!("{}\n", hex(&signature.to_bytes())).as_bytes(),
)?;
Ok(hex(&Sha256::digest(key.verifying_key().as_bytes())))
}
pub fn verify(directory: &Path, trusted_public: &Path) -> Result<Manifest> {
let key = public(trusted_public)?;
let directory = Directory::open(directory)?;
let (bytes, manifest) = directory.manifest()?;
let encoded = bounded(directory.file(SIGNATURE)?, 256)?;
let signature =
std::str::from_utf8(&encoded).map_err(|_| Error("Invalid signature encoding".into()))?;
let signature = Signature::from_bytes(&unhex::<64>(
signature
.strip_suffix('\n')
.ok_or_else(|| Error("Signature needs a final newline".into()))?,
)?);
key.verify_strict(&message(&bytes), &signature)
.map_err(|_| Error("Release signature does not match the supplied trusted key".into()))?;
directory.verify_files(&manifest)?;
Ok(manifest)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rfc8032_test_vector_one_and_malleability_rejection() {
// RFC 8032 section 7.1 public test data, never a release signing key.
let seed = unhex::<32>("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")
.unwrap();
let expected_key =
unhex::<32>("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a")
.unwrap();
let expected_signature = unhex::<64>("e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b").unwrap();
let key = SigningKey::from_bytes(&seed);
assert_eq!(key.verifying_key().to_bytes(), expected_key);
let signature: Signature = key.sign(b"");
assert_eq!(signature.to_bytes(), expected_signature);
assert!(key.verifying_key().verify_strict(b"", &signature).is_ok());
assert!(
key.verifying_key()
.verify_strict(b"changed", &signature)
.is_err()
);
let mut altered = expected_signature;
altered[63] |= 0x80;
assert!(
key.verifying_key()
.verify_strict(b"", &Signature::from_bytes(&altered))
.is_err()
);
assert!(
key.verifying_key()
.verify_strict(&message(b""), &signature)
.is_err()
);
}
#[test]
fn manifest_rejects_duplicate_reserved_and_traversing_paths() {
for name in ["", "../root", "/absolute", ".hidden", "a/b", "a\\b"] {
assert!(filename(name).is_err());
}
let mut manifest = Manifest {
format: 1,
version: "0.1.0".into(),
source_epoch: 1,
source_sha256: "a".repeat(64),
void_commit: "b".repeat(40),
hardware_validation: "deferred".into(),
files: vec![Artifact {
name: "image.img".into(),
bytes: 1,
sha256: "c".repeat(64),
}],
};
assert!(manifest.validate().is_ok());
manifest.files.push(Artifact {
name: "image.img".into(),
bytes: 1,
sha256: "d".repeat(64),
});
assert!(manifest.validate().is_err());
manifest.files.pop();
manifest.files[0].name = MANIFEST.into();
assert!(manifest.validate().is_err());
}
}
+109
View File
@@ -0,0 +1,109 @@
use clap::{CommandFactory, Parser, Subcommand};
use fds_common::Result;
use std::{path::PathBuf, process::ExitCode};
#[derive(Parser)]
#[command(
version,
about = "Sign and verify FDS release artifacts",
after_help = "Keys and signatures are never overwritten. Verification checks every listed artifact.
An independently trusted public key is required; a bundled key is not automatically trusted."
)]
struct Cli {
#[command(subcommand)]
command: Option<Action>,
}
#[derive(Subcommand)]
enum Action {
/// Create a new private/public key pair (PREFIX.key and PREFIX.pub).
Keygen { new_prefix: PathBuf },
/// Derive a new public-key file from an existing private key.
PublicKey {
private_key: PathBuf,
new_public_key: PathBuf,
},
/// Sign a release directory using an explicit private key.
Sign {
directory: PathBuf,
#[arg(long)]
key: PathBuf,
},
/// Verify the signature and every artifact using a trusted public key.
Verify {
directory: PathBuf,
#[arg(long)]
key: PathBuf,
},
}
fn run() -> Result<()> {
match Cli::parse().command {
None => {
Cli::command().print_help()?;
println!();
}
Some(Action::Keygen { new_prefix }) => println!(
"Created signing key and public key. Public-key SHA-256: {}",
fds_release::keygen(&new_prefix)?
),
Some(Action::PublicKey {
private_key,
new_public_key,
}) => println!(
"Public-key SHA-256: {}",
fds_release::public_key(&private_key, &new_public_key)?
),
Some(Action::Sign { directory, key }) => println!(
"Signed release manifest. Public-key SHA-256: {}",
fds_release::sign(&directory, &key)?
),
Some(Action::Verify { directory, key }) => {
let manifest = fds_release::verify(&directory, &key)?;
println!(
"VERIFIED FDS/OS {}: signature and {} artifact hashes match the supplied key.\nHardware validation: {}.",
manifest.version,
manifest.files.len(),
manifest.hardware_validation
);
}
}
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("fds-release: {error}");
ExitCode::from(2)
}
}
}
#[cfg(test)]
mod cli_tests {
use super::*;
use clap::{CommandFactory, Parser};
#[test]
fn typed_command_contract() {
Cli::command().debug_assert();
for action in ["sign", "verify"] {
assert!(
Cli::try_parse_from(["fds-release", action, "release", "--key", "key"]).is_ok()
);
assert!(Cli::try_parse_from(["fds-release", action, "--key=key", "release"]).is_ok());
assert!(Cli::try_parse_from(["fds-release", action, "release"]).is_err());
assert!(
Cli::try_parse_from([
"fds-release",
action,
"release",
"--key",
"one",
"--key",
"two"
])
.is_err()
);
}
assert!(Cli::try_parse_from(["fds-release", "keygen", "new", "--force"]).is_err());
}
}
+5
View File
@@ -0,0 +1,5 @@
[package]
name = "fds-smoketest"
version = "0.1.0"
edition = "2021"
publish = false
+9
View File
@@ -0,0 +1,9 @@
#[cfg(not(all(target_os = "linux", target_arch = "aarch64", target_env = "musl")))]
compile_error!("fds-smoketest requires aarch64-unknown-linux-musl");
#[cfg(not(target_feature = "crt-static"))]
compile_error!("fds-smoketest must be statically linked");
fn main() {
println!("FDS/OS M0: aarch64 static-musl OK");
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "fds-software"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "Bounded software catalogues and verified xz tar bundles"
[dependencies]
fds-common = { path = "../fds-common" }
serde = { version = "1", features = ["derive"] }
toml = "0.8"
sha2 = "=0.10.9"
tar = { version = "=0.4.46", default-features = false }
libc = "0.2"
+499
View File
@@ -0,0 +1,499 @@
//! Streaming xz/ustar bundles with explicit file types, paths and resource limits.
use crate::{MAX_ARCHIVE, MAX_ENTRIES, MAX_UNPACKED, Software, relative};
use fds_common::{Error, Result};
use sha2::{Digest, Sha256};
use std::{
collections::{BTreeMap, BTreeSet},
fs::{self, File, OpenOptions},
io::{self, Read, Seek, SeekFrom, Write},
os::unix::fs::{OpenOptionsExt, PermissionsExt},
path::Path,
process::{Child, Command, Stdio},
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Stats {
pub bytes: u64,
pub entries: u32,
}
pub fn digest(file: &File) -> Result<String> {
let mut input = file.try_clone()?;
input.seek(SeekFrom::Start(0))?;
let mut hash = Sha256::new();
let mut buffer = [0; 64 * 1024];
loop {
let n = input.read(&mut buffer)?;
if n == 0 {
break;
}
hash.update(&buffer[..n]);
}
Ok(hash.finalize().iter().map(|b| format!("{b:02x}")).collect())
}
pub fn open(path: &Path) -> Result<File> {
let file = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC)
.open(path)?;
if !file.metadata()?.is_file() || !(1..=MAX_ARCHIVE).contains(&file.metadata()?.len()) {
return Err(Error(
"Software archive must be a nonempty regular file of at most 512 MiB".into(),
));
}
Ok(file)
}
struct Process(Child);
impl Drop for Process {
fn drop(&mut self) {
if self.0.try_wait().ok().flatten().is_none() {
let _ = self.0.kill();
}
let _ = self.0.wait();
}
}
struct Budget<R> {
inner: R,
remaining: u64,
}
impl<R: Read> Read for Budget<R> {
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
if buffer.is_empty() {
return Ok(0);
}
if self.remaining == 0 {
let mut probe = [0];
if self.inner.read(&mut probe)? == 0 {
return Ok(0);
}
return Err(io::Error::other(
"Software archive exceeds its decompression limit",
));
}
let limit = (buffer.len() as u64).min(self.remaining) as usize;
let n = self.inner.read(&mut buffer[..limit])?;
self.remaining -= n as u64;
Ok(n)
}
}
fn elf(prefix: &[u8], architecture: &str) -> Result<()> {
if prefix.starts_with(b"\x7fELF")
&& (architecture != "aarch64"
|| prefix.len() < 20
|| prefix[4] != 2
|| prefix[5] != 1
|| prefix[18..20] != [183, 0])
{
return Err(Error(
"Software contains an ELF executable/library for the wrong architecture".into(),
));
}
Ok(())
}
/// Decode only directories and regular files. Ownership, timestamps, links,
/// sparse files, device nodes and extension records never control extraction.
/// DEST must be a newly created private directory, inaccessible to other users.
pub fn read_tar(
input: impl Read,
software: &Software,
destination: Option<&Path>,
) -> Result<Stats> {
software.validate()?;
if let Some(root) = destination {
let metadata = root.symlink_metadata()?;
if !metadata.is_dir()
|| metadata.permissions().mode() & 0o077 != 0
|| fs::read_dir(root)?.next().is_some()
{
return Err(Error(
"Software extraction requires an empty private directory".into(),
));
}
}
let budget = software
.unpacked_bytes
.checked_add(u64::from(software.entries) * 1024 + 64 * 1024)
.ok_or_else(|| Error("Archive budget overflow".into()))?;
let mut archive = tar::Archive::new(Budget {
inner: input,
remaining: budget,
});
let mut seen: BTreeMap<String, (bool, bool)> = BTreeMap::new();
let mut stats = Stats {
bytes: 0,
entries: 0,
};
for entry in archive.entries()?.raw(true) {
let mut entry = entry?;
let kind = entry.header().entry_type();
if !(kind.is_file() || kind.is_dir()) {
return Err(Error("Software tarballs permit only regular files and directories (no links or extensions)".into()));
}
let name = String::from_utf8(entry.path_bytes().into_owned())
.map_err(|_| Error("Archive path is not UTF-8".into()))?;
let name = if kind.is_dir() {
name.strip_suffix('/').unwrap_or(&name)
} else {
&name
};
if !relative(name) || seen.contains_key(name) || entry.header().mode()? & 0o7000 != 0 {
return Err(Error(
"Unsafe, duplicate or privileged software archive entry".into(),
));
}
for parent in Path::new(name)
.ancestors()
.skip(1)
.filter(|p| !p.as_os_str().is_empty())
{
if seen
.get(parent.to_str().unwrap())
.is_some_and(|(directory, _)| !directory)
{
return Err(Error("Software entry has a non-directory parent".into()));
}
}
if kind.is_file() {
let prefix = format!("{name}/");
if seen
.range(prefix.clone()..)
.next()
.is_some_and(|(path, _)| path.starts_with(&prefix))
{
return Err(Error(
"Software file replaces a previously implied directory".into(),
));
}
}
let size = entry.size();
if kind.is_dir() && size != 0 {
return Err(Error("Directory entry contains data".into()));
}
stats.entries += 1;
stats.bytes = stats
.bytes
.checked_add(size)
.ok_or_else(|| Error("Software size overflow".into()))?;
if stats.entries > software.entries
|| stats.entries > MAX_ENTRIES
|| stats.bytes > software.unpacked_bytes
|| stats.bytes > MAX_UNPACKED
{
return Err(Error("Software exceeds declared extraction limits".into()));
}
let executable = entry.header().mode()? & 0o111 != 0;
seen.insert(name.to_owned(), (kind.is_dir(), executable));
let output = destination.map(|root| root.join(name));
if kind.is_dir() {
if let Some(path) = output {
directories(destination.unwrap(), &path)?;
}
} else {
let mut prefix = vec![0; size.min(64) as usize];
entry.read_exact(&mut prefix)?;
elf(&prefix, &software.architecture)?;
if let Some(path) = output {
directories(destination.unwrap(), path.parent().unwrap())?;
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(if executable { 0o755 } else { 0o644 })
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
.open(path)?;
file.write_all(&prefix)?;
io::copy(&mut entry, &mut file)?;
file.set_permissions(fs::Permissions::from_mode(if executable {
0o755
} else {
0o644
}))?;
} else {
io::copy(&mut entry, &mut io::sink())?;
}
}
}
// Reject a second tar archive or nonzero content following the tar end marker.
let mut input = archive.into_inner();
let mut buffer = [0; 4096];
loop {
let n = input.read(&mut buffer)?;
if n == 0 {
break;
}
if buffer[..n].iter().any(|b| *b != 0) {
return Err(Error("Unexpected trailing archive content".into()));
}
}
if stats.bytes != software.unpacked_bytes || stats.entries != software.entries {
return Err(Error(
"Software contents do not match declared sizes/counts".into(),
));
}
for command in software.commands.values() {
if seen.get(command) != Some(&(false, true)) {
return Err(Error(format!(
"Declared command is not an executable regular file: {command}"
)));
}
}
Ok(stats)
}
fn directories(root: &Path, path: &Path) -> Result<()> {
let mut current = root.to_path_buf();
for component in path
.strip_prefix(root)
.map_err(|_| Error("Extraction path escaped its root".into()))?
.components()
{
current.push(component);
match fs::create_dir(&current) {
Ok(()) => fs::set_permissions(&current, fs::Permissions::from_mode(0o755))?,
Err(error)
if error.kind() == io::ErrorKind::AlreadyExists
&& fs::symlink_metadata(&current)?.is_dir() =>
{
()
}
Err(error) => return Err(error.into()),
}
}
Ok(())
}
pub fn verify(file: &File, software: &Software, destination: Option<&Path>) -> Result<Stats> {
software.validate()?;
if !file.metadata()?.is_file()
|| file.metadata()?.len() != software.archive_bytes
|| digest(file)? != software.sha256
{
return Err(Error("Software archive length or SHA-256 mismatch".into()));
}
let mut input = file.try_clone()?;
input.seek(SeekFrom::Start(0))?;
let mut child = Process(
Command::new("xz")
.args(["--decompress", "--stdout", "--memlimit-decompress=256MiB"])
.stdin(Stdio::from(input))
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()?,
);
let result = read_tar(child.0.stdout.take().unwrap(), software, destination)?;
if !child.0.wait()?.success() {
return Err(Error("XZ decompression failed".into()));
}
if file.metadata()?.len() != software.archive_bytes || digest(file)? != software.sha256 {
return Err(Error("Software archive changed during verification".into()));
}
Ok(result)
}
/// Workstation construction emits deterministic USTAR. In-tree file symlinks
/// are copied as regular files; directory links and escaping links are rejected.
pub fn write_tar(root: &Path, output: impl Write, architecture: &str) -> Result<Stats> {
if !matches!(architecture, "aarch64" | "any") {
return Err(Error("Expected architecture aarch64 or any".into()));
}
let root = root.canonicalize()?;
let mut paths = BTreeSet::new();
fn walk(root: &Path, dir: &Path, paths: &mut BTreeSet<String>) -> Result<()> {
for item in fs::read_dir(dir)? {
let item = item?;
let path = item.path();
let name = path
.strip_prefix(root)
.unwrap()
.to_str()
.ok_or_else(|| Error("Software paths must be UTF-8".into()))?
.to_owned();
if !relative(&name) || name.len() > 255 {
return Err(Error(
"Software path is unsafe or exceeds USTAR's 255-byte limit".into(),
));
}
let kind = item.file_type()?;
if kind.is_dir() {
walk(root, &path, paths)?;
} else if !(kind.is_file()
|| kind.is_symlink() && path.canonicalize()?.starts_with(root) && path.is_file())
{
return Err(Error(
"Software tree contains an unsupported node or an escaping/directory symlink"
.into(),
));
}
paths.insert(name);
if paths.len() > MAX_ENTRIES as usize {
return Err(Error("Too many software files".into()));
}
}
Ok(())
}
walk(&root, &root, &mut paths)?;
let mut builder = tar::Builder::new(output);
let mut stats = Stats {
bytes: 0,
entries: 0,
};
for name in paths {
let path = root.join(&name);
let metadata = path.metadata()?;
let size = if metadata.is_dir() { 0 } else { metadata.len() };
stats.bytes = stats
.bytes
.checked_add(size)
.ok_or_else(|| Error("Software too large".into()))?;
if stats.bytes > MAX_UNPACKED {
return Err(Error("Software tree exceeds 1 GiB".into()));
}
let mut header = tar::Header::new_ustar();
header.set_path(&name)?;
header.set_uid(0);
header.set_gid(0);
header.set_mtime(0);
header.set_size(size);
header.set_entry_type(if metadata.is_dir() {
tar::EntryType::Directory
} else {
tar::EntryType::Regular
});
header.set_mode(
if metadata.is_dir() || metadata.permissions().mode() & 0o111 != 0 {
0o755
} else {
0o644
},
);
header.set_cksum();
if metadata.is_dir() {
builder.append(&header, io::empty())?;
} else {
let mut input = File::open(&path)?;
let mut prefix = vec![0; size.min(64) as usize];
input.read_exact(&mut prefix)?;
elf(&prefix, architecture)?;
input.seek(SeekFrom::Start(0))?;
builder.append(&header, &mut input)?;
}
stats.entries += 1;
}
builder.finish()?;
Ok(stats)
}
#[cfg(test)]
mod tests {
use super::*;
fn metadata() -> Software {
Software {
id: "test.tool".into(),
name: "Tool".into(),
version: "1".into(),
architecture: "any".into(),
partition: 2,
archive_bytes: 100,
unpacked_bytes: 3,
entries: 1,
sha256: "a".repeat(64),
commands: [("tool".into(), "bin/tool".into())].into(),
}
}
fn tar(kind: tar::EntryType, path: &str, mode: u32) -> Vec<u8> {
let mut data = Vec::new();
let mut b = tar::Builder::new(&mut data);
let mut h = tar::Header::new_ustar();
h.set_path(path).unwrap();
h.set_mode(mode);
h.set_uid(0);
h.set_gid(0);
h.set_mtime(0);
h.set_size(if kind.is_file() { 3 } else { 0 });
h.set_entry_type(kind);
h.set_cksum();
b.append(&h, if kind.is_file() { &b"abc"[..] } else { &[][..] })
.unwrap();
b.finish().unwrap();
drop(b);
data
}
#[test]
fn file_cannot_replace_an_implicit_parent_directory() {
let mut data = tar(tar::EntryType::Regular, "bin/tool", 0o755);
data.truncate(1024);
data.extend(tar(tar::EntryType::Regular, "bin", 0o644));
let mut software = metadata();
software.entries = 2;
software.unpacked_bytes = 6;
assert!(read_tar(&data[..], &software, None).is_err());
let root = std::env::temp_dir().join(format!("fds-archive-mode-{}", std::process::id()));
fs::create_dir(&root).unwrap();
fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap();
let good = tar(tar::EntryType::Regular, "bin/tool", 0o755);
read_tar(&good[..], &metadata(), Some(&root)).unwrap();
assert_eq!(
root.join("bin").metadata().unwrap().permissions().mode() & 0o777,
0o755
);
assert_eq!(
root.join("bin/tool")
.metadata()
.unwrap()
.permissions()
.mode()
& 0o777,
0o755
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn file_types_modes_counts_trailing_data_and_commands_are_checked() {
let good = tar(tar::EntryType::Regular, "bin/tool", 0o755);
assert_eq!(
read_tar(&good[..], &metadata(), None).unwrap(),
Stats {
bytes: 3,
entries: 1
}
);
for kind in [
tar::EntryType::Symlink,
tar::EntryType::Link,
tar::EntryType::Fifo,
tar::EntryType::Char,
tar::EntryType::GNUSparse,
] {
assert!(read_tar(&tar(kind, "bin/tool", 0o755)[..], &metadata(), None).is_err());
}
assert!(
read_tar(
&tar(tar::EntryType::Regular, "bin/tool", 0o4755)[..],
&metadata(),
None
)
.is_err()
);
assert!(
read_tar(
&tar(tar::EntryType::Regular, "bin/tool", 0o644)[..],
&metadata(),
None
)
.is_err()
);
let mut short = metadata();
short.unpacked_bytes = 2;
assert!(read_tar(&good[..], &short, None).is_err());
let mut trailing = good.clone();
trailing.extend_from_slice(b"unexpected");
assert!(read_tar(&trailing[..], &metadata(), None).is_err());
let mut unsafe_name = good.clone();
unsafe_name[..100].fill(0);
unsafe_name[..9].copy_from_slice(b"../escape");
unsafe_name[148..156].fill(b' ');
let checksum: u32 = unsafe_name[..512].iter().map(|b| u32::from(*b)).sum();
unsafe_name[148..156].copy_from_slice(format!("{checksum:06o}\0 ").as_bytes());
assert!(read_tar(&unsafe_name[..], &metadata(), None).is_err());
}
}
+3
View File
@@ -0,0 +1,3 @@
//! Verified software archives shared by workstation and guest tools.
pub use fds_common::software::*;
pub mod archive;
View File
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "fds-stage0"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "Static FDS early boot discovery and root handoff"
[dependencies]
clap.workspace = true
fds-common = { path = "../fds-common" }
serde_json = "1"
libc = "0.2"
+533
View File
@@ -0,0 +1,533 @@
//! Linux PID-1 operations. No shell, external mount program or udev is used.
use fds_common::{
Error, Result,
boot::BootOptions,
read_text,
sysfs::{self, BlockPartition, Selection},
trace::{self, Point},
};
use std::{
ffi::CString,
fs,
io::{self, Read, Write},
os::{
fd::{AsRawFd, FromRawFd, OwnedFd},
unix::{
fs::{FileTypeExt, MetadataExt, PermissionsExt},
process::CommandExt,
},
},
path::Path,
process::{Child, Command, Stdio},
};
fn c(value: &str) -> Result<CString> {
CString::new(value).map_err(|_| Error("NUL in syscall argument".into()))
}
fn checked(value: libc::c_int, operation: &str) -> Result<()> {
if value < 0 {
Err(Error(format!(
"{operation}: {}",
io::Error::last_os_error()
)))
} else {
Ok(())
}
}
fn mount(source: &str, target: &str, kind: &str, flags: libc::c_ulong) -> Result<()> {
let (source, target, kind) = (c(source)?, c(target)?, c(kind)?);
// All strings remain alive for the syscall; no data/options pointer is passed.
checked(
unsafe {
libc::mount(
source.as_ptr(),
target.as_ptr(),
kind.as_ptr(),
flags,
std::ptr::null(),
)
},
"mount",
)
}
fn move_mount(source: &str, target: &str) -> Result<()> {
let (source, target) = (c(source)?, c(target)?);
checked(
unsafe {
libc::mount(
source.as_ptr(),
target.as_ptr(),
std::ptr::null(),
libc::MS_MOVE,
std::ptr::null(),
)
},
"move mount",
)
}
fn unmount(target: &str) -> Result<()> {
checked(unsafe { libc::umount(c(target)?.as_ptr()) }, "unmount")
}
fn prepare() -> Result<()> {
if unsafe { libc::getpid() } != 1 || unsafe { libc::geteuid() } != 0 {
return Err(Error(
"Normal stage0 boot requires root PID 1 in an initramfs".into(),
));
}
let mut stat: libc::statfs = unsafe { std::mem::zeroed() };
checked(
unsafe { libc::statfs(c("/")?.as_ptr(), &mut stat) },
"inspect initial root",
)?;
// Linux UAPI linux/magic.h; libc exposes differing signed types by ABI.
if ![0x8584_58f6_u64, 0x0102_1994_u64].contains(&(stat.f_type as u64)) {
return Err(Error(
"Refusing root handoff: initial root is not ramfs/tmpfs".into(),
));
}
for path in ["/proc", "/sys", "/dev", "/newroot"] {
fs::create_dir_all(path)?;
}
mount(
"proc",
"/proc",
"proc",
libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC,
)?;
mount(
"sysfs",
"/sys",
"sysfs",
libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC,
)?;
mount("devtmpfs", "/dev", "devtmpfs", libc::MS_NOSUID)?;
fs::create_dir_all("/dev/fds-early")?;
fs::set_permissions("/dev/fds-early", fs::Permissions::from_mode(0o700))?;
Ok(())
}
fn netlink() -> Result<OwnedFd> {
let fd = unsafe {
libc::socket(
libc::AF_NETLINK,
libc::SOCK_DGRAM | libc::SOCK_CLOEXEC,
libc::NETLINK_KOBJECT_UEVENT,
)
};
checked(fd, "open kernel uevent socket")?;
let fd = unsafe { OwnedFd::from_raw_fd(fd) };
let mut address: libc::sockaddr_nl = unsafe { std::mem::zeroed() };
address.nl_family = libc::AF_NETLINK as u16;
address.nl_groups = 1;
checked(
unsafe {
libc::bind(
fd.as_raw_fd(),
(&address as *const libc::sockaddr_nl).cast(),
std::mem::size_of_val(&address) as _,
)
},
"bind kernel uevent socket",
)?;
Ok(fd)
}
fn child_events() -> Result<OwnedFd> {
let mut signals: libc::sigset_t = unsafe { std::mem::zeroed() };
unsafe {
libc::sigemptyset(&mut signals);
libc::sigaddset(&mut signals, libc::SIGCHLD);
}
checked(
unsafe { libc::sigprocmask(libc::SIG_BLOCK, &signals, std::ptr::null_mut()) },
"block SIGCHLD",
)?;
let fd = unsafe { libc::signalfd(-1, &signals, libc::SFD_CLOEXEC | libc::SFD_NONBLOCK) };
checked(fd, "open child event descriptor")?;
Ok(unsafe { OwnedFd::from_raw_fd(fd) })
}
fn start_monitor(debug: bool) -> Result<Child> {
let mut command = Command::new("/sbin/dasungd");
command
.args(["--config", "/etc/dasungd-early.toml", "daemon"])
.env_clear();
if !debug {
command.stdout(Stdio::null()).stderr(Stdio::null());
}
command
.spawn()
.map_err(|e| Error(format!("Start early Dasung controller: {e}")))
}
fn stop_monitor(child: &mut Child) -> Result<()> {
if child.try_wait()?.is_some() {
return Ok(());
}
let pid = child.id() as libc::pid_t;
let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) } as libc::c_int;
checked(fd, "open early-controller process handle")?;
let fd = unsafe { OwnedFd::from_raw_fd(fd) };
checked(
unsafe { libc::kill(pid, libc::SIGTERM) },
"stop early monitor controller",
)?;
let mut poll = libc::pollfd {
fd: fd.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
loop {
// This is a process-exit deadline, not a delay before proceeding.
let ready = unsafe { libc::poll(&mut poll, 1, 2000) };
if ready < 0 && io::Error::last_os_error().kind() == io::ErrorKind::Interrupted {
continue;
}
checked(ready, "wait for early controller exit")?;
if ready == 0 {
child.kill()?;
}
child.wait()?;
return Ok(());
}
}
fn try_root(device: &BlockPartition) -> Result<()> {
let node = fs::File::open(&device.device)?;
let stat = node.metadata()?;
if !stat.file_type().is_block_device()
|| libc::major(stat.rdev()) != device.major
|| libc::minor(stat.rdev()) != device.minor
{
return Err(Error("Block device changed during discovery".into()));
}
mount(
&format!("/proc/self/fd/{}", node.as_raw_fd()),
"/newroot",
"erofs",
libc::MS_RDONLY,
)?;
let verify = || -> Result<()> {
let identity = read_text(Path::new("/newroot/usr/lib/os-release"), 16384)?;
if !identity.lines().any(|line| line == "ID=fds") {
return Err(Error("Selected root is not an FDS image".into()));
}
for path in [
"/newroot/sbin/init",
"/newroot/usr/bin/execlineb",
"/newroot/usr/bin/s6-linux-init",
] {
let meta = fs::metadata(path)?;
if !meta.is_file() || meta.mode() & 0o111 == 0 {
return Err(Error(format!("Missing executable: {path}")));
}
}
for path in ["/newroot/proc", "/newroot/sys", "/newroot/dev"] {
let meta = fs::symlink_metadata(path)?;
if !meta.is_dir() {
return Err(Error(format!("Invalid early mountpoint: {path}")));
}
}
Ok(())
};
if let Err(error) = verify() {
unmount("/newroot")?;
return Err(error);
}
Ok(())
}
fn remove_initial_tree(path: &Path, device: u64) -> Result<()> {
for entry in fs::read_dir(path)? {
let entry = entry?;
let metadata = fs::symlink_metadata(entry.path())?;
if metadata.dev() != device {
continue;
}
if metadata.is_dir() {
remove_initial_tree(&entry.path(), device)?;
fs::remove_dir(entry.path())?;
} else {
fs::remove_file(entry.path())?;
}
}
Ok(())
}
fn handoff(mut monitor: Child, debug: bool) -> Result<()> {
stop_monitor(&mut monitor)?;
if let Ok(instant) = trace::now() {
trace::mark_early(Point::RootSwitch, instant);
}
for source in ["/proc", "/sys", "/dev"] {
move_mount(source, &format!("/newroot{source}"))?;
}
let old_device = fs::metadata("/")?.dev();
if fs::metadata("/newroot")?.dev() == old_device {
return Err(Error("New root is not a separate filesystem".into()));
}
std::env::set_current_dir("/newroot")?;
// Only the initial ramfs/tmpfs is removed. Moved mounts and EROFS are skipped.
remove_initial_tree(Path::new("/"), old_device)?;
move_mount("/newroot", "/")?;
checked(unsafe { libc::chroot(c(".")?.as_ptr()) }, "enter new root")?;
std::env::set_current_dir("/")?;
let mut empty: libc::sigset_t = unsafe { std::mem::zeroed() };
unsafe {
libc::sigemptyset(&mut empty);
}
checked(
unsafe { libc::sigprocmask(libc::SIG_SETMASK, &empty, std::ptr::null_mut()) },
"restore init signal mask",
)?;
if debug {
println!("FDS_STAGE0_HANDOFF: native s6 on read-only SYSTEM");
}
let error = Command::new("/sbin/init")
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("LANG", "en_US.UTF-8")
.exec();
Err(Error(format!("Execute native init: {error}")))
}
fn power(command: &str) -> Result<()> {
let action = match command {
"reboot" => libc::LINUX_REBOOT_CMD_RESTART,
"poweroff" => libc::LINUX_REBOOT_CMD_POWER_OFF,
_ => return Err(Error("Invalid power command".into())),
};
checked(
unsafe { libc::reboot(action) },
"early-userspace power request",
)
}
#[derive(Default)]
struct ConsoleInput {
pending: Vec<u8>,
discard: bool,
}
impl ConsoleInput {
fn accept(&mut self, bytes: &[u8]) -> Vec<String> {
let mut commands = Vec::new();
for &byte in bytes {
if byte == b'\n' || byte == b'\r' {
if !self.discard {
commands.push(String::from_utf8_lossy(&self.pending).trim().to_owned());
}
self.pending.clear();
self.discard = false;
} else if !self.discard && self.pending.len() < 256 {
self.pending.push(byte);
} else {
// Discard the complete overlong line, including any command suffix.
self.pending.clear();
self.discard = true;
}
}
commands
}
}
fn read_console(input: &mut ConsoleInput) -> Result<Option<Vec<String>>> {
let mut bytes = [0; 256];
let count = io::stdin().read(&mut bytes)?;
if count == 0 {
return Ok(None);
}
Ok(Some(input.accept(&bytes[..count])))
}
fn block_event(fd: &OwnedFd) -> Result<bool> {
let mut bytes = [0; 65536];
let mut from: libc::sockaddr_nl = unsafe { std::mem::zeroed() };
let mut length = std::mem::size_of_val(&from) as libc::socklen_t;
let count = unsafe {
libc::recvfrom(
fd.as_raw_fd(),
bytes.as_mut_ptr().cast(),
bytes.len(),
0,
(&mut from as *mut libc::sockaddr_nl).cast(),
&mut length,
)
};
if count < 0 {
// Lost notifications require a fresh scan, never an arbitrary wait.
if io::Error::last_os_error().raw_os_error() == Some(libc::ENOBUFS) {
return Ok(true);
}
return Err(io::Error::last_os_error().into());
}
Ok(from.nl_pid == 0
&& bytes[..count as usize]
.split(|b| *b == 0)
.any(|field| field == b"SUBSYSTEM=block"))
}
pub fn boot() -> Result<()> {
let entered = trace::now().ok();
prepare()?;
if let Some(instant) = entered {
trace::mark_early(Point::Stage0Start, instant);
}
let mut options = BootOptions::parse(&read_text(Path::new("/proc/cmdline"), 65536)?)?;
let events = netlink()?; // Bind before scanning so insertion cannot fall into a gap.
let signals = child_events()?;
let mut monitor = start_monitor(options.debug)?;
println!("FELIS DATA SYSTEMS\nPORTABLE COMPUTER FP-85\n\nFDS BOOT ROM 0.1");
println!("Commands while waiting: list, rescan, recovery, reboot, poweroff");
let mut previous = String::new();
let mut pending = ConsoleInput::default();
let mut console_open = true;
let mut restart_count = 0;
loop {
let candidates = sysfs::partitions(Path::new("/sys"))?;
let selection = sysfs::select(&candidates, options.root_label());
let state = match &selection {
Selection::Missing => format!(
"{} MEDIA NOT PRESENT\nINSERT {} CARTRIDGE",
options.root_label(),
options.root_label()
),
Selection::Ambiguous(devices) => format!(
"MULTIPLE {} CARTRIDGES ({})\nREMOVE EXTRA MEDIA OR ENTER recovery",
options.root_label(),
devices.len()
),
Selection::Unique(device) => {
let found = trace::now().ok();
match try_root(device) {
Ok(()) => {
if let Some(instant) = found {
trace::mark_early(Point::SystemFound, instant);
}
if let Ok(instant) = trace::now() {
trace::mark_early(Point::RootMounted, instant);
}
if options.debug {
println!("FDS_STAGE0_ROOT: {}", options.root_label());
}
println!("MEMORY ........ READY\nSYSTEM ........ FDS/OS 0.1");
return handoff(monitor, options.debug);
}
Err(error) => {
format!("SYSTEM CANNOT BE USED: {error}\nREPLACE MEDIA OR ENTER recovery")
}
}
}
};
if state != previous {
println!("{state}");
if options.debug {
println!("FDS_STAGE0_WAIT");
}
io::stdout().flush()?;
previous = state;
}
loop {
let mut fds = [
libc::pollfd {
fd: events.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
},
libc::pollfd {
fd: if console_open { 0 } else { -1 },
events: libc::POLLIN,
revents: 0,
},
libc::pollfd {
fd: signals.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
},
];
let ready = unsafe { libc::poll(fds.as_mut_ptr(), fds.len() as _, -1) };
if ready < 0 && io::Error::last_os_error().kind() == io::ErrorKind::Interrupted {
continue;
}
checked(ready, "wait for boot events")?;
if fds[2].revents & libc::POLLIN != 0 {
let mut info: libc::signalfd_siginfo = unsafe { std::mem::zeroed() };
unsafe {
libc::read(
signals.as_raw_fd(),
(&mut info as *mut libc::signalfd_siginfo).cast(),
std::mem::size_of_val(&info),
);
}
if let Some(status) = monitor.try_wait()? {
if restart_count >= 3 {
return Err(Error(format!(
"Early monitor controller repeatedly exited: {status}"
)));
}
restart_count += 1;
monitor = start_monitor(options.debug)?;
}
}
let mut rescan = fds[0].revents & libc::POLLIN != 0 && block_event(&events)?;
if fds[1].revents & libc::POLLIN != 0 {
let commands = read_console(&mut pending)?;
console_open = commands.is_some();
for command in commands.into_iter().flatten() {
match command.as_str() {
"recovery" => {
options.mode = fds_common::boot::BootMode::Recovery;
rescan = true;
}
"rescan" => rescan = true,
"list" => println!(
"{}",
serde_json::to_string(&candidates).map_err(|e| Error(e.to_string()))?
),
"reboot" | "poweroff" => {
stop_monitor(&mut monitor)?;
power(&command)?;
}
"" => (),
_ => println!("Commands: list, rescan, recovery, reboot, poweroff"),
}
}
}
if fds[1].revents & (libc::POLLHUP | libc::POLLERR | libc::POLLNVAL) != 0 {
console_open = false;
}
if rescan {
break;
}
}
}
}
pub fn emergency(error: &Error) -> ! {
eprintln!("FDS_STAGE0_ERROR: {error}\nBoot stopped. Enter reboot or poweroff.");
let mut pending = ConsoleInput::default();
let mut console_open = true;
loop {
let mut input = libc::pollfd {
fd: if console_open { 0 } else { -1 },
events: libc::POLLIN,
revents: 0,
};
let ready = unsafe { libc::poll(&mut input, 1, -1) };
if ready > 0 && input.revents & libc::POLLIN != 0 {
if let Ok(commands) = read_console(&mut pending) {
console_open = commands.is_some();
for command in commands.into_iter().flatten() {
if matches!(command.as_str(), "reboot" | "poweroff") {
let _ = power(&command);
}
}
}
} else if ready > 0 {
// With no console, wait for an external signal instead of busy looping.
unsafe {
libc::pause();
}
}
}
}
#[cfg(test)]
mod tests {
use super::ConsoleInput;
#[test]
fn console_handles_split_input_and_discards_overlong_command_suffixes() {
let mut input = ConsoleInput::default();
assert!(input.accept(b"reco").is_empty());
assert_eq!(input.accept(b"very\n"), ["recovery"]);
assert!(input.accept(&[b'x'; 256]).is_empty());
assert!(input.accept(b"xpoweroff\n").is_empty());
assert_eq!(input.accept(b"list\n"), ["list"]);
}
}
+104
View File
@@ -0,0 +1,104 @@
mod linux;
use clap::{Parser, ValueEnum};
use fds_common::{Error, MAX_CONFIG_BYTES, Result, boot::BootOptions, read_text, sysfs};
use std::{path::PathBuf, process::ExitCode};
#[derive(Parser)]
#[command(
version,
about = "Early boot discovery and native s6 handoff",
after_help = "With no arguments, root PID 1 boots FDS. Diagnostic options only read their supplied inputs."
)]
struct Cli {
/// Validate a kernel command-line file without booting.
#[arg(long, value_name = "FILE", conflicts_with = "probe")]
check_cmdline: Option<PathBuf>,
/// Inspect SYSTEM or RECOVERY discovery in the supplied sysfs tree.
#[arg(long, value_name = "SYSFS_ROOT")]
probe: Option<PathBuf>,
#[arg(value_enum, requires = "probe", conflicts_with = "check_cmdline")]
label: Option<Label>,
}
#[derive(Clone, Copy, ValueEnum)]
#[value(rename_all = "SCREAMING_SNAKE_CASE")]
enum Label {
FdsSystem,
FdsRecovery,
}
fn run(cli: Cli) -> Result<()> {
if let Some(path) = cli.check_cmdline {
let options = BootOptions::parse(&read_text(&path, MAX_CONFIG_BYTES)?)?;
println!(
"{}",
serde_json::to_string(&options).map_err(|e| Error(e.to_string()))?
);
} else if let Some(path) = cli.probe {
let label = match cli.label.unwrap_or(Label::FdsSystem) {
Label::FdsSystem => "FDS_SYSTEM",
Label::FdsRecovery => "FDS_RECOVERY",
};
let selection = sysfs::select(&sysfs::partitions(&path)?, label);
println!(
"{}",
serde_json::to_string(&selection).map_err(|e| Error(e.to_string()))?
);
} else {
return linux::boot();
}
Ok(())
}
fn main() -> ExitCode {
// PID 1 must enter the emergency path instead of exiting on malformed options.
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(error) => {
if unsafe { libc::getpid() } == 1 {
linux::emergency(&Error(error.to_string()));
}
error.exit();
}
};
match run(cli) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
if unsafe { libc::getpid() } == 1 {
linux::emergency(&error);
}
eprintln!("fds-stage0: {error}");
ExitCode::from(2)
}
}
}
#[cfg(test)]
mod cli_tests {
use super::*;
use clap::{CommandFactory, Parser};
#[test]
fn typed_command_contract() {
Cli::command().debug_assert();
assert!(Cli::try_parse_from(["fds-stage0"]).unwrap().probe.is_none());
for args in [
vec!["fds-stage0", "--probe", "/sys"],
vec!["fds-stage0", "--probe", "/sys", "FDS_RECOVERY"],
vec!["fds-stage0", "--check-cmdline", "cmdline"],
] {
assert!(Cli::try_parse_from(args).is_ok());
}
for args in [
vec!["fds-stage0", "FDS_SYSTEM"],
vec!["fds-stage0", "--probe", "/sys", "other"],
vec![
"fds-stage0",
"--probe",
"/sys",
"--check-cmdline",
"cmdline",
],
vec!["fds-stage0", "--check-cmdline", "cmdline", "FDS_RECOVERY"],
] {
assert!(Cli::try_parse_from(&args).is_err(), "{args:?}");
}
}
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "fds-workstation"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "Linux workstation software and cartridge tools"
[dependencies]
clap.workspace = true
fds-common = { path = "../fds-common" }
fds-burn = { path = "../fds-burn" }
fds-software = { path = "../fds-software" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
sha2 = "=0.10.9"
libc = "0.2"
[[bin]]
name = "fds-cartridge"
path = "src/main.rs"
[[bin]]
name = "fds-emulator"
path = "src/emulator-main.rs"
+283
View File
@@ -0,0 +1,283 @@
use crate::{Work, image_tool, parent, success, workstation};
use fds_burn::{
create,
image::{self, Image, Layout},
};
use fds_common::{
Error, Result,
manifest::{Cartridge, Class, Manifest, Media},
read_text,
};
use fds_software::{Catalogue, archive};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
fs::{self, File, OpenOptions},
io::{Read, Seek, SeekFrom, Write},
os::unix::fs::{OpenOptionsExt, PermissionsExt},
path::{Path, PathBuf},
process::Stdio,
};
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Payload {
bundles: Vec<PathBuf>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Recipe {
format: u32,
id: String,
name: String,
version: String,
payload: Vec<Payload>,
}
#[derive(Debug, Serialize)]
pub struct Inspection {
pub image: Image,
pub cartridge: Manifest,
pub catalogue: Option<Catalogue>,
}
pub fn open_image(path: &Path) -> Result<File> {
let file = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK)
.open(path)?;
if !file.metadata()?.is_file() {
return Err(Error("A prepared image must be a regular file".into()));
}
Ok(file)
}
fn uuid(bytes: &[u8]) -> [u8; 16] {
let mut id: [u8; 16] = Sha256::digest(bytes)[..16].try_into().unwrap();
id[7] = (id[7] & 15) | 0x50;
id[8] = (id[8] & 63) | 0x80;
id
}
pub fn create(recipe: &Path, output: &Path, runner: Option<&Path>) -> Result<Inspection> {
workstation()?;
if unsafe { libc::geteuid() } == 0 {
return Err(Error(
"Create cartridge images as an ordinary workstation user".into(),
));
}
let plan: Recipe = toml::from_str(&read_text(recipe, 65536)?)
.map_err(|e| Error(format!("Invalid cartridge recipe: {e}")))?;
if plan.format != 1
|| !(1..=32).contains(&plan.payload.len())
|| plan.payload.iter().any(|p| p.bundles.is_empty())
{
return Err(Error(
"Cartridge recipe requires format 1 and 1..32 nonempty payload partitions".into(),
));
}
let metadata = Manifest {
format: 1,
cartridge: Cartridge {
id: plan.id,
name: plan.name,
version: plan.version,
class: Class::Program,
},
media: Media { writable: false },
activation: None,
};
metadata.validate()?;
let work = Work::new(&parent(output)?)?;
let base = parent(recipe)?;
let tree = work.0.join("metadata");
fs::create_dir_all(tree.join("FDS"))?;
fs::write(tree.join("FDS/CARTRIDGE.TOML"), metadata.to_toml()?)?;
let mut catalogue = Catalogue {
format: 1,
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()?;
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()),
)?;
catalogue.software.push(entry);
}
trees.push(tree);
}
fs::write(tree.join("FDS/SOFTWARE.TOML"), catalogue.to_toml()?)?;
let mut files = Vec::new();
let mut lengths = Vec::new();
let mut ids = Vec::new();
let mut identity = Vec::new();
for (index, tree) in trees.iter().enumerate() {
let label = if index == 0 {
"FDS_METADATA".into()
} else {
format!("FDS_PAYLOAD{:02}", index + 1)
};
let file = work.0.join(format!("part{}.erofs", index + 1));
let seed = uuid(format!("{}:{label}", catalogue.to_toml()?).as_bytes());
let uuid_text = format!(
"{:08x}-{:04x}-{:04x}-{}-{}",
image::u32le(&seed, 0),
u16::from_le_bytes(seed[4..6].try_into().unwrap()),
u16::from_le_bytes(seed[6..8].try_into().unwrap()),
image::hex(&seed[8..10]),
image::hex(&seed[10..])
);
success(
image_tool(runner, "mkfs.erofs")
.args([
"--quiet",
"-b4096",
"-T0",
"--all-time",
"-x-1",
"--all-root",
"-U",
&uuid_text,
"-L",
&label,
])
.arg(&file)
.arg(tree)
.stdout(Stdio::null()),
)?;
success(
image_tool(runner, "fsck.erofs")
.arg("--extract")
.arg(&file)
.stdout(Stdio::null()),
)?;
let hash = archive::digest(&File::open(&file)?)?;
identity.extend_from_slice(hash.as_bytes());
ids.push(uuid(format!("{label}:{hash}").as_bytes()));
lengths.push(file.metadata()?.len());
files.push(file);
}
let layout = Layout::software(&lengths, uuid(&identity), &ids)?;
let staged = work.0.join("cartridge.img");
let mut disk = OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.mode(0o600)
.open(&staged)?;
layout.write(&disk)?;
for (part, file) in layout.partitions.iter().zip(files) {
disk.seek(SeekFrom::Start(part.start))?;
std::io::copy(&mut File::open(file)?, &mut disk)?;
}
disk.flush()?;
disk.sync_all()?;
// Inspect the actual assembled image, not only the source trees.
let inspection = inspect(&staged, runner)?;
fs::set_permissions(&staged, fs::Permissions::from_mode(0o644))?;
fs::hard_link(&staged, output)?;
File::open(parent(output)?)?.sync_all()?;
Ok(inspection)
}
pub fn inspect(path: &Path, runner: Option<&Path>) -> Result<Inspection> {
if unsafe { libc::geteuid() } == 0 {
return Err(Error(
"Inspect untrusted filesystems as an ordinary user before privileged writes".into(),
));
}
let mut file = open_image(path)?;
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"
.into(),
));
}
let work = Work::new(&std::env::temp_dir())?;
let original = image::digest(&file, info.bytes, |_| Ok(()))?;
let mut trees = Vec::new();
for part in &info.partitions {
let payload = work.0.join(format!("part{}.erofs", part.number));
let mut output = OpenOptions::new()
.write(true)
.create_new(true)
.open(&payload)?;
file.seek(SeekFrom::Start(part.start))?;
if std::io::copy(&mut (&mut file).take(part.bytes), &mut output)? != part.bytes {
return Err(Error("Truncated partition".into()));
}
let tree = work.0.join(format!("tree{}", part.number));
success(
crate::extract_tool(runner, &work.0)?
.arg(format!("--extract={}", tree.display()))
.arg(&payload)
.stdout(Stdio::null()),
)?;
trees.push(tree);
}
let cartridge = create::tree_manifest(&trees[0])?;
if cartridge.cartridge.class != info.class {
return Err(Error("Cartridge metadata disagrees with GPT".into()));
}
let catalogue = if info.partitions[0].name == "FDS_METADATA" {
let path = trees[0].join("FDS/SOFTWARE.TOML");
if !path.symlink_metadata()?.is_file() {
return Err(Error("Software catalogue must be a regular file".into()));
}
let catalogue = Catalogue::parse(&read_text(&path, 65536)?)?;
if catalogue.partition_count() != info.partitions.len() {
return Err(Error(
"Catalogue does not describe every GPT payload partition".into(),
));
}
for software in &catalogue.software {
let root = &trees[usize::from(software.partition) - 1];
let directory = root.join("bundles");
if !directory.symlink_metadata()?.is_dir() {
return Err(Error("Bundle directory must not be a symlink".into()));
}
archive::verify(
&archive::open(&root.join(software.archive_path()))?,
software,
None,
)?;
}
// Reject unlisted files or software hidden in a payload partition.
for (index, root) in trees.iter().enumerate().skip(1) {
if fs::read_dir(root)?.count() != 1 {
return Err(Error("Payload partitions may contain only bundles/".into()));
}
let mut actual: Vec<_> = fs::read_dir(root.join("bundles"))?
.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)))
.collect();
actual.sort();
expected.sort();
if actual != expected {
return Err(Error(
"Payload archive inventory disagrees with metadata".into(),
));
}
}
Some(catalogue)
} else {
None
};
if image::digest(&file, info.bytes, |_| Ok(()))? != original {
return Err(Error("Image changed while being inspected".into()));
}
info.sha256 = Some(original);
Ok(Inspection {
image: info,
cartridge,
catalogue,
})
}
+57
View File
@@ -0,0 +1,57 @@
//! Explicit dependency checks; no packages are installed by these commands.
use fds_common::{Error, Result};
use serde_json::{Value, json};
use std::{path::Path, process::Command};
fn version(command: &mut Command) -> Result<String> {
let result = command
.output()
.map_err(|e| Error(format!("Cannot run {command:?}: {e}")))?;
if !result.status.success() {
return Err(Error(format!(
"{command:?} failed: {}",
String::from_utf8_lossy(&result.stderr)
)));
}
Ok(String::from_utf8_lossy(&result.stdout)
.lines()
.next()
.unwrap_or("")
.to_owned())
}
pub fn cartridge(runner: Option<&Path>) -> Result<Value> {
crate::workstation()?;
let xz = version(Command::new("xz").arg("--version"))?;
let mkfs = version(crate::image_tool(runner, "mkfs.erofs").arg("-V"))?;
let fsck = version(crate::image_tool(runner, "fsck.erofs").arg("-V"))?;
let sandbox = Command::new("bwrap")
.args([
"--unshare-user",
"--unshare-net",
"--ro-bind",
"/",
"/",
"--",
"true",
])
.output()
.map_err(|e| {
Error(format!(
"bubblewrap is required to inspect untrusted filesystems: {e}"
))
})?;
if !sandbox.status.success() {
return Err(Error(format!(
"Unprivileged bubblewrap namespaces are unavailable: {}",
String::from_utf8_lossy(&sandbox.stderr)
)));
}
Ok(
json!({"xz":xz,"mkfs.erofs":mkfs,"fsck.erofs":fsck,"unprivileged_sandbox":"available","cross_compiler":"recipe-specific; use aarch64 output or architecture=any scripts"}),
)
}
pub fn emulator(runner: Option<&Path>) -> Result<Value> {
crate::workstation()?;
Ok(
json!({"qemu-system-aarch64":version(crate::image_tool(runner,"qemu-system-aarch64").arg("--version"))?,"qemu-img":version(crate::image_tool(runner,"qemu-img").arg("--version"))?,"acceleration":"portable TCG; no KVM requirement"}),
)
}
+135
View File
@@ -0,0 +1,135 @@
use clap::{Parser, Subcommand};
use fds_common::{Bay, Result};
use fds_workstation::emulator::Session;
use std::{path::PathBuf, process::ExitCode};
#[derive(Parser)]
#[command(
version,
about = "Boot FDS/OS and hotplug virtual USB cartridges on a Linux workstation",
after_help = "QEMU virt tests FDS software, not Raspberry Pi firmware or physical hardware. DATA uses retained temporary overlays; source images remain unchanged."
)]
struct Cli {
/// Private session directory. Choose a new directory for each boot.
#[arg(long, global = true, default_value = "out/emulator")]
session: PathBuf,
#[command(subcommand)]
command: Action,
}
#[derive(Subcommand)]
enum Action {
/// Check workstation QEMU tools without creating or starting a VM.
Doctor {
#[arg(long)]
qemu_runner: Option<PathBuf>,
},
/// Boot the supplied FDS kernel, initramfs and SYSTEM; wait for its user console.
Start {
#[arg(long, default_value = "out/kernel/boot/kernel_2712.img")]
kernel: PathBuf,
#[arg(long, default_value = "out/fds-initramfs.img")]
initramfs: PathBuf,
#[arg(long, default_value = "out/fds-system-cli.img")]
system: PathBuf,
/// Optional wrapper accepting qemu-system-aarch64 or qemu-img plus arguments.
#[arg(long)]
qemu_runner: Option<PathBuf>,
/// Guest RAM in MiB; emulation uses two CPU cores with TCG.
#[arg(long, default_value_t=1024, value_parser=clap::value_parser!(u32).range(512..=32768))]
memory_mib: u32,
#[arg(long, default_value_t=180, value_parser=clap::value_parser!(u64).range(1..=1800))]
timeout: u64,
},
/// Report actual QEMU state, devices, and retained image/overlay locations.
Status,
/// Attach the ordinary FDS console; Ctrl-] detaches without stopping QEMU.
Console,
/// Execute one ordinary-user guest command; arguments are passed literally.
Guest {
#[arg(long, default_value_t=120, value_parser=clap::value_parser!(u64).range(1..=1800))]
timeout: u64,
#[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
arguments: Vec<String>,
},
/// Insert a regular cartridge disk image into bay 01 through 12.
Insert { bay: Bay, image: PathBuf },
/// Ask FDS to stop users and declare SAFE, then remove the virtual USB device.
Eject { bay: Bay },
/// Simulate pulling a cartridge without guest eject; writable data may be lost.
Unplug { bay: Bay },
/// Request native FDS shutdown; --force instead cuts virtual power immediately.
Stop {
#[arg(long)]
force: bool,
},
}
fn run() -> Result<u8> {
let cli = Cli::parse();
let value = match cli.command {
Action::Doctor { qemu_runner } => {
fds_workstation::doctor::emulator(qemu_runner.as_deref())?
}
Action::Start {
kernel,
initramfs,
system,
qemu_runner,
memory_mib,
timeout,
} => Session::start(
&cli.session,
&kernel,
&initramfs,
&system,
qemu_runner.as_deref(),
memory_mib,
timeout,
)?,
action => {
let mut session = Session::load(&cli.session)?;
match action {
Action::Status => session.status()?,
Action::Console => {
session.console()?;
return Ok(0);
}
Action::Guest { timeout, arguments } => {
let result = session.guest(&arguments, timeout)?;
print!("{}", result.output);
return Ok(result.status);
}
Action::Insert { bay, image } => session.insert(bay, &image)?,
Action::Eject { bay } => session.remove(bay, false)?,
Action::Unplug { bay } => session.remove(bay, true)?,
Action::Stop { force } => session.stop(force)?,
Action::Start { .. } | Action::Doctor { .. } => unreachable!(),
}
}
};
println!("{}", serde_json::to_string_pretty(&value).unwrap());
Ok(0)
}
fn main() -> ExitCode {
match run() {
Ok(code) => ExitCode::from(code),
Err(error) => {
eprintln!("fds-emulator: {error}");
ExitCode::from(2)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn clap_preserves_guest_options_and_bounds_hardware() {
Cli::command().debug_assert();
let value =
Cli::try_parse_from(["fds-emulator", "guest", "--", "fds", "--json", "bays"]).unwrap();
assert!(
matches!(value.command,Action::Guest { arguments,.. } if arguments == ["fds","--json","bays"])
);
assert!(Cli::try_parse_from(["fds-emulator", "insert", "13", "file.img"]).is_err());
assert!(Cli::try_parse_from(["fds-emulator", "start", "--memory-mib", "0"]).is_err());
}
}
+359
View File
@@ -0,0 +1,359 @@
//! Persistent QEMU sessions with QMP hotplug and ordinary-user guest control.
use crate::{
qmp::Qmp,
serial::{Output, Serial},
};
use fds_burn::image;
use fds_common::{Bay, Error, Result, manifest::Class};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::{
collections::BTreeMap,
fs::{self, File, OpenOptions},
io::Write,
os::{
fd::AsRawFd,
unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt},
},
path::{Path, PathBuf},
process::Stdio,
};
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Cartridge {
pub image: PathBuf,
pub class: Class,
pub overlay: Option<PathBuf>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct State {
pub format: u8,
pub name: String,
pub kernel: PathBuf,
pub initramfs: PathBuf,
pub system: PathBuf,
pub qemu_runner: Option<PathBuf>,
pub cartridges: BTreeMap<u8, Cartridge>,
}
pub struct Session {
root: PathBuf,
_lock: File,
state: State,
}
fn ordinary() -> Result<()> {
crate::workstation()?;
if unsafe { libc::geteuid() } == 0 {
return Err(Error(
"Run the emulator as an ordinary workstation user".into(),
));
}
Ok(())
}
fn regular(path: &Path) -> Result<PathBuf> {
let resolved = path.canonicalize()?;
let file = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(&resolved)?;
if !file.metadata()?.is_file() {
return Err(Error(format!(
"{} must be a regular file, never a physical drive",
path.display()
)));
}
Ok(resolved)
}
fn inspect(path: &Path) -> Result<image::Image> {
let file = File::open(path)?;
image::inspect(&file, file.metadata()?.len())
}
fn lock(root: &Path) -> Result<File> {
let meta = fs::symlink_metadata(root)?;
if !meta.is_dir() || meta.uid() != unsafe { libc::geteuid() } || meta.mode() & 0o077 != 0 {
return Err(Error(
"Session directory must be private (0700) and owned by you".into(),
));
}
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(root.join("control.lock"))?;
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
return Err(Error(
"Another emulator control operation is in progress".into(),
));
}
Ok(file)
}
impl Session {
pub fn load(root: &Path) -> Result<Self> {
ordinary()?;
let guard = lock(root)?;
let state: State =
serde_json::from_str(&fds_common::read_text(&root.join("session.json"), 65536)?)
.map_err(|e| Error(format!("Invalid emulator session: {e}")))?;
if state.format != 1
|| !state.name.starts_with("fds-")
|| state.cartridges.keys().any(|b| !(1..=12).contains(b))
{
return Err(Error("Unsupported emulator session".into()));
}
Ok(Self {
root: root.canonicalize()?,
_lock: guard,
state,
})
}
fn save(&self) -> Result<()> {
let path = self.root.join("session.next");
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(&path)?;
file.write_all(&serde_json::to_vec_pretty(&self.state).map_err(|e| Error(e.to_string()))?)?;
file.sync_all()?;
fs::rename(path, self.root.join("session.json"))?;
File::open(&self.root)?.sync_all()?;
Ok(())
}
fn qmp(&self) -> Result<Qmp> {
let mut qmp = Qmp::connect(&self.root.join("qmp.sock"))?;
if qmp.execute("query-name", json!({}))?["name"] != self.state.name {
return Err(Error(
"QEMU session identity mismatch; no operation performed".into(),
));
}
Ok(qmp)
}
pub fn start(
root: &Path,
kernel: &Path,
initramfs: &Path,
system: &Path,
runner: Option<&Path>,
memory: u32,
timeout: u64,
) -> Result<Value> {
ordinary()?;
let kernel = regular(kernel)?;
let initramfs = regular(initramfs)?;
let system = regular(system)?;
if inspect(&system)?.class != Class::System {
return Err(Error("Boot image must be an FDS SYSTEM cartridge".into()));
}
let root = crate::parent(root)?.join(
root.file_name()
.ok_or_else(|| Error("Missing session directory name".into()))?,
);
if root.as_os_str().len() + 16 >= 108 || root.to_string_lossy().contains([',', '\n', '\r'])
{
return Err(Error(
"Use a shorter session path (under 90 bytes), without commas or line breaks".into(),
));
}
fs::DirBuilder::new()
.mode(0o700)
.create(&root)
.map_err(|e| {
Error(format!(
"Create a new session directory {}: {e}",
root.display()
))
})?;
let session = Self {
_lock: lock(&root)?,
root,
state: State {
format: 1,
name: format!("fds-{}", image::hex(&image::random_id()?)),
kernel,
initramfs,
system,
qemu_runner: runner.map(regular).transpose()?,
cartridges: BTreeMap::new(),
},
};
session.save()?;
let mut command =
crate::image_tool(session.state.qemu_runner.as_deref(), "qemu-system-aarch64");
let block = json!({"driver":"raw","node-name":"system","read-only":true,"file":{"driver":"file","filename":session.state.system}});
command.args(["-machine","virt","-cpu","max","-accel","tcg","-m", &memory.to_string(),"-smp","2","-nodefaults","-display","none","-nic","none","-no-reboot","-daemonize","-S","-name",&session.state.name,"-pidfile"])
.arg(session.root.join("qemu.pid"))
.arg("-qmp").arg(format!("unix:{}/qmp.sock,server=on,wait=off", session.root.display()))
.arg("-chardev").arg(format!("socket,id=console,path={}/console.sock,server=on,wait=off,logfile={}/console.log,logappend=on", session.root.display(),session.root.display()))
.args(["-serial","chardev:console","-kernel"]).arg(&session.state.kernel)
.arg("-initrd").arg(&session.state.initramfs)
.args(["-append","console=ttyAMA0 rdinit=/init ro quiet loglevel=3 fds.emulator=1","-blockdev"]).arg(block.to_string())
.args(["-device","virtio-blk-pci,drive=system","-device","qemu-xhci,id=xhci,addr=05.0,p2=12,p3=12"])
.stdin(Stdio::null()).stdout(Stdio::null()).stderr(File::create(session.root.join("qemu.log"))?);
crate::success(&mut command)
.map_err(|e| Error(format!("{e}; inspect {}/qemu.log", session.root.display())))?;
let boot = (|| -> Result<()> {
let mut qmp = session.qmp()?;
let mut console = Serial::connect(&session.root, timeout)?;
qmp.execute("cont", json!({}))?;
drop(qmp);
console.until(b"FDS> ")?;
let settings = console.command(&[
"fds".into(),
"--json".into(),
"machine".into(),
"status".into(),
])?;
let value: Value = serde_json::from_str(&settings.output)
.map_err(|e| Error(format!("Guest machine settings: {e}: {}", settings.output)))?;
if settings.status != 0 || value["source"] != "qemu_emulator" {
return Err(Error("SYSTEM image lacks emulator bay configuration; rebuild it with make rootfs PROFILE=cli and make system-card PROFILE=cli".into()));
}
Ok(())
})();
if let Err(error) = boot {
if let Ok(mut qmp) = session.qmp() {
let _ = qmp.execute("quit", json!({}));
}
return Err(Error(format!(
"Emulator boot failed: {error}; inspect {}/console.log",
session.root.display()
)));
}
session.status()
}
pub fn status(&self) -> Result<Value> {
let mut qmp = self.qmp()?;
Ok(
json!({"session":self.root,"state":self.state,"qemu":qmp.execute("query-status",json!({}))?,"devices":qmp.execute("qom-list",json!({"path":"/machine/peripheral"}))?,"block_nodes":qmp.execute("query-named-block-nodes",json!({"flat":true}))?}),
)
}
pub fn guest(&self, args: &[String], timeout: u64) -> Result<Output> {
self.qmp()?;
Serial::connect(&self.root, timeout)?.command(args)
}
pub fn console(self) -> Result<()> {
self.qmp()?;
// Interactive use must not block QMP forced unplug from another terminal.
let console = Serial::connect(&self.root, 120)?;
drop(self._lock);
console.console()
}
pub fn insert(&mut self, bay: Bay, path: &Path) -> Result<Value> {
let number = bay.number();
if self.state.cartridges.contains_key(&number) {
return Err(Error(
"Bay is occupied or has an incomplete insertion; eject or unplug it first".into(),
));
}
let path = regular(path)?;
let info = inspect(&path)?;
let mut qmp = self.qmp()?;
let node = format!("disk{bay}");
let id = format!("cart{bay}");
let mut cartridge = Cartridge {
image: path.clone(),
class: info.class,
overlay: None,
};
if info.class == Class::Data {
let overlay = self.root.join(format!(
"data-{bay}-{}.qcow2",
image::hex(&image::random_id()?)
));
crate::success(
crate::image_tool(self.state.qemu_runner.as_deref(), "qemu-img")
.args(["create", "-q", "-f", "qcow2", "-F", "raw", "-b"])
.arg(&path)
.arg(&overlay),
)?;
cartridge.overlay = Some(overlay);
}
let backing =
json!({"driver":"raw","read-only":true,"file":{"driver":"file","filename":path}});
let block = if let Some(overlay) = &cartridge.overlay {
json!({"driver":"qcow2","node-name":node,"file":{"driver":"file","filename":overlay},"backing":backing})
} else {
json!({"driver":"raw","node-name":node,"read-only":true,"file":{"driver":"file","filename":path}})
};
// Persist intent before changing QEMU so an interrupted client can recover
// with unplug, including a block node left between add and device_add.
self.state.cartridges.insert(number, cartridge);
self.save()?;
if let Err(error) = qmp.execute("blockdev-add", block).and_then(|_| qmp.execute("device_add", json!({"driver":"usb-storage","id":id,"drive":node,"bus":"xhci.0","port":number.to_string(),"serial":format!("FDS-{bay}"),"removable":true}))) {
let _ = qmp.execute("blockdev-del", json!({"node-name":node}));
// Retain intent: unplug reconciles actual QMP state after a timeout.
return Err(Error(format!("{error}; run unplug {bay} to clean up the incomplete insertion")));
}
Ok(
json!({"bay":number,"inserted":self.state.cartridges[&number],"guest_detection":"asynchronous; use guest -- fds bay BAY"}),
)
}
pub fn remove(&mut self, bay: Bay, force: bool) -> Result<Value> {
let number = bay.number();
if !self.state.cartridges.contains_key(&number) {
return Err(Error("No cartridge is recorded in that bay".into()));
}
let id = format!("cart{bay}");
let node = format!("disk{bay}");
let mut qmp = self.qmp()?;
let devices = qmp.execute("qom-list", json!({"path":"/machine/peripheral"}))?;
let present = devices
.as_array()
.is_some_and(|a| a.iter().any(|d| d["name"] == id));
if present {
if !force {
let response = Serial::connect(&self.root, 120)?.command(&[
"fds".into(),
"--json".into(),
"eject".into(),
bay.to_string(),
])?;
let report: Value = serde_json::from_str(&response.output)
.map_err(|_| Error(format!("Guest eject failed: {}", response.output)))?;
if response.status != 0
|| report["bays"].as_array().is_none_or(|a| {
a.len() != 1 || a[0]["state"] != "safe" || a[0]["bay"] != number
})
{
return Err(Error(format!(
"Guest has not declared bay {bay} SAFE: {}",
response.output
)));
}
}
qmp.execute("device_del", json!({"id":id}))?;
qmp.deleted(&id)?;
}
let nodes = qmp.execute("query-named-block-nodes", json!({"flat":true}))?;
if nodes
.as_array()
.is_some_and(|a| a.iter().any(|n| n["node-name"] == node))
{
qmp.execute("blockdev-del", json!({"node-name":node}))?;
}
let removed = self.state.cartridges.remove(&number).unwrap();
self.save()?;
Ok(
json!({"bay":number,"removed":removed,"mode":if force {"forced_unplug"} else {"safe_eject"},"overlay_retained":true}),
)
}
pub fn stop(&self, force: bool) -> Result<Value> {
let mut qmp = self.qmp()?;
if force {
qmp.execute("quit", json!({}))?;
} else {
let output =
Serial::connect(&self.root, 120)?.command(&["fds".into(), "poweroff".into()])?;
if output.status != 0 {
return Err(Error(format!("Guest refused shutdown: {}", output.output)));
}
}
qmp.closed()?;
Ok(json!({"stopped":self.root,"forced":force,"logs_and_data_overlays_retained":true}))
}
}
+101
View File
@@ -0,0 +1,101 @@
//! Host tools use normal Linux programs found in PATH, not a target OS service.
pub mod cartridge;
pub mod doctor;
pub mod emulator;
mod qmp;
mod serial;
pub mod software;
pub mod writing;
use fds_common::{Error, Result};
use std::{
fs,
os::unix::fs::DirBuilderExt,
path::{Path, PathBuf},
process::Command,
};
pub fn workstation() -> Result<()> {
if Path::new("/usr/share/fds/image-profile").exists()
|| fs::read_to_string("/proc/device-tree/model").is_ok_and(|s| s.contains("Raspberry Pi"))
{
return Err(Error("Build software and cartridges on a Linux workstation, not the Raspberry Pi/FDS runtime".into()));
}
Ok(())
}
pub struct Work(pub PathBuf);
impl Work {
pub fn new(parent: &Path) -> Result<Self> {
let path = parent.join(format!(
".fds-work-{}",
fds_burn::image::hex(&fds_burn::image::random_id()?)
));
fs::DirBuilder::new().mode(0o700).create(&path)?;
Ok(Self(path))
}
}
impl Drop for Work {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
pub fn parent(path: &Path) -> Result<PathBuf> {
Ok(path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."))
.canonicalize()?)
}
pub fn success(command: &mut Command) -> Result<()> {
let description = format!("{command:?}");
let status = command
.status()
.map_err(|e| Error(format!("Cannot run {description}: {e}")))?;
if !status.success() {
return Err(Error(format!("Command failed ({status}): {description}")));
}
Ok(())
}
/// Optional explicit runner supports the project's existing image-tool prefix.
/// Without it, the installed Linux erofs-utils commands are used directly.
pub fn image_tool(runner: Option<&Path>, program: &str) -> Command {
if let Some(runner) = runner {
let mut command = Command::new(runner);
command.arg(program);
command
} else {
Command::new(program)
}
}
/// Filesystem extraction can write only its private staging directory. The
/// workstation and project remain read-only even for malformed filesystem data.
pub fn extract_tool(runner: Option<&Path>, work: &Path) -> Result<Command> {
// Extraction changes directory inside the sandbox; resolve a caller-relative
// wrapper before entering it. Tool arguments remain separate argv entries.
let runner = runner.map(fs::canonicalize).transpose()?;
let tool = image_tool(runner.as_deref(), "fsck.erofs");
let mut command = Command::new("bwrap");
command
.args([
"--unshare-user",
"--unshare-net",
"--ro-bind",
"/",
"/",
"--dev",
"/dev",
"--proc",
"/proc",
"--tmpfs",
"/tmp",
])
.arg("--bind")
.arg(work)
.arg(work)
.arg("--chdir")
.arg(work)
.arg(tool.get_program())
.args(tool.get_args());
Ok(command)
}
+137
View File
@@ -0,0 +1,137 @@
use clap::{Parser, Subcommand};
use fds_common::Result;
use std::{path::PathBuf, process::ExitCode};
#[derive(Parser)]
#[command(
version,
about = "Build software bundles and metadata-first 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(subcommand)]
command: Action,
}
#[derive(Subcommand)]
enum Action {
/// Check xz, erofs-utils and the unprivileged filesystem inspection sandbox.
Doctor,
/// Build or package software on the workstation, never on the Pi.
Software {
#[command(subcommand)]
command: Software,
},
/// 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.
Inspect { image: PathBuf },
/// Verify a prepared image and save an image/target-bound write preview.
Preview {
image: PathBuf,
target: PathBuf,
output: PathBuf,
/// Use an existing disposable regular file instead of a physical USB drive.
#[arg(long)]
file_target: bool,
},
/// Write the entire prepared image only after exact preview confirmation.
Write {
preview: PathBuf,
#[arg(long)]
confirm: String,
},
}
#[derive(Subcommand)]
enum Software {
/// Execute an explicit trusted workstation build recipe, then package its output.
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.
Inspect { directory: PathBuf },
}
fn run() -> Result<()> {
let cli = Cli::parse();
let result = match cli.command {
Action::Doctor => Ok(fds_workstation::doctor::cartridge(
cli.image_tool_runner.as_deref(),
)?),
Action::Preview {
image,
target,
output,
file_target,
} => serde_json::to_value(fds_workstation::writing::preview(
&image,
&target,
&output,
file_target,
cli.image_tool_runner.as_deref(),
)?),
Action::Write { preview, confirm } => {
fds_workstation::writing::write(&preview, &confirm)?;
return Ok(());
}
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)?
}
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::Inspect { image } => serde_json::to_value(fds_workstation::cartridge::inspect(
&image,
cli.image_tool_runner.as_deref(),
)?),
}
.map_err(|e| fds_common::Error(e.to_string()))?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("fds-cartridge: {e}");
ExitCode::from(2)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn typed_grammar() {
Cli::command().debug_assert();
assert!(
Cli::try_parse_from([
"fds-cartridge",
"software",
"build",
"recipe.toml",
"bundle"
])
.is_ok()
);
assert!(
Cli::try_parse_from([
"fds-cartridge",
"create",
"recipe.toml",
"card.img",
"--force"
])
.is_err()
);
assert!(Cli::try_parse_from(["fds-cartridge", "create", "recipe.toml"]).is_err());
}
}
+127
View File
@@ -0,0 +1,127 @@
//! Bounded QMP transport; command replies and asynchronous events are distinct.
use fds_common::{Error, Result};
use serde_json::{Value, json};
use std::{
collections::VecDeque,
io::{BufRead, BufReader, Write},
os::unix::net::UnixStream,
path::Path,
time::Duration,
};
pub struct Qmp {
reader: BufReader<UnixStream>,
events: VecDeque<Value>,
sequence: u64,
}
impl Qmp {
pub fn connect(path: &Path) -> Result<Self> {
let socket = UnixStream::connect(path).map_err(|e| {
Error(format!(
"QEMU control unavailable at {}: {e}",
path.display()
))
})?;
socket.set_read_timeout(Some(Duration::from_secs(30)))?;
socket.set_write_timeout(Some(Duration::from_secs(10)))?;
let mut result = Self {
reader: BufReader::new(socket),
events: VecDeque::new(),
sequence: 0,
};
loop {
let value = result.read()?;
if value.get("QMP").is_some() {
break;
}
result.event(value)?;
}
result.execute("qmp_capabilities", json!({}))?;
Ok(result)
}
fn read(&mut self) -> Result<Value> {
let mut line = Vec::new();
loop {
let data = self.reader.fill_buf()?;
if data.is_empty() {
return Err(Error("QEMU control connection closed".into()));
}
let count = data
.iter()
.position(|b| *b == b'\n')
.map_or(data.len(), |n| n + 1);
line.extend_from_slice(&data[..count]);
self.reader.consume(count);
if line.len() > 4 * 1024 * 1024 {
return Err(Error("QMP reply exceeds 4 MiB".into()));
}
if line.last() == Some(&b'\n') {
break;
}
}
serde_json::from_slice(&line).map_err(|e| Error(format!("Invalid QMP reply: {e}")))
}
fn event(&mut self, value: Value) -> Result<()> {
if value.get("event").is_none() {
return Err(Error(format!("Unexpected QMP message: {value}")));
}
if self.events.len() >= 1024 {
return Err(Error("QMP event queue overflow".into()));
}
self.events.push_back(value);
Ok(())
}
pub fn execute(&mut self, command: &str, arguments: Value) -> Result<Value> {
self.sequence += 1;
let id = self.sequence;
let mut data =
serde_json::to_vec(&json!({"execute":command,"arguments":arguments,"id":id})).unwrap();
data.push(b'\n');
self.reader.get_mut().write_all(&data)?;
loop {
let value = self.read()?;
if value.get("event").is_some() {
self.event(value)?;
continue;
}
if value["id"] != id {
return Err(Error("QMP response id mismatch".into()));
}
if let Some(error) = value.get("error") {
return Err(Error(format!("QEMU {command}: {error}")));
}
return value
.get("return")
.cloned()
.ok_or_else(|| Error("QMP response has no result".into()));
}
}
pub fn deleted(&mut self, device: &str) -> Result<()> {
loop {
while let Some(event) = self.events.pop_front() {
if event["data"]["device"] == device
|| event["data"]["path"]
.as_str()
.is_some_and(|p| p == format!("/machine/peripheral/{device}"))
{
if event["event"] == "DEVICE_DELETED" {
return Ok(());
}
if event["event"] == "DEVICE_UNPLUG_GUEST_ERROR" {
return Err(Error("Guest refused device removal".into()));
}
}
}
let value = self.read()?;
self.event(value)?;
}
}
pub fn closed(&mut self) -> Result<()> {
loop {
match self.read() {
Err(Error(e)) if e == "QEMU control connection closed" => return Ok(()),
Err(error) => return Err(error),
Ok(value) => self.event(value)?,
}
}
}
}
+219
View File
@@ -0,0 +1,219 @@
//! Serial console access is exclusive. Guest commands execute once; only their
//! checked response transfer may be retried when kernel messages interleave.
use fds_common::{Error, Result};
use sha2::{Digest, Sha256};
use std::{
fs::{File, OpenOptions},
io::{Read, Write},
os::{
fd::AsRawFd,
unix::{fs::OpenOptionsExt, net::UnixStream},
},
path::Path,
time::{Duration, Instant},
};
pub struct Serial {
socket: UnixStream,
_lock: File,
data: Vec<u8>,
deadline: Instant,
}
#[derive(Debug, serde::Serialize)]
pub struct Output {
pub status: u8,
pub output: String,
}
fn quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
impl Serial {
pub fn connect(root: &Path, timeout: u64) -> Result<Self> {
let lock = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(root.join("console.lock"))?;
if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
return Err(Error("The console is already in use; detach it before running guest commands or safe eject".into()));
}
let socket = UnixStream::connect(root.join("console.sock"))?;
socket.set_write_timeout(Some(Duration::from_secs(10)))?;
Ok(Self {
socket,
_lock: lock,
data: Vec::new(),
deadline: Instant::now() + Duration::from_secs(timeout),
})
}
fn send(&mut self, text: &str) -> Result<()> {
self.socket.write_all(text.as_bytes())?;
self.socket.write_all(b"\n")?;
Ok(())
}
pub fn until(&mut self, marker: &[u8]) -> Result<Vec<u8>> {
loop {
if let Some(at) = self.data.windows(marker.len()).position(|w| w == marker) {
let before = self.data[..at].to_vec();
self.data.drain(..at + marker.len());
return Ok(before);
}
let remaining = self.deadline.checked_duration_since(Instant::now()).ok_or_else(|| Error("Guest console timed out; the command may still be running. Inspect console.log before retrying".into()))?;
self.socket.set_read_timeout(Some(remaining))?;
let mut buffer = [0u8; 8192];
let count = self.socket.read(&mut buffer)?;
if count == 0 {
return Err(Error("Guest console disconnected".into()));
}
self.data
.extend(buffer[..count].iter().filter(|b| **b != b'\r'));
if marker == b"FDS> " {
// Preserve the complete boot diagnostic before stopping QEMU.
// These words are ordinary data during later guest commands.
if let Some(line) = self.data.split_inclusive(|b| *b == b'\n').find(|line| {
line.ends_with(b"\n")
&& (line
.windows(b"FDS_STAGE0_ERROR:".len())
.any(|w| w == b"FDS_STAGE0_ERROR:")
|| line
.windows(b"Kernel panic".len())
.any(|w| w == b"Kernel panic"))
}) {
return Err(Error(String::from_utf8_lossy(line).trim().to_owned()));
}
}
if self.data.len() > 8 * 1024 * 1024 {
return Err(Error("Guest console response exceeds 8 MiB".into()));
}
}
}
pub fn command(&mut self, args: &[String]) -> Result<Output> {
if args.is_empty() || args.iter().any(|s| s.contains(['\n', '\r', '\0'])) {
return Err(Error(
"A guest command and single-line arguments are required".into(),
));
}
let command = args.iter().map(|s| quote(s)).collect::<Vec<_>>().join(" ");
if command.len() > 2048 {
return Err(Error("Guest command exceeds the console line limit".into()));
}
self.send("\u{15}")?;
let token = fds_burn::image::hex(&fds_burn::image::random_id()?);
let path = format!("/tmp/fds-emulator-{token}");
// The directory is private to the ordinary guest user. No root agent or
// shell evaluation of caller arguments is involved.
self.send(&format!("mkdir -m 700 {path} && {{ ( {command} ) >{path}/output 2>&1; printf '%s' \"$?\" >{path}/status; printf '\\nSAVED_{token}\\n'; }}"))?;
self.until(format!("\nSAVED_{token}\n").as_bytes())?;
for _ in 0..3 {
self.send(&format!("printf '\\nBEGIN_{token}\\n'; od -An -v -tx1 {path}/output; printf '\\nHASH '; sha256sum {path}/output; printf 'STATUS '; cat {path}/status; printf '\\nEND_{token}\\n'"))?;
self.until(format!("\nBEGIN_{token}\n").as_bytes())?;
let frame = self.until(format!("\nEND_{token}\n").as_bytes())?;
if let Some(result) = decode(&frame) {
self.send(&format!("rm -rf -- {path}; printf '\\nCLEAN_{token}\\n'"))?;
self.until(format!("\nCLEAN_{token}\n").as_bytes())?;
return Ok(result);
}
}
Err(Error("Repeated serial response corruption; command was executed once and its result remains in guest /tmp".into()))
}
pub fn console(mut self) -> Result<()> {
let fd = std::io::stdin().as_raw_fd();
let mut saved = std::mem::MaybeUninit::<libc::termios>::uninit();
let terminal = unsafe { libc::tcgetattr(fd, saved.as_mut_ptr()) } == 0;
struct Restore(Option<libc::termios>);
impl Drop for Restore {
fn drop(&mut self) {
if let Some(t) = self.0 {
unsafe {
libc::tcsetattr(0, libc::TCSANOW, &t);
}
}
}
}
let original = terminal.then(|| unsafe { saved.assume_init() });
let _restore = Restore(original);
if let Some(mut raw) = original {
unsafe {
libc::cfmakeraw(&mut raw);
libc::tcsetattr(fd, libc::TCSANOW, &raw);
}
}
eprintln!("Connected to FDS. Press Ctrl-] to detach; the VM keeps running.");
self.socket.set_read_timeout(None)?;
self.send("")?;
let mut poll = [
libc::pollfd {
fd,
events: libc::POLLIN,
revents: 0,
},
libc::pollfd {
fd: self.socket.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
},
];
loop {
let rc = unsafe { libc::poll(poll.as_mut_ptr(), 2, -1) };
if rc < 0 {
if std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted {
continue;
}
return Err(std::io::Error::last_os_error().into());
}
let mut data = [0u8; 8192];
if poll[0].revents != 0 {
let n = std::io::stdin().read(&mut data)?;
if n == 0 {
return Ok(());
}
if let Some(at) = data[..n].iter().position(|b| *b == 29) {
self.socket.write_all(&data[..at])?;
return Ok(());
}
self.socket.write_all(&data[..n])?;
}
if poll[1].revents != 0 {
let n = self.socket.read(&mut data)?;
if n == 0 {
return Ok(());
}
std::io::stdout().write_all(&data[..n])?;
std::io::stdout().flush()?;
}
}
}
}
fn decode(frame: &[u8]) -> Option<Output> {
let text = std::str::from_utf8(frame).ok()?;
let (encoded, tail) = text.split_once("\nHASH ")?;
let (hash, status) = tail.split_once("\nSTATUS ")?;
let mut bytes = Vec::new();
for pair in encoded.split_whitespace() {
if pair.len() != 2 {
return None;
}
bytes.push(u8::from_str_radix(pair, 16).ok()?);
}
if hash.split_whitespace().next()? != fds_burn::image::hex(&Sha256::digest(&bytes)) {
return None;
}
Some(Output {
status: status.trim().parse().ok()?,
output: String::from_utf8_lossy(&bytes).into_owned(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_results_require_matching_digest() {
let hash = fds_burn::image::hex(&Sha256::digest(b"hello\n"));
let frame = format!(" 68 65 6c 6c 6f 0a\n\nHASH {hash} /tmp/result\nSTATUS 7");
assert_eq!(decode(frame.as_bytes()).unwrap().status, 7);
assert!(decode(frame.replace("68", "69").as_bytes()).is_none());
assert_eq!(quote("a'b $(id)"), "'a'\\''b $(id)'");
}
}
+152
View File
@@ -0,0 +1,152 @@
use crate::{Work, parent, workstation};
use fds_common::{Error, Result, manifest::identifier, read_text};
use fds_software::{Software, archive};
use serde::Deserialize;
use std::{
collections::BTreeMap,
fs::{self, File, OpenOptions},
os::unix::fs::{OpenOptionsExt, PermissionsExt},
path::{Path, PathBuf},
process::{Command, Stdio},
};
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Build {
directory: PathBuf,
command: Vec<String>,
#[serde(default)]
environment: BTreeMap<String, String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Recipe {
format: u32,
id: String,
name: String,
version: String,
architecture: String,
root: PathBuf,
commands: BTreeMap<String, String>,
build: Option<Build>,
}
pub fn build(recipe: &Path, output: &Path, compile: bool) -> Result<Software> {
workstation()?;
if unsafe { libc::geteuid() } == 0 {
return Err(Error(
"Run software builds 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) {
return Err(Error(
"Build recipe requires format 1 and a valid software id".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 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 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 software = Software {
id: input.id,
name: input.name,
version: input.version,
architecture: input.architecture,
partition: 2,
archive_bytes: file.metadata()?.len(),
unpacked_bytes: stats.bytes,
entries: stats.entries,
sha256: archive::digest(&file)?,
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())
.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()))?;
if unsafe {
libc::renameat2(
libc::AT_FDCWD,
from.as_ptr(),
libc::AT_FDCWD,
to.as_ptr(),
libc::RENAME_NOREPLACE,
)
} < 0
{
return Err(std::io::Error::last_os_error().into());
}
File::open(output_parent)?.sync_all()?;
Ok(software)
}
pub fn load(directory: &Path) -> Result<Software> {
let path = directory.join("software.toml");
let metadata = fs::symlink_metadata(&path)?;
if !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,
)?;
Ok(software)
}
+253
View File
@@ -0,0 +1,253 @@
//! A prepared full image and a recorded target identity precede every write.
use crate::cartridge::{self, Inspection};
use fds_burn::{
device::Disk,
image::{self, Image},
write,
};
use fds_common::{Error, Result, read_text};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
fs::{self, File, OpenOptions},
io::Write,
os::{
fd::AsRawFd,
unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt},
},
path::{Path, PathBuf},
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileTarget {
path: PathBuf,
device: u64,
inode: u64,
bytes: u64,
mtime: i64,
mtime_ns: i64,
sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Target {
Usb { disk: Disk },
File { identity: FileTarget },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Preview {
pub format: u32,
pub image_path: PathBuf,
pub image: Image,
pub target: Target,
pub confirmation: String,
}
fn file_identity(path: &Path, file: &File) -> Result<FileTarget> {
let before = file.metadata()?;
if !before.is_file() {
return Err(Error("Test target must be an existing regular file".into()));
}
let sha256 = image::digest(file, before.len(), |_| Ok(()))?;
let after = file.metadata()?;
if before.mtime() != after.mtime()
|| before.mtime_nsec() != after.mtime_nsec()
|| before.len() != after.len()
{
return Err(Error("Test target changed during preview".into()));
}
Ok(FileTarget {
path: path.to_owned(),
device: before.dev(),
inode: before.ino(),
bytes: before.len(),
mtime: before.mtime(),
mtime_ns: before.mtime_nsec(),
sha256,
})
}
fn target(path: &Path, allow_file: bool) -> Result<Target> {
let path = path.canonicalize()?;
let meta = fs::metadata(&path)?;
if allow_file {
return Ok(Target::File {
identity: file_identity(&path, &cartridge::open_image(&path)?)?,
});
}
if !meta.file_type().is_block_device() {
return Err(Error("Target must be a whole USB block device; --file-target is only for disposable test files".into()));
}
let sysfs = fs::canonicalize(format!(
"/sys/dev/block/{}:{}",
libc::major(meta.rdev()),
libc::minor(meta.rdev())
))?;
if sysfs.join("partition").exists() {
return Err(Error("Select the whole USB drive, not a partition".into()));
}
let usb = sysfs
.ancestors()
.find(|p| p.join("idVendor").is_file() && p.join("idProduct").is_file())
.ok_or_else(|| Error("Refusing a non-USB disk".into()))?;
let disk = Disk::select_current(usb)?;
if disk.path != path {
return Err(Error(
"USB topology does not identify the requested whole disk".into(),
));
}
disk.protect(Path::new("/sys"), Path::new("/proc"))?;
Ok(Target::Usb { disk })
}
impl Target {
fn bytes(&self) -> u64 {
match self {
Self::Usb { disk } => disk.bytes,
Self::File { identity } => identity.bytes,
}
}
fn path(&self) -> &Path {
match self {
Self::Usb { disk } => &disk.path,
Self::File { identity } => &identity.path,
}
}
}
fn phrase(image: &Image, target: &Target) -> Result<String> {
let hash = image
.sha256
.as_deref()
.ok_or_else(|| Error("Missing prepared-image digest".into()))?;
let data = serde_json::to_vec(target).map_err(|e| Error(e.to_string()))?;
let binding = image::hex(&Sha256::digest(data));
Ok(format!(
"WRITE {} {} {}",
target.path().display(),
hash,
&binding[..16]
))
}
pub fn preview(
image_path: &Path,
target_path: &Path,
output: &Path,
allow_file: bool,
runner: Option<&Path>,
) -> Result<Preview> {
crate::workstation()?;
let image_path = image_path.canonicalize()?;
let Inspection { image, .. } = cartridge::inspect(&image_path, runner)?;
let target = target(target_path, allow_file)?;
if image_path == target.path() {
return Err(Error("Image and target must differ".into()));
}
if target.bytes() < image.bytes || target.bytes() % 512 != 0 {
return Err(Error(
"Target is smaller than the complete image or not sector aligned".into(),
));
}
let preview = Preview {
format: 1,
confirmation: phrase(&image, &target)?,
image_path,
image,
target,
};
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
.open(output)?;
file.write_all(
serde_json::to_string_pretty(&preview)
.map_err(|e| Error(e.to_string()))?
.as_bytes(),
)?;
file.write_all(b"\n")?;
file.sync_all()?;
Ok(preview)
}
pub fn write(preview: &Path, confirmation: &str) -> Result<()> {
crate::workstation()?;
if !preview.symlink_metadata()?.is_file() {
return Err(Error("Write preview must be a regular file".into()));
}
let approval: Preview = serde_json::from_str(&read_text(preview, 65536)?)
.map_err(|e| Error(format!("Invalid write preview: {e}")))?;
if approval.format != 1
|| phrase(&approval.image, &approval.target)? != approval.confirmation
|| confirmation != approval.confirmation
{
return Err(Error(
"Confirmation does not match this exact image and target".into(),
));
}
let source = cartridge::open_image(&approval.image_path)?;
let target = match &approval.target {
Target::Usb { disk } => {
if target(&disk.path, false)? != approval.target {
return Err(Error(
"USB target identity no longer matches the preview".into(),
));
}
disk.open_exclusive()?
}
Target::File { identity } => {
if unsafe { libc::geteuid() } == 0 {
return Err(Error(
"Disposable file-target tests must run as an ordinary user".into(),
));
}
let file = OpenOptions::new()
.read(true)
.write(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK)
.open(&identity.path)?;
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } < 0 {
return Err(std::io::Error::last_os_error().into());
}
if file_identity(&identity.path, &file)? != *identity {
return Err(Error(
"Test target changed since preview; no bytes written".into(),
));
}
file
}
};
if source.metadata()?.dev() == target.metadata()?.dev()
&& source.metadata()?.ino() == target.metadata()?.ino()
{
return Err(Error("Source and target refer to the same file".into()));
}
if let Target::Usb { disk } = &approval.target {
disk.protect(Path::new("/sys"), Path::new("/proc"))?;
}
write::transfer(
&source,
&target,
&approval.image,
approval.target.bytes(),
|phase, bytes| {
eprintln!("{phase}: {bytes} bytes");
if let Target::Usb { disk } = &approval.target {
if !disk.present() {
return Err(Error("USB target changed during transfer".into()));
}
}
Ok(())
},
)?;
if let Target::Usb { .. } = approval.target {
// Refresh the kernel's view only after verified full-image transfer.
if unsafe { libc::ioctl(target.as_raw_fd(), 0x125f as libc::Ioctl) } < 0 {
return Err(Error(format!(
"Image verified, but partition reread failed: {}",
std::io::Error::last_os_error()
)));
}
}
println!("VERIFIED: complete cartridge image written, flushed and read back");
Ok(())
}