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
+1 -1
View File
@@ -41,7 +41,7 @@ claim Pi boot or physical power-cycle recovery is verified.
Read `fds-os.wiki/Workstation.md`, `fds-os.wiki/Software-Format.md` and Read `fds-os.wiki/Workstation.md`, `fds-os.wiki/Software-Format.md` and
`fds-os.wiki/Developer-Workstation-Tooling-Plan.md` for this extension. Software builds and new `fds-os.wiki/Developer-Workstation-Tooling-Plan.md` for this extension. Software builds and new
PROGRAM cartridge creation run on generic Linux workstations. The public native PROGRAM cartridge creation run on generic Linux workstations. The public native
Clap tools are `fds-cartridge` and `fds-emulator`. Software images have GPT Clap tools are `fds-cartridge`, `fds-emulator` and `fds-flash`. Software images have GPT
metadata partition 1 plus m EROFS payload partitions containing installed Void metadata partition 1 plus m EROFS payload partitions containing installed Void
package trees. Build source templates with xbps-src on the workstation; do not package trees. Build source templates with xbps-src on the workstation; do not
create new xz software bundles or extract programs at guest launch. Keep create new xz software bundles or extract programs at guest launch. Keep
+13
View File
@@ -15,6 +15,8 @@ help:
'make workstation-install Install the Arch fds-tools package with pacman' \ 'make workstation-install Install the Arch fds-tools package with pacman' \
'make workstation-binaries Build native binaries on generic Linux' \ 'make workstation-binaries Build native binaries on generic Linux' \
'make workstation-test Verify software builds, multi-partition images and file write/readback' \ 'make workstation-test Verify software builds, multi-partition images and file write/readback' \
'make flash-test Verify guided/unattended flashing with disposable files' \
'make flash-vm-test Verify block-device flashing and rejection paths in an isolated ARM VM' \
'make emulator-test Boot FDS and test cartridge hotplug with the public emulator' \ 'make emulator-test Boot FDS and test cartridge hotplug with the public emulator' \
'make bootstrap Prepare Arch x86_64 host and Void build container' \ 'make bootstrap Prepare Arch x86_64 host and Void build container' \
'make packages Build and export every FDS base package' \ 'make packages Build and export every FDS base package' \
@@ -265,7 +267,18 @@ workstation-binaries:
workstation-test: workstation-binaries workstation-test: workstation-binaries
cargo test --locked --offline --target $$(rustc -vV | sed -n 's/^host: //p') -p fds-common -p fds-burn -p fds-software -p fds-workstation cargo test --locked --offline --target $$(rustc -vV | sed -n 's/^host: //p') -p fds-common -p fds-burn -p fds-software -p fds-workstation
python3 tests/integration/workstation-flash.py --cli out/workstation/fds-flash
python3 tests/integration/workstation-images.py --cli out/workstation/fds-cartridge --image-tool-runner tools/in-image-tools --xbps-tool-runner tools/in-void --xbps-bin "$(CURDIR)/.host/xbps/usr/bin" 2>&1 | tee out/logs/workstation-images.log python3 tests/integration/workstation-images.py --cli out/workstation/fds-cartridge --image-tool-runner tools/in-image-tools --xbps-tool-runner tools/in-void --xbps-bin "$(CURDIR)/.host/xbps/usr/bin" 2>&1 | tee out/logs/workstation-images.log
emulator-test: workstation-binaries emulator-test: workstation-binaries
python3 tests/integration/workstation-emulator.py --cli out/workstation/fds-emulator --qemu-runner tools/in-void 2>&1 | tee out/logs/workstation-emulator.log python3 tests/integration/workstation-emulator.py --cli out/workstation/fds-emulator --qemu-runner tools/in-void 2>&1 | tee out/logs/workstation-emulator.log
.PHONY: flash-test flash-vm-test
flash-test: workstation-binaries
cargo test --locked --offline --target $$(rustc -vV | sed -n 's/^host: //p') -p fds-workstation flash::
python3 tests/integration/workstation-flash.py --cli out/workstation/fds-flash
flash-vm-test: flash-test
./tools/cargo-build --locked --offline --release --target aarch64-unknown-linux-musl -p fds-workstation --bin fds-flash
./tools/verify-elf target/aarch64-unknown-linux-musl/release/fds-flash aarch64 static
python3 tests/integration/workstation-flash-vm.py
+1
View File
@@ -10,6 +10,7 @@ authoritative manual:
- [Project overview](http://gitea.home.arpa/felis/fds-os/wiki/Overview) - [Project overview](http://gitea.home.arpa/felis/fds-os/wiki/Overview)
- [Run on real hardware, including HDMI + USB wiring](http://gitea.home.arpa/felis/fds-os/wiki/Hardware-Setup) - [Run on real hardware, including HDMI + USB wiring](http://gitea.home.arpa/felis/fds-os/wiki/Hardware-Setup)
- [Flash NVMe and cartridge disks](http://gitea.home.arpa/felis/fds-os/wiki/Flashing)
- [Build the OS and start the emulator](http://gitea.home.arpa/felis/fds-os/wiki/Getting-Started) - [Build the OS and start the emulator](http://gitea.home.arpa/felis/fds-os/wiki/Getting-Started)
- [Build software cartridges and use workstation tools](http://gitea.home.arpa/felis/fds-os/wiki/Workstation) - [Build software cartridges and use workstation tools](http://gitea.home.arpa/felis/fds-os/wiki/Workstation)
- [Use cartridges](http://gitea.home.arpa/felis/fds-os/wiki/Cartridges), [DATA](http://gitea.home.arpa/felis/fds-os/wiki/Data) and [the desktop](http://gitea.home.arpa/felis/fds-os/wiki/Desktop) - [Use cartridges](http://gitea.home.arpa/felis/fds-os/wiki/Cartridges), [DATA](http://gitea.home.arpa/felis/fds-os/wiki/Data) and [the desktop](http://gitea.home.arpa/felis/fds-os/wiki/Desktop)
+3 -3
View File
@@ -3,7 +3,7 @@ pkgname=fds-tools
_fds_source=${FDS_SOURCE_DIR:?Run make workstation from the FDS checkout} _fds_source=${FDS_SOURCE_DIR:?Run make workstation from the FDS checkout}
pkgver=$("$_fds_source/tools/version") pkgver=$("$_fds_source/tools/version")
pkgrel=1 pkgrel=1
pkgdesc='FDS/OS workstation cartridge builder and twelve-bay ARM emulator' pkgdesc='FDS/OS workstation cartridge builder, disk flasher and twelve-bay ARM emulator'
arch=('x86_64' 'aarch64') arch=('x86_64' 'aarch64')
license=('MIT') license=('MIT')
depends=('glibc' 'gcc-libs' 'xz') depends=('glibc' 'gcc-libs' 'xz')
@@ -27,7 +27,7 @@ build() {
check() { check() {
local tool local tool
for tool in fds-cartridge fds-emulator; do for tool in fds-cartridge fds-emulator fds-flash; do
[[ $("$_fds_source/out/workstation/$tool" --version) == "$tool $pkgver" ]] [[ $("$_fds_source/out/workstation/$tool" --version) == "$tool $pkgver" ]]
"$_fds_source/out/workstation/$tool" --help >/dev/null "$_fds_source/out/workstation/$tool" --help >/dev/null
done done
@@ -35,7 +35,7 @@ check() {
package() { package() {
local tool local tool
for tool in fds-cartridge fds-emulator; do for tool in fds-cartridge fds-emulator fds-flash; do
install -Dm755 "$_fds_source/out/workstation/$tool" "$pkgdir/usr/bin/$tool" install -Dm755 "$_fds_source/out/workstation/$tool" "$pkgdir/usr/bin/$tool"
done done
install -Dm644 "$_fds_source/LICENSE" "$pkgdir/usr/share/licenses/fds-tools/LICENSE" install -Dm644 "$_fds_source/LICENSE" "$pkgdir/usr/share/licenses/fds-tools/LICENSE"
+4
View File
@@ -24,3 +24,7 @@ path = "src/main.rs"
[[bin]] [[bin]]
name = "fds-emulator" name = "fds-emulator"
path = "src/emulator-main.rs" 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 cartridge;
pub mod doctor; pub mod doctor;
pub mod emulator; pub mod emulator;
pub mod flash;
mod qmp; mod qmp;
mod serial; mod serial;
pub mod software; pub mod software;
+7 -3
View File
@@ -24,7 +24,7 @@ result=subprocess.run(command,capture_output=True,text=True)
(work/'install.log').write_text(result.stdout+result.stderr) (work/'install.log').write_text(result.stdout+result.stderr)
assert result.returncode==0,(result.stdout,result.stderr) assert result.returncode==0,(result.stdout,result.stderr)
version=subprocess.check_output([str(project/'tools/version')],text=True).strip() version=subprocess.check_output([str(project/'tools/version')],text=True).strip()
for tool in ['fds-cartridge','fds-emulator']: for tool in ['fds-cartridge','fds-emulator','fds-flash']:
output=subprocess.check_output([str(root/'usr/bin'/tool),'--version'],text=True).strip() output=subprocess.check_output([str(root/'usr/bin'/tool),'--version'],text=True).strip()
assert output==f'{tool} {version}',output assert output==f'{tool} {version}',output
subprocess.run([str(root/'usr/bin'/tool),'--help'],check=True,stdout=subprocess.DEVNULL) subprocess.run([str(root/'usr/bin'/tool),'--help'],check=True,stdout=subprocess.DEVNULL)
@@ -37,6 +37,10 @@ assert not (manual/'.git').exists()
assert (root/'usr/share/licenses/fds-tools/RUST-NOTICES.txt').stat().st_size>0 assert (root/'usr/share/licenses/fds-tools/RUST-NOTICES.txt').stat().st_size>0
installed=subprocess.check_output(['pacman','--config',str(config),'--root',str(root),'--dbpath',str(root/'var/lib/pacman'),'-Q','fds-tools'],text=True).strip() installed=subprocess.check_output(['pacman','--config',str(config),'--root',str(root),'--dbpath',str(root/'var/lib/pacman'),'-Q','fds-tools'],text=True).strip()
assert installed==f'fds-tools {version}-1',installed assert installed==f'fds-tools {version}-1',installed
(work/'acceptance.json').write_text(json.dumps(dict(status='passed',package=str(package),installed=installed,installer='tools/install-workstation',host_install=False),indent=2)+'\n') # makepkg may set different compiler flags from a direct Cargo build. Exercise
# the installed flasher itself against disposable files, not just its version.
subprocess.run(['python3',str(project/'tests/integration/workstation-flash.py'),
'--cli',str(root/'usr/bin/fds-flash')],check=True)
(work/'acceptance.json').write_text(json.dumps(dict(status='passed',package=str(package),installed=installed,installer='tools/install-workstation',installed_flash_verified=True,host_install=False),indent=2)+'\n')
(project/'out/arch-package-current.txt').write_text(str(work)+'\n') (project/'out/arch-package-current.txt').write_text(str(work)+'\n')
print('PASS: real pacman installation and both installed commands:',work) print('PASS: real pacman installation and all three installed commands:',work)
+8 -1
View File
@@ -88,6 +88,12 @@ class CleanupTests(unittest.TestCase):
self.file('out/manifests/dasung-s6-database.txt', str(self.root / 'out/dasung-s6.new000/compiled')) self.file('out/manifests/dasung-s6-database.txt', str(self.root / 'out/dasung-s6.new000/compiled'))
self.file('out/workstation-images.new000/acceptance.json') self.file('out/workstation-images.new000/acceptance.json')
self.file('out/workstation-images-current.txt', 'out/workstation-images.new000\n') self.file('out/workstation-images-current.txt', 'out/workstation-images.new000\n')
self.file('out/workstation-flash.old000/disk.img')
self.file('out/workstation-flash.new000/acceptance.json')
self.file('out/workstation-flash-current.txt', 'out/workstation-flash.new000\n')
self.file('out/workstation-flash-vm.old000/fixture.tar')
self.file('out/workstation-flash-vm.new000/acceptance.json')
self.file('out/workstation-flash-vm-current.txt', 'out/workstation-flash-vm.new000\n')
self.file('out/m9-images.old000/root/program.img') self.file('out/m9-images.old000/root/program.img')
self.file('out/m9-images.new000/root/program.img') self.file('out/m9-images.new000/root/program.img')
os.utime(self.root / 'out/m9-images.old000', ns=(1, 1)) os.utime(self.root / 'out/m9-images.old000', ns=(1, 1))
@@ -95,7 +101,8 @@ class CleanupTests(unittest.TestCase):
self.file('out/m8-vm.other0/disk.img') self.file('out/m8-vm.other0/disk.img')
(self.root / 'out/m8-vm.kept00/dependency').symlink_to('../m8-vm.other0') (self.root / 'out/m8-vm.kept00/dependency').symlink_to('../m8-vm.other0')
self.assertEqual(self.selected(), { self.assertEqual(self.selected(), {
'out/emu-test.old000', 'out/dasung-s6.old000', 'out/m9-images.old000'}) 'out/emu-test.old000', 'out/dasung-s6.old000', 'out/m9-images.old000',
'out/workstation-flash.old000', 'out/workstation-flash-vm.old000'})
def test_symlinks_do_not_delete_external_content(self): def test_symlinks_do_not_delete_external_content(self):
outside = Path(self.temporary.name) / 'outside' outside = Path(self.temporary.name) / 'outside'
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Run the public flasher against disposable NVMe and USB disks in an ARM VM."""
import io
import json
from pathlib import Path
import shlex
import subprocess
import sys
import tarfile
import tempfile
import time
project = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(project/'tools'))
from image_formats import digest, gpt, LINUX_FILESYSTEM
from vm_test import VM
work = Path(tempfile.mkdtemp(prefix='workstation-flash-vm.', dir=project/'out'))
fixtures = Path((project/'out/workstation-flash-current.txt').read_text().strip())
binary = project/'target/aarch64-unknown-linux-musl/release/fds-flash'
archive = project/'out/rootfs-cli.tar'
for path in (binary, archive, fixtures/'internal.img', fixtures/'system.img'):
assert path.is_file(), path
inputs = [binary, archive, project/'out/kernel/boot/kernel_2712.img', project/'out/fds-initramfs.img']
(work/'inputs.sha256').write_text(''.join(f'{digest(p)} {p}\n' for p in inputs))
# This is an isolated fixture with a root serial console and the new workstation
# binary. It does not alter production login, rootfs archives or attached disks.
additions = {
'usr/libexec/fds/console-session': (b"#!/bin/bash\nexec env HOME=/root PS1='FLASH# ' bash --noprofile --norc\n", 0o755),
'usr/bin/fds-flash': (binary.read_bytes(), 0o755),
'usr/share/fds/flash-internal.img': ((fixtures/'internal.img').read_bytes(), 0o644),
'usr/share/fds/flash-system.img': ((fixtures/'system.img').read_bytes(), 0o644),
}
with tarfile.open(archive) as source, tarfile.open(work/'fixture.tar','w',format=tarfile.PAX_FORMAT) as target:
for member in source:
if member.name not in additions:
target.addfile(member, source.extractfile(member) if member.isfile() else None)
for name,(data,mode) in additions.items():
member=tarfile.TarInfo(name);member.size=len(data);member.mode=mode
target.addfile(member,io.BytesIO(data))
(work/'system').mkdir()
with (work/'build.log').open('w') as log:
subprocess.run([str(project/'image/build-system-cartridge'),'--rootfs',str(work/'fixture.tar'),
'--output-directory',str(work/'system')],check=True,stdout=log,stderr=subprocess.STDOUT)
filesystem=work/'mountable.ext4'
with filesystem.open('xb') as stream:stream.truncate(32*1024*1024)
subprocess.run([str(project/'tools/in-image-tools'),'mke2fs','-q','-F','-t','ext4',str(filesystem)],check=True)
disk=work/'nvme.img'
gpt(disk,[('FDS_DATA',LINUX_FILESYSTEM,filesystem)])
with (work/'4k.img').open('xb') as stream:stream.truncate(32*1024*1024)
extra=['-device','qemu-xhci,id=xhci,addr=05.0',
'-drive',f'file={disk},if=none,id=nvme,format=raw',
'-device','nvme,drive=nvme,serial=FDS-FLASH',
'-drive',f'file={work/"4k.img"},if=none,id=fourk,format=raw',
'-device','nvme,drive=fourk,serial=FDS-4K,logical_block_size=4096,physical_block_size=4096']
def wait(read, predicate, timeout=60):
deadline=time.monotonic()+timeout
while True:
result=read()
if predicate(result):return result
assert time.monotonic()<deadline,result
def cmd(vm, arguments, ok=True):
try:
return vm.capture(shlex.join(['fds-flash',*map(str,arguments)])+' 2>/tmp/flash-error',ok=ok)
except AssertionError as error:
raise AssertionError((arguments, vm.capture('cat /tmp/flash-error'))) from error
def preview(vm,image,path,ok=True):
text=cmd(vm,['--image',image,'--device',path,'--dry-run','--json'],ok)
return json.loads(text)['plan'] if ok else text
def write(vm,image,path,plan,ok=True):
return cmd(vm,['--image',image,'--device',path,'--unattended',
'--expect-target',plan['target_id'],'--sha256',plan['sha256'],'--json'],ok)
record = {}
with VM(work,'flash',work/'system/system.img',extra=extra) as vm:
vm.expect(rb'FLASH# ')
vm.capture('s6-rc -b -l /run/s6-rc -d change cartridged')
rows=json.loads(cmd(vm,['list','--json']))
nvme=next(row for row in rows if row['target'].get('serial')=='FDS-FLASH')['target']['path']
fourk=next(row for row in rows if row['target'].get('serial')=='FDS-4K')['target']['path']
source='/usr/share/fds/flash-internal.img'
before=digest(disk)
preview(vm,source,'/dev/vda',False)
assert 'read-only' in vm.capture('cat /tmp/flash-error')
preview(vm,source,fourk,False)
assert '512-byte' in vm.capture('cat /tmp/flash-error')
preview(vm,source,nvme+'p1',False)
assert 'whole disk' in vm.capture('cat /tmp/flash-error')
vm.capture('mkdir /tmp/flash-mounted; mount -o ro,noload '+shlex.quote(nvme+'p1')+' /tmp/flash-mounted')
preview(vm,source,nvme,False)
assert 'mounted' in vm.capture('cat /tmp/flash-error')
vm.capture('umount /tmp/flash-mounted')
assert digest(disk)==before
plan=preview(vm,source,nvme)
assert json.loads(write(vm,source,nvme,plan))['status']=='verified'
# Kernel partition names are independent of the asynchronous udev/blkid
# cache (the deliberately inert payloads are not mountable filesystems).
def labels():
return vm.capture('cat /sys/class/block/'+Path(nvme).name+"p*/uevent | sed -n 's/^PARTNAME=//p'").split()
labels=wait(labels,lambda names:names==['FDS_BOOT','FDS_RECOVERY','FDS_INTERNAL'])
assert labels==['FDS_BOOT','FDS_RECOVERY','FDS_INTERNAL'],labels
record['nvme_internal_write_and_kernel_partition_reread']=True
# Reinstalling the same offline FDS disk is an intended operation.
source='/usr/share/fds/flash-system.img'
plan=preview(vm,source,nvme)
assert json.loads(write(vm,source,nvme,plan))['status']=='verified'
assert wait(lambda:vm.capture('cat /sys/class/block/'+Path(nvme).name+"p*/uevent | sed -n 's/^PARTNAME=//p'").split(),
lambda names:names==['FDS_SYSTEM'])==['FDS_SYSTEM']
record['reflash_internal_disk_with_system']=True
record['mounted_partition_readonly_and_4k_refused']=True
# Hotplug only generated files into this VM. No host block paths are used.
def attach(name, rule=None):
path=work/(name+'.img')
with path.open('xb') as stream:stream.truncate(32*1024*1024)
vm.qmp('blockdev-add',{'driver':'raw','node-name':name,'file':{'driver':'file','filename':str(path)}})
top=name
if rule:
top=name+'-fault'
vm.qmp('blockdev-add',{'driver':'blkdebug','node-name':top,'image':name,
'inject-error':[{'event':'none','errno':5,'once':False,**rule}]})
vm.qmp('device_add',{'driver':'usb-storage','id':name,'drive':top,'bus':'xhci.0','port':'2','serial':name})
def rows():return json.loads(cmd(vm,['list','--json']))
entries=wait(rows,lambda entries:any(row['target'].get('serial')==name for row in entries))
return path,next(row['target']['path'] for row in entries if row['target'].get('serial')==name)
def detach(name):
vm.qmp('device_del',{'id':name})
wait(lambda:json.loads(cmd(vm,['list','--json'])),lambda entries:all(row['target'].get('serial')!=name for row in entries))
usb,path=attach('usb-first')
plan=preview(vm,source,path)
detach('usb-first')
usb2,path2=attach('usb-replacement')
write(vm,source,path2,plan,False)
assert 'Target identity' in vm.capture('cat /tmp/flash-error')
fresh=preview(vm,source,path2)
assert json.loads(write(vm,source,path2,fresh))['status']=='verified'
record['usb_replacement_refused_and_fresh_write_verified']=True
detach('usb-replacement')
for name,rule in [('write-error',{'iotype':'write','sector':2056}),
('flush-error',{'iotype':'flush'}),
('readback-error',{'iotype':'read','sector':2056})]:
target,path=attach(name,rule)
plan=preview(vm,source,path)
output=write(vm,source,path,plan,False)
error=vm.capture('cat /tmp/flash-error')
assert 'os error 5' in error,(name,error)
assert 'verified' not in output
record[name]=True
detach(name)
# The complete wizard must select the intended disk without CLI paths.
rows=json.loads(cmd(vm,['list','--json']))
selection=next(n+1 for n,row in enumerate(rows) if row['target']['path']==nvme)
vm.send('fds-flash; printf "\\nFLASH_WIZARD_STATUS:%s\\n" "$?"')
vm.expect(rb'Image path: ');vm.send(source)
vm.expect(rb'Disk number \(no default\): ');vm.send(str(selection))
vm.expect(rb' to proceed: ');vm.send('ERASE '+nvme)
vm.expect(rb'VERIFIED: image written')
vm.expect(rb'FLASH_WIZARD_STATUS:0\r?\n')
record['interactive_image_disk_selection_and_confirmation']=True
vm.capture('s6-rc -b -l /run/s6-rc -u change cartridged')
vm.send('fds poweroff');vm.expect(rb'reboot: Power down');assert vm.child.wait(timeout=20)==0
# The final successful NVMe write has a valid GPT on the actual backing file.
subprocess.run(['sfdisk','--verify',str(disk)],check=True)
record.update(status='passed',physical_hardware='not tested',source_binary_sha256=digest(binary))
(work/'acceptance.json').write_text(json.dumps(record,indent=2)+'\n')
(project/'out/workstation-flash-vm-current.txt').write_text(str(work)+'\n')
print('PASS: real virtual NVMe/USB flashing, protected disks, replacement and I/O failures:',work)
print('SKIP: physical Pi, drive/controller flush behavior and power-loss recovery')
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Exercise the actual interactive and unattended flasher using disposable files."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import pty
import selectors
import shutil
import struct
import subprocess
import sys
import tempfile
import time
project = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(project / 'tools'))
from image_formats import gpt, LINUX_FILESYSTEM, EFI_SYSTEM, digest
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--cli', type=Path, required=True)
args = parser.parse_args()
cli = args.cli.resolve()
work = Path(tempfile.mkdtemp(prefix='workstation-flash.', dir=project / 'out'))
log = (work / 'commands.log').open('w')
# Deliberately inert filesystem signatures in small independent GPT fixtures.
# These test the writer and table validation, not filesystem usability or boot.
parts = []
for name, kind, signature, offset in [
('FDS_BOOT', EFI_SYSTEM, b'FAT32 ', 82),
('FDS_RECOVERY', LINUX_FILESYSTEM, bytes.fromhex('e2e1f5e0'), 1024),
('FDS_INTERNAL', LINUX_FILESYSTEM, bytes.fromhex('53ef'), 1080),
]:
path = work / (name + '.bin')
with path.open('xb') as stream:
stream.truncate(1024 * 1024)
stream.seek(offset); stream.write(signature)
if name == 'FDS_BOOT':
stream.seek(11); stream.write(b'\0\x02')
stream.seek(510); stream.write(b'\x55\xaa')
stream.seek(8192); stream.write(b'known payload, not a bootable filesystem')
parts.append((name, kind, path))
internal = work / 'internal.img'
gpt(internal, parts)
system = work / 'system.img'
gpt(system, [('FDS_SYSTEM', LINUX_FILESYSTEM, parts[1][2])])
def invoke(arguments, ok=True):
result = subprocess.run([str(cli), *map(str, arguments)], capture_output=True, text=True, timeout=60)
log.write(repr(arguments) + '\n' + result.stdout + result.stderr); log.flush()
assert (result.returncode == 0) == ok, (arguments, result.returncode, result.stdout, result.stderr)
return result
def target(name, size):
path = work / name
with path.open('xb') as stream: stream.truncate(size)
return path
def preview(image, disk):
return json.loads(invoke(['--image', image, '--device', disk, '--file-target', '--dry-run', '--json']).stdout)['plan']
def unattended(image, disk, plan, **changes):
return ['--image', image, '--device', disk, '--file-target', '--unattended',
'--expect-target', changes.get('target_id', plan['target_id']),
'--sha256', changes.get('sha256', plan['sha256']), '--json']
def verify(image, disk):
subprocess.run(['sfdisk', '--verify', str(disk)], check=True, stdout=log, stderr=log)
original = json.loads(subprocess.check_output(['sfdisk', '--json', str(image)]))['partitiontable']
written = json.loads(subprocess.check_output(['sfdisk', '--json', str(disk)]))['partitiontable']
assert original['id'] == written['id']
assert written['lastlba'] == disk.stat().st_size // 512 - 34
with image.open('rb') as source, disk.open('rb') as output:
for a, b in zip(original['partitions'], written['partitions'], strict=True):
for key in ('start', 'size', 'type', 'uuid', 'name'): assert a[key] == b[key], (a,b)
source.seek(a['start']*512); output.seek(b['start']*512)
assert source.read(a['size']*512) == output.read(b['size']*512)
if disk.stat().st_size == image.stat().st_size: assert digest(image) == digest(disk)
for image in [internal, system]:
source_hash = digest(image)
for extra in [0, 512, 4*1024*1024]:
disk = target(f'{image.stem}-{extra}.target', image.stat().st_size + extra)
before = digest(disk)
plan = preview(image, disk)
assert digest(disk) == before
result = invoke(unattended(image, disk, plan, target_id='0'*64), False)
assert 'Target identity' in result.stderr and digest(disk) == before
result = invoke(unattended(image, disk, plan, sha256='0'*64), False)
assert 'SHA-256' in result.stderr and digest(disk) == before
assert json.loads(invoke(unattended(image, disk, plan)).stdout)['status'] == 'verified'
verify(image, disk)
invoke(unattended(image, disk, plan), False) # A file target's contents changed.
assert digest(image) == source_hash
for name, partitions in [
('data', [('FDS_DATA', LINUX_FILESYSTEM, parts[2][2])]),
('environment', [('FDS_ENVIRONMENT', LINUX_FILESYSTEM, parts[1][2])]),
('program', [('FDS_PROGRAM', LINUX_FILESYSTEM, parts[1][2])]),
('software', [(label, LINUX_FILESYSTEM, parts[1][2]) for label in ('FDS_METADATA','FDS_PAYLOAD02','FDS_PAYLOAD03')]),
]:
image=work/(name+'.img');gpt(image,partitions)
disk=target(name+'.target',image.stat().st_size+1024*1024)
plan=preview(image,disk)
assert json.loads(invoke(unattended(image,disk,plan)).stdout)['status']=='verified'
verify(image,disk)
# Invalid source geometry and destinations must fail without writing.
disk = target('protected.target', internal.stat().st_size)
before = digest(disk)
invoke(['--image', internal, '--device', disk, '--unattended'], False)
invoke(['--image', internal, '--device', disk, '--file-target'], False) # No terminal.
invoke(['--image', internal, '--device', disk, '--dry-run'], False) # Not a block device.
small = target('small.target', 1024)
invoke(['--image', internal, '--device', small, '--file-target', '--dry-run'], False)
invoke(['--image', internal, '--device', internal, '--file-target', '--dry-run'], False)
alias = work/'hardlink.img'; os.link(internal, alias)
invoke(['--image', internal, '--device', alias, '--file-target', '--dry-run'], False)
for name, change in [('bad-primary', 512+16), ('bad-backup', internal.stat().st_size-512+16),
('bad-table', 1024+56), ('bad-filesystem', 2048*512+82)]:
image = work/(name+'.img'); shutil.copyfile(internal,image)
with image.open('r+b') as stream:
stream.seek(change); byte=stream.read(1); stream.seek(change); stream.write(bytes([byte[0]^1]))
invoke(['--image', image, '--device', disk, '--file-target', '--dry-run'], False)
invoke(['--image', parts[0][2], '--device', disk, '--file-target', '--dry-run'], False)
assert digest(disk) == before
# Drive the real terminal workflow, including cancellation and changed inputs
# after the review is displayed. No shell evaluates the user's paths or input.
def interactive(name, reply, mutate=None, ok=True, include_image=True):
disk = target(name+'.target', system.stat().st_size + 1024*1024)
image = work/(name+'.img'); shutil.copyfile(system,image)
before = digest(disk)
master, slave = pty.openpty()
command = [str(cli), '--device', str(disk), '--file-target', '--json']
if include_image: command += ['--image', str(image)]
child = subprocess.Popen(command, stdin=slave, stderr=slave, stdout=subprocess.PIPE, text=True)
os.close(slave)
selector=selectors.DefaultSelector();selector.register(master,selectors.EVENT_READ)
output=bytearray()
def until(marker):
deadline=time.monotonic()+20
while marker not in output:
assert time.monotonic()<deadline, output
for _,_ in selector.select(max(0,deadline-time.monotonic())):
chunk=os.read(master,65536); assert chunk; output.extend(chunk)
try:
if not include_image:
until(b'Image path: ');os.write(master,(str(image)+'\n').encode())
until(b' to proceed: ')
if mutate: mutate(image,disk)
expected = 'ERASE '+str(disk)
os.write(master,((expected if reply else 'cancel')+'\n').encode())
deadline=time.monotonic()+30
while child.poll() is None:
assert time.monotonic()<deadline, output
for _,_ in selector.select(1):
try: output.extend(os.read(master,65536))
except OSError: pass
stdout=child.stdout.read()
assert (child.returncode==0)==ok, (stdout,output)
if ok:
assert json.loads(stdout)['status']=='verified'
verify(image,disk)
elif mutate is None or name=='changed-source': assert digest(disk)==before
(work/(name+'.terminal.log')).write_bytes(output+stdout.encode())
finally:
if child.poll() is None: child.kill();child.wait()
child.stdout.close();selector.close();os.close(master)
interactive('interactive-cancel',False,ok=False)
interactive('interactive-write',True,include_image=False)
def change_source(image,disk):
with image.open('r+b') as stream:stream.seek(1024*1024+8192);stream.write(b'CHANGED')
interactive('changed-source',True,change_source,ok=False)
def change_target(image,disk):
disk.unlink()
with disk.open('xb') as stream:stream.truncate(image.stat().st_size+1024*1024)
interactive('replaced-target',True,change_target,ok=False)
actual_images = []
for name in ('fds-internal.img', 'fds-system-cli.img'):
image=project/'out'/name
if image.is_file():
disk=target('actual-'+name+'.target',image.stat().st_size)
plan=preview(image,disk)
assert plan['image']['bytes']==image.stat().st_size
actual_images.append(dict(path=str(image.resolve()),sha256=plan['sha256']))
else:
print('SKIP: build image not available for read-only inspection:',image)
record=dict(status='passed',cli=str(cli),cli_sha256=digest(cli),internal_and_all_cartridge_classes=True,interactive_terminal=True,
unattended_bindings=True,exact_larger_and_overlapping_gpt=True,independent_sfdisk=True,
partition_payloads_unchanged=True,invalid_sources_and_targets_rejected=True,
changed_source_and_target_rejected=True,actual_build_images_inspected=actual_images,physical_disks_written=False)
(work/'acceptance.json').write_text(json.dumps(record,indent=2)+'\n')
(project/'out/workstation-flash-current.txt').write_text(str(work)+'\n')
print('PASS: interactive/unattended flash, rejection paths, payload readback and relocated GPT:',work)
print('SKIP: physical devices; fixtures are disposable files with inert filesystem signatures')
+1
View File
@@ -12,4 +12,5 @@ cargo build --locked --release --target "$fds_host_target" -p fds-workstation
mkdir -p out/workstation mkdir -p out/workstation
install -m755 "target/$fds_host_target/release/fds-cartridge" out/workstation/fds-cartridge install -m755 "target/$fds_host_target/release/fds-cartridge" out/workstation/fds-cartridge
install -m755 "target/$fds_host_target/release/fds-emulator" out/workstation/fds-emulator install -m755 "target/$fds_host_target/release/fds-emulator" out/workstation/fds-emulator
install -m755 "target/$fds_host_target/release/fds-flash" out/workstation/fds-flash
printf 'Built native Linux tools in out/workstation/\n' printf 'Built native Linux tools in out/workstation/\n'
+4 -1
View File
@@ -24,7 +24,8 @@ PREFIXES = (
'm9-images', 'm9-vm', 'm9-system', 'm10-vm', 'm11-vm', 'm11-faults', 'm9-images', 'm9-vm', 'm9-system', 'm10-vm', 'm11-vm', 'm11-faults',
'm12-clock', 'm12-development', 'm12-eeprom', 'm12-internal', 'm12-clock', 'm12-development', 'm12-eeprom', 'm12-internal',
'm12-recovery', 'm12-release-contracts', 'm12-signing', 'm12-recovery', 'm12-release-contracts', 'm12-signing',
'rust-profiles', 'workstation-images', 'emu-test', 'arch-package-test', 'version-image-test', 'rust-profiles', 'workstation-images', 'workstation-flash', 'workstation-flash-vm',
'emu-test', 'arch-package-test', 'version-image-test',
) )
GENERATED = re.compile(r'(?:' + '|'.join(PREFIXES) + r')\.[A-Za-z0-9_-]{6,8}') GENERATED = re.compile(r'(?:' + '|'.join(PREFIXES) + r')\.[A-Za-z0-9_-]{6,8}')
POINTERS = ( POINTERS = (
@@ -32,6 +33,8 @@ POINTERS = (
'out/version-image-current.txt', 'out/version-image-current.txt',
'out/workstation-images-current.txt', 'out/workstation-images-current.txt',
'out/workstation-emulator-current.txt', 'out/workstation-emulator-current.txt',
'out/workstation-flash-current.txt',
'out/workstation-flash-vm-current.txt',
'out/manifests/dasung-s6-database.txt', 'out/manifests/dasung-s6-database.txt',
) )
LOCKS = ('.clean.lock', '.rootfs.lock', '.base-packages.lock', '.image-tools.lock', '.workstation-package.lock') LOCKS = ('.clean.lock', '.rootfs.lock', '.base-packages.lock', '.image-tools.lock', '.workstation-package.lock')