FDS/OS 1.0

This commit is contained in:
2026-09-21 22:29:23 +08:00
commit 99bc3d15c5
430 changed files with 34876 additions and 0 deletions
View File
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "fds-common"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "Shared bounded configuration and Linux discovery for FDS tools"
[dependencies]
serde = { version = "1", features = ["derive"] }
toml = "0.8"
serde_json = "1"
libc = "0.2"
+84
View File
@@ -0,0 +1,84 @@
use crate::{Error, Result};
use serde::Serialize;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum BootMode {
#[default]
Normal,
Recovery,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct BootOptions {
pub mode: BootMode,
pub debug: bool,
pub emulator: bool,
}
impl BootOptions {
pub fn parse(command_line: &str) -> Result<Self> {
if command_line.len() > 65536 || command_line.contains('\0') {
return Err(Error("Invalid kernel command line".into()));
}
let mut options = Self::default();
let mut seen = std::collections::BTreeSet::new();
for word in command_line
.split_ascii_whitespace()
.filter(|w| w.starts_with("fds."))
{
let (key, value) = word
.split_once('=')
.ok_or_else(|| Error(format!("Expected key=value: {word}")))?;
if !seen.insert(key) {
return Err(Error(format!("Duplicate boot option: {key}")));
}
match (key, value) {
("fds.boot", "normal") => options.mode = BootMode::Normal,
("fds.boot", "recovery") => options.mode = BootMode::Recovery,
("fds.debug", "0") => options.debug = false,
("fds.debug", "1") => options.debug = true,
("fds.emulator", "0") => options.emulator = false,
("fds.emulator", "1") => options.emulator = true,
_ => return Err(Error(format!("Unknown or invalid boot option: {word}"))),
}
}
Ok(options)
}
pub fn root_label(self) -> &'static str {
match self.mode {
BootMode::Normal => "FDS_SYSTEM",
BootMode::Recovery => "FDS_RECOVERY",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recovery_is_explicit_and_options_fail_closed() {
assert_eq!(
BootOptions::parse("console=ttyAMA0 ro")
.unwrap()
.root_label(),
"FDS_SYSTEM"
);
assert_eq!(
BootOptions::parse("quiet fds.boot=recovery fds.debug=1")
.unwrap()
.root_label(),
"FDS_RECOVERY"
);
assert!(BootOptions::parse("fds.emulator=1").unwrap().emulator);
assert!(!BootOptions::parse("fds.emulator=0").unwrap().emulator);
for bad in [
"fds.emulator=2",
"fds.emulator=1 fds.emulator=0",
"fds.boot=anything",
"fds.debug=2",
"fds.boot=normal fds.boot=recovery",
"fds.boot",
"fds.execute=/bin/sh",
] {
assert!(BootOptions::parse(bad).is_err(), "{bad}");
}
}
}
+233
View File
@@ -0,0 +1,233 @@
//! Bounded, versioned local control protocol. No client-supplied device paths.
use crate::{Bay, Error, Result, manifest::Manifest, topology::UsbDevice};
use serde::{Deserialize, Serialize};
use std::{
io::{Read, Write},
os::unix::net::UnixStream,
time::Duration,
};
pub const SOCKET: &str = "/run/fds/control.sock";
pub const LIMIT: usize = 256 * 1024;
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "command", rename_all = "snake_case", deny_unknown_fields)]
pub enum Request {
Poweroff,
Reboot,
PowerStatus,
PowerResume,
PowerPrepare,
RecoveryData {
bay: Bay,
repair: bool,
confirmation: Option<String>,
},
Bays,
Profiles,
Profile {
profile: String,
},
Network {
enabled: bool,
},
Bay {
bay: Bay,
},
Disk {
bay: Bay,
},
Topology,
Rescan,
Eject {
bay: Bay,
},
DataUse {
bay: Bay,
},
Run {
bay: Bay,
arguments: Vec<String>,
},
MediaPrepare {
bay: Bay,
image: String,
class: crate::manifest::Class,
},
MediaStatus {
id: String,
after_sequence: Option<u64>,
},
MediaConfirm {
id: String,
confirmation: String,
},
MediaCancel {
id: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaJob {
pub id: String,
pub bay: Bay,
pub sequence: u64,
pub phase: String,
pub diskseq: u64,
pub target_bytes: u64,
pub model: String,
pub serial: Option<String>,
pub image_class: crate::manifest::Class,
pub image_bytes: Option<u64>,
pub image_sha256: Option<String>,
pub progress_bytes: u64,
pub confirmation: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BayDisk {
pub bay: Bay,
pub diskseq: u64,
pub bytes: u64,
pub sector_bytes: u32,
pub model: String,
pub serial: Option<String>,
pub protected: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecoveryReport {
pub disk: BayDisk,
pub confirmation: Option<String>,
pub checked: bool,
pub repaired: bool,
pub log: Option<String>,
}
impl MediaJob {
pub fn finished(&self) -> bool {
matches!(self.phase.as_str(), "complete" | "failed" | "cancelled")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BayState {
pub bay: Bay,
pub state: String,
pub name: Option<String>,
pub detail: Option<String>,
pub devices: Vec<UsbDevice>,
pub manifest: Option<Manifest>,
pub mount: Option<String>,
pub consumers: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub software: Option<crate::software::Catalogue>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProfileState {
pub desktop: String,
pub environment_bay: Option<Bay>,
pub network: Vec<String>,
pub manual_network: bool,
pub activation_ns: Option<u64>,
pub ready_ns: Option<u64>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PowerEvent {
pub phase: String,
pub at_ns: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PowerState {
pub phase: String,
pub action: Option<String>,
pub native_pending: bool,
pub error: Option<String>,
pub events: Vec<PowerEvent>,
}
impl Default for PowerState {
fn default() -> Self {
Self {
phase: "idle".into(),
action: None,
native_pending: false,
error: None,
events: Vec::new(),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Response {
pub format: u32,
pub error: Option<String>,
pub bays: Vec<BayState>,
pub unmapped: Vec<UsbDevice>,
pub started_pid: Option<u32>,
#[serde(default)]
pub profiles: Option<ProfileState>,
#[serde(default)]
pub media_job: Option<MediaJob>,
#[serde(default)]
pub disk: Option<BayDisk>,
#[serde(default)]
pub power: Option<PowerState>,
#[serde(default)]
pub recovery: Option<RecoveryReport>,
}
impl Response {
pub fn failure(error: impl ToString) -> Self {
Self {
format: 1,
error: Some(error.to_string()),
bays: Vec::new(),
unmapped: Vec::new(),
started_pid: None,
profiles: None,
media_job: None,
disk: None,
power: None,
recovery: None,
}
}
}
pub fn request(request: &Request) -> Result<Response> {
let mut stream = UnixStream::connect(SOCKET)
.map_err(|e| Error(format!("Cartridge service unavailable: {e}")))?;
// Flushing real removable storage can legitimately outlast a status query.
// A timeout remains an error, never a substituted SAFE result.
stream.set_read_timeout(Some(Duration::from_secs(
if matches!(request, Request::RecoveryData { .. }) {
1800
} else if matches!(
request,
Request::Run { .. }
| Request::Eject { .. }
| Request::Poweroff
| Request::Reboot
| Request::PowerPrepare
| Request::Profile { .. }
| Request::Network { .. }
| Request::MediaStatus { .. }
) {
120
} else {
10
},
)))?;
stream.set_write_timeout(Some(Duration::from_secs(10)))?;
let mut data = serde_json::to_vec(request).map_err(|e| Error(e.to_string()))?;
if data.len() + 1 > LIMIT {
return Err(Error("Cartridge request exceeds protocol limit".into()));
}
data.push(b'\n');
stream.write_all(&data)?;
let mut bytes = Vec::new();
stream.take((LIMIT + 1) as u64).read_to_end(&mut bytes)?;
if bytes.len() > LIMIT {
return Err(Error("Cartridge response exceeds protocol limit".into()));
}
let response: Response = serde_json::from_slice(&bytes)
.map_err(|e| Error(format!("Invalid cartridge response: {e}")))?;
if response.format != 1 {
return Err(Error("Unsupported cartridge protocol".into()));
}
if let Some(error) = &response.error {
return Err(Error(error.clone()));
}
Ok(response)
}
+102
View File
@@ -0,0 +1,102 @@
//! Shared data contracts. Cartridge contents are data, never startup commands.
pub mod boot;
pub mod control;
pub mod machine;
pub mod manifest;
pub mod software;
pub mod sysfs;
pub mod topology;
pub mod trace;
use std::{fmt, fs::File, io::Read, path::Path, str::FromStr};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const MAX_CONFIG_BYTES: u64 = 64 * 1024;
pub type Result<T> = std::result::Result<T, Error>;
/// The profile marker is part of the immutable root, never supplied by media.
pub fn recovery_mode() -> Result<bool> {
Ok(read_text(Path::new("/usr/share/fds/image-profile"), 64)?.trim() == "recovery")
}
#[derive(Debug)]
pub struct Error(pub String);
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for Error {}
impl From<std::io::Error> for Error {
fn from(value: std::io::Error) -> Self {
Self(value.to_string())
}
}
/// Read at most the limit plus a sentinel byte, including on special files.
pub fn read_text(path: &Path, limit: u64) -> Result<String> {
let file = File::open(path).map_err(|e| Error(format!("{}: {e}", path.display())))?;
let mut bytes = Vec::new();
file.take(limit + 1).read_to_end(&mut bytes)?;
if bytes.len() as u64 > limit {
return Err(Error(format!("{} exceeds {limit} bytes", path.display())));
}
String::from_utf8(bytes).map_err(|_| Error(format!("{} is not UTF-8", path.display())))
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
#[serde(try_from = "u8", into = "u8")]
pub struct Bay(u8);
impl Bay {
pub fn number(self) -> u8 {
self.0
}
}
impl TryFrom<u8> for Bay {
type Error = Error;
fn try_from(value: u8) -> Result<Self> {
if (1..=12).contains(&value) {
Ok(Self(value))
} else {
Err(Error("Bay must be between 1 and 12".into()))
}
}
}
impl From<Bay> for u8 {
fn from(value: Bay) -> Self {
value.0
}
}
impl FromStr for Bay {
type Err = Error;
fn from_str(value: &str) -> Result<Self> {
if value.is_empty() || value.len() > 2 || !value.bytes().all(|c| c.is_ascii_digit()) {
return Err(Error("Bay must be a number from 01 to 12".into()));
}
Self::try_from(
value
.parse::<u8>()
.map_err(|_| Error("Invalid bay".into()))?,
)
}
}
impl fmt::Display for Bay {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:02}", self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bay_identifiers_are_bounded() {
assert_eq!("02".parse::<Bay>().unwrap().number(), 2);
assert_eq!(Bay::try_from(12).unwrap().to_string(), "12");
for invalid in ["", "0", "13", "-1", "+2", " 2", "002", "a"] {
assert!(invalid.parse::<Bay>().is_err(), "{invalid}");
}
}
}
+217
View File
@@ -0,0 +1,217 @@
//! A single atomic settings document; TOML contents remain declarative data.
use crate::{
Error, MAX_CONFIG_BYTES, Result, read_text,
topology::{BayMap, Catalog},
};
use serde::{Deserialize, Serialize};
use std::{
fs::{self, File, OpenOptions},
io::Read,
os::unix::fs::{MetadataExt, OpenOptionsExt},
path::Path,
};
pub const SNAPSHOT: &str = "/run/fds/machine/config.json";
pub const STATUS: &str = "/run/fds/machine/status.json";
pub const MAX_BUNDLE: u64 = 256 * 1024;
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub format: u8,
pub name: String,
pub bays: String,
pub hardware_catalog: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Identity {
format: u8,
name: String,
}
impl Config {
pub fn validate(&self) -> Result<()> {
if self.format != 1
|| self.name.is_empty()
|| self.name.len() > 128
|| self.name.chars().any(char::is_control)
|| self.bays.len() as u64 > MAX_CONFIG_BYTES
|| self.hardware_catalog.len() as u64 > MAX_CONFIG_BYTES
{
return Err(Error(
"Invalid machine settings format, name or size".into(),
));
}
BayMap::parse(&self.bays)?;
Catalog::parse(&self.hardware_catalog)?;
// JSON escaping can expand two individually valid TOML inputs beyond
// the loader's bundle limit. Reject that before an installation writes.
self.json()?;
Ok(())
}
pub fn parse(text: &str) -> Result<Self> {
if text.len() as u64 > MAX_BUNDLE {
return Err(Error("Machine settings are too large".into()));
}
let result: Self = serde_json::from_str(text)
.map_err(|e| Error(format!("Invalid machine settings: {e}")))?;
result.validate()?;
Ok(result)
}
pub fn directory(path: &Path) -> Result<Self> {
let identity: Identity = toml::from_str(&source_text(&path.join("machine.toml"), 4096)?)
.map_err(|e| Error(format!("Invalid machine identity: {e}")))?;
let result = Self {
format: identity.format,
name: identity.name,
bays: source_text(&path.join("bays.toml"), MAX_CONFIG_BYTES)?,
hardware_catalog: source_text(&path.join("hardware-catalog.toml"), MAX_CONFIG_BYTES)?,
};
result.validate()?;
Ok(result)
}
pub fn fallback() -> Result<Self> {
let result = Self {
format: 1,
name: "FDS image defaults".into(),
bays: read_text(Path::new("/etc/fds/bays.toml"), MAX_CONFIG_BYTES)?,
hardware_catalog: read_text(
Path::new("/etc/fds/hardware-catalog.toml"),
MAX_CONFIG_BYTES,
)?,
};
result.validate()?;
Ok(result)
}
pub fn active() -> Result<Self> {
if Path::new(SNAPSHOT).exists() {
Self::parse(&trusted_text(Path::new(SNAPSHOT), MAX_BUNDLE)?)
} else {
Self::fallback()
}
}
pub fn json(&self) -> Result<Vec<u8>> {
let mut bytes = serde_json::to_vec_pretty(self).map_err(|e| Error(e.to_string()))?;
bytes.push(b'\n');
if bytes.len() as u64 > MAX_BUNDLE {
return Err(Error("Encoded machine settings are too large".into()));
}
Ok(bytes)
}
}
fn source_text(path: &Path, limit: u64) -> Result<String> {
let file = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(path)?;
if !file.metadata()?.is_file() {
return Err(Error(
"Machine settings inputs must be regular files".into(),
));
}
let mut text = String::new();
file.take(limit + 1).read_to_string(&mut text)?;
if text.len() as u64 > limit {
return Err(Error("Machine settings exceed the size limit".into()));
}
Ok(text)
}
/// Reject symlinks, FIFOs, device nodes, and unprivileged-writable settings.
pub fn trusted_text(path: &Path, limit: u64) -> Result<String> {
for parent in path.ancestors().skip(1) {
let meta = fs::symlink_metadata(parent)?;
if !meta.is_dir() || meta.uid() != 0 || meta.mode() & 0o022 != 0 {
return Err(Error(format!(
"Untrusted machine settings directory: {}",
parent.display()
)));
}
}
let file: File = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(path)?;
let meta = file.metadata()?;
if !meta.is_file() || meta.uid() != 0 || meta.mode() & 0o022 != 0 {
return Err(Error(
"Machine settings must be a root-owned regular file without group/other write access"
.into(),
));
}
let mut text = String::new();
file.take(limit + 1).read_to_string(&mut text)?;
if text.len() as u64 > limit {
return Err(Error("Machine settings exceed the size limit".into()));
}
Ok(text)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_settings_reject_symlinks_fifos_and_oversized_files() {
let path = std::env::temp_dir().join(format!("fds-machine-source-{}", std::process::id()));
fs::create_dir(&path).unwrap();
fs::write(path.join("ordinary"), "hello").unwrap();
std::os::unix::fs::symlink("ordinary", path.join("link")).unwrap();
let fifo = std::ffi::CString::new(path.join("fifo").to_str().unwrap()).unwrap();
assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0);
assert!(source_text(&path.join("link"), 10).is_err());
assert!(source_text(&path.join("fifo"), 10).is_err());
assert!(source_text(&path.join("ordinary"), 4).is_err());
assert_eq!(source_text(&path.join("ordinary"), 5).unwrap(), "hello");
fs::remove_dir_all(path).unwrap();
}
#[test]
fn bundle_reuses_strict_topology_and_catalog_contracts() {
let good = Config {
format: 1,
name: "FP-85".into(),
bays: "".into(),
hardware_catalog: "device=[]".into(),
};
assert!(Config::parse(&String::from_utf8(good.json().unwrap()).unwrap()).is_ok());
for bad in [
Config { format: 2, ..good },
Config {
format: 1,
name: "bad\nname".into(),
bays: "".into(),
hardware_catalog: "".into(),
},
] {
assert!(bad.validate().is_err());
}
assert!(
Config::parse(
r#"{"format":1,"name":"FP-85","bays":"","hardware_catalog":"execute='sh'"}"#
)
.is_err()
);
assert!(
Config::parse(
r#"{"format":1,"name":"FP-85","bays":"","hardware_catalog":"","command":"sh"}"#
)
.is_err()
);
}
#[test]
fn escaped_toml_cannot_produce_an_unloadable_bundle() {
// Backslashes in TOML comments are legal, but JSON doubles each byte.
let comment = format!("#{}", "\\".repeat(MAX_CONFIG_BYTES as usize - 1));
let config = Config {
format: 1,
name: "FP-85".into(),
bays: comment.clone(),
hardware_catalog: comment,
};
assert!(BayMap::parse(&config.bays).is_ok());
assert!(Catalog::parse(&config.hardware_catalog).is_ok());
assert!(config.validate().is_err());
assert!(config.json().is_err());
}
}
+168
View File
@@ -0,0 +1,168 @@
use crate::{Error, MAX_CONFIG_BYTES, Result, read_text};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Class {
System,
Data,
Program,
Environment,
Hardware,
Utility,
}
impl Class {
pub fn label(self) -> &'static str {
match self {
Self::System => "SYSTEM",
Self::Data => "DATA",
Self::Program => "PROGRAM",
Self::Environment => "ENVIRONMENT",
Self::Hardware => "HARDWARE",
Self::Utility => "UTILITY",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Cartridge {
pub id: String,
pub name: String,
pub class: Class,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Media {
pub writable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Activation {
pub profile: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub format: u32,
pub cartridge: Cartridge,
pub media: Media,
#[serde(skip_serializing_if = "Option::is_none")]
pub activation: Option<Activation>,
}
pub fn identifier(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 64
&& value.as_bytes()[0].is_ascii_alphanumeric()
&& value
.bytes()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || b"._-".contains(&c))
&& !value.contains("..")
}
fn display_text(value: &str, limit: usize) -> bool {
!value.trim().is_empty() && value.len() <= limit && !value.chars().any(char::is_control)
}
impl Manifest {
pub fn to_toml(&self) -> Result<String> {
self.validate()?;
toml::to_string(self).map_err(|e| Error(e.to_string()))
}
pub fn load(path: &Path) -> Result<Self> {
Self::parse(&read_text(path, MAX_CONFIG_BYTES)?)
}
pub fn parse(input: &str) -> Result<Self> {
if input.len() as u64 > MAX_CONFIG_BYTES {
return Err(Error("Cartridge manifest exceeds 64 KiB".into()));
}
let value: Self =
toml::from_str(input).map_err(|e| Error(format!("Invalid cartridge manifest: {e}")))?;
value.validate()?;
Ok(value)
}
pub fn validate(&self) -> Result<()> {
if self.format != 1 {
return Err(Error("Unsupported cartridge format; expected 1".into()));
}
if !identifier(&self.cartridge.id) {
return Err(Error("Invalid cartridge id".into()));
}
if !display_text(&self.cartridge.name, 128) || !display_text(&self.cartridge.version, 32) {
return Err(Error(
"Cartridge name/version is empty, too long, or contains control characters".into(),
));
}
if self.cartridge.class == Class::Data && !self.media.writable {
return Err(Error("DATA requires writable media".into()));
}
if matches!(
self.cartridge.class,
Class::System | Class::Program | Class::Environment
) && self.media.writable
{
return Err(Error(
"SYSTEM, PROGRAM and ENVIRONMENT media must be read-only".into(),
));
}
match (&self.activation, self.cartridge.class) {
(Some(value), Class::Environment) if identifier(&value.profile) => (),
(None, Class::Environment) => {
return Err(Error("ENVIRONMENT requires an activation profile".into()));
}
(Some(_), _) => {
return Err(Error(
"Only ENVIRONMENT permits a valid declarative activation profile".into(),
));
}
(None, _) => (),
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
const VALID: &str = include_str!("../../../tests/fixtures/manifests/windowmaker.toml");
#[test]
fn master_plan_manifest_roundtrips() {
let value = Manifest::parse(VALID).unwrap();
assert_eq!(value.cartridge.class, Class::Environment);
assert_eq!(value.activation.as_ref().unwrap().profile, "windowmaker");
assert_eq!(
Manifest::parse(&toml::to_string(&value).unwrap())
.unwrap()
.cartridge
.id,
value.cartridge.id
);
}
#[test]
fn untrusted_manifest_cannot_inject_actions_or_paths() {
for invalid in [
VALID.replace("format = 1", "format = 2"),
VALID.replace("fds.windowmaker", "../../etc"),
VALID.replace("profile = \"windowmaker\"", "run = \"/FDS/autorun.sh\""),
VALID.replace("writable = false", "writable = true"),
VALID.replace("WINDOW SYSTEM", "WINDOW\\nSYSTEM"),
VALID.replace("profile = \"windowmaker\"", "profile = \"/bin/sh\""),
format!("{VALID}\n[autorun]\ncommand = 'id'\n"),
] {
assert!(Manifest::parse(&invalid).is_err(), "{invalid}");
}
assert!(Manifest::parse(&" ".repeat(65 * 1024)).is_err());
}
#[test]
fn data_and_profile_classes_are_not_interchangeable() {
assert!(Manifest::parse(&VALID.replace("environment", "data")).is_err());
let data = VALID
.split("[activation]")
.next()
.unwrap()
.replace("environment", "data")
.replace("writable = false", "writable = true");
assert!(Manifest::parse(&data).is_ok());
}
}
+184
View File
@@ -0,0 +1,184 @@
//! Software metadata is descriptive. Bundles never contain privileged build hooks.
use crate::{Error, Result, manifest::identifier};
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, BTreeSet},
path::{Component, Path},
};
pub const MAX_ARCHIVE: u64 = 512 * 1024 * 1024;
pub const MAX_UNPACKED: u64 = 1024 * 1024 * 1024;
pub const MAX_ENTRIES: u32 = 65_536;
pub const MAX_CATALOGUE: usize = 64 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Software {
pub id: String,
pub name: String,
pub version: String,
pub architecture: String,
/// GPT partition number, starting at 2 after FDS_METADATA.
pub partition: u8,
pub archive_bytes: u64,
pub unpacked_bytes: u64,
pub entries: u32,
pub sha256: String,
/// Command names mapped to relative regular executables inside the bundle.
pub commands: BTreeMap<String, String>,
}
impl Software {
pub fn archive_path(&self) -> String {
format!("bundles/{}.tar.xz", self.id)
}
pub fn validate(&self) -> Result<()> {
let display = |s: &str, max: usize| {
!s.trim().is_empty() && s.len() <= max && !s.chars().any(char::is_control)
};
if !identifier(&self.id)
|| !display(&self.name, 128)
|| !display(&self.version, 32)
|| !matches!(self.architecture.as_str(), "aarch64" | "any")
|| !(2..=33).contains(&self.partition)
|| !(1..=MAX_ARCHIVE).contains(&self.archive_bytes)
|| self.unpacked_bytes > MAX_UNPACKED
|| !(1..=MAX_ENTRIES).contains(&self.entries)
|| self.sha256.len() != 64
|| !self
.sha256
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|| self.commands.is_empty()
|| self.commands.len() > 64
|| self
.commands
.iter()
.any(|(name, path)| !identifier(name) || !relative(path))
{
return Err(Error(
"Invalid software identity, architecture, partition, digest, limits or commands"
.into(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Catalogue {
pub format: u32,
pub software: Vec<Software>,
}
impl Catalogue {
pub fn parse(text: &str) -> Result<Self> {
if text.len() > MAX_CATALOGUE {
return Err(Error("Software catalogue exceeds 64 KiB".into()));
}
let value: Self =
toml::from_str(text).map_err(|e| Error(format!("Invalid software catalogue: {e}")))?;
value.validate()?;
Ok(value)
}
pub fn validate(&self) -> Result<()> {
if self.format != 1 || self.software.is_empty() || self.software.len() > 128 {
return Err(Error(
"Software catalogue requires format 1 and 1..128 software entries".into(),
));
}
let mut ids = BTreeSet::new();
let mut partitions = BTreeSet::new();
for software in &self.software {
software.validate()?;
if !ids.insert(&software.id) {
return Err(Error("Duplicate software id".into()));
}
partitions.insert(software.partition);
}
if partitions
.iter()
.copied()
.ne(2..=partitions.len() as u8 + 1)
{
return Err(Error(
"Every payload partition must be described, consecutively from partition 2".into(),
));
}
Ok(())
}
pub fn partition_count(&self) -> usize {
self.software.iter().map(|s| s.partition).max().unwrap_or(1) as usize
}
pub fn to_toml(&self) -> Result<String> {
self.validate()?;
let result = toml::to_string(self).map_err(|e| Error(e.to_string()))?;
if result.len() > MAX_CATALOGUE {
return Err(Error("Software catalogue exceeds 64 KiB".into()));
}
Ok(result)
}
}
pub fn relative(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 1024
&& !value.chars().any(char::is_control)
&& !value.contains('\\')
&& !value
.split('/')
.any(|s| s.is_empty() || s == "." || s == "..")
&& Path::new(value)
.components()
.all(|c| matches!(c, Component::Normal(_)))
}
#[cfg(test)]
mod tests {
use super::*;
fn software(id: &str, partition: u8) -> Software {
Software {
id: id.into(),
name: id.into(),
version: "1".into(),
architecture: "aarch64".into(),
partition,
archive_bytes: 100,
unpacked_bytes: 200,
entries: 1,
sha256: "a".repeat(64),
commands: [("hello".into(), "bin/hello".into())].into(),
}
}
#[test]
fn multiple_programs_can_share_or_span_payload_partitions() {
let c = Catalogue {
format: 1,
software: vec![software("one", 2), software("two", 2), software("three", 3)],
};
assert_eq!(Catalogue::parse(&c.to_toml().unwrap()).unwrap(), c);
assert_eq!(c.partition_count(), 3);
let mut bad = c.clone();
bad.software[2].partition = 4;
assert!(bad.validate().is_err());
let mut bad = c.clone();
bad.software[2].id = "one".into();
assert!(bad.validate().is_err());
let mut bad = c.clone();
bad.software[0].architecture = "x86_64".into();
assert!(bad.validate().is_err());
let mut bad = c.clone();
bad.software[0]
.commands
.insert("escape".into(), "../bin/sh".into());
assert!(bad.validate().is_err());
assert!(Catalogue::parse(&(c.to_toml().unwrap() + "\n[autorun]\ncommand='sh'\n")).is_err());
}
#[test]
fn paths_are_strictly_relative() {
for bad in [
"", "/bin/sh", "../x", "a/../x", "a//x", "./x", "a/", "a\\b", "a\nb",
] {
assert!(!relative(bad), "{bad:?}");
}
assert!(relative("share/document with spaces.txt"));
}
}
+115
View File
@@ -0,0 +1,115 @@
use crate::{Error, Result, read_text};
use serde::Serialize;
use std::{collections::BTreeMap, fs, path::Path};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BlockPartition {
pub device: String,
pub major: u32,
pub minor: u32,
pub partition_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "state", content = "devices", rename_all = "snake_case")]
pub enum Selection {
Missing,
Unique(BlockPartition),
Ambiguous(Vec<BlockPartition>),
}
/// Read kernel-reported partition identities, never disk enumeration order.
pub fn partitions(sysfs: &Path) -> Result<Vec<BlockPartition>> {
let mut result = Vec::new();
for entry in fs::read_dir(sysfs.join("class/block"))? {
let path = entry?.path();
let input = match read_text(&path.join("uevent"), 16384) {
Ok(input) => input,
Err(_) if !path.exists() => continue, // Removed during the scan; the next event retries it.
Err(error) => return Err(error),
};
let mut fields = BTreeMap::new();
for line in input.lines() {
if let Some((key, value)) = line.split_once('=') {
if fields.insert(key, value).is_some() {
return Err(Error(format!("Duplicate sysfs field: {key}")));
}
}
}
if fields.get("DEVTYPE") != Some(&"partition") {
continue;
}
let Some(name) = fields.get("PARTNAME") else {
continue;
};
let device = fields
.get("DEVNAME")
.ok_or_else(|| Error("Missing sysfs DEVNAME".into()))?;
if device.is_empty()
|| !device
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"_-".contains(&c))
{
return Err(Error("Unsafe sysfs device name".into()));
}
let number = |key| -> Result<u32> {
fields
.get(key)
.ok_or_else(|| Error(format!("Missing sysfs {key}")))?
.parse()
.map_err(|_| Error(format!("Invalid sysfs {key}")))
};
result.push(BlockPartition {
device: format!("/dev/{device}"),
major: number("MAJOR")?,
minor: number("MINOR")?,
partition_name: name.to_string(),
});
}
result.sort_by_key(|device| (device.major, device.minor));
for pair in result.windows(2) {
if (pair[0].major, pair[0].minor) == (pair[1].major, pair[1].minor) {
return Err(Error("Duplicate block device identity".into()));
}
}
Ok(result)
}
pub fn select(devices: &[BlockPartition], label: &str) -> Selection {
let mut candidates: Vec<_> = devices
.iter()
.filter(|d| d.partition_name == label)
.cloned()
.collect();
match candidates.len() {
0 => Selection::Missing,
1 => Selection::Unique(candidates.pop().unwrap()),
_ => Selection::Ambiguous(candidates),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selection_never_chooses_the_first_of_multiple_systems() {
let a = BlockPartition {
device: "/dev/sdz1".into(),
major: 8,
minor: 241,
partition_name: "FDS_SYSTEM".into(),
};
let b = BlockPartition {
device: "/dev/nvme0n1p2".into(),
major: 259,
minor: 2,
..a.clone()
};
assert_eq!(select(&[], "FDS_SYSTEM"), Selection::Missing);
assert_eq!(
select(&[a.clone()], "FDS_SYSTEM"),
Selection::Unique(a.clone())
);
assert!(matches!(
select(&[b, a], "FDS_SYSTEM"),
Selection::Ambiguous(_)
));
}
}
+322
View File
@@ -0,0 +1,322 @@
//! Controller/port identity independent of USB bus numbers and block names.
use crate::{Bay, Error, MAX_CONFIG_BYTES, Result, read_text};
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::{Path, PathBuf},
};
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Hub {
pub hub: String,
pub ports: BTreeMap<String, Bay>,
}
#[derive(Debug, Default)]
pub struct BayMap(pub BTreeMap<String, Bay>);
impl BayMap {
pub fn parse(text: &str) -> Result<Self> {
let groups: BTreeMap<String, Hub> =
toml::from_str(text).map_err(|e| Error(format!("Invalid bay map: {e}")))?;
let mut map = BTreeMap::new();
for group in groups.values() {
if group.hub.is_empty()
|| group.hub.len() > 512
|| group.hub.chars().any(char::is_control)
|| !group.hub.contains(":usb")
|| group.hub.contains("..")
|| group.hub.ends_with('/')
{
return Err(Error("Invalid stable hub identity".into()));
}
let mut seen = BTreeSet::new();
for (port, bay) in &group.ports {
if port
.parse::<u8>()
.ok()
.filter(|n| *n > 0)
.map(|n| n.to_string())
.as_ref()
!= Some(port)
|| !seen.insert(*bay)
{
return Err(Error("Invalid port or duplicate bay within a hub".into()));
}
let key = format!("{}/{}", group.hub, port);
if map.insert(key, *bay).is_some() {
return Err(Error("Duplicate physical port mapping".into()));
}
}
}
for key in map.keys() {
if map
.keys()
.any(|other| other != key && other.starts_with(&format!("{key}/")))
{
return Err(Error("Bay mappings overlap a parent and child port".into()));
}
}
Ok(Self(map))
}
pub fn load(path: &Path) -> Result<Self> {
Self::parse(&read_text(path, MAX_CONFIG_BYTES)?)
}
pub fn bay(&self, identity: &str) -> Option<Bay> {
self.0
.iter()
.find(|(key, _)| identity == key.as_str() || identity.starts_with(&format!("{key}/")))
.map(|(_, bay)| *bay)
}
pub fn configured(&self, bay: Bay) -> bool {
self.0.values().any(|b| *b == bay)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsbDevice {
pub topology: String,
pub vendor: String,
pub product: String,
pub serial: Option<String>,
pub class: String,
pub interfaces: Vec<String>,
#[serde(skip)]
pub path: PathBuf,
}
fn attribute(path: &Path, name: &str) -> Result<String> {
let text = read_text(&path.join(name), 4096)?.trim().to_owned();
if text.chars().any(char::is_control) {
return Err(Error(format!("USB {name} contains control characters")));
}
Ok(text)
}
fn hex(text: &str, len: usize) -> bool {
text.len() == len && text.bytes().all(|b| b.is_ascii_hexdigit())
}
pub fn devices(sys: &Path) -> Result<Vec<UsbDevice>> {
let base = sys.join("devices").canonicalize()?;
let mut devices = Vec::new();
let entries = match fs::read_dir(sys.join("bus/usb/devices")) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(devices),
Err(error) => return Err(error.into()),
};
for entry in entries {
let path = entry?.path();
// Root hubs and interface directories are not cartridge devices.
if !path.join("idVendor").exists()
|| path
.file_name()
.unwrap()
.to_string_lossy()
.starts_with("usb")
{
continue;
}
let inspect = || -> Result<UsbDevice> {
let path = path.canonicalize()?;
let root = path
.ancestors()
.find(|p| {
p.file_name().is_some_and(|n| {
let n = n.to_string_lossy();
n.starts_with("usb") && n[3..].bytes().all(|c| c.is_ascii_digit())
})
})
.ok_or_else(|| Error("USB device has no root hub".into()))?;
let controller = root
.parent()
.unwrap()
.strip_prefix(&base)
.map_err(|_| Error("USB path escapes sysfs".into()))?;
let version = attribute(root, "version")?;
let protocol = if version.starts_with('3') {
"usb3"
} else if version.starts_with('2') || version.starts_with('1') {
"usb2"
} else {
return Err(Error("Unknown USB root protocol".into()));
};
let chain = attribute(&path, "devpath")?;
if chain.split('.').any(|p| {
p.parse::<u8>()
.ok()
.filter(|n| *n > 0)
.map(|n| n.to_string())
.as_deref()
!= Some(p)
}) {
return Err(Error("Invalid USB port chain".into()));
}
let vendor = attribute(&path, "idVendor")?.to_ascii_lowercase();
let product = attribute(&path, "idProduct")?.to_ascii_lowercase();
let class = attribute(&path, "bDeviceClass")?.to_ascii_lowercase();
if !hex(&vendor, 4) || !hex(&product, 4) || !hex(&class, 2) {
return Err(Error("Invalid USB descriptor identity".into()));
}
let mut interfaces = Vec::new();
for child in fs::read_dir(&path)? {
let child = child?.path();
if child.join("bInterfaceClass").exists() {
let value = attribute(&child, "bInterfaceClass")?.to_ascii_lowercase();
if !hex(&value, 2) {
return Err(Error("Invalid USB interface class".into()));
}
interfaces.push(value);
}
}
interfaces.sort();
interfaces.dedup();
Ok(UsbDevice {
topology: format!(
"{}:{protocol}/{}",
controller.display(),
chain.replace('.', "/")
),
vendor,
product,
class,
interfaces,
serial: if path.join("serial").exists() {
// USB strings are data. JSON escapes control characters;
// a device's serial string must not stop global discovery.
Some(read_text(&path.join("serial"), 4096)?.trim().to_owned())
} else {
None
},
path,
})
};
match inspect() {
Ok(device) => devices.push(device),
Err(_) if !path.exists() => (),
Err(error) => eprintln!("Incomplete USB device {}: {error}", path.display()),
}
}
devices.sort_by(|a, b| a.topology.cmp(&b.topology));
Ok(devices)
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Hardware {
pub name: String,
pub vendor: String,
pub product: String,
pub serial: Option<String>,
pub class: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Catalog {
#[serde(default)]
pub device: Vec<Hardware>,
}
impl Catalog {
pub fn load(path: &Path) -> Result<Self> {
Self::parse(&read_text(path, MAX_CONFIG_BYTES)?)
}
pub fn parse(text: &str) -> Result<Self> {
let value: Self = toml::from_str(text).map_err(|e| Error(e.to_string()))?;
for item in &value.device {
if item.name.is_empty()
|| item.name.len() > 128
|| item.name.chars().any(char::is_control)
|| !hex(&item.vendor, 4)
|| !hex(&item.product, 4)
|| item.class.as_ref().is_some_and(|c| !hex(c, 2))
{
return Err(Error("Invalid hardware catalog entry".into()));
}
}
Ok(value)
}
pub fn identify(&self, usb: &UsbDevice) -> Result<Option<String>> {
let matches: Vec<_> = self
.device
.iter()
.filter(|h| {
h.vendor.eq_ignore_ascii_case(&usb.vendor)
&& h.product.eq_ignore_ascii_case(&usb.product)
&& h.serial
.as_ref()
.is_none_or(|s| Some(s) == usb.serial.as_ref())
&& h.class.as_ref().is_none_or(|c| {
c.eq_ignore_ascii_case(&usb.class)
|| usb.interfaces.iter().any(|i| c.eq_ignore_ascii_case(i))
})
})
.collect();
if matches.len() > 1 {
return Err(Error("Ambiguous hardware catalog entries".into()));
}
Ok(matches.first().map(|h| h.name.clone()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn topology_aliases_are_explicit_and_overlaps_rejected() {
let map = BayMap::parse("[front]\nhub='pci/controller:usb2/1'\n[front.ports]\n1=1\n2=2\n[fast]\nhub='pci/controller:usb3/1'\n[fast.ports]\n1=1\n").unwrap();
assert_eq!(map.bay("pci/controller:usb3/1/1").unwrap().number(), 1);
assert_eq!(map.bay("pci/controller:usb2/1/2/3").unwrap().number(), 2);
assert!(map.bay("pci/controller:usb2/1/12").is_none());
assert!(BayMap::parse("[a]\nhub='x:usb2'\n[a.ports]\n1=1\n2=1").is_err());
assert!(BayMap::parse("[a]\nhub='x:usb2'\n[a.ports]\n1=13").is_err());
assert!(
BayMap::parse("[a]\nhub='x:usb2'\n[a.ports]\n1=1\n[b]\nhub='x:usb2/1'\n[b.ports]\n2=2")
.is_err()
);
}
}
#[cfg(test)]
mod fixture_tests {
use super::*;
use std::os::unix::fs::symlink;
#[test]
fn missing_usb_subsystem_is_an_empty_inventory() {
let sys = std::env::temp_dir().join(format!("fds-no-usb-{}", std::process::id()));
fs::create_dir_all(sys.join("devices")).unwrap();
assert!(devices(&sys).unwrap().is_empty());
fs::remove_dir_all(sys).unwrap();
}
#[test]
fn enumeration_numbers_do_not_change_physical_identity() {
let base = std::env::temp_dir().join(format!("fds-topology-{}", std::process::id()));
fs::create_dir_all(&base).unwrap();
for bus in [1, 7] {
let sys = base.join(bus.to_string());
let root = sys.join(format!("devices/platform/controller/usb{bus}"));
let usb = root.join(format!("{bus}-2.4"));
let interface = usb.join(format!("{bus}-2.4:1.0"));
fs::create_dir_all(&interface).unwrap();
fs::create_dir_all(sys.join("bus/usb/devices")).unwrap();
fs::write(root.join("version"), " 2.00\n").unwrap();
for (name, value) in [
("idVendor", "1234"),
("idProduct", "abcd"),
("bDeviceClass", "00"),
("devpath", "2.4"),
("serial", "UNIT-1"),
] {
fs::write(usb.join(name), value).unwrap();
}
fs::write(interface.join("bInterfaceClass"), "08").unwrap();
symlink(&usb, sys.join(format!("bus/usb/devices/{bus}-2.4"))).unwrap();
// Removal can leave a device directory visible after attributes
// vanish. One incomplete entry must not hide the healthy device.
let partial = root.join(format!("{bus}-5"));
fs::create_dir_all(&partial).unwrap();
fs::write(partial.join("idVendor"), "1234").unwrap();
symlink(&partial, sys.join(format!("bus/usb/devices/{bus}-5"))).unwrap();
let result = devices(&sys).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].topology, "platform/controller:usb2/2/4");
assert_eq!(result[0].interfaces, ["08"]);
}
fs::remove_dir_all(base).unwrap();
}
}
+308
View File
@@ -0,0 +1,308 @@
//! Boot observations use the kernel's monotonic boot clock, never wall-clock time.
use crate::{Error, Result, read_text};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, fs, io::Write, os::unix::fs::OpenOptionsExt, path::Path};
pub const EARLY: &str = "/dev/fds-early/boot-trace";
pub const RUNTIME: &str = "/run/fds/boot-trace";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Point {
Stage0Start,
SystemFound,
RootMounted,
RootSwitch,
S6Start,
ConsoleReady,
DesktopReady,
}
impl Point {
pub const ALL: [Self; 7] = [
Self::Stage0Start,
Self::SystemFound,
Self::RootMounted,
Self::RootSwitch,
Self::S6Start,
Self::ConsoleReady,
Self::DesktopReady,
];
pub fn name(self) -> &'static str {
match self {
Self::Stage0Start => "stage0-start",
Self::SystemFound => "system-found",
Self::RootMounted => "root-mounted",
Self::RootSwitch => "root-switch",
Self::S6Start => "s6-start",
Self::ConsoleReady => "console-ready",
Self::DesktopReady => "desktop-ready",
}
}
pub fn parse(name: &str) -> Result<Self> {
Self::ALL
.into_iter()
.find(|point| point.name() == name)
.ok_or_else(|| Error("Unknown boot event".into()))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Event {
pub format: u32,
pub boot_id: String,
pub point: Point,
pub boot_ns: u64,
}
pub fn now() -> Result<u64> {
let mut clock: libc::timespec = unsafe { std::mem::zeroed() };
if unsafe { libc::clock_gettime(libc::CLOCK_BOOTTIME, &mut clock) } < 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(clock.tv_sec as u64 * 1_000_000_000 + clock.tv_nsec as u64)
}
pub fn boot_id() -> Result<String> {
Ok(
read_text(Path::new("/proc/sys/kernel/random/boot_id"), 128)?
.trim()
.into(),
)
}
pub fn save(directory: &Path, point: Point, boot_ns: u64) -> Result<()> {
let event = Event {
format: 1,
boot_id: boot_id()?,
point,
boot_ns,
};
fs::create_dir_all(directory)?;
let target = directory.join(format!("{}.json", point.name()));
let existing = || -> Result<()> {
if !fs::symlink_metadata(&target)?.is_file() {
return Err(Error("Boot trace record is not a regular file".into()));
}
let recorded: Event =
serde_json::from_str(&read_text(&target, 4096)?).map_err(|e| Error(e.to_string()))?;
if recorded.format != 1 || recorded.point != point || recorded.boot_id != event.boot_id {
return Err(Error(
"Existing event belongs to a different boot or point".into(),
));
}
Ok(())
};
// A restarted console must not replace the first readiness observation.
if target.try_exists()? {
return existing();
}
let temporary = directory.join(format!(".{}-{}.tmp", point.name(), std::process::id()));
let mut stream = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o644)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
.open(&temporary)?;
let result = (|| -> Result<()> {
serde_json::to_writer(&mut stream, &event).map_err(|e| Error(e.to_string()))?;
stream.write_all(b"\n")?;
stream.flush()?;
match fs::hard_link(&temporary, &target) {
Ok(()) => (),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => existing()?,
Err(error) => return Err(error.into()),
}
Ok(())
})();
let _ = fs::remove_file(temporary);
result
}
pub fn mark_early(point: Point, instant: u64) {
if let Err(error) = save(Path::new(EARLY), point, instant) {
eprintln!("Boot trace unavailable: {error}");
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Report {
pub format: u32,
pub clock: String,
pub boot_id: String,
pub platform: String,
pub kernel: String,
pub events_ns: BTreeMap<String, u64>,
pub durations_ns: BTreeMap<String, u64>,
pub missing_events: Vec<String>,
}
impl Report {
pub fn from_events(events: &[Event], platform: String, kernel: String) -> Result<Self> {
let mut report = Self {
format: 1,
clock: "Linux CLOCK_BOOTTIME".into(),
boot_id: events
.first()
.map(|e| e.boot_id.clone())
.unwrap_or_default(),
platform,
kernel,
events_ns: BTreeMap::new(),
durations_ns: BTreeMap::new(),
missing_events: Vec::new(),
};
for event in events {
if event.format != 1 || event.boot_id != report.boot_id || event.boot_id.is_empty() {
return Err(Error("Incompatible boot trace records".into()));
}
if report
.events_ns
.insert(event.point.name().into(), event.boot_ns)
.is_some()
{
return Err(Error("Duplicate boot event".into()));
}
}
let mut previous = 0;
for point in Point::ALL {
if let Some(&instant) = report.events_ns.get(point.name()) {
if instant < previous {
return Err(Error("Boot events are out of order".into()));
}
previous = instant;
} else {
report.missing_events.push(point.name().into());
}
}
for (label, start, end) in [
("kernel-to-console", None, Point::ConsoleReady),
("kernel-to-desktop", None, Point::DesktopReady),
("kernel-to-stage0", None, Point::Stage0Start),
(
"system-discovery",
Some(Point::Stage0Start),
Point::SystemFound,
),
("root-mount", Some(Point::SystemFound), Point::RootMounted),
("root-handoff", Some(Point::RootMounted), Point::S6Start),
("s6-to-console", Some(Point::S6Start), Point::ConsoleReady),
] {
let begin = match start {
None => Some(0),
Some(point) => report.events_ns.get(point.name()).copied(),
};
if let (Some(begin), Some(&finish)) = (begin, report.events_ns.get(end.name())) {
report.durations_ns.insert(label.into(), finish - begin);
}
}
Ok(report)
}
pub fn print(&self, json: bool) -> Result<()> {
if json {
println!(
"{}",
serde_json::to_string_pretty(self).map_err(|e| Error(e.to_string()))?
);
} else {
println!(
"FDS BOOT PROFILE\nPLATFORM {}\nCLOCK {}",
self.platform, self.clock
);
for (name, ns) in &self.durations_ns {
println!("{name:22} {:8.3} ms", *ns as f64 / 1_000_000.0);
}
if !self.missing_events.is_empty() {
println!("NOT RECORDED {}", self.missing_events.join(", "));
}
println!("Power-on and firmware time are outside this clock.");
}
Ok(())
}
}
pub fn load(directory: &Path) -> Result<Report> {
let mut events = Vec::new();
for point in Point::ALL {
let folder = if point == Point::ConsoleReady {
directory.join("console")
} else {
directory.to_owned()
};
let path = folder.join(format!("{}.json", point.name()));
if !path.exists() {
continue;
}
let event: Event =
serde_json::from_str(&read_text(&path, 4096)?).map_err(|e| Error(e.to_string()))?;
if event.point != point {
return Err(Error(
"Boot event filename does not match its record".into(),
));
}
events.push(event);
}
if events.is_empty() {
return Err(Error("No boot trace recorded".into()));
}
let platform = read_text(Path::new("/proc/device-tree/model"), 4096)
.unwrap_or_else(|_| "unknown platform".into())
.trim_matches('\0')
.trim()
.to_owned();
let kernel = read_text(Path::new("/proc/sys/kernel/osrelease"), 4096)?
.trim()
.to_owned();
Report::from_events(&events, platform, kernel)
}
#[cfg(test)]
mod tests {
use super::*;
fn event(point: Point, time: u64) -> Event {
Event {
format: 1,
boot_id: "fixture".into(),
point,
boot_ns: time,
}
}
#[test]
fn durations_are_measured_and_missing_events_are_not_zeroes() {
let result = Report::from_events(
&[event(Point::S6Start, 700), event(Point::ConsoleReady, 900)],
"VM fixture".into(),
"test".into(),
)
.unwrap();
assert_eq!(result.durations_ns["s6-to-console"], 200);
assert_eq!(result.durations_ns["kernel-to-console"], 900);
assert!(!result.durations_ns.contains_key("system-discovery"));
}
#[test]
fn mixed_boots_duplicates_and_backwards_events_are_rejected() {
let start = event(Point::Stage0Start, 100);
let mut finish = event(Point::ConsoleReady, 50);
assert!(
Report::from_events(&[start.clone(), finish.clone()], "VM".into(), "test".into())
.is_err()
);
finish.boot_ns = 200;
finish.boot_id = "different".into();
assert!(Report::from_events(&[start.clone(), finish], "VM".into(), "test".into()).is_err());
assert!(Report::from_events(&[start.clone(), start], "VM".into(), "test".into()).is_err());
}
#[test]
fn restarting_the_console_preserves_the_first_ready_time() {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let directory =
std::env::temp_dir().join(format!("fds-trace-{}-{unique}", std::process::id()));
save(&directory, Point::ConsoleReady, 100).unwrap();
save(&directory, Point::ConsoleReady, 200).unwrap();
let record: Event = serde_json::from_str(
&fs::read_to_string(directory.join("console-ready.json")).unwrap(),
)
.unwrap();
assert_eq!(record.boot_ns, 100);
fs::remove_dir_all(directory).unwrap();
}
}