update fds-flash tool

This commit is contained in:
2026-09-24 14:51:56 +08:00
parent 685b4135df
commit a14ea77145
18 changed files with 1761 additions and 10 deletions
+4
View File
@@ -24,3 +24,7 @@ path = "src/main.rs"
[[bin]]
name = "fds-emulator"
path = "src/emulator-main.rs"
[[bin]]
name = "fds-flash"
path = "src/flash-main.rs"
+12
View File
@@ -0,0 +1,12 @@
use clap::Parser;
use std::process::ExitCode;
fn main() -> ExitCode {
match fds_workstation::flash::cli::run(fds_workstation::flash::cli::Cli::parse()) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("fds-flash: {error}");
ExitCode::from(2)
}
}
}
+282
View File
@@ -0,0 +1,282 @@
use super::{Prepared, device, prepare};
use clap::{Parser, Subcommand};
use fds_common::{Error, Result};
use std::{
io::{self, BufRead, IsTerminal, Write},
path::{Path, PathBuf},
};
fn digest(value: &str) -> std::result::Result<String, String> {
if value.len() != 64 || !value.bytes().all(|c| c.is_ascii_hexdigit()) {
return Err("expected 64 hexadecimal characters".into());
}
Ok(value.to_ascii_lowercase())
}
#[derive(Debug, Parser)]
#[command(name = "fds-flash", version = env!("FDS_BUILD_VERSION"),
about = "Flash complete FDS internal and cartridge disk images on Linux",
after_help = "With no flags, choose an image and disk interactively. Physical writes need root. Every write flushes and verifies readback; no partition or filesystem is expanded. Verify downloaded release signatures separately.",
args_conflicts_with_subcommands = true)]
pub struct Cli {
#[command(subcommand)]
command: Option<Action>,
/// Complete, uncompressed GPT image. Prompted for in interactive mode.
#[arg(long)]
image: Option<PathBuf>,
/// Whole disk, preferably /dev/disk/by-id/...; never a partition.
#[arg(long)]
device: Option<PathBuf>,
/// Do not prompt. Requires explicit image, disk, target ID and image SHA-256.
#[arg(long, requires_all = ["image", "device", "expect_target", "sha256"], conflicts_with = "dry_run")]
unattended: bool,
/// Exact target_id obtained from a reviewed dry run or disk listing.
#[arg(long, value_parser = digest, requires = "unattended")]
expect_target: Option<String>,
/// Expected image SHA-256 (required for unattended writes).
#[arg(long, value_parser = digest)]
sha256: Option<String>,
/// Validate and show the plan without opening the target for writing.
#[arg(long, requires_all = ["image", "device"])]
dry_run: bool,
/// Emit the plan/result as JSON on stdout; prompts and progress use stderr.
#[arg(long)]
json: bool,
/// Test only: use an existing disposable regular file as an ordinary user.
#[arg(long, requires = "device")]
file_target: bool,
}
#[derive(Debug, Subcommand)]
enum Action {
/// List physical whole disks, identities and reasons they cannot be flashed.
List {
#[arg(long)]
json: bool,
},
}
fn prompt(text: &str) -> Result<String> {
eprint!("{text}");
io::stderr().flush()?;
let mut value = String::new();
if io::stdin().lock().read_line(&mut value)? == 0 {
return Err(Error("Input closed; flash cancelled".into()));
}
if value.len() > 4096 {
return Err(Error("Input is too long".into()));
}
let value = value.trim().to_owned();
if value.is_empty() {
return Err(Error("Empty input; flash cancelled".into()));
}
Ok(value)
}
fn describe(target: &device::Target) -> String {
match target {
device::Target::Disk {
path,
bytes,
model,
serial,
sector_bytes,
..
} => format!(
"{} | {:.2} GiB | model: {} | serial: {} | {}-byte sectors",
path.display(),
*bytes as f64 / 1024f64.powi(3),
if model.is_empty() { "unknown" } else { model },
if serial.is_empty() {
"unavailable"
} else {
serial
},
sector_bytes
),
device::Target::File { path, bytes, .. } => format!(
"{} | {} bytes | DISPOSABLE TEST FILE",
path.display(),
bytes
),
}
}
fn choose_device() -> Result<(PathBuf, String)> {
let disks = device::list()?;
if disks.is_empty() {
return Err(Error("No physical disks found".into()));
}
eprintln!("Choose the destination by model, serial and capacity:");
for (n, disk) in disks.iter().enumerate() {
eprintln!(
" {}. {}{}",
n + 1,
describe(&disk.target),
disk.blocked
.as_ref()
.map(|r| format!(" | BLOCKED: {r}"))
.unwrap_or_default()
);
}
let choice: usize = prompt("Disk number (no default): ")?
.parse()
.map_err(|_| Error("Invalid disk number".into()))?;
let selected = choice
.checked_sub(1)
.and_then(|n| disks.get(n))
.ok_or_else(|| Error("Disk number is out of range".into()))?;
if let Some(reason) = &selected.blocked {
return Err(Error(format!("Selected disk is blocked: {reason}")));
}
Ok((
selected.target.path().to_owned(),
selected.target_id.clone(),
))
}
fn show(prepared: &Prepared) {
let plan = &prepared.plan;
eprintln!(
"Image: {}\nKind: {}\nImage bytes: {}\nSHA-256: {}\nTarget: {}\nTarget ID: {}",
plan.image_path.display(),
plan.image.kind,
plan.image.bytes,
plan.sha256,
describe(&plan.target),
plan.target_id
);
for partition in &plan.image.partitions {
eprintln!(
" Partition {}: {} ({} bytes)",
partition.number, partition.name, partition.bytes
);
}
eprintln!(
"This replaces the selected disk's partition table and image contents. Backup GPT relocation and readback are automatic; filesystems keep their image sizes."
);
}
pub fn run(cli: Cli) -> Result<()> {
if let Some(Action::List { json }) = cli.command {
let disks = device::list()?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&disks).map_err(|e| Error(e.to_string()))?
);
} else {
for disk in disks {
println!(
"{}\n target_id: {}\n {}",
describe(&disk.target),
disk.target_id,
disk.blocked
.map(|r| format!("BLOCKED: {r}"))
.unwrap_or_else(|| "Available for explicit selection".into())
);
}
}
return Ok(());
}
if !cli.unattended && !cli.dry_run && !io::stdin().is_terminal() {
return Err(Error("Interactive flashing requires a terminal; use --dry-run or explicit --unattended options".into()));
}
if !cli.dry_run && !cli.file_target && unsafe { libc::geteuid() } != 0 {
return Err(Error(
"Physical disk writes require root; run fds-flash with sudo".into(),
));
}
let image = match cli.image {
Some(path) => path,
None => {
eprintln!(
"Use a complete FDS .img file, such as out/fds-internal.img or out/fds-system-cli.img."
);
PathBuf::from(prompt("Image path: ")?)
}
};
let (device, expected_target) = match cli.device {
Some(path) => (path, cli.expect_target),
None => {
let (path, identity) = choose_device()?;
(path, Some(identity))
}
};
eprintln!("Inspecting image and destination...");
let prepared = prepare(
Path::new(&image),
&device,
cli.file_target,
cli.sha256.as_deref(),
)?;
if expected_target
.as_ref()
.is_some_and(|expected| *expected != prepared.plan.target_id)
{
return Err(Error(
"Target identity changed or does not match the expected target; no bytes written"
.into(),
));
}
show(&prepared);
if !cli.dry_run {
if !cli.unattended
&& prompt(&format!("Type {} to proceed: ", prepared.plan.confirmation))?
!= prepared.plan.confirmation
{
return Err(Error(
"Confirmation did not match; flash cancelled without writing".into(),
));
}
prepared.write()?;
}
let status = if cli.dry_run { "dry_run" } else { "verified" };
if cli.json {
println!(
"{}",
serde_json::to_string_pretty(
&serde_json::json!({"format": 1, "status": status, "plan": prepared.plan})
)
.map_err(|e| Error(e.to_string()))?
);
} else if cli.dry_run {
println!("DRY RUN: target untouched");
} else {
println!(
"VERIFIED: image written, flushed and read back; backup GPT is at the end of the disk"
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn unattended_requires_explicit_image_and_target_bindings() {
Cli::command().debug_assert();
assert!(Cli::try_parse_from(["fds-flash"]).is_ok());
assert!(Cli::try_parse_from(["fds-flash", "list", "--json"]).is_ok());
assert!(
Cli::try_parse_from(["fds-flash", "--unattended", "--image", "x", "--device", "y"])
.is_err()
);
assert!(Cli::try_parse_from(["fds-flash", "--dry-run"]).is_err());
let args = [
"fds-flash",
"--unattended",
"--image",
"x",
"--device",
"y",
"--expect-target",
&"a".repeat(64),
"--sha256",
&"b".repeat(64),
];
assert!(Cli::try_parse_from(args).is_ok());
assert!(digest("wrong").is_err());
}
}
+535
View File
@@ -0,0 +1,535 @@
//! Workstation destinations: physical whole disks or explicit disposable files.
use fds_burn::image;
use fds_common::{Error, Result, read_text};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::{
collections::BTreeSet,
fs::{self, File, OpenOptions},
os::{
fd::AsRawFd,
unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt},
},
path::{Path, PathBuf},
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Target {
Disk {
path: PathBuf,
sysfs: PathBuf,
major: u32,
minor: u32,
diskseq: u64,
boot_id: String,
bytes: u64,
sector_bytes: u32,
model: String,
serial: String,
},
File {
path: PathBuf,
device: u64,
inode: u64,
bytes: u64,
mtime: i64,
mtime_ns: i64,
sha256: String,
},
}
fn number(path: &Path) -> Result<u64> {
read_text(path, 128)?
.trim()
.parse()
.map_err(|_| Error(format!("Invalid kernel number: {}", path.display())))
}
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(|| Error("Invalid block device number".into()))?;
Ok((
major
.parse()
.map_err(|_| Error("Invalid device major".into()))?,
minor
.parse()
.map_err(|_| Error("Invalid device minor".into()))?,
))
}
fn text(path: &Path) -> String {
read_text(path, 4096)
.unwrap_or_default()
.trim()
.chars()
.filter(|c| !c.is_control())
.take(128)
.collect()
}
fn inspect_disk(path: &Path, sysfs: &Path, major: u32, minor: u32) -> Result<Target> {
if sysfs.join("partition").exists() {
return Err(Error("Select a whole disk, not a partition".into()));
}
if sysfs.starts_with("/sys/devices/virtual") {
return Err(Error(
"Virtual, loop, RAID and device-mapper destinations are not physical disks".into(),
));
}
if dev(sysfs)? != (major, minor) {
return Err(Error("Block device identity mismatch".into()));
}
let bytes = number(&sysfs.join("size"))?
.checked_mul(512)
.ok_or_else(|| Error("Disk size overflow".into()))?;
let sector_bytes = number(&sysfs.join("queue/logical_block_size"))?
.try_into()
.map_err(|_| Error("Invalid logical sector size".into()))?;
let mut model = String::new();
let mut serial = String::new();
for ancestor in sysfs
.ancestors()
.take_while(|p| *p != Path::new("/sys/devices"))
{
if model.is_empty() {
model = text(&ancestor.join("device/model"));
}
if model.is_empty() {
model = text(&ancestor.join("model"));
}
if serial.is_empty() {
serial = text(&ancestor.join("device/serial"));
}
if serial.is_empty() {
serial = text(&ancestor.join("serial"));
}
}
Ok(Target::Disk {
path: path.into(),
sysfs: sysfs.into(),
major,
minor,
diskseq: number(&sysfs.join("diskseq"))?,
boot_id: read_text(Path::new("/proc/sys/kernel/random/boot_id"), 128)?
.trim()
.into(),
bytes,
sector_bytes,
model,
serial,
})
}
fn inspect_file(path: &Path, file: &File) -> Result<Target> {
let before = file.metadata()?;
if !before.is_file() {
return Err(Error(
"--file-target requires an existing disposable regular file".into(),
));
}
let sha256 = image::digest(file, before.len(), |_| Ok(()))?;
let after = file.metadata()?;
if before.len() != after.len()
|| before.mtime() != after.mtime()
|| before.mtime_nsec() != after.mtime_nsec()
{
return Err(Error("Disposable target changed during inspection".into()));
}
Ok(Target::File {
path: path.into(),
device: before.dev(),
inode: before.ino(),
bytes: before.len(),
mtime: before.mtime(),
mtime_ns: before.mtime_nsec(),
sha256,
})
}
pub fn inspect(path: &Path, file_target: bool) -> Result<Target> {
let path = path.canonicalize()?;
if file_target {
if unsafe { libc::geteuid() } == 0 {
return Err(Error(
"Disposable file tests must run as an ordinary user".into(),
));
}
return inspect_file(&path, &crate::cartridge::open_image(&path)?);
}
let metadata = fs::metadata(&path)?;
if !metadata.file_type().is_block_device() {
return Err(Error(
"Destination must be a whole block device; use --file-target only for disposable files"
.into(),
));
}
let major = libc::major(metadata.rdev());
let minor = libc::minor(metadata.rdev());
let sysfs = fs::canonicalize(format!("/sys/dev/block/{major}:{minor}"))?;
inspect_disk(&path, &sysfs, major, minor)
}
impl Target {
pub fn path(&self) -> &Path {
match self {
Self::Disk { path, .. } | Self::File { path, .. } => path,
}
}
pub fn bytes(&self) -> u64 {
match self {
Self::Disk { bytes, .. } | Self::File { bytes, .. } => *bytes,
}
}
pub fn id(&self) -> Result<String> {
Ok(image::hex(&Sha256::digest(
serde_json::to_vec(self).map_err(|e| Error(e.to_string()))?,
)))
}
pub fn is_file(&self) -> bool {
matches!(self, Self::File { .. })
}
pub fn present(&self) -> Result<()> {
if let Self::Disk {
sysfs,
major,
minor,
diskseq,
bytes,
..
} = self
{
if fs::canonicalize(format!("/sys/dev/block/{major}:{minor}"))? != *sysfs
|| number(&sysfs.join("diskseq"))? != *diskseq
|| number(&sysfs.join("size"))?.checked_mul(512) != Some(*bytes)
{
return Err(Error(
"Target insertion or capacity changed; flash aborted".into(),
));
}
}
Ok(())
}
pub fn protect(&self) -> Result<()> {
if let Self::Disk {
sysfs,
major,
minor,
sector_bytes,
..
} = self
{
self.present()?;
protect(
sysfs,
Path::new("/sys"),
Path::new("/proc"),
(*major, *minor),
*sector_bytes,
)?;
}
Ok(())
}
pub fn open(&self) -> Result<File> {
if !self.is_file() && unsafe { libc::geteuid() } != 0 {
return Err(Error(
"Physical disk writes require root; run fds-flash with sudo".into(),
));
}
if inspect(self.path(), self.is_file())? != *self {
return Err(Error(
"Target identity changed after preview; no bytes written".into(),
));
}
self.protect()?;
let mut flags = libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK;
if !self.is_file() {
flags |= libc::O_EXCL;
}
let file = OpenOptions::new()
.read(true)
.write(true)
.custom_flags(flags)
.open(self.path())
.map_err(|e| {
Error(format!(
"Cannot exclusively open {}: {e}. Physical writes need root and an unused disk",
self.path().display()
))
})?;
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } < 0 {
return Err(Error("Target is locked by another process".into()));
}
match self {
Self::File { .. } => {
if inspect_file(self.path(), &file)? != *self {
return Err(Error("Disposable target changed; no bytes written".into()));
}
}
Self::Disk {
major,
minor,
diskseq,
bytes,
sector_bytes,
..
} => {
let metadata = file.metadata()?;
if !metadata.file_type().is_block_device()
|| libc::major(metadata.rdev()) != *major
|| libc::minor(metadata.rdev()) != *minor
{
return Err(Error("Opened disk identity mismatch".into()));
}
let mut actual_bytes = 0u64;
let mut actual_sector = 0u32;
let mut actual_sequence = 0u64;
let mut readonly = 0u32;
for (request, pointer) in [
(
0x80081272u64,
(&mut actual_bytes as *mut u64).cast::<libc::c_void>(),
),
(0x1268, (&mut actual_sector as *mut u32).cast()),
(0x80081280, (&mut actual_sequence as *mut u64).cast()),
(0x125e, (&mut readonly as *mut u32).cast()),
] {
if unsafe { libc::ioctl(file.as_raw_fd(), request as libc::Ioctl, pointer) } < 0
{
return Err(std::io::Error::last_os_error().into());
}
}
if actual_bytes != *bytes
|| actual_sector != *sector_bytes
|| actual_sequence != *diskseq
|| readonly != 0
{
return Err(Error(
"Opened disk capacity, sector size, insertion or write protection changed"
.into(),
));
}
}
}
self.protect()?;
Ok(file)
}
}
fn protect(disk: &Path, sys: &Path, proc: &Path, identity: (u32, u32), sector: u32) -> Result<()> {
if sector != 512 {
return Err(Error("FDS images require 512-byte logical sectors".into()));
}
if number(&disk.join("ro"))? != 0 {
return Err(Error("Disk is read-only".into()));
}
let mut devices = BTreeSet::new();
for entry in fs::read_dir(sys.join("class/block"))? {
let path = entry?.path().canonicalize()?;
if path != disk && !path.starts_with(disk) {
continue;
}
devices.insert(dev(&path)?);
if fs::read_dir(path.join("holders"))?
.next()
.transpose()?
.is_some()
{
return Err(Error(
"Disk or partition has an active kernel holder (LVM, RAID or encryption)".into(),
));
}
}
if !devices.contains(&identity) {
return Err(Error("Disk vanished during protection checks".into()));
}
for line in read_text(&proc.join("self/mountinfo"), 4 * 1024 * 1024)?.lines() {
let fields: Vec<_> = line.split_whitespace().collect();
if fields.len() < 6 {
return Err(Error("Malformed mount table".into()));
}
let number = fields[2]
.split_once(':')
.ok_or_else(|| Error("Invalid mount device".into()))?;
let id = (
number.0.parse::<u32>().map_err(|e| Error(e.to_string()))?,
number.1.parse::<u32>().map_err(|e| Error(e.to_string()))?,
);
if devices.contains(&id) {
return Err(Error(format!(
"Target is mounted at {}; unmount it explicitly before flashing",
fields[4]
)));
}
}
for line in read_text(&proc.join("swaps"), 1024 * 1024)?.lines().skip(1) {
let path = line
.split_whitespace()
.next()
.ok_or_else(|| Error("Invalid swap table".into()))?;
let metadata = fs::metadata(unescape(path)?)?;
let id = if metadata.file_type().is_block_device() {
metadata.rdev()
} else {
metadata.dev()
};
if devices.contains(&(libc::major(id), libc::minor(id))) {
return Err(Error(
"Target contains active swap; disable it explicitly before flashing".into(),
));
}
}
Ok(())
}
fn unescape(value: &str) -> Result<PathBuf> {
use std::os::unix::ffi::OsStringExt;
let bytes = value.as_bytes();
let mut out = Vec::new();
let mut n = 0;
while n < bytes.len() {
if bytes[n] == b'\\' {
if n + 3 >= bytes.len()
|| !bytes[n + 1..n + 4]
.iter()
.all(|c| (b'0'..=b'7').contains(c))
{
return Err(Error("Invalid escaped swap path".into()));
}
let number = ((bytes[n + 1] - b'0') as u16 * 64)
+ ((bytes[n + 2] - b'0') as u16 * 8)
+ (bytes[n + 3] - b'0') as u16;
out.push(u8::try_from(number).map_err(|_| Error("Invalid swap path byte".into()))?);
n += 4;
} else {
out.push(bytes[n]);
n += 1;
}
}
Ok(std::ffi::OsString::from_vec(out).into())
}
#[derive(Serialize)]
pub struct Candidate {
pub target: Target,
pub target_id: String,
pub blocked: Option<String>,
}
pub fn list() -> Result<Vec<Candidate>> {
let mut result = Vec::new();
for entry in fs::read_dir("/sys/class/block")? {
let entry = entry?;
let sysfs = match entry.path().canonicalize() {
Ok(path) => path,
Err(_) if !entry.path().exists() => continue,
Err(error) => return Err(error.into()),
};
if sysfs.join("partition").exists() || sysfs.starts_with("/sys/devices/virtual") {
continue;
}
let path = Path::new("/dev").join(entry.file_name());
let target = match inspect(&path, false) {
Ok(target) => target,
Err(error) => {
// Enumeration is a snapshot: sysfs and /dev appear/disappear
// independently during hotplug. A selected target is always
// inspected afresh and failures there still prohibit writing.
eprintln!("Skipping unavailable disk {}: {error}", path.display());
continue;
}
};
result.push(Candidate {
target_id: target.id()?,
blocked: target.protect().err().map(|e| e.to_string()),
target,
});
}
result.sort_by(|a, b| a.target.path().cmp(b.target.path()));
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::symlink;
#[test]
fn mount_holder_swap_and_sector_protections() {
let root = std::env::temp_dir().join(format!(
"fds-flash-protection-{}",
image::hex(&image::random_id().unwrap())
));
let sys = root.join("sys");
let proc = root.join("proc");
let disk = sys.join("devices/block/test");
for directory in [
disk.join("holders"),
disk.join("test1/holders"),
sys.join("class/block"),
proc.join("self"),
] {
fs::create_dir_all(directory).unwrap();
}
let swap = root.join("swap file");
fs::write(&swap, b"fixture").unwrap();
let host_dev = swap.metadata().unwrap().dev();
let identity = (libc::major(host_dev), libc::minor(host_dev));
fs::write(disk.join("dev"), format!("{}:{}\n", identity.0, identity.1)).unwrap();
fs::write(disk.join("test1/dev"), "254:241\n").unwrap();
fs::write(disk.join("ro"), "0\n").unwrap();
symlink(&disk, sys.join("class/block/test")).unwrap();
symlink(disk.join("test1"), sys.join("class/block/test1")).unwrap();
fs::write(proc.join("self/mountinfo"), "").unwrap();
fs::write(proc.join("swaps"), "Filename Type Size Used Priority\n").unwrap();
protect(&disk, &sys, &proc, identity, 512).unwrap();
fs::write(
proc.join("self/mountinfo"),
"1 0 254:241 / /data rw - ext4 /dev/test1 rw\n",
)
.unwrap();
assert!(
protect(&disk, &sys, &proc, identity, 512)
.unwrap_err()
.to_string()
.contains("mounted")
);
fs::write(proc.join("self/mountinfo"), "").unwrap();
fs::write(disk.join("test1/holders/dm-0"), "").unwrap();
assert!(
protect(&disk, &sys, &proc, identity, 512)
.unwrap_err()
.to_string()
.contains("holder")
);
fs::remove_file(disk.join("test1/holders/dm-0")).unwrap();
fs::write(
proc.join("swaps"),
format!(
"Filename Type Size Used Priority\n{} file 1 0 -1\n",
swap.display().to_string().replace(' ', "\\040")
),
)
.unwrap();
assert!(
protect(&disk, &sys, &proc, identity, 512)
.unwrap_err()
.to_string()
.contains("swap")
);
fs::write(proc.join("swaps"), "Filename Type Size Used Priority\n").unwrap();
assert!(
protect(&disk, &sys, &proc, identity, 4096)
.unwrap_err()
.to_string()
.contains("512-byte")
);
fs::write(disk.join("ro"), "1\n").unwrap();
assert!(
protect(&disk, &sys, &proc, identity, 512)
.unwrap_err()
.to_string()
.contains("read-only")
);
fs::remove_dir_all(root).unwrap();
}
}
+407
View File
@@ -0,0 +1,407 @@
//! Complete FDS disk images, including the internal FAT/EROFS/ext4 disk.
//! Cartridge validation remains shared with the cartridge writer. Internal
//! installation is deliberately separate from its reserved-partition policy.
use fds_burn::image::{
self, LINUX_TYPE, Partition, TABLE_BYTES, crc32, put32, put64, u32le, u64le,
};
use fds_common::{Error, Result};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::{
fs::File,
os::{
fd::AsRawFd,
unix::fs::{FileExt, FileTypeExt},
},
};
const EFI_TYPE: [u8; 16] = [
0x28, 0x73, 0x2a, 0xc1, 0x1f, 0xf8, 0xd2, 0x11, 0xba, 0x4b, 0, 0xa0, 0xc9, 0x3e, 0xc9, 0x3b,
];
fn bad(message: &str) -> Error {
Error(format!("Invalid FDS disk image: {message}"))
}
#[derive(Debug, PartialEq, Eq, Serialize)]
pub struct Geometry {
pub bytes: u64,
pub kind: String,
pub disk_uuid: String,
pub partitions: Vec<Partition>,
#[serde(skip)]
head: Vec<u8>,
#[serde(skip)]
tail: Vec<u8>,
}
pub fn inspect(file: &File, bytes: u64) -> Result<Geometry> {
if bytes % 512 != 0 || bytes < (2048 + 2048 + 33) * 512 {
return Err(bad(
"use a complete, sector-aligned GPT .img, not a compressed archive or partition payload",
));
}
let mut head = vec![0; 1024 + TABLE_BYTES];
let mut tail = vec![0; 512 + TABLE_BYTES];
file.read_exact_at(&mut head, 0)?;
let tail_offset = bytes - tail.len() as u64;
file.read_exact_at(&mut tail, tail_offset)?;
if let Ok(cartridge) = image::inspect(file, bytes) {
return Ok(Geometry {
bytes,
kind: format!("{:?}", cartridge.class).to_lowercase(),
disk_uuid: cartridge.disk_uuid,
partitions: cartridge.partitions,
head,
tail,
});
}
// The only additional accepted layout is the complete internal disk.
// Both GPT copies, CRCs, bounds, types, names and filesystem signatures
// must agree before a destination can be opened for writing.
let sectors = bytes / 512;
if head[510..512] != [0x55, 0xaa]
|| head[446] != 0
|| head[450] != 0xee
|| u32le(&head, 454) != 1
|| u32le(&head, 458) != (sectors - 1).min(u32::MAX as u64) as u32
|| head[462..510].iter().any(|b| *b != 0)
{
return Err(bad(
"missing protective MBR; expected a complete FDS GPT image",
));
}
let primary = &head[512..1024];
let backup = &tail[TABLE_BYTES..];
for (header, current, alternate, table) in [
(primary, 1, sectors - 1, 2),
(backup, sectors - 1, 1, sectors - 33),
] {
let mut checked = header.to_vec();
put32(&mut checked, 16, 0);
if &header[..8] != b"EFI PART"
|| u32le(header, 8) != 0x10000
|| u32le(header, 12) != 92
|| u32le(header, 20) != 0
|| header[92..].iter().any(|b| *b != 0)
|| crc32(&checked[..92]) != u32le(header, 16)
|| u64le(header, 24) != current
|| u64le(header, 32) != alternate
|| u64le(header, 40) != 34
|| u64le(header, 48) != sectors - 34
|| u64le(header, 72) != table
|| u32le(header, 80) != 128
|| u32le(header, 84) != 128
|| header[56..72].iter().all(|b| *b == 0)
{
return Err(bad("GPT geometry or header checksum is invalid"));
}
}
let table = &head[1024..];
if primary[40..72] != backup[40..72]
|| primary[80..92] != backup[80..92]
|| table != &tail[..TABLE_BYTES]
|| crc32(table) != u32le(primary, 88)
|| table[3 * 128..].iter().any(|b| *b != 0)
{
return Err(bad(
"internal disk GPT copies disagree or do not contain exactly three partitions",
));
}
let mut partitions = Vec::new();
let mut ids = std::collections::BTreeSet::new();
let mut next = 2048;
for (index, name) in ["FDS_BOOT", "FDS_RECOVERY", "FDS_INTERNAL"]
.iter()
.enumerate()
{
let entry = &table[index * 128..(index + 1) * 128];
let first = u64le(entry, 32);
let last = u64le(entry, 40);
let mut encoded = [0; 72];
for (n, unit) in name.encode_utf16().enumerate() {
encoded[n * 2..n * 2 + 2].copy_from_slice(&unit.to_le_bytes());
}
if entry[..16] != if index == 0 { EFI_TYPE } else { LINUX_TYPE }
|| entry[16..32].iter().all(|b| *b == 0)
|| !ids.insert(entry[16..32].to_vec())
|| u64le(entry, 48) != 0
|| entry[56..] != encoded
|| first < next
|| first % 2048 != 0
|| (index == 0 && first != 2048)
|| first > sectors - 34
|| last < first
|| last > sectors - 34
|| (last - first + 1) % 2048 != 0
{
return Err(bad(
"unsupported internal partition type, name, UUID or bounds",
));
}
let start = first * 512;
let mut signature = [0; 2048];
file.read_exact_at(&mut signature, start)?;
let valid = match index {
0 => {
signature[510..512] == [0x55, 0xaa]
&& &signature[82..90] == b"FAT32 "
&& signature[11..13] == [0, 2]
}
1 => signature[1024..1028] == [0xe2, 0xe1, 0xf5, 0xe0],
_ => signature[1080..1082] == [0x53, 0xef],
};
if !valid {
return Err(bad("internal disk requires FAT32, EROFS and ext4 in order"));
}
partitions.push(Partition {
number: (index + 1) as u8,
name: (*name).into(),
start,
bytes: (last - first + 1) * 512,
});
next = last + 1;
}
Ok(Geometry {
bytes,
kind: "internal".into(),
disk_uuid: image::hex(&primary[56..72]),
partitions,
head,
tail,
})
}
fn overlay(buffer: &mut [u8], offset: u64, patch: &[u8], position: u64) {
let begin = offset.max(position);
let end = (offset + buffer.len() as u64).min(position + patch.len() as u64);
if begin < end {
buffer[(begin - offset) as usize..(end - offset) as usize]
.copy_from_slice(&patch[(begin - position) as usize..(end - position) as usize]);
}
}
/// Verify all written bytes, including the relocated backup GPT. Unallocated
/// space outside the image is not erased, and no filesystem is expanded.
pub fn transfer(
source: &File,
target: &File,
geometry: &Geometry,
sha256: &str,
target_bytes: u64,
mut progress: impl FnMut(&str, u64) -> Result<()>,
) -> Result<()> {
if target_bytes < geometry.bytes || target_bytes % 512 != 0 {
return Err(bad("target is too small or not sector aligned"));
}
if source.metadata()?.len() != geometry.bytes
|| image::digest(source, geometry.bytes, |n| progress("checking", n))? != sha256
|| inspect(source, geometry.bytes)? != *geometry
{
return Err(Error(
"Image changed after preview; target untouched".into(),
));
}
let mut head = geometry.head.clone();
let mut tail = geometry.tail.clone();
let sectors = target_bytes / 512;
put32(&mut head, 458, (sectors - 1).min(u32::MAX as u64) as u32);
for (header, lba, other, entries) in [
(&mut head[512..1024], 1, sectors - 1, 2),
(&mut tail[TABLE_BYTES..], sectors - 1, 1, sectors - 33),
] {
put64(header, 24, lba);
put64(header, 32, other);
put64(header, 48, sectors - 34);
put64(header, 72, entries);
put32(header, 16, 0);
let crc = crc32(&header[..92]);
put32(header, 16, crc);
}
let old_tail = vec![0; tail.len()];
let patch = |buffer: &mut [u8], offset| {
overlay(buffer, offset, &head, 0);
if target_bytes != geometry.bytes {
overlay(
buffer,
offset,
&old_tail,
geometry.bytes - old_tail.len() as u64,
);
}
overlay(buffer, offset, &tail, target_bytes - tail.len() as u64);
};
let mut original = vec![0; 1024 * 1024];
let mut written = vec![0; original.len()];
for phase in ["writing", "verifying"] {
progress(phase, 0)?;
let mut hash = Sha256::new();
let mut offset = 0;
while offset < geometry.bytes {
let n = original.len().min((geometry.bytes - offset) as usize);
source.read_exact_at(&mut original[..n], offset)?;
hash.update(&original[..n]);
patch(&mut original[..n], offset);
if phase == "writing" {
target.write_all_at(&original[..n], offset)?;
} else {
target.read_exact_at(&mut written[..n], offset)?;
if written[..n] != original[..n] {
return Err(Error("Disk readback mismatch; flash failed".into()));
}
}
offset += n as u64;
if offset % (64 * 1024 * 1024) == 0 || offset == geometry.bytes {
progress(phase, offset)?;
}
}
if image::hex(&hash.finalize()) != sha256 || source.metadata()?.len() != geometry.bytes {
return Err(Error(
"Source changed during transfer; target is incomplete".into(),
));
}
if phase == "writing" {
target.write_all_at(&tail, target_bytes - tail.len() as u64)?;
target.sync_all()?;
if target.metadata()?.file_type().is_block_device() {
if unsafe { libc::ioctl(target.as_raw_fd(), 0x1261 as libc::Ioctl) } < 0 {
return Err(std::io::Error::last_os_error().into());
}
} else {
let rc = unsafe {
libc::posix_fadvise(target.as_raw_fd(), 0, 0, libc::POSIX_FADV_DONTNEED)
};
if rc != 0 {
return Err(std::io::Error::from_raw_os_error(rc).into());
}
}
}
}
let mut actual_tail = vec![0; tail.len()];
target.read_exact_at(&mut actual_tail, target_bytes - tail.len() as u64)?;
let observed = inspect(target, target_bytes)?;
if actual_tail != tail
|| observed.partitions != geometry.partitions
|| observed.disk_uuid != geometry.disk_uuid
|| observed.kind != geometry.kind
{
return Err(Error(
"Written GPT or backup metadata failed verification".into(),
));
}
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-flash-test".as_ptr(), libc::MFD_CLOEXEC) };
assert!(fd >= 0);
unsafe { File::from_raw_fd(fd) }
}
fn fixture() -> (File, Geometry, String) {
let source = memory();
let layout =
image::Layout::new(Class::System, 1024 * 1024, None, [1; 16], [2; 16]).unwrap();
layout.write(&source).unwrap();
source
.write_all_at(&[0xe2, 0xe1, 0xf5, 0xe0], 1024 * 1024 + 1024)
.unwrap();
let geometry = inspect(&source, layout.bytes).unwrap();
let hash = image::digest(&source, layout.bytes, |_| Ok(())).unwrap();
(source, geometry, hash)
}
#[test]
fn exact_larger_and_overlapping_backup_locations() {
let (source, geometry, hash) = fixture();
for extra in [0, 512, 4 * 1024 * 1024] {
let target = memory();
let size = geometry.bytes + extra;
target.set_len(size).unwrap();
transfer(&source, &target, &geometry, &hash, size, |_, _| Ok(())).unwrap();
assert_eq!(
inspect(&target, size).unwrap().partitions,
geometry.partitions
);
}
assert_eq!(
image::digest(&source, geometry.bytes, |_| Ok(())).unwrap(),
hash
);
}
#[test]
fn changed_source_is_rejected_before_any_write() {
let (source, geometry, hash) = fixture();
let target = memory();
target.set_len(geometry.bytes).unwrap();
let before = image::digest(&target, geometry.bytes, |_| Ok(())).unwrap();
source.write_all_at(b"changed", 1024 * 1024 + 8192).unwrap();
assert!(
transfer(
&source,
&target,
&geometry,
&hash,
geometry.bytes,
|_, _| Ok(())
)
.is_err()
);
assert_eq!(
image::digest(&target, geometry.bytes, |_| Ok(())).unwrap(),
before
);
}
#[test]
fn readback_corruption_and_io_failure_never_succeed() {
let (source, geometry, hash) = fixture();
let target = memory();
target.set_len(geometry.bytes).unwrap();
let failure = transfer(
&source,
&target,
&geometry,
&hash,
geometry.bytes,
|phase, n| {
if phase == "verifying" && n == 0 {
target.write_all_at(b"corrupt", 1024 * 1024 + 8192)?;
}
Ok(())
},
)
.unwrap_err();
assert!(failure.to_string().contains("readback mismatch"));
let readonly = File::open(format!("/proc/self/fd/{}", target.as_raw_fd())).unwrap();
assert!(
transfer(
&source,
&readonly,
&geometry,
&hash,
geometry.bytes,
|_, _| Ok(())
)
.is_err()
);
let interrupted = transfer(
&source,
&target,
&geometry,
&hash,
geometry.bytes,
|phase, n| {
if phase == "writing" && n > 0 {
return Err(Error("Target removed".into()));
}
Ok(())
},
)
.unwrap_err();
assert!(interrupted.to_string().contains("removed"));
}
}
+103
View File
@@ -0,0 +1,103 @@
//! Guided and unattended installation of complete disk images on Linux.
pub mod cli;
pub mod device;
mod image;
use fds_common::{Error, Result};
use serde::Serialize;
use std::{
fs::File,
os::{fd::AsRawFd, unix::fs::MetadataExt},
path::{Path, PathBuf},
};
#[derive(Serialize)]
pub struct Plan {
pub image_path: PathBuf,
pub image: image::Geometry,
pub sha256: String,
pub target: device::Target,
pub target_id: String,
pub confirmation: String,
}
pub struct Prepared {
pub plan: Plan,
source: File,
}
pub fn prepare(
image_path: &Path,
device_path: &Path,
file_target: bool,
expected_sha256: Option<&str>,
) -> Result<Prepared> {
let image_path = image_path.canonicalize()?;
let source = crate::cartridge::open_image(&image_path)?;
let geometry = image::inspect(&source, source.metadata()?.len())?;
let sha256 = fds_burn::image::digest(&source, geometry.bytes, |_| Ok(()))?;
if expected_sha256.is_some_and(|expected| expected != sha256) {
return Err(Error(
"Image SHA-256 does not match --sha256; target untouched".into(),
));
}
if image::inspect(&source, source.metadata()?.len())? != geometry {
return Err(Error("Image geometry changed during inspection".into()));
}
let target = device::inspect(device_path, file_target)?;
let metadata = std::fs::metadata(target.path())?;
if source.metadata()?.dev() == metadata.dev() && source.metadata()?.ino() == metadata.ino() {
return Err(Error("Image and target refer to the same file".into()));
}
target.protect()?;
if target.bytes() < geometry.bytes || target.bytes() % 512 != 0 {
return Err(Error(
"Destination is smaller than the image or is not sector aligned".into(),
));
}
let target_id = target.id()?;
let confirmation = format!("ERASE {}", target.path().display());
Ok(Prepared {
plan: Plan {
image_path,
image: geometry,
sha256,
target,
target_id,
confirmation,
},
source,
})
}
impl Prepared {
pub fn write(&self) -> Result<()> {
let target = self.plan.target.open()?;
let source_meta = self.source.metadata()?;
let target_meta = target.metadata()?;
if source_meta.dev() == target_meta.dev() && source_meta.ino() == target_meta.ino() {
return Err(Error("Image and destination are the same open file".into()));
}
image::transfer(
&self.source,
&target,
&self.plan.image,
&self.plan.sha256,
self.plan.target.bytes(),
|phase, bytes| {
self.plan.target.present()?;
eprintln!("{phase}: {bytes}/{} bytes", self.plan.image.bytes);
Ok(())
},
)?;
self.plan.target.present()?;
if !self.plan.target.is_file()
&& unsafe { libc::ioctl(target.as_raw_fd(), 0x125f as libc::Ioctl) } < 0
{
return Err(Error(format!(
"Image verified, but the kernel could not reload the partition table: {}. Do not use the disk until it is reconnected and inspected",
std::io::Error::last_os_error()
)));
}
Ok(())
}
}
+1
View File
@@ -2,6 +2,7 @@
pub mod cartridge;
pub mod doctor;
pub mod emulator;
pub mod flash;
mod qmp;
mod serial;
pub mod software;