FDS/OS 1.0
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user