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