FDS/OS 1.0
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user