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
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "fds-burn"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "Verified cartridge images and protected FDS media writes"
[dependencies]
clap.workspace = true
fds-common = { path = "../fds-common" }
libc = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "=0.10.9"
+215
View File
@@ -0,0 +1,215 @@
//! Typed command grammar shared by `fds burn`, `fds format`, and `fds-burn`.
use clap::{Args, Subcommand};
use fds_common::Bay;
use std::path::PathBuf;
#[derive(Debug, Subcommand)]
pub enum BurnCommand {
/// Write a prepared SYSTEM image after confirmation.
System(ImageArgs),
/// Write a prepared PROGRAM image after confirmation.
Program(ImageArgs),
/// Write IMAGE BAY, or create DATA with BAY [--label NAME] [--size-mib N].
Data(DataWrite),
/// Write IMAGE BAY, or create ENVIRONMENT with BAY [--profile NAME].
Environment(EnvironmentWrite),
/// Show a media operation's current state.
Status { id: String },
/// Wait for a media operation to finish.
Wait { id: String },
/// Confirm the exact phrase returned by a write preview.
Confirm { id: String, confirmation: String },
/// Cancel a media operation.
Cancel { id: String },
}
#[derive(Debug, Args)]
pub struct ImageArgs {
pub image: PathBuf,
#[arg(value_parser = crate::client::bay)]
pub bay: Bay,
}
#[derive(Debug, Args)]
pub struct DataWrite {
/// Prepared image path, or a bay number to create a new DATA filesystem.
#[arg(value_name = "IMAGE_OR_BAY")]
pub source: PathBuf,
#[arg(value_parser = crate::client::bay, conflicts_with_all = ["label", "id", "size_mib"])]
pub bay: Option<Bay>,
#[command(flatten)]
pub options: DataOptions,
}
#[derive(Debug, Args)]
pub struct EnvironmentWrite {
/// Prepared image path, or a bay number to create a new ENVIRONMENT.
#[arg(value_name = "IMAGE_OR_BAY")]
pub source: PathBuf,
#[arg(value_parser = crate::client::bay, conflicts_with_all = ["label", "id", "profile"])]
pub bay: Option<Bay>,
#[command(flatten)]
pub options: EnvironmentOptions,
}
#[derive(Debug, Default, Args)]
pub struct MetadataOptions {
/// Human-readable cartridge name.
#[arg(long, value_name = "NAME")]
pub label: Option<String>,
/// Cartridge identifier (generated when omitted).
#[arg(long)]
pub id: Option<String>,
}
#[derive(Debug, Args)]
pub struct DataOptions {
#[command(flatten)]
pub metadata: MetadataOptions,
/// Filesystem size in MiB (otherwise fill the target, leaving GPT space).
#[arg(long, value_name = "N")]
pub size_mib: Option<u64>,
}
#[derive(Debug, Args)]
pub struct EnvironmentOptions {
#[command(flatten)]
pub metadata: MetadataOptions,
/// Activation profile (defaults to windowmaker).
#[arg(long, value_name = "NAME")]
pub profile: Option<String>,
}
#[derive(Debug, Args)]
pub struct DataFormat {
#[arg(value_name = "BAY", value_parser = crate::client::bay)]
pub target: Bay,
#[command(flatten)]
pub options: DataOptions,
}
#[derive(Debug, Args)]
pub struct EnvironmentFormat {
#[arg(value_name = "BAY", value_parser = crate::client::bay)]
pub target: Bay,
#[command(flatten)]
pub options: EnvironmentOptions,
}
#[derive(Debug, Subcommand)]
pub enum FormatCommand {
/// Create DATA and preview a confirmed cartridge write.
Data(DataFormat),
/// Create an ENVIRONMENT descriptor and preview its write.
Environment(EnvironmentFormat),
/// Package an application directory containing bin/ and preview its write.
Program {
#[arg(value_name = "APP_DIRECTORY")]
source: PathBuf,
#[arg(value_name = "BAY", value_parser = crate::client::bay)]
target: Bay,
#[command(flatten)]
metadata: MetadataOptions,
},
/// Package a prepared SYSTEM root and preview its write.
System {
#[arg(value_name = "ROOT_DIRECTORY")]
source: PathBuf,
#[arg(value_name = "BAY", value_parser = crate::client::bay)]
target: Bay,
},
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{CommandFactory, Parser};
#[derive(Debug, Parser)]
struct Burn {
#[command(subcommand)]
command: BurnCommand,
}
#[derive(Debug, Parser)]
struct Format {
#[command(subcommand)]
command: FormatCommand,
}
#[test]
fn prepared_images_and_format_shorthand_have_distinct_options() {
Burn::command().debug_assert();
Format::command().debug_assert();
let parsed = Burn::try_parse_from([
"burn",
"data",
"BAY2",
"--label",
"My data",
"--id",
"user.data",
"--size-mib",
"64",
])
.unwrap();
let BurnCommand::Data(args) = parsed.command else {
panic!("DATA")
};
assert!(args.bay.is_none());
assert_eq!(args.options.size_mib, Some(64));
assert_eq!(args.options.metadata.label.as_deref(), Some("My data"));
for class in ["data", "environment", "system", "program"] {
assert!(Burn::try_parse_from(["burn", class, "card.img", "12"]).is_ok());
assert!(Burn::try_parse_from(["burn", class, "card.img", "13"]).is_err());
assert!(
Burn::try_parse_from(["burn", class, "card.img", "1", "--label", "name"]).is_err()
);
}
assert!(Burn::try_parse_from(["burn", "confirm", "id", "phrase with spaces"]).is_ok());
assert!(Burn::try_parse_from(["burn", "confirm", "id"]).is_err());
assert!(Burn::try_parse_from(["burn", "data", "1", "--size-mib", "bad"]).is_err());
assert!(
Burn::try_parse_from(["burn", "data", "1", "--label", "one", "--label", "two"])
.is_err()
);
}
#[test]
fn format_rejects_unknown_duplicate_and_inapplicable_options() {
for arguments in [
vec![
"format",
"data",
"BAY12",
"--label",
"DATA",
"--id",
"test.data",
],
vec![
"format",
"environment",
"2",
"--profile",
"windowmaker",
"--label",
"GUI",
],
vec!["format", "program", "app", "3", "--label", "Editor"],
vec!["format", "system", "root", "4"],
] {
assert!(Format::try_parse_from(&arguments).is_ok(), "{arguments:?}");
}
for arguments in [
vec!["format", "data", "1", "--profile", "windowmaker"],
vec!["format", "data", "1", "--size-mib", "-1"],
vec!["format", "data", "1", "--label", "a", "--label", "b"],
vec!["format", "data", "1", "--force"],
vec!["format", "environment", "1", "--size-mib", "32"],
vec!["format", "program", "app", "1", "--profile", "cli"],
vec!["format", "system", "root", "1", "--label", "SYS"],
vec!["format", "system", "root"],
] {
assert!(Format::try_parse_from(&arguments).is_err(), "{arguments:?}");
}
}
}
+326
View File
@@ -0,0 +1,326 @@
use crate::{
cli::{BurnCommand, DataFormat, EnvironmentFormat, FormatCommand, MetadataOptions},
create, image,
};
use fds_common::{
Bay, Error, Result,
control::{self, MediaJob, Request},
manifest::{Activation, Cartridge, Class, Manifest, Media},
};
use std::{
fs,
io::{self, IsTerminal, Write},
path::Path,
process::{Command, Stdio},
};
fn job(request: Request) -> Result<MediaJob> {
control::request(&request)?
.media_job
.ok_or_else(|| Error("Missing media operation response".into()))
}
pub fn bay(value: &str) -> Result<Bay> {
value.strip_prefix("BAY").unwrap_or(value).parse()
}
fn print(job: &MediaJob, json: bool) -> Result<()> {
if json {
println!(
"{}",
serde_json::to_string_pretty(job).map_err(|e| Error(e.to_string()))?
);
return Ok(());
}
println!(
"BAY {} {} {} bytes\nOPERATION {} {}",
job.bay,
job.model,
job.target_bytes,
job.id,
job.phase.to_uppercase().replace('_', " ")
);
if let Some(bytes) = job.image_bytes {
println!("IMAGE {} {bytes} bytes", job.image_class.label());
}
if let Some(hash) = &job.image_sha256 {
println!("SHA256 {hash}");
}
if let Some(serial) = &job.serial {
println!("SERIAL {serial}");
}
println!("INSERTION {}", job.diskseq);
if let Some(error) = &job.error {
println!("ERROR {error}");
}
if job.phase == "complete" {
println!("VERIFIED — SAFE TO REMOVE");
}
Ok(())
}
fn wait(mut current: MediaJob, until_ready: bool) -> Result<MediaJob> {
while !current.finished() && !(until_ready && current.phase == "awaiting_confirmation") {
current = job(Request::MediaStatus {
id: current.id.clone(),
after_sequence: Some(current.sequence),
})?;
}
Ok(current)
}
fn result(current: MediaJob, json: bool) -> Result<()> {
print(&current, json)?;
if let Some(error) = current.error {
return Err(Error(error));
}
Ok(())
}
pub fn burn(command: BurnCommand, json: bool) -> Result<()> {
match command {
BurnCommand::Status { id } => result(
job(Request::MediaStatus {
id,
after_sequence: None,
})?,
json,
),
BurnCommand::Wait { id } => result(
wait(
job(Request::MediaStatus {
id,
after_sequence: None,
})?,
false,
)?,
json,
),
BurnCommand::Confirm { id, confirmation } => result(
wait(job(Request::MediaConfirm { id, confirmation })?, false)?,
json,
),
BurnCommand::Cancel { id } => result(wait(job(Request::MediaCancel { id })?, false)?, json),
BurnCommand::System(args) => prepare(Class::System, &args.image, args.bay, json),
BurnCommand::Program(args) => prepare(Class::Program, &args.image, args.bay, json),
BurnCommand::Data(args) => match args.bay {
Some(target) => prepare(Class::Data, &args.source, target, json),
None => format(
FormatCommand::Data(DataFormat {
target: format_target(&args.source)?,
options: args.options,
}),
json,
),
},
BurnCommand::Environment(args) => match args.bay {
Some(target) => prepare(Class::Environment, &args.source, target, json),
None => format(
FormatCommand::Environment(EnvironmentFormat {
target: format_target(&args.source)?,
options: args.options,
}),
json,
),
},
}
}
fn format_target(source: &Path) -> Result<Bay> {
source.to_str().and_then(|value| bay(value).ok()).ok_or_else(||
Error("Supply IMAGE BAY to write an image, or BAY with format options to create a cartridge".into()))
}
pub fn prepare(class: Class, path: &Path, target: Bay, json: bool) -> Result<()> {
let image = fs::canonicalize(path)?
.to_str()
.ok_or_else(|| Error("Image path must be UTF-8".into()))?
.to_owned();
let current = wait(
job(Request::MediaPrepare {
bay: target,
image,
class,
})?,
true,
)?;
print(&current, json)?;
if let Some(error) = &current.error {
return Err(Error(error.clone()));
}
if current.phase != "awaiting_confirmation" {
return Err(Error("Media operation did not reach confirmation".into()));
}
let phrase = current
.confirmation
.as_deref()
.ok_or_else(|| Error("Missing confirmation phrase".into()))?;
if json {
return Ok(());
}
println!("This erases the entire selected cartridge.\nType exactly: {phrase}");
if !io::stdin().is_terminal() {
println!(
"To proceed: fds burn confirm {} '{phrase}'\nTo cancel: fds burn cancel {}",
current.id, current.id
);
return Ok(());
}
print!("> ");
io::stdout().flush()?;
let mut reply = String::new();
io::stdin().read_line(&mut reply)?;
if reply.trim_end() != phrase {
let _ = job(Request::MediaCancel { id: current.id });
return Err(Error("Cancelled before writing".into()));
}
result(
wait(
job(Request::MediaConfirm {
id: current.id,
confirmation: phrase.into(),
})?,
false,
)?,
false,
)
}
pub fn inspect_bay(target: Bay, json: bool) -> Result<()> {
let disk = control::request(&Request::Disk { bay: target })?
.disk
.ok_or_else(|| Error("Missing disk inspection".into()))?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&disk).map_err(|e| Error(e.to_string()))?
);
} else {
println!(
"BAY {} {}\nCAPACITY {} bytes\nSECTOR {} bytes\nINSERTION {}",
disk.bay, disk.model, disk.bytes, disk.sector_bytes, disk.diskseq
);
if let Some(serial) = disk.serial {
println!("SERIAL {serial}");
}
println!(
"{}",
disk.protected.map_or_else(
|| "AVAILABLE FOR CONFIRMED WRITE".into(),
|reason| format!("PROTECTED: {reason}")
)
);
}
Ok(())
}
/// Prepare a user-owned filesystem tree; never run source scripts or use shell
/// interpolation. Explicit confirmation follows creation and privileged preview.
pub fn format(command: FormatCommand, json: bool) -> Result<()> {
let (class, source, target, metadata, profile, size) = match command {
FormatCommand::Data(args) => (
Class::Data,
None,
args.target,
args.options.metadata,
None,
args.options.size_mib,
),
FormatCommand::Environment(args) => (
Class::Environment,
None,
args.target,
args.options.metadata,
args.options.profile,
None,
),
FormatCommand::Program {
source,
target,
metadata,
} => (Class::Program, Some(source), target, metadata, None, None),
FormatCommand::System { source, target } => (
Class::System,
Some(source),
target,
MetadataOptions::default(),
None,
None,
),
};
let disk = control::request(&Request::Disk { bay: target })?
.disk
.ok_or_else(|| Error("Missing target geometry".into()))?;
if let Some(reason) = disk.protected {
return Err(Error(reason));
}
let label = metadata
.label
.unwrap_or_else(|| format!("FDS {}", class.label()));
let profile = profile.unwrap_or_else(|| "windowmaker".into());
let id = match metadata.id {
Some(id) => id,
None => format!(
"fds.{}.{}",
class.label().to_ascii_lowercase(),
image::hex(&image::random_id()?)
),
};
let manifest = Manifest {
format: 1,
cartridge: Cartridge {
id,
name: label,
class,
version: fds_common::VERSION.into(),
},
media: Media {
writable: class == Class::Data,
},
activation: if class == Class::Environment {
Some(Activation { profile })
} else {
None
},
};
let text = manifest.to_toml()?;
let parent = std::env::current_dir()?;
let work = create::Work::new(&parent)?;
let tree = work.0.join("source");
fs::create_dir(&tree)?;
if class != Class::System {
fs::create_dir(tree.join("FDS"))?;
fs::write(tree.join("FDS/CARTRIDGE.TOML"), text)?;
}
if class == Class::Program {
let source = fs::canonicalize(source.as_ref().unwrap())?;
if !source.is_dir() || !source.join("bin").is_dir() {
return Err(Error(
"PROGRAM input must contain bin, with optional lib and share".into(),
));
}
let status = Command::new("/usr/bin/cp")
.args(["-a", "--no-preserve=ownership", "--"])
.arg(source)
.arg(tree.join("app"))
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()?;
if !status.success() {
return Err(Error("Copying PROGRAM input failed".into()));
}
}
let source = if class == Class::System {
source.as_ref().unwrap().as_path()
} else {
tree.as_path()
};
let size = if class == Class::Data {
Some(
size.unwrap_or(
(disk.bytes / (1024 * 1024))
.checked_sub(2)
.filter(|mib| *mib >= 32)
.ok_or_else(|| {
Error("DATA target must hold at least a 32 MiB filesystem plus GPT".into())
})?,
),
)
} else {
None
};
let output = work.0.join("cartridge.img");
create::create(class, source, &output, size)?;
prepare(class, &output, target, json)
}
+231
View File
@@ -0,0 +1,231 @@
//! Filesystem utilities run only against private regular staging files. Creating
//! an image does not open a disk; writing a cartridge has a separate confirmation.
use crate::image::{self, Image, Layout};
use fds_common::{
Error, Result,
manifest::{Class, Manifest},
};
use std::{
fs::{self, File, OpenOptions},
io::{Read, Write},
os::{
fd::{AsRawFd, BorrowedFd, FromRawFd},
unix::fs::{OpenOptionsExt, PermissionsExt},
},
path::{Path, PathBuf},
process::{Command, Stdio},
};
pub(crate) struct Work(pub PathBuf);
impl Work {
pub(crate) fn new(parent: &Path) -> Result<Self> {
let path = parent.join(format!(".fds-image-{}", image::hex(&image::random_id()?)));
let mut builder = fs::DirBuilder::new();
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700).create(&path)?;
Ok(Self(path))
}
}
impl Drop for Work {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn command(program: &str, args: &[&str]) -> Result<()> {
let result = Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::from(
unsafe { BorrowedFd::borrow_raw(2) }.try_clone_to_owned()?,
))
.stderr(Stdio::inherit())
.status()
.map_err(|e| Error(format!("Run {program}: {e}")))?;
if !result.success() {
return Err(Error(format!("{program} failed: {result}")));
}
Ok(())
}
fn text(path: &Path) -> Result<&str> {
path.to_str()
.ok_or_else(|| Error("Image paths must be UTF-8".into()))
}
pub fn class(value: &str) -> Result<Class> {
match value {
"system" => Ok(Class::System),
"data" => Ok(Class::Data),
"program" => Ok(Class::Program),
"environment" => Ok(Class::Environment),
_ => Err(Error(
"Expected system, data, program or environment".into(),
)),
}
}
pub fn tree_manifest(tree: &Path) -> Result<Manifest> {
let root = OpenOptions::new()
.read(true)
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
.open(tree)?;
let dir = unsafe {
libc::openat(
root.as_raw_fd(),
c"FDS".as_ptr(),
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
)
};
if dir < 0 {
return Err(std::io::Error::last_os_error().into());
}
let dir = unsafe { File::from_raw_fd(dir) };
let fd = unsafe {
libc::openat(
dir.as_raw_fd(),
c"CARTRIDGE.TOML".as_ptr(),
libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC,
)
};
if fd < 0 {
return Err(std::io::Error::last_os_error().into());
}
let file = unsafe { File::from_raw_fd(fd) };
if !file.metadata()?.is_file() {
return Err(Error("CARTRIDGE.TOML must be a regular file".into()));
}
let mut contents = String::new();
file.take(fds_common::MAX_CONFIG_BYTES + 1)
.read_to_string(&mut contents)?;
Manifest::parse(&contents)
}
pub fn create(class: Class, tree: &Path, output: &Path, size_mib: Option<u64>) -> Result<Image> {
if class == Class::Program && Path::new("/usr/share/fds/image-profile").exists() {
return Err(Error("Build software cartridges on a Linux workstation with fds-cartridge software build and fds-cartridge create; no software build runs on the Pi".into()));
}
let tree = fs::canonicalize(tree)?;
if !tree.is_dir() {
return Err(Error("Image source must be a directory".into()));
}
let manifest = tree_manifest(&tree)?;
if manifest.cartridge.class != class {
return Err(Error(
"Requested class does not match CARTRIDGE.TOML".into(),
));
}
match class {
Class::System => {
for name in [
"sbin/init",
"usr/bin/fds",
"usr/bin/fds-cartridged",
"usr/bin/dasungd",
] {
if !tree.join(name).is_file() {
return Err(Error(format!("SYSTEM source lacks {name}")));
}
}
}
Class::Program if !tree.join("app/bin").is_dir() => {
return Err(Error("PROGRAM source needs app/bin".into()));
}
Class::Environment if size_mib.is_some() => {
return Err(Error(
"ENVIRONMENT size is determined by its contents".into(),
));
}
_ => (),
}
let parent = fs::canonicalize(
output
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new(".")),
)?;
// A staging file inside SOURCE could be consumed by its own image builder.
if parent.starts_with(&tree) {
return Err(Error(
"Image output must be outside its source directory".into(),
));
}
let work = Work::new(&parent)?;
let payload = work.0.join("filesystem.img");
let payload_arg = text(&payload)?;
let source_arg = text(&tree)?;
let label = image::label(class)?;
if class == Class::Data {
let bytes = size_mib
.unwrap_or(128)
.checked_mul(1024 * 1024)
.filter(|v| *v >= 32 * 1024 * 1024 && *v <= 1024 * 1024 * 1024 * 1024)
.ok_or_else(|| Error("DATA filesystem size must be 32..1048576 MiB".into()))?;
OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o600)
.open(&payload)?
.set_len(bytes)?;
// Explicit root ownership supports both ordinary-user and recovery builds.
// Lazy initialization is disabled: all construction happens before boot.
command(
"/usr/bin/mkfs.ext4",
&[
"-q",
"-F",
"-m",
"0",
"-L",
label,
"-E",
"root_owner=1000:1000,lazy_itable_init=0,lazy_journal_init=0",
"-d",
source_arg,
payload_arg,
],
)?;
command("/usr/bin/e2fsck", &["-f", "-n", payload_arg])?;
} else {
if size_mib.is_some() {
return Err(Error("--size-mib applies only to DATA filesystems".into()));
}
command(
"/usr/bin/mkfs.erofs",
&[
"--quiet",
"-b4096",
"-T0",
"--mkfs-time",
"-L",
label,
payload_arg,
source_arg,
],
)?;
command("/usr/bin/fsck.erofs", &["--extract", payload_arg])?;
}
let mut payload = File::open(&payload)?;
let layout = Layout::new(
class,
payload.metadata()?.len(),
None,
image::random_id()?,
image::random_id()?,
)?;
let staged = work.0.join("cartridge.img");
let mut disk = OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.mode(0o600)
.open(&staged)?;
layout.write(&disk)?;
use std::io::{Seek, SeekFrom};
disk.seek(SeekFrom::Start(image::FIRST_LBA * image::SECTOR))?;
std::io::copy(&mut payload, &mut disk)?;
disk.flush()?;
disk.sync_all()?;
let mut info = image::inspect(&disk, layout.bytes)?;
info.sha256 = Some(image::digest(&disk, layout.bytes, |_| Ok(()))?);
// Atomic no-replace publication: never truncate an existing path or follow
// a symlink. Staging is on the same filesystem as the final image.
fs::set_permissions(&staged, fs::Permissions::from_mode(0o644))?;
fs::hard_link(&staged, output)?;
File::open(parent)?.sync_all()?;
Ok(info)
}
+526
View File
@@ -0,0 +1,526 @@
//! Whole-disk selection and conservative protection checks. A bay maps to a
//! kernel USB path. Names are used only to open the verified kernel identity.
use fds_common::{Error, Result, read_text};
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, BTreeSet},
fs::{self, File, OpenOptions},
os::{
fd::AsRawFd,
unix::fs::{FileExt, FileTypeExt, MetadataExt, OpenOptionsExt},
},
path::{Path, PathBuf},
};
const BLKGETSIZE64: libc::c_ulong = 0x80081272;
const BLKSSZGET: libc::c_ulong = 0x1268;
const BLKROGET: libc::c_ulong = 0x125e;
const BLKGETDISKSEQ: libc::c_ulong = 0x80081280;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Disk {
pub path: PathBuf,
pub sysfs_path: PathBuf,
pub major: u32,
pub minor: u32,
pub diskseq: u64,
pub bytes: u64,
pub sector_bytes: u32,
pub model: String,
pub serial: Option<String>,
}
fn bad(message: impl Into<String>) -> Error {
Error(message.into())
}
fn number(path: &Path) -> Result<u64> {
read_text(path, 128)?
.trim()
.parse()
.map_err(|_| bad(format!("Invalid kernel block number: {}", path.display())))
}
fn fields(path: &Path) -> Result<BTreeMap<String, String>> {
let mut fields = BTreeMap::new();
for line in read_text(path, 16384)?.lines() {
if let Some((k, v)) = line.split_once('=') {
if fields.insert(k.into(), v.into()).is_some() {
return Err(bad("Duplicate block uevent field"));
}
}
}
Ok(fields)
}
fn dev(path: &Path) -> Result<(u32, u32)> {
let value = read_text(&path.join("dev"), 128)?;
let (major, minor) = value
.trim()
.split_once(':')
.ok_or_else(|| bad("Invalid kernel device number"))?;
Ok((
major.parse().map_err(|_| bad("Invalid device major"))?,
minor.parse().map_err(|_| bad("Invalid device minor"))?,
))
}
fn text_field(path: &Path) -> Result<String> {
match read_text(path, 4096) {
Ok(s) => Ok(s
.trim()
.chars()
.filter(|c| !c.is_control())
.take(128)
.collect()),
Err(_) if !path.exists() => Ok(String::new()),
Err(e) => Err(e),
}
}
pub fn select(sysfs: &Path, usb: &Path) -> Result<Disk> {
let usb = fs::canonicalize(usb)?;
if !usb.starts_with(fs::canonicalize(sysfs.join("devices"))?) {
return Err(bad("USB identity is outside kernel devices"));
}
let mut matches = Vec::new();
for entry in fs::read_dir(sysfs.join("class/block"))? {
let entry = entry?.path();
let path = match fs::canonicalize(&entry) {
Ok(p) => p,
Err(_) if !entry.exists() => continue,
Err(e) => return Err(e.into()),
};
if !path.starts_with(&usb) || path.join("partition").exists() {
continue;
}
let values = fields(&path.join("uevent"))?;
if values.get("DEVTYPE").map(String::as_str) != Some("disk") {
continue;
}
let name = values
.get("DEVNAME")
.ok_or_else(|| bad("Missing disk name"))?;
if name.is_empty()
|| !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"_-".contains(&b))
{
return Err(bad("Unsafe disk name"));
}
let (major, minor) = dev(&path)?;
let bytes = number(&path.join("size"))?
.checked_mul(512)
.ok_or_else(|| bad("Disk size overflow"))?;
let sector_bytes = number(&path.join("queue/logical_block_size"))?
.try_into()
.map_err(|_| bad("Invalid sector size"))?;
let serial = text_field(&usb.join("serial"))?;
matches.push(Disk {
path: Path::new("/dev").join(name),
sysfs_path: path.clone(),
major,
minor,
diskseq: number(&path.join("diskseq"))?,
bytes,
sector_bytes,
model: text_field(&path.join("device/model"))?,
serial: if serial.is_empty() {
None
} else {
Some(serial)
},
});
}
if matches.len() != 1 {
return Err(bad("Bay must contain exactly one whole USB disk"));
}
Ok(matches.remove(0))
}
/// Inspect both existing GPTs directly as well as using kernel partitions. A
/// freshly enumerated disk can appear before all partition uevents arrive.
fn protect_existing_gpt(file: &File, bytes: u64) -> Result<()> {
use crate::image::{u32le, u64le};
if bytes < 1024 || bytes % 512 != 0 {
return Err(bad("Invalid target capacity"));
}
for offset in [512, bytes - 512] {
let mut header = [0u8; 512];
file.read_exact_at(&mut header, offset)?;
if &header[..8] != b"EFI PART" {
continue;
}
let count = u32le(&header, 80) as usize;
let stride = u32le(&header, 84) as usize;
if count == 0 || count > 4096 || !(128..=1024).contains(&stride) || stride % 128 != 0 {
return Err(bad("Cannot safely inspect existing GPT entry geometry"));
}
let start = u64le(&header, 72)
.checked_mul(512)
.ok_or_else(|| bad("Existing GPT table offset overflow"))?;
let length = count * stride;
if start < 1024
|| start
.checked_add(length as u64)
.is_none_or(|end| end > bytes - 512)
{
return Err(bad("Existing GPT table extends outside the target"));
}
let mut entries = vec![0; length];
file.read_exact_at(&mut entries, start)?;
for entry in entries.chunks_exact(stride) {
let name: Vec<u16> = entry[56..128]
.chunks_exact(2)
.map(|c| u16::from_le_bytes(c.try_into().unwrap()))
.take_while(|c| *c != 0)
.collect();
let name = String::from_utf16_lossy(&name);
if ["FDS_BOOT", "FDS_RECOVERY", "FDS_INTERNAL"].contains(&name.as_str()) {
return Err(bad(format!(
"Protected internal partition in existing GPT: {name}"
)));
}
}
}
Ok(())
}
impl Disk {
pub fn select_current(usb: &Path) -> Result<Self> {
select(Path::new("/sys"), usb)
}
pub fn key(&self) -> String {
format!(
"{}:{}:{}:{}:{}",
self.sysfs_path.display(),
self.major,
self.minor,
self.diskseq,
self.bytes
)
}
pub fn present(&self) -> bool {
self.present_at(Path::new("/sys"))
}
fn present_at(&self, sysfs: &Path) -> bool {
fs::canonicalize(
sysfs
.join("dev/block")
.join(format!("{}:{}", self.major, self.minor)),
)
.is_ok_and(|p| {
p == self.sysfs_path
&& number(&p.join("diskseq")).is_ok_and(|s| s == self.diskseq)
&& number(&p.join("size")).is_ok_and(|s| s.checked_mul(512) == Some(self.bytes))
})
}
/// Refuse all mounted children, swap, holders, or reserved internal labels.
/// This is re-run after exclusive open and immediately before a confirmed write.
pub fn protect(&self, sysfs: &Path, proc: &Path) -> Result<()> {
if !self.present_at(sysfs) {
return Err(bad(
"Cartridge identity changed; inspect and confirm it again",
));
}
if self.sector_bytes != 512 {
return Err(bad(
"Only 512-byte logical sectors are supported for cartridge writes",
));
}
if number(&self.sysfs_path.join("ro"))? != 0 {
return Err(bad("Disk is write protected"));
}
let mut children = BTreeSet::new();
let mut paths = BTreeSet::new();
for entry in fs::read_dir(sysfs.join("class/block"))? {
let entry = entry?.path();
let path = match fs::canonicalize(&entry) {
Ok(p) => p,
Err(_) if !entry.exists() => continue,
Err(e) => return Err(e.into()),
};
if path != self.sysfs_path && !path.starts_with(&self.sysfs_path) {
continue;
}
children.insert(dev(&path)?);
let values = fields(&path.join("uevent"))?;
if let Some(label) = values.get("PARTNAME") {
if ["FDS_BOOT", "FDS_RECOVERY", "FDS_INTERNAL"].contains(&label.as_str()) {
return Err(bad(format!("Protected internal partition: {label}")));
}
}
if let Some(name) = values.get("DEVNAME") {
paths.insert(format!("/dev/{name}"));
}
if fs::read_dir(path.join("holders"))?
.next()
.transpose()?
.is_some()
{
return Err(bad(
"Disk or partition has a kernel holder (RAID, encryption or device mapper)",
));
}
}
if !children.contains(&(self.major, self.minor)) {
return Err(bad("Target vanished during protection check"));
}
for line in read_text(&proc.join("self/mountinfo"), 4 * 1024 * 1024)?.lines() {
let parts: Vec<_> = line.split_whitespace().collect();
if parts.len() < 6 {
return Err(bad("Malformed mount table; refusing to write"));
}
let (major, minor) = parts[2]
.split_once(':')
.ok_or_else(|| bad("Invalid mount device"))?;
let id = (
major.parse().map_err(|_| bad("Invalid mount major"))?,
minor.parse().map_err(|_| bad("Invalid mount minor"))?,
);
if children.contains(&id) {
return Err(bad(format!(
"Mounted disk or partition at {}; eject it before writing",
parts[4]
)));
}
}
// Swap on a block path is excluded directly. Swap files necessarily live
// on a mounted filesystem, already excluded above.
for line in read_text(&proc.join("swaps"), 1024 * 1024)?.lines().skip(1) {
let path = line
.split_whitespace()
.next()
.ok_or_else(|| bad("Invalid swap table"))?;
if paths.contains(path)
|| fs::metadata(path).is_ok_and(|m| {
m.file_type().is_block_device()
&& children.contains(&(libc::major(m.rdev()), libc::minor(m.rdev())))
})
{
return Err(bad("Disk or partition is active swap"));
}
}
Ok(())
}
pub fn open_exclusive(&self) -> Result<File> {
self.protect(Path::new("/sys"), Path::new("/proc"))?;
let file = OpenOptions::new()
.read(true)
.write(true)
.custom_flags(libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK)
.open(&self.path)?;
let metadata = file.metadata()?;
if !metadata.file_type().is_block_device()
|| libc::major(metadata.rdev()) != self.major
|| libc::minor(metadata.rdev()) != self.minor
{
return Err(bad("Opened target does not match the selected disk"));
}
let mut bytes = 0u64;
let mut sector = 0u32;
let mut ro = 0u32;
let mut diskseq = 0u64;
for (request, pointer) in [
(
BLKGETSIZE64,
(&mut bytes as *mut u64).cast::<libc::c_void>(),
),
(BLKSSZGET, (&mut sector as *mut u32).cast()),
(BLKROGET, (&mut ro as *mut u32).cast()),
(BLKGETDISKSEQ, (&mut diskseq as *mut u64).cast()),
] {
if unsafe { libc::ioctl(file.as_raw_fd(), request as _, pointer) } < 0 {
return Err(std::io::Error::last_os_error().into());
}
}
if bytes != self.bytes || sector != self.sector_bytes || ro != 0 || diskseq != self.diskseq
{
return Err(bad(
"Opened disk changed size, sector format, write protection or insertion identity",
));
}
protect_existing_gpt(&file, bytes)?;
self.protect(Path::new("/sys"), Path::new("/proc"))?;
Ok(file)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::symlink;
struct Fixture {
root: PathBuf,
sys: PathBuf,
proc: PathBuf,
usb: PathBuf,
disk: PathBuf,
}
impl Fixture {
fn new() -> Self {
let root = std::env::temp_dir().join(format!(
"fds-disk-test-{}",
crate::image::hex(&crate::image::random_id().unwrap())
));
let sys = root.join("sys");
let proc = root.join("proc");
let usb = sys.join("devices/platform/usb2/2-1");
let disk = usb.join("host/target/block/sdz");
for path in [
disk.join("queue"),
disk.join("device"),
disk.join("holders"),
sys.join("class/block"),
sys.join("dev/block"),
proc.join("self"),
] {
fs::create_dir_all(path).unwrap();
}
for (path, text) in [
(disk.join("uevent"), "DEVTYPE=disk\nDEVNAME=sdz\n"),
(disk.join("dev"), "8:240\n"),
(disk.join("diskseq"), "12\n"),
(disk.join("size"), "262144\n"),
(disk.join("ro"), "0\n"),
(disk.join("queue/logical_block_size"), "512\n"),
(disk.join("device/model"), "Test disk\n"),
(
proc.join("self/mountinfo"),
"1 0 0:1 / / rw - tmpfs none rw\n",
),
(proc.join("swaps"), "Filename Type Size Used Priority\n"),
] {
fs::write(path, text).unwrap();
}
symlink(&disk, sys.join("class/block/sdz")).unwrap();
symlink(&disk, sys.join("dev/block/8:240")).unwrap();
Self {
root,
sys,
proc,
usb,
disk,
}
}
fn partition(&self, label: &str) {
let p = self.disk.join("sdz1");
fs::create_dir_all(p.join("holders")).unwrap();
fs::write(p.join("partition"), "1\n").unwrap();
fs::write(p.join("dev"), "8:241\n").unwrap();
fs::write(
p.join("uevent"),
format!("DEVTYPE=partition\nDEVNAME=sdz1\nPARTNAME={label}\n"),
)
.unwrap();
symlink(&p, self.sys.join("class/block/sdz1")).unwrap();
}
}
impl Drop for Fixture {
fn drop(&mut self) {
fs::remove_dir_all(&self.root).unwrap();
}
}
#[test]
fn internal_labels_are_protected_without_partition_uevents() {
use std::os::fd::FromRawFd;
let fd = unsafe { libc::memfd_create(c"fds-existing-gpt".as_ptr(), libc::MFD_CLOEXEC) };
assert!(fd >= 0);
let file = unsafe { File::from_raw_fd(fd) };
let layout = crate::image::Layout::new(
fds_common::manifest::Class::Data,
1024 * 1024,
None,
[1; 16],
[2; 16],
)
.unwrap();
layout.write(&file).unwrap();
protect_existing_gpt(&file, layout.bytes).unwrap();
let mut name = [0u8; 72];
for (i, c) in "FDS_INTERNAL".encode_utf16().enumerate() {
name[2 * i..2 * i + 2].copy_from_slice(&c.to_le_bytes());
}
// Protection is conservative even when the old table CRC is damaged.
file.write_all_at(&name, layout.bytes - layout.tail.len() as u64 + 56)
.unwrap();
file.write_all_at(&[0; 512], 512).unwrap();
assert!(
protect_existing_gpt(&file, layout.bytes)
.unwrap_err()
.to_string()
.contains("FDS_INTERNAL")
);
}
#[test]
fn blank_media_selects_by_usb_and_replacement_invalidates_identity() {
let f = Fixture::new();
let disk = select(&f.sys, &f.usb).unwrap();
assert_eq!(disk.bytes, 128 * 1024 * 1024);
disk.protect(&f.sys, &f.proc).unwrap();
fs::write(f.disk.join("diskseq"), "13\n").unwrap();
assert!(
disk.protect(&f.sys, &f.proc)
.unwrap_err()
.to_string()
.contains("identity changed")
);
}
#[test]
fn internal_partitions_remain_protected_while_unmounted() {
for label in ["FDS_BOOT", "FDS_RECOVERY", "FDS_INTERNAL"] {
let f = Fixture::new();
f.partition(label);
let disk = select(&f.sys, &f.usb).unwrap();
assert!(
disk.protect(&f.sys, &f.proc)
.unwrap_err()
.to_string()
.contains("Protected internal")
);
}
}
#[test]
fn mounts_swap_and_holders_block_writes() {
let f = Fixture::new();
f.partition("FDS_SYSTEM");
let disk = select(&f.sys, &f.usb).unwrap();
disk.protect(&f.sys, &f.proc).unwrap();
fs::write(
f.proc.join("self/mountinfo"),
"1 0 8:241 / / ro - erofs /dev/sdz1 ro\n",
)
.unwrap();
assert!(
disk.protect(&f.sys, &f.proc)
.unwrap_err()
.to_string()
.contains("Mounted")
);
fs::write(f.proc.join("self/mountinfo"), "").unwrap();
fs::write(
f.proc.join("swaps"),
"Filename Type Size Used Priority\n/dev/sdz1 partition 1 0 -1\n",
)
.unwrap();
assert!(
disk.protect(&f.sys, &f.proc)
.unwrap_err()
.to_string()
.contains("swap")
);
fs::write(f.proc.join("swaps"), "Filename Type Size Used Priority\n").unwrap();
fs::write(f.disk.join("sdz1/holders/dm-0"), "").unwrap();
assert!(
disk.protect(&f.sys, &f.proc)
.unwrap_err()
.to_string()
.contains("holder")
);
}
#[test]
fn sector_size_and_readonly_are_checked() {
let f = Fixture::new();
let mut disk = select(&f.sys, &f.usb).unwrap();
disk.sector_bytes = 4096;
assert!(disk.protect(&f.sys, &f.proc).is_err());
disk.sector_bytes = 512;
fs::write(f.disk.join("ro"), "1\n").unwrap();
assert!(
disk.protect(&f.sys, &f.proc)
.unwrap_err()
.to_string()
.contains("write protected")
);
}
}
+538
View File
@@ -0,0 +1,538 @@
//! Bounded FDS GPT images: legacy single-filesystem cartridges and metadata-first
//! software cartridges, with both table/header CRCs and exact backup agreement.
use fds_common::{Error, Result, manifest::Class};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{fs::File, io::Read, os::unix::fs::FileExt};
pub const SECTOR: u64 = 512;
pub const TABLE_BYTES: usize = 128 * 128;
pub const FIRST_LBA: u64 = 2048;
pub const LINUX_TYPE: [u8; 16] = [
0xaf, 0x3d, 0xc6, 0x0f, 0x83, 0x84, 0x72, 0x47, 0x8e, 0x79, 0x3d, 0x69, 0xd8, 0x47, 0x7d, 0xe4,
];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Partition {
pub number: u8,
pub name: String,
pub start: u64,
pub bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Image {
pub bytes: u64,
pub partition_start: u64,
pub partition_bytes: u64,
pub class: Class,
pub filesystem: String,
pub disk_uuid: String,
pub sha256: Option<String>,
pub partitions: Vec<Partition>,
}
fn bad(message: &str) -> Error {
Error(format!("Invalid cartridge image: {message}"))
}
pub fn u32le(data: &[u8], at: usize) -> u32 {
u32::from_le_bytes(data[at..at + 4].try_into().unwrap())
}
pub fn u64le(data: &[u8], at: usize) -> u64 {
u64::from_le_bytes(data[at..at + 8].try_into().unwrap())
}
pub fn put32(data: &mut [u8], at: usize, value: u32) {
data[at..at + 4].copy_from_slice(&value.to_le_bytes());
}
pub fn put64(data: &mut [u8], at: usize, value: u64) {
data[at..at + 8].copy_from_slice(&value.to_le_bytes());
}
/// IEEE CRC-32 for GPT's small fixed tables; no cryptographic role.
pub fn crc32(data: &[u8]) -> u32 {
let mut crc = !0u32;
for byte in data {
crc ^= *byte as u32;
for _ in 0..8 {
crc = (crc >> 1) ^ (0xedb88320 & 0u32.wrapping_sub(crc & 1));
}
}
!crc
}
pub fn hex(data: &[u8]) -> String {
data.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn digest(
file: &File,
bytes: u64,
mut progress: impl FnMut(u64) -> Result<()>,
) -> Result<String> {
let mut hash = Sha256::new();
let mut buffer = vec![0; 1024 * 1024];
let mut offset = 0;
while offset < bytes {
let n = buffer.len().min((bytes - offset) as usize);
file.read_exact_at(&mut buffer[..n], offset)?;
hash.update(&buffer[..n]);
offset += n as u64;
if offset % (64 * 1024 * 1024) == 0 || offset == bytes {
progress(offset)?;
}
}
Ok(hex(&hash.finalize()))
}
pub fn random_id() -> Result<[u8; 16]> {
let mut id = [0; 16];
File::open("/dev/urandom")?.read_exact(&mut id)?;
// GPT stores the first UUID fields little endian.
id[7] = (id[7] & 15) | 0x40;
id[8] = (id[8] & 63) | 0x80;
Ok(id)
}
fn uuid(id: &[u8]) -> String {
format!(
"{:08x}-{:04x}-{:04x}-{}-{}",
u32le(id, 0),
u16::from_le_bytes(id[4..6].try_into().unwrap()),
u16::from_le_bytes(id[6..8].try_into().unwrap()),
hex(&id[8..10]),
hex(&id[10..])
)
}
pub fn label(class: Class) -> Result<&'static str> {
match class {
Class::System => Ok("FDS_SYSTEM"),
Class::Data => Ok("FDS_DATA"),
Class::Program => Ok("FDS_PROGRAM"),
Class::Environment => Ok("FDS_ENVIRONMENT"),
_ => Err(bad(
"only SYSTEM, DATA, PROGRAM and ENVIRONMENT are writable cartridge classes",
)),
}
}
fn header(file: &File, lba: u64, sectors: u64) -> Result<[u8; 512]> {
let mut data = [0; 512];
file.read_exact_at(&mut data, lba * SECTOR)?;
if &data[..8] != b"EFI PART"
|| u32le(&data, 8) != 0x10000
|| u32le(&data, 12) != 92
|| u32le(&data, 20) != 0
|| data[92..].iter().any(|b| *b != 0)
{
return Err(bad("unsupported or malformed GPT header"));
}
let recorded = u32le(&data, 16);
let mut checked = data;
put32(&mut checked, 16, 0);
if crc32(&checked[..92]) != recorded {
return Err(bad("GPT header CRC mismatch"));
}
if u64le(&data, 24) != lba
|| u64le(&data, 32) != if lba == 1 { sectors - 1 } else { 1 }
|| u64le(&data, 40) != 34
|| u64le(&data, 48) != sectors - 34
|| data[56..72].iter().all(|b| *b == 0)
|| u64le(&data, 72) != if lba == 1 { 2 } else { sectors - 33 }
|| u32le(&data, 80) != 128
|| u32le(&data, 84) != 128
{
return Err(bad("GPT geometry, UUID or entry format is invalid"));
}
Ok(data)
}
pub fn inspect(file: &File, bytes: u64) -> Result<Image> {
if bytes % SECTOR != 0 || bytes < (FIRST_LBA + 2048 + 33) * SECTOR {
return Err(bad("image is too small or is not sector aligned"));
}
let sectors = bytes / SECTOR;
let mut mbr = [0; 512];
file.read_exact_at(&mut mbr, 0)?;
if mbr[510..] != [0x55, 0xaa]
|| mbr[446] != 0
|| mbr[450] != 0xee
|| u32le(&mbr, 454) != 1
|| u32le(&mbr, 458) != (sectors - 1).min(u32::MAX as u64) as u32
|| mbr[462..510].iter().any(|b| *b != 0)
{
return Err(bad(
"missing protective MBR or unsupported hybrid partitions",
));
}
let main = header(file, 1, sectors)?;
let backup = header(file, sectors - 1, sectors)?;
if main[40..72] != backup[40..72] || main[80..92] != backup[80..92] {
return Err(bad("primary and backup GPT disagree"));
}
let mut table = vec![0; TABLE_BYTES];
let mut mirror = vec![0; TABLE_BYTES];
file.read_exact_at(&mut table, 2 * SECTOR)?;
file.read_exact_at(&mut mirror, (sectors - 33) * SECTOR)?;
if table != mirror || crc32(&table) != u32le(&main, 88) {
return Err(bad("GPT table CRC or backup mismatch"));
}
let mut partitions = Vec::new();
let mut ids = std::collections::BTreeSet::new();
let mut next = FIRST_LBA;
for (index, entry) in table.chunks_exact(128).enumerate() {
if entry[..16].iter().all(|b| *b == 0) {
if entry.iter().any(|b| *b != 0) {
return Err(bad("nonempty unused GPT entry"));
}
continue;
}
if index != partitions.len() || partitions.len() == 33 {
return Err(bad("partition entries must be consecutive and at most 33"));
}
if entry[..16] != LINUX_TYPE
|| entry[16..32].iter().all(|b| *b == 0)
|| !ids.insert(entry[16..32].to_vec())
|| u64le(entry, 48) != 0
{
return Err(bad(
"unsupported partition type, duplicate UUID or attributes",
));
}
let first = u64le(entry, 32);
let last = u64le(entry, 40);
if first < next
|| first > sectors - 34
|| first % 2048 != 0
|| last < first
|| last > sectors - 34
|| (last - first + 1) < 2048
|| (last - first + 1) % 2048 != 0
|| (index == 0 && first != FIRST_LBA)
{
return Err(bad(
"partition overlaps another partition or GPT, or is not MiB aligned",
));
}
next = last + 1;
let units: Vec<u16> = entry[56..128]
.chunks_exact(2)
.map(|c| u16::from_le_bytes(c.try_into().unwrap()))
.collect();
let end = units
.iter()
.position(|u| *u == 0)
.ok_or_else(|| bad("partition name has no terminator"))?;
if units[end..].iter().any(|u| *u != 0) {
return Err(bad("partition name contains trailing data"));
}
let name =
String::from_utf16(&units[..end]).map_err(|_| bad("invalid UTF-16 partition name"))?;
partitions.push(Partition {
number: (index + 1) as u8,
name,
start: first * SECTOR,
bytes: (last - first + 1) * SECTOR,
});
}
let first = partitions
.first()
.ok_or_else(|| bad("no cartridge partition"))?;
let software = first.name == "FDS_METADATA";
let class = if software {
if partitions.len() < 2
|| partitions
.iter()
.skip(1)
.any(|p| p.name != format!("FDS_PAYLOAD{:02}", p.number))
{
return Err(bad(
"software requires metadata followed by consecutive payload partitions",
));
}
Class::Program
} else {
if partitions.len() != 1 {
return Err(bad("legacy cartridges require exactly one partition"));
}
match first.name.as_str() {
"FDS_SYSTEM" => Class::System,
"FDS_DATA" => Class::Data,
"FDS_PROGRAM" => Class::Program,
"FDS_ENVIRONMENT" => Class::Environment,
_ => return Err(bad("unrecognized cartridge partition name")),
}
};
let filesystem = if class == Class::Data {
"ext4"
} else {
"erofs"
};
for part in &partitions {
let mut superblock = [0; 1024];
file.read_exact_at(&mut superblock, part.start + 1024)?;
if filesystem == "ext4" {
if superblock[56..58] != [0x53, 0xef] {
return Err(bad("DATA needs ext4"));
}
} else if superblock[..4] != [0xe2, 0xe1, 0xf5, 0xe0] {
return Err(bad("every read-only partition needs EROFS"));
}
}
Ok(Image {
bytes,
partition_start: first.start,
partition_bytes: first.bytes,
class,
filesystem: filesystem.into(),
disk_uuid: uuid(&main[56..72]),
sha256: None,
partitions,
})
}
/// Fixed GPT metadata for bounded filesystem payloads. Callers write only new
/// regular image files; privileged block writes are a separate confirmed step.
pub struct Layout {
pub head: Vec<u8>,
pub tail: Vec<u8>,
pub bytes: u64,
pub partition_bytes: u64,
pub partitions: Vec<Partition>,
}
impl Layout {
pub fn new(
class: Class,
payload_bytes: u64,
total: Option<u64>,
disk_id: [u8; 16],
part_id: [u8; 16],
) -> Result<Self> {
Self::from_parts(
&[(label(class)?.into(), payload_bytes, part_id)],
total,
disk_id,
)
}
/// Metadata is first; the remaining entries contain xz software bundles.
pub fn software(payloads: &[u64], disk_id: [u8; 16], ids: &[[u8; 16]]) -> Result<Self> {
if !(2..=33).contains(&payloads.len()) || ids.len() != payloads.len() {
return Err(bad(
"software images require one metadata and 1..32 payload partitions",
));
}
let parts: Vec<_> = payloads
.iter()
.enumerate()
.map(|(i, bytes)| {
(
if i == 0 {
"FDS_METADATA".into()
} else {
format!("FDS_PAYLOAD{:02}", i + 1)
},
*bytes,
ids[i],
)
})
.collect();
Self::from_parts(&parts, None, disk_id)
}
fn from_parts(
parts: &[(String, u64, [u8; 16])],
total: Option<u64>,
disk_id: [u8; 16],
) -> Result<Self> {
let mut table = vec![0; TABLE_BYTES];
let mut partitions = Vec::new();
let mut offset = FIRST_LBA * SECTOR;
let mut ids = std::collections::BTreeSet::new();
if disk_id == [0; 16] {
return Err(bad("zero disk UUID"));
}
for (i, (name, payload_bytes, part_id)) in parts.iter().enumerate() {
let allocated = payload_bytes
.checked_add(1024 * 1024 - 1)
.ok_or_else(|| bad("payload too large"))?
/ (1024 * 1024)
* (1024 * 1024);
if allocated == 0 || *part_id == [0; 16] || !ids.insert(*part_id) {
return Err(bad("empty payload or invalid partition UUID"));
}
let end = offset
.checked_add(allocated)
.ok_or_else(|| bad("image size overflow"))?;
let entry = &mut table[i * 128..(i + 1) * 128];
entry[..16].copy_from_slice(&LINUX_TYPE);
entry[16..32].copy_from_slice(part_id);
put64(entry, 32, offset / SECTOR);
put64(entry, 40, end / SECTOR - 1);
for (j, unit) in name.encode_utf16().enumerate() {
entry[56 + 2 * j..58 + 2 * j].copy_from_slice(&unit.to_le_bytes());
}
partitions.push(Partition {
number: (i + 1) as u8,
name: name.clone(),
start: offset,
bytes: allocated,
});
offset = end;
}
let minimum = offset
.checked_add(33 * SECTOR)
.ok_or_else(|| bad("image size overflow"))?;
let rounded = minimum
.checked_add(1024 * 1024 - 1)
.ok_or_else(|| bad("image size overflow"))?
/ (1024 * 1024)
* (1024 * 1024);
let bytes = total.unwrap_or(rounded);
if bytes < minimum || bytes % SECTOR != 0 {
return Err(bad("invalid output size"));
}
let sectors = bytes / SECTOR;
let table_crc = crc32(&table);
let make_header = |lba, alternate, entries| {
let mut h = [0u8; 512];
h[..8].copy_from_slice(b"EFI PART");
put32(&mut h, 8, 0x10000);
put32(&mut h, 12, 92);
put64(&mut h, 24, lba);
put64(&mut h, 32, alternate);
put64(&mut h, 40, 34);
put64(&mut h, 48, sectors - 34);
h[56..72].copy_from_slice(&disk_id);
put64(&mut h, 72, entries);
put32(&mut h, 80, 128);
put32(&mut h, 84, 128);
put32(&mut h, 88, table_crc);
let crc = crc32(&h[..92]);
put32(&mut h, 16, crc);
h
};
let mut head = vec![0; 1024 + TABLE_BYTES];
head[447..450].copy_from_slice(&[0, 2, 0]);
head[450] = 0xee;
head[451..454].fill(255);
put32(&mut head, 454, 1);
put32(&mut head, 458, (sectors - 1).min(u32::MAX as u64) as u32);
head[510..512].copy_from_slice(&[0x55, 0xaa]);
head[512..1024].copy_from_slice(&make_header(1, sectors - 1, 2));
head[1024..].copy_from_slice(&table);
let mut tail = table;
tail.extend_from_slice(&make_header(sectors - 1, 1, sectors - 33));
Ok(Self {
head,
tail,
bytes,
partition_bytes: partitions[0].bytes,
partitions,
})
}
pub fn write(&self, output: &File) -> Result<()> {
if !output.metadata()?.is_file() {
return Err(bad("image output must be a regular file"));
}
output.set_len(self.bytes)?;
output.write_all_at(&self.head, 0)?;
output.write_all_at(&self.tail, self.bytes - self.tail.len() as u64)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::fd::FromRawFd;
fn example(class: Class) -> File {
let name = c"fds-gpt-test";
let fd = unsafe { libc::memfd_create(name.as_ptr(), libc::MFD_CLOEXEC) };
assert!(fd >= 0);
let f = unsafe { File::from_raw_fd(fd) };
Layout::new(class, 1024 * 1024, None, [1; 16], [2; 16])
.unwrap()
.write(&f)
.unwrap();
if class == Class::Data {
f.write_all_at(&[0x53, 0xef], FIRST_LBA * SECTOR + 1080)
.unwrap();
} else {
f.write_all_at(&[0xe2, 0xe1, 0xf5, 0xe0], FIRST_LBA * SECTOR + 1024)
.unwrap();
}
f
}
#[test]
fn known_hash_and_crc() {
assert_eq!(crc32(b"123456789"), 0xcbf43926);
assert_eq!(
hex(&Sha256::digest(b"abc")),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn four_classes_roundtrip_and_corruption_is_rejected() {
for class in [
Class::System,
Class::Data,
Class::Program,
Class::Environment,
] {
let f = example(class);
let bytes = f.metadata().unwrap().len();
assert_eq!(inspect(&f, bytes).unwrap().class, class);
for offset in [
510,
512,
1024,
bytes - 512,
bytes - 1024,
FIRST_LBA * SECTOR + if class == Class::Data { 1080 } else { 1024 },
] {
let mut old = [0];
f.read_exact_at(&mut old, offset).unwrap();
f.write_all_at(&[old[0] ^ 0x80], offset).unwrap();
assert!(
inspect(&f, bytes).is_err(),
"accepted corrupt offset {offset}"
);
f.write_all_at(&old, offset).unwrap();
}
}
}
#[test]
fn untrusted_lengths_are_bounded() {
let f = example(Class::Data);
for bytes in [0, 511, 512, 1024, 1024 * 1024, u64::MAX] {
assert!(inspect(&f, bytes).is_err());
}
assert!(Layout::new(Class::Data, u64::MAX, None, [1; 16], [2; 16]).is_err());
assert!(Layout::new(Class::Data, 1024 * 1024, Some(1024), [1; 16], [2; 16]).is_err());
}
#[test]
fn metadata_first_software_gpt_roundtrips_and_rejects_overlap() {
let file = example(Class::Program);
let layout =
Layout::software(&[4096, 8192, 4096], [9; 16], &[[1; 16], [2; 16], [3; 16]]).unwrap();
layout.write(&file).unwrap();
for part in &layout.partitions {
file.write_all_at(&[0xe2, 0xe1, 0xf5, 0xe0], part.start + 1024)
.unwrap();
}
let parsed = inspect(&file, layout.bytes).unwrap();
assert_eq!(parsed.partitions, layout.partitions);
assert_eq!(parsed.class, Class::Program);
// Keep both CRCs and both copies valid: geometry must reject the overlap.
let mut head = layout.head.clone();
put64(&mut head[1024..], 128 + 32, FIRST_LBA);
let table_crc = crc32(&head[1024..]);
put32(&mut head[512..1024], 88, table_crc);
put32(&mut head[512..1024], 16, 0);
let header_crc = crc32(&head[512..604]);
put32(&mut head[512..1024], 16, header_crc);
let mut tail = layout.tail.clone();
tail[..TABLE_BYTES].copy_from_slice(&head[1024..]);
put32(&mut tail[TABLE_BYTES..], 88, table_crc);
put32(&mut tail[TABLE_BYTES..], 16, 0);
let crc = crc32(&tail[TABLE_BYTES..TABLE_BYTES + 92]);
put32(&mut tail[TABLE_BYTES..], 16, crc);
file.write_all_at(&head, 0).unwrap();
file.write_all_at(&tail, layout.bytes - tail.len() as u64)
.unwrap();
assert!(
inspect(&file, layout.bytes)
.unwrap_err()
.to_string()
.contains("overlaps")
);
assert!(Layout::software(&[4096], [9; 16], &[[1; 16]]).is_err());
assert!(Layout::software(&[4096, 4096], [9; 16], &[[1; 16], [1; 16]]).is_err());
}
}
+9
View File
@@ -0,0 +1,9 @@
//! Shared image validation and media identity checks. Device paths come from
//! kernel topology; public commands select a physical bay, never /dev/sdX.
pub mod cli;
pub mod client;
pub mod create;
pub mod device;
pub mod image;
pub mod worker;
pub mod write;
+132
View File
@@ -0,0 +1,132 @@
use clap::{CommandFactory, Parser, Subcommand};
use fds_burn::{cli::BurnCommand, create, image};
use fds_common::{Error, Result, manifest::Class};
use std::{fs::OpenOptions, os::unix::fs::OpenOptionsExt, path::PathBuf, process::ExitCode};
#[derive(Parser)]
#[command(
version,
about = "Create cartridge images and preview confirmed media writes",
after_help = "Creation writes a new regular file. Writes require confirmation tied to the selected insertion and image hash."
)]
struct Cli {
#[command(subcommand)]
command: Option<Action>,
}
#[derive(Subcommand)]
enum Action {
/// Validate a regular cartridge image and report its SHA-256 digest.
Inspect { image: PathBuf },
/// Create a new image from a tree containing FDS/CARTRIDGE.TOML.
Create {
/// Cartridge class: system, data, program, or environment.
#[arg(value_parser = create::class)]
class: Class,
source_directory: PathBuf,
output: PathBuf,
/// DATA filesystem size in MiB.
#[arg(long, value_name = "N")]
size_mib: Option<u64>,
},
#[command(flatten)]
Burn(BurnCommand),
#[command(long_flag = "worker", hide = true)]
Worker,
}
fn run() -> Result<()> {
match Cli::parse().command {
None => {
Cli::command().print_help()?;
println!();
Ok(())
}
Some(Action::Worker) => fds_burn::worker::run(),
Some(Action::Inspect { image: path }) => {
let file = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK)
.open(path)?;
let metadata = file.metadata()?;
if !metadata.is_file() {
return Err(Error(
"Image inspection requires a regular file; use bay inspection for devices"
.into(),
));
}
let mut info = image::inspect(&file, metadata.len())?;
info.sha256 = Some(image::digest(&file, info.bytes, |_| Ok(()))?);
println!(
"{}",
serde_json::to_string_pretty(&info).map_err(|e| Error(e.to_string()))?
);
Ok(())
}
Some(Action::Create {
class,
source_directory,
output,
size_mib,
}) => {
let info = create::create(class, &source_directory, &output, size_mib)?;
println!(
"{}",
serde_json::to_string_pretty(&info).map_err(|e| Error(e.to_string()))?
);
Ok(())
}
Some(Action::Burn(command)) => fds_burn::client::burn(command, false),
}
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("fds-burn: {e}");
ExitCode::from(2)
}
}
}
#[cfg(test)]
mod cli_tests {
use super::*;
use clap::{CommandFactory, Parser};
#[test]
fn typed_command_contract() {
Cli::command().debug_assert();
assert!(matches!(
Cli::try_parse_from(["fds-burn", "--worker"])
.unwrap()
.command,
Some(Action::Worker)
));
assert!(Cli::try_parse_from(["fds-burn", "--worker", "data", "1"]).is_err());
assert!(
Cli::try_parse_from([
"fds-burn",
"create",
"data",
"source",
"output",
"--size-mib",
"32"
])
.is_ok()
);
assert!(
Cli::try_parse_from(["fds-burn", "create", "unknown", "source", "output"]).is_err()
);
assert!(
Cli::try_parse_from([
"fds-burn",
"create",
"data",
"source",
"output",
"--size-mib",
"bad"
])
.is_err()
);
}
}
+435
View File
@@ -0,0 +1,435 @@
//! Root worker for one prepared media operation. All source-file access is
//! checked using the IPC caller's effective identity before privileges return.
use crate::{create, device::Disk, image, write};
use fds_common::{
Error, Result,
control::{LIMIT, MediaJob},
manifest::Class,
};
use serde::{Deserialize, Serialize};
use std::{
ffi::CString,
fs::{self, File, OpenOptions},
io::{self, BufRead, Read, Write},
os::{
fd::{AsRawFd, BorrowedFd},
unix::{
fs::{MetadataExt, OpenOptionsExt},
process::CommandExt,
},
},
path::Path,
process::{Command, Stdio},
time::{Duration, Instant},
};
#[derive(Serialize, Deserialize)]
pub struct Preparation {
pub disk: Disk,
pub image_path: String,
pub uid: u32,
pub job: MediaJob,
}
fn check(value: i32, action: &str) -> Result<()> {
if value < 0 {
Err(Error(format!("{action}: {}", io::Error::last_os_error())))
} else {
Ok(())
}
}
fn c(s: &str) -> Result<CString> {
CString::new(s).map_err(|_| Error("NUL in path".into()))
}
fn report_error(error: &str) -> String {
// Parser diagnostics may quote an entire untrusted manifest. Keep the IPC
// record bounded and prevent terminal controls from reaching CLI output.
let mut result = String::new();
for ch in error.chars() {
let ch = if ch.is_control() { ' ' } else { ch };
if result.len() + ch.len_utf8() > 2048 {
result.push_str(" [truncated]");
break;
}
result.push(ch);
}
result
}
fn emit(job: &mut MediaJob, phase: &str, progress: u64) -> Result<()> {
job.sequence += 1;
job.phase = phase.into();
job.progress_bytes = progress;
let mut stdout = io::stdout().lock();
serde_json::to_writer(&mut stdout, job).map_err(|e| Error(e.to_string()))?;
stdout.write_all(b"\n")?;
stdout.flush()?;
Ok(())
}
fn source(path: &str, uid: u32) -> Result<File> {
if ![0, 1000].contains(&uid) || !path.starts_with('/') {
return Err(Error("Invalid image owner or path".into()));
}
check(
unsafe { libc::setgroups(0, std::ptr::null()) },
"clear worker groups",
)?;
check(unsafe { libc::setegid(uid) }, "select image group")?;
check(unsafe { libc::seteuid(uid) }, "select image owner")?;
let opened = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC)
.open(path);
check(unsafe { libc::seteuid(0) }, "restore worker identity")?;
check(unsafe { libc::setegid(0) }, "restore worker group")?;
let file = opened?;
if !file.metadata()?.is_file() {
return Err(Error("Source image must be a regular file".into()));
}
Ok(file)
}
#[repr(C)]
struct LoopInfo {
device: u64,
inode: u64,
rdevice: u64,
offset: u64,
sizelimit: u64,
number: u32,
encrypt_type: u32,
key_size: u32,
flags: u32,
file_name: [u8; 64],
crypt_name: [u8; 64],
key: [u8; 32],
init: [u64; 2],
}
#[repr(C)]
struct LoopConfig {
fd: u32,
block_size: u32,
info: LoopInfo,
reserved: [u64; 8],
}
fn loop_image(source: &File, info: &image::Image, id: &str) -> Result<File> {
let control = OpenOptions::new()
.read(true)
.write(true)
.open("/dev/loop-control")
.map_err(|e| Error(format!("open loop-control: {e}")))?;
// LOOP_CONFIGURE is atomic. A competing loop user causes EBUSY, never an
// accidental configuration change to another user's loop device.
for _ in 0..8 {
let number = unsafe { libc::ioctl(control.as_raw_fd(), 0x4c82 as libc::Ioctl) };
check(number, "allocate image loop device")?;
let file = OpenOptions::new()
.read(true)
.custom_flags(libc::O_CLOEXEC)
.open(format!("/dev/loop{number}"))
.map_err(|e| Error(format!("open loop{number}: {e}")))?;
let mut config: LoopConfig = unsafe { std::mem::zeroed() };
config.fd = source.as_raw_fd() as u32;
config.block_size = 512;
config.info.offset = info.partition_start;
config.info.sizelimit = info.partition_bytes;
config.info.flags = 1 | 4; // READ_ONLY | AUTOCLEAR
let result = unsafe { libc::ioctl(file.as_raw_fd(), 0x4c0a as libc::Ioctl, &config) };
if result >= 0 {
// Give the unprivileged checker an already-open private inode for
// this loop device. Reopening /proc/self/fd/3 still checks inode
// permissions; changing /dev/loopN permissions would expose it.
// /run intentionally has nodev. Use a root-private directory
// on devtmpfs, then unlink its device inode after opening it.
let private = create::Work::new(Path::new("/dev"))?;
let path = private
.0
.join(format!("burn-{id}.device"))
.display()
.to_string();
check(
unsafe {
libc::mknod(
c(&path)?.as_ptr(),
libc::S_IFBLK | 0o400,
file.metadata()?.rdev(),
)
},
"create private inspection handle",
)?;
let opened = (|| -> Result<File> {
check(
unsafe { libc::chown(c(&path)?.as_ptr(), 1000, 1000) },
"set inspection handle owner",
)?;
Ok(OpenOptions::new()
.read(true)
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW)
.open(&path)
.map_err(|e| Error(format!("open private loop handle: {e}")))?)
})();
fs::remove_file(path)?;
return opened;
}
if io::Error::last_os_error().raw_os_error() != Some(libc::EBUSY) {
check(result, "configure read-only image loop")?;
}
}
Err(Error("Image loop devices remained busy".into()))
}
fn check_filesystem(device: &File, info: &image::Image) -> Result<()> {
let (program, args): (&str, &[&str]) = if info.class == Class::Data {
("/usr/bin/e2fsck", &["-f", "-n"])
} else {
("/usr/bin/fsck.erofs", &["--extract"])
};
let fd = device.as_raw_fd();
let parent = unsafe { libc::getpid() };
let mut command = Command::new(program);
command
.args(args)
.arg("/proc/self/fd/3")
.stdin(Stdio::null())
.stdout(Stdio::from(
unsafe { BorrowedFd::borrow_raw(2) }.try_clone_to_owned()?,
))
.stderr(Stdio::inherit())
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("LC_ALL", "C");
unsafe {
command.pre_exec(move || {
if libc::dup2(fd, 3) < 0 || libc::fcntl(3, libc::F_SETFD, 0) < 0 {
return Err(io::Error::last_os_error());
}
if libc::setgroups(0, std::ptr::null()) < 0
|| libc::setgid(1000) < 0
|| libc::setuid(1000) < 0
{
return Err(io::Error::last_os_error());
}
if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0
|| libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) < 0
{
return Err(io::Error::last_os_error());
}
if libc::getppid() != parent {
return Err(io::Error::other("Media worker exited"));
}
Ok(())
});
}
let status = command
.status()
.map_err(|e| Error(format!("start filesystem checker: {e}")))?;
if !status.success() {
return Err(Error(format!(
"Image filesystem verification failed: {status}"
)));
}
Ok(())
}
fn verify_manifest(device: &File, info: &image::Image, id: &str) -> Result<()> {
check_filesystem(device, info)?;
let path = format!("/run/fds/probe/burn-{id}");
fs::create_dir(&path).map_err(|e| Error(format!("create image probe directory: {e}")))?;
let source = format!("/proc/self/fd/{}", device.as_raw_fd());
let options = if info.class == Class::Data {
Some(c("noload")?)
} else {
None
};
let mounted = check(
unsafe {
libc::mount(
c(&source)?.as_ptr(),
c(&path)?.as_ptr(),
c(&info.filesystem)?.as_ptr(),
libc::MS_RDONLY | libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC,
options
.as_ref()
.map_or(std::ptr::null(), |s| s.as_ptr().cast()),
)
},
"mount image for metadata validation",
);
if let Err(e) = mounted {
let _ = fs::remove_dir(&path);
return Err(e);
}
let parsed = create::tree_manifest(Path::new(&path));
let unmounted = check(
unsafe { libc::umount2(c(&path)?.as_ptr(), 0) },
"unmount inspected image",
);
let _ = fs::remove_dir(&path);
unmounted?;
let manifest = parsed?;
if manifest.cartridge.class != info.class {
return Err(Error(
"Image metadata disagrees with its partition class".into(),
));
}
Ok(())
}
fn cancelled(input: &mut impl BufRead) -> Result<()> {
let mut fd = libc::pollfd {
fd: 0,
events: libc::POLLIN,
revents: 0,
};
check(
unsafe { libc::poll(&mut fd, 1, 0) },
"check media cancellation",
)?;
if fd.revents != 0 {
let mut line = String::new();
input.read_line(&mut line)?;
return Err(Error(
"Media operation cancelled; partial media is not SAFE".into(),
));
}
Ok(())
}
fn perform(preparation: &mut Preparation, input: &mut impl BufRead) -> Result<()> {
let Preparation {
disk,
image_path,
uid,
job,
} = preparation;
check(
unsafe { libc::unshare(libc::CLONE_NEWNS) },
"isolate image inspection mounts",
)?;
check(
unsafe {
libc::mount(
std::ptr::null(),
c("/")?.as_ptr(),
std::ptr::null(),
libc::MS_PRIVATE | libc::MS_REC,
std::ptr::null(),
)
},
"isolate mount propagation",
)?;
let target = disk.open_exclusive()?;
let source = source(image_path, *uid)?;
let mut info = image::inspect(&source, source.metadata()?.len())?;
if info.class != job.image_class {
return Err(Error(
"Image class does not match the requested operation".into(),
));
}
if info.bytes > disk.bytes {
return Err(Error("Image is larger than the selected cartridge".into()));
}
job.image_bytes = Some(info.bytes);
emit(job, "inspecting", 0)?;
info.sha256 = Some(image::digest(&source, info.bytes, |n| {
cancelled(input)?;
emit(job, "inspecting", n)
})?);
let image_device = loop_image(&source, &info, &job.id)?;
verify_manifest(&image_device, &info, &job.id)?;
drop(image_device);
job.image_sha256 = info.sha256.clone();
job.confirmation = Some(format!("ERASE BAY{} {}", job.bay, job.id));
emit(job, "awaiting_confirmation", 0)?;
// Readiness and confirmation are event-driven; expiration is a deadline.
let deadline = Instant::now() + Duration::from_secs(300);
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(Error("Confirmation expired; target untouched".into()));
}
let mut fd = libc::pollfd {
fd: 0,
events: libc::POLLIN,
revents: 0,
};
let result = unsafe {
libc::poll(
&mut fd,
1,
remaining.as_millis().min(i32::MAX as u128) as i32,
)
};
if result < 0 && io::Error::last_os_error().kind() == io::ErrorKind::Interrupted {
continue;
}
check(result, "wait for media confirmation")?;
if result == 0 {
continue;
}
let mut line = String::new();
input.take(LIMIT as u64).read_line(&mut line)?;
if line.trim_end() != job.confirmation.as_deref().unwrap_or("") {
return Err(Error("Media operation cancelled before writing".into()));
}
break;
}
job.confirmation = None;
disk.protect(Path::new("/sys"), Path::new("/proc"))?;
write::transfer(&source, &target, &info, disk.bytes, |phase, n| {
if !disk.present() {
return Err(Error(
"Cartridge removed during write; no SAFE status issued".into(),
));
}
cancelled(input)?;
emit(job, phase, n)
})?;
check(
unsafe { libc::ioctl(target.as_raw_fd(), 0x125f as libc::Ioctl) },
"reread verified partition table",
)?;
drop(target);
emit(job, "complete", info.bytes)?;
Ok(())
}
pub fn run() -> Result<()> {
if unsafe { libc::geteuid() } != 0 {
return Err(Error("Internal media worker requires root".into()));
}
let mut input = io::BufReader::with_capacity(1, io::stdin());
let mut line = String::new();
(&mut input).take((LIMIT + 1) as u64).read_line(&mut line)?;
if line.len() > LIMIT {
return Err(Error("Oversized media preparation".into()));
}
let mut preparation: Preparation =
serde_json::from_str(&line).map_err(|e| Error(e.to_string()))?;
if preparation.job.id.len() != 32 || !preparation.job.id.bytes().all(|b| b.is_ascii_hexdigit())
{
return Err(Error("Invalid media operation identifier".into()));
}
if let Err(e) = perform(&mut preparation, &mut input) {
preparation.job.error = Some(report_error(&e.to_string()));
preparation.job.confirmation = None;
emit(&mut preparation.job, "failed", 0)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn untrusted_diagnostics_remain_bounded_and_printable() {
let malformed = format!(
"format = 1\n[cartridge]\nname = \"{}",
"x".repeat(60 * 1024)
);
let error = fds_common::manifest::Manifest::parse(&malformed).unwrap_err();
let reported = report_error(&error.to_string());
assert!(reported.starts_with("Invalid cartridge manifest:"));
assert!(reported.len() <= 2060);
assert!(!reported.chars().any(char::is_control));
assert_eq!(report_error("a\x1b[2J\r\nb\0"), "a [2J b ");
let multibyte = report_error(&"\u{1f642}".repeat(1000));
assert!(multibyte.len() <= 2060);
assert!(multibyte.ends_with(" [truncated]"));
}
#[test]
fn loop_ioctl_layout_matches_linux_uapi() {
assert_eq!(std::mem::size_of::<LoopInfo>(), 232);
assert_eq!(std::mem::size_of::<LoopConfig>(), 304);
}
}
+277
View File
@@ -0,0 +1,277 @@
//! Byte-for-byte verified image transfer. GPT backup metadata is relocated to
//! the end of a larger target; filesystem contents are never silently resized.
//! The caller must own an exclusively opened, identity-checked and confirmed disk.
use crate::image::{self, Image};
use fds_common::{Error, Result};
use sha2::{Digest, Sha256};
use std::{
fs::File,
os::{
fd::AsRawFd,
unix::fs::{FileExt, FileTypeExt},
},
};
fn error(s: &str) -> Error {
Error(s.into())
}
fn apply(buffer: &mut [u8], offset: u64, patch: &[u8], at: u64) {
let start = offset.max(at);
let end = (offset + buffer.len() as u64).min(at + patch.len() as u64);
if start < end {
buffer[(start - offset) as usize..(end - offset) as usize]
.copy_from_slice(&patch[(start - at) as usize..(end - at) as usize]);
}
}
struct Patches {
head: Vec<u8>,
tail: Vec<u8>,
old_tail: Vec<u8>,
new_bytes: u64,
old_bytes: u64,
}
impl Patches {
fn new(source: &File, image: &Image, target_bytes: u64) -> Result<Self> {
if target_bytes < image.bytes || target_bytes % 512 != 0 {
return Err(error(
"Target is smaller than the image or is not sector aligned",
));
}
let mut head = vec![0; 1024 + image::TABLE_BYTES];
source.read_exact_at(&mut head, 0)?;
let mut tail = vec![0; 512 + image::TABLE_BYTES];
let tail_offset = image.bytes - tail.len() as u64;
source.read_exact_at(&mut tail, tail_offset)?;
let sectors = target_bytes / 512;
image::put32(&mut head, 458, (sectors - 1).min(u32::MAX as u64) as u32);
for (h, lba, backup, entries) in [
(&mut head[512..1024], 1, sectors - 1, 2),
(
&mut tail[image::TABLE_BYTES..],
sectors - 1,
1,
sectors - 33,
),
] {
image::put64(h, 24, lba);
image::put64(h, 32, backup);
image::put64(h, 48, sectors - 34);
image::put64(h, 72, entries);
image::put32(h, 16, 0);
let crc = image::crc32(&h[..92]);
image::put32(h, 16, crc);
}
Ok(Self {
head,
tail,
old_tail: vec![0; 512 + image::TABLE_BYTES],
new_bytes: target_bytes,
old_bytes: image.bytes,
})
}
fn overlay(&self, buffer: &mut [u8], offset: u64) {
apply(buffer, offset, &self.head, 0);
if self.new_bytes != self.old_bytes {
apply(
buffer,
offset,
&self.old_tail,
self.old_bytes - self.old_tail.len() as u64,
);
}
apply(
buffer,
offset,
&self.tail,
self.new_bytes - self.tail.len() as u64,
);
}
}
pub fn transfer(
source: &File,
target: &File,
approved: &Image,
target_bytes: u64,
mut progress: impl FnMut(&str, u64) -> Result<()>,
) -> Result<()> {
let expected = approved
.sha256
.as_ref()
.filter(|s| s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit()))
.ok_or_else(|| error("Write requires a previously approved SHA-256"))?;
if !source.metadata()?.is_file() || source.metadata()?.len() != approved.bytes {
return Err(error("Source image changed type or size"));
}
let mut observed = image::inspect(source, approved.bytes)?;
observed.sha256 = approved.sha256.clone();
if &observed != approved {
return Err(error("Source image geometry changed after inspection"));
}
progress("checking", 0)?;
if image::digest(source, approved.bytes, |n| progress("checking", n))? != *expected {
return Err(error(
"Source image changed after confirmation; target untouched",
));
}
let patches = Patches::new(source, approved, target_bytes)?;
let mut original = vec![0; 1024 * 1024];
let mut output = vec![0; original.len()];
let mut hash = Sha256::new();
let mut offset = 0;
progress("writing", 0)?;
while offset < approved.bytes {
let n = original.len().min((approved.bytes - offset) as usize);
source.read_exact_at(&mut original[..n], offset)?;
hash.update(&original[..n]);
output[..n].copy_from_slice(&original[..n]);
patches.overlay(&mut output[..n], offset);
target.write_all_at(&output[..n], offset)?;
offset += n as u64;
if offset % (64 * 1024 * 1024) == 0 || offset == approved.bytes {
progress("writing", offset)?;
}
}
// This also handles a target only one sector larger than the source, where
// the old and new backup tables overlap.
target.write_all_at(&patches.tail, target_bytes - patches.tail.len() as u64)?;
target.sync_all()?;
if image::hex(&hash.finalize()) != *expected {
return Err(error(
"Source changed during transfer; cartridge is incomplete, not SAFE",
));
}
let metadata = target.metadata()?;
if metadata.file_type().is_block_device() {
// Flush and invalidate the block cache so readback reaches the device.
if unsafe { libc::ioctl(target.as_raw_fd(), 0x1261 as libc::Ioctl) } < 0 {
return Err(std::io::Error::last_os_error().into());
}
} else if metadata.is_file() {
let result =
unsafe { libc::posix_fadvise(target.as_raw_fd(), 0, 0, libc::POSIX_FADV_DONTNEED) };
if result != 0 {
return Err(std::io::Error::from_raw_os_error(result).into());
}
} else {
return Err(error("Unexpected target file type"));
}
progress("verifying", 0)?;
let mut hash = Sha256::new();
let mut offset = 0;
while offset < approved.bytes {
let n = original.len().min((approved.bytes - offset) as usize);
source.read_exact_at(&mut original[..n], offset)?;
hash.update(&original[..n]);
patches.overlay(&mut original[..n], offset);
target.read_exact_at(&mut output[..n], offset)?;
if original[..n] != output[..n] {
return Err(error("Cartridge readback mismatch; no SAFE status issued"));
}
offset += n as u64;
if offset % (64 * 1024 * 1024) == 0 || offset == approved.bytes {
progress("verifying", offset)?;
}
}
let mut backup = vec![0; patches.tail.len()];
let backup_offset = target_bytes - backup.len() as u64;
target.read_exact_at(&mut backup, backup_offset)?;
if backup != patches.tail || image::hex(&hash.finalize()) != *expected {
return Err(error(
"Image or backup GPT changed during verification; no SAFE status issued",
));
}
let target_info = image::inspect(target, target_bytes)?;
if target_info.class != approved.class
|| target_info.partition_start != approved.partition_start
|| target_info.partition_bytes != approved.partition_bytes
|| target_info.partitions != approved.partitions
|| target_info.disk_uuid != approved.disk_uuid
{
return Err(error("Written cartridge geometry failed verification"));
}
target.sync_all()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use fds_common::manifest::Class;
use std::os::fd::FromRawFd;
fn memory() -> File {
let fd = unsafe { libc::memfd_create(c"fds-write-test".as_ptr(), libc::MFD_CLOEXEC) };
assert!(fd >= 0);
unsafe { File::from_raw_fd(fd) }
}
fn source() -> (File, Image) {
let f = memory();
let l = image::Layout::new(Class::Program, 1024 * 1024, None, [1; 16], [2; 16]).unwrap();
l.write(&f).unwrap();
f.write_all_at(&[0xe2, 0xe1, 0xf5, 0xe0], image::FIRST_LBA * 512 + 1024)
.unwrap();
f.write_all_at(
b"Approved application contents",
image::FIRST_LBA * 512 + 8192,
)
.unwrap();
let mut i = image::inspect(&f, l.bytes).unwrap();
i.sha256 = Some(image::digest(&f, l.bytes, |_| Ok(())).unwrap());
(f, i)
}
#[test]
fn exact_and_larger_targets_keep_verified_payload_and_valid_backup() {
for extra in [0, 512, 16 * 1024, 1024 * 1024] {
let (source, i) = source();
let target = memory();
target.set_len(i.bytes + extra).unwrap();
transfer(&source, &target, &i, i.bytes + extra, |_, _| Ok(())).unwrap();
let parsed = image::inspect(&target, i.bytes + extra).unwrap();
assert_eq!(parsed.partition_bytes, i.partition_bytes);
if extra == 0 {
assert_eq!(
image::digest(&target, i.bytes, |_| Ok(())).unwrap(),
i.sha256.unwrap()
);
}
}
}
#[test]
fn changed_source_is_rejected_before_writing() {
let (source, i) = source();
let target = memory();
target.set_len(i.bytes).unwrap();
target.write_all_at(b"UNCHANGED", 0).unwrap();
source
.write_all_at(b"x", image::FIRST_LBA * 512 + 8192)
.unwrap();
assert!(transfer(&source, &target, &i, i.bytes, |_, _| Ok(())).is_err());
let mut marker = [0; 9];
target.read_exact_at(&mut marker, 0).unwrap();
assert_eq!(&marker, b"UNCHANGED");
}
#[test]
fn faults_during_write_and_readback_never_succeed() {
let (source, i) = source();
let target = memory();
target.set_len(i.bytes).unwrap();
let result = transfer(&source, &target, &i, i.bytes, |phase, n| {
if phase == "verifying" && n == 0 {
target.write_all_at(b"wrong", image::FIRST_LBA * 512 + 8192)?;
}
Ok(())
});
assert!(
result
.unwrap_err()
.to_string()
.contains("readback mismatch")
);
let result = transfer(&source, &target, &i, i.bytes, |phase, _| {
if phase == "writing" {
Err(error("Cancelled"))
} else {
Ok(())
}
});
assert!(result.is_err());
}
}