update fds-flash tool
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user