Files
fds-os/rust/fds-burn/src/client.rs
T
2026-09-21 22:29:23 +08:00

327 lines
10 KiB
Rust

use crate::{
cli::{BurnCommand, DataFormat, EnvironmentFormat, FormatCommand, MetadataOptions},
create, image,
};
use fds_common::{
Bay, Error, Result,
control::{self, MediaJob, Request},
manifest::{Activation, Cartridge, Class, Manifest, Media},
};
use std::{
fs,
io::{self, IsTerminal, Write},
path::Path,
process::{Command, Stdio},
};
fn job(request: Request) -> Result<MediaJob> {
control::request(&request)?
.media_job
.ok_or_else(|| Error("Missing media operation response".into()))
}
pub fn bay(value: &str) -> Result<Bay> {
value.strip_prefix("BAY").unwrap_or(value).parse()
}
fn print(job: &MediaJob, json: bool) -> Result<()> {
if json {
println!(
"{}",
serde_json::to_string_pretty(job).map_err(|e| Error(e.to_string()))?
);
return Ok(());
}
println!(
"BAY {} {} {} bytes\nOPERATION {} {}",
job.bay,
job.model,
job.target_bytes,
job.id,
job.phase.to_uppercase().replace('_', " ")
);
if let Some(bytes) = job.image_bytes {
println!("IMAGE {} {bytes} bytes", job.image_class.label());
}
if let Some(hash) = &job.image_sha256 {
println!("SHA256 {hash}");
}
if let Some(serial) = &job.serial {
println!("SERIAL {serial}");
}
println!("INSERTION {}", job.diskseq);
if let Some(error) = &job.error {
println!("ERROR {error}");
}
if job.phase == "complete" {
println!("VERIFIED — SAFE TO REMOVE");
}
Ok(())
}
fn wait(mut current: MediaJob, until_ready: bool) -> Result<MediaJob> {
while !current.finished() && !(until_ready && current.phase == "awaiting_confirmation") {
current = job(Request::MediaStatus {
id: current.id.clone(),
after_sequence: Some(current.sequence),
})?;
}
Ok(current)
}
fn result(current: MediaJob, json: bool) -> Result<()> {
print(&current, json)?;
if let Some(error) = current.error {
return Err(Error(error));
}
Ok(())
}
pub fn burn(command: BurnCommand, json: bool) -> Result<()> {
match command {
BurnCommand::Status { id } => result(
job(Request::MediaStatus {
id,
after_sequence: None,
})?,
json,
),
BurnCommand::Wait { id } => result(
wait(
job(Request::MediaStatus {
id,
after_sequence: None,
})?,
false,
)?,
json,
),
BurnCommand::Confirm { id, confirmation } => result(
wait(job(Request::MediaConfirm { id, confirmation })?, false)?,
json,
),
BurnCommand::Cancel { id } => result(wait(job(Request::MediaCancel { id })?, false)?, json),
BurnCommand::System(args) => prepare(Class::System, &args.image, args.bay, json),
BurnCommand::Program(args) => prepare(Class::Program, &args.image, args.bay, json),
BurnCommand::Data(args) => match args.bay {
Some(target) => prepare(Class::Data, &args.source, target, json),
None => format(
FormatCommand::Data(DataFormat {
target: format_target(&args.source)?,
options: args.options,
}),
json,
),
},
BurnCommand::Environment(args) => match args.bay {
Some(target) => prepare(Class::Environment, &args.source, target, json),
None => format(
FormatCommand::Environment(EnvironmentFormat {
target: format_target(&args.source)?,
options: args.options,
}),
json,
),
},
}
}
fn format_target(source: &Path) -> Result<Bay> {
source.to_str().and_then(|value| bay(value).ok()).ok_or_else(||
Error("Supply IMAGE BAY to write an image, or BAY with format options to create a cartridge".into()))
}
pub fn prepare(class: Class, path: &Path, target: Bay, json: bool) -> Result<()> {
let image = fs::canonicalize(path)?
.to_str()
.ok_or_else(|| Error("Image path must be UTF-8".into()))?
.to_owned();
let current = wait(
job(Request::MediaPrepare {
bay: target,
image,
class,
})?,
true,
)?;
print(&current, json)?;
if let Some(error) = &current.error {
return Err(Error(error.clone()));
}
if current.phase != "awaiting_confirmation" {
return Err(Error("Media operation did not reach confirmation".into()));
}
let phrase = current
.confirmation
.as_deref()
.ok_or_else(|| Error("Missing confirmation phrase".into()))?;
if json {
return Ok(());
}
println!("This erases the entire selected cartridge.\nType exactly: {phrase}");
if !io::stdin().is_terminal() {
println!(
"To proceed: fds burn confirm {} '{phrase}'\nTo cancel: fds burn cancel {}",
current.id, current.id
);
return Ok(());
}
print!("> ");
io::stdout().flush()?;
let mut reply = String::new();
io::stdin().read_line(&mut reply)?;
if reply.trim_end() != phrase {
let _ = job(Request::MediaCancel { id: current.id });
return Err(Error("Cancelled before writing".into()));
}
result(
wait(
job(Request::MediaConfirm {
id: current.id,
confirmation: phrase.into(),
})?,
false,
)?,
false,
)
}
pub fn inspect_bay(target: Bay, json: bool) -> Result<()> {
let disk = control::request(&Request::Disk { bay: target })?
.disk
.ok_or_else(|| Error("Missing disk inspection".into()))?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&disk).map_err(|e| Error(e.to_string()))?
);
} else {
println!(
"BAY {} {}\nCAPACITY {} bytes\nSECTOR {} bytes\nINSERTION {}",
disk.bay, disk.model, disk.bytes, disk.sector_bytes, disk.diskseq
);
if let Some(serial) = disk.serial {
println!("SERIAL {serial}");
}
println!(
"{}",
disk.protected.map_or_else(
|| "AVAILABLE FOR CONFIRMED WRITE".into(),
|reason| format!("PROTECTED: {reason}")
)
);
}
Ok(())
}
/// Prepare a user-owned filesystem tree; never run source scripts or use shell
/// interpolation. Explicit confirmation follows creation and privileged preview.
pub fn format(command: FormatCommand, json: bool) -> Result<()> {
let (class, source, target, metadata, profile, size) = match command {
FormatCommand::Data(args) => (
Class::Data,
None,
args.target,
args.options.metadata,
None,
args.options.size_mib,
),
FormatCommand::Environment(args) => (
Class::Environment,
None,
args.target,
args.options.metadata,
args.options.profile,
None,
),
FormatCommand::Program {
source,
target,
metadata,
} => (Class::Program, Some(source), target, metadata, None, None),
FormatCommand::System { source, target } => (
Class::System,
Some(source),
target,
MetadataOptions::default(),
None,
None,
),
};
let disk = control::request(&Request::Disk { bay: target })?
.disk
.ok_or_else(|| Error("Missing target geometry".into()))?;
if let Some(reason) = disk.protected {
return Err(Error(reason));
}
let label = metadata
.label
.unwrap_or_else(|| format!("FDS {}", class.label()));
let profile = profile.unwrap_or_else(|| "windowmaker".into());
let id = match metadata.id {
Some(id) => id,
None => format!(
"fds.{}.{}",
class.label().to_ascii_lowercase(),
image::hex(&image::random_id()?)
),
};
let manifest = Manifest {
format: 1,
cartridge: Cartridge {
id,
name: label,
class,
version: fds_common::VERSION.into(),
},
media: Media {
writable: class == Class::Data,
},
activation: if class == Class::Environment {
Some(Activation { profile })
} else {
None
},
};
let text = manifest.to_toml()?;
let parent = std::env::current_dir()?;
let work = create::Work::new(&parent)?;
let tree = work.0.join("source");
fs::create_dir(&tree)?;
if class != Class::System {
fs::create_dir(tree.join("FDS"))?;
fs::write(tree.join("FDS/CARTRIDGE.TOML"), text)?;
}
if class == Class::Program {
let source = fs::canonicalize(source.as_ref().unwrap())?;
if !source.is_dir() || !source.join("bin").is_dir() {
return Err(Error(
"PROGRAM input must contain bin, with optional lib and share".into(),
));
}
let status = Command::new("/usr/bin/cp")
.args(["-a", "--no-preserve=ownership", "--"])
.arg(source)
.arg(tree.join("app"))
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()?;
if !status.success() {
return Err(Error("Copying PROGRAM input failed".into()));
}
}
let source = if class == Class::System {
source.as_ref().unwrap().as_path()
} else {
tree.as_path()
};
let size = if class == Class::Data {
Some(
size.unwrap_or(
(disk.bytes / (1024 * 1024))
.checked_sub(2)
.filter(|mib| *mib >= 32)
.ok_or_else(|| {
Error("DATA target must hold at least a 32 MiB filesystem plus GPT".into())
})?,
),
)
} else {
None
};
let output = work.0.join("cartridge.img");
create::create(class, source, &output, size)?;
prepare(class, &output, target, json)
}