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