FDS/OS 1.0
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
@@ -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 (¶meter, &value) in &config.startup {
|
||||
parameter.validate(value)?;
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(¶meter);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
}))
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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, ¤t, 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());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(¤t, 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(¤t, json)?;
|
||||
if let Some(error) = ¤t.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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(¤t, machine::MAX_BUNDLE)?;
|
||||
atomic(
|
||||
¤t.with_file_name("previous.json"),
|
||||
previous.as_bytes(),
|
||||
0o600,
|
||||
)?;
|
||||
atomic(¤t, &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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
[package]
|
||||
name = "fds-smoketest"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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(¤t) {
|
||||
Ok(()) => fs::set_permissions(¤t, fs::Permissions::from_mode(0o755))?,
|
||||
Err(error)
|
||||
if error.kind() == io::ErrorKind::AlreadyExists
|
||||
&& fs::symlink_metadata(¤t)?.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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Verified software archives shared by workstation and guest tools.
|
||||
pub use fds_common::software::*;
|
||||
pub mod archive;
|
||||
@@ -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"
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"}),
|
||||
)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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}))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)?,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)'");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user