327 lines
10 KiB
Rust
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(¤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<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(¤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)
|
|
}
|