use std::io::Write;
use std::path::{Path, PathBuf};
use super::agent::{Agent, NO_PASS};
use super::bundle::install_current;
use super::types::{Error, Report};
const USAGE: &str = concat!(
"haematite install --bundle <new directory> --config <service.json> ",
"[--lys-issuer <origin> --lys-agent <id> --key-file <path>] ",
"[--lys-issuer <origin> --lys-credential-file <absolute path>]",
"\nWith the Lys pair, the schema Lys holds for haematite is checked first ",
"and a drift refuses before anything is written. ",
"\nThe bundled serve.json is a snapshot. ",
"Later socket or data_dir changes require a new bundle directory; ",
"a repeat with changed inputs refuses bundle_conflict.",
);
pub fn run(args: impl Iterator<Item = String>) -> Result<(), String> {
let outcome = parse(args).and_then(|(directory, config, lys, agent)| {
check_lys_schema(&mut std::io::stderr(), lys)?;
if agent.is_none() {
writeln!(std::io::stderr(), "bundle_no_pass: {NO_PASS}")
.map_err(|error| Error::new("bundle_report_write_failed", &error))?;
}
install_current(&config, &directory, agent.as_ref(), || {
let binary = std::env::current_exe()
.map_err(|error| Error::new("bundle_source_invalid", &error))?;
canonical_binary(&binary)
})
});
write_outcome(&mut std::io::stdout(), &mut std::io::stderr(), outcome)
.map_err(|error| error.to_string())
}
fn write_outcome(
output: &mut impl Write,
diagnostics: &mut impl Write,
outcome: Result<Report, Error>,
) -> Result<(), Error> {
match outcome {
Ok(report) => write_stale_stages(diagnostics, &report.stale_stages)
.and_then(|()| write_report(output, &report))
.map_err(|error| error.with_stale_stages(report.stale_stages)),
Err(error) => {
if let Err(report_error) = write_stale_stages(diagnostics, &error.stale_stages) {
return Err(error.with_cleanup(&report_error));
}
Err(error)
}
}
}
fn check_lys_schema(
diagnostics: &mut impl Write,
lys: Option<(String, PathBuf)>,
) -> Result<(), Error> {
let Some((issuer, credential)) = lys else {
return writeln!(
diagnostics,
"lys_schema_unchecked: no schema credential was given, so the registered schema \
was not compared with this binary's"
)
.map_err(|error| Error::new("bundle_report_write_failed", &error));
};
let args = [
"--issuer".to_owned(),
issuer,
"--credential-file".to_owned(),
credential.display().to_string(),
"--check".to_owned(),
];
crate::serve::lys_schema::run(args.into_iter())
.map_err(|refusal| Error::new("bundle_lys_schema_refused", &refusal))
}
fn write_stale_stages(output: &mut impl Write, stages: &[String]) -> Result<(), Error> {
let stages = serde_json::to_string(stages)
.map_err(|error| Error::new("bundle_encoding_failed", &error))?;
writeln!(output, "bundle_stale_stages: {stages}")
.map_err(|error| Error::new("bundle_report_write_failed", &error))
}
fn canonical_binary(path: &Path) -> Result<PathBuf, Error> {
std::fs::canonicalize(path).map_err(|error| Error::new("bundle_source_invalid", &error))
}
fn write_report(output: &mut impl Write, report: &Report) -> Result<(), Error> {
let report = serde_json::to_string(report)
.map_err(|error| Error::new("bundle_encoding_failed", &error))?;
writeln!(output, "{report}").map_err(|error| Error::new("bundle_report_write_failed", &error))
}
type Parsed = (PathBuf, PathBuf, Option<(String, PathBuf)>, Option<Agent>);
fn parse(args: impl Iterator<Item = String>) -> Result<Parsed, Error> {
let mut args = args;
let mut bundle = None;
let mut config = None;
let mut issuer = None;
let mut credential = None;
let mut agent = None;
let mut key = None;
while let Some(flag) = args.next() {
let target = match flag.as_str() {
"--bundle" => &mut bundle,
"--config" => &mut config,
"--lys-issuer" => &mut issuer,
"--lys-credential-file" => &mut credential,
"--lys-agent" => &mut agent,
"--key-file" => &mut key,
_ => return Err(Error::new("bundle_arguments_invalid", USAGE)),
};
if target.is_some() {
return Err(Error::new("bundle_arguments_invalid", "duplicate option"));
}
let value = args
.next()
.filter(|value| !value.is_empty() && !value.starts_with("--"))
.ok_or_else(|| Error::new("bundle_arguments_invalid", "option requires a path"))?;
*target = Some(PathBuf::from(value));
}
let proof = if agent.is_some() || key.is_some() || credential.is_none() {
Agent::parse(issuer.clone(), agent, key)?
} else {
None
};
let lys = match (issuer, credential) {
(Some(issuer), Some(credential)) if credential.is_absolute() => {
Some((issuer.display().to_string(), credential))
}
(_, None) => None,
_ => return Err(Error::new("bundle_arguments_invalid", USAGE)),
};
Ok((
bundle.ok_or_else(|| Error::new("bundle_arguments_invalid", USAGE))?,
config.ok_or_else(|| Error::new("bundle_arguments_invalid", USAGE))?,
lys,
proof,
))
}
#[cfg(test)]
mod tests {
use super::parse;
#[test]
fn arguments_require_each_path_once_and_do_not_consume_flags_as_values()
-> Result<(), super::Error> {
for values in [
vec![],
vec!["--bundle"],
vec!["--bundle", "--config"],
vec!["--bundle", "a", "--bundle", "b"],
vec!["--bundle", "a", "--config", "b", "extra"],
vec![
"--bundle",
"a",
"--config",
"b",
"--lys-credential-file",
"/c",
],
vec![
"--bundle",
"a",
"--config",
"b",
"--lys-issuer",
"http://lys",
"--lys-credential-file",
"c",
],
] {
assert_eq!(
parse(values.into_iter().map(str::to_owned))
.err()
.map(|error| error.code),
Some("bundle_arguments_invalid")
);
}
let values = parse(
["--bundle", "a", "--config", "b"]
.into_iter()
.map(str::to_owned),
)?;
assert_eq!(values, ("a".into(), "b".into(), None, None));
let values = parse(
[
"--bundle",
"a",
"--config",
"b",
"--lys-issuer",
"http://lys",
"--lys-credential-file",
"/c",
]
.into_iter()
.map(str::to_owned),
)?;
assert_eq!(
values,
(
"a".into(),
"b".into(),
Some(("http://lys".to_owned(), "/c".into())),
None
)
);
Ok(())
}
#[test]
fn the_deploy_check_is_named_when_skipped_and_refuses_when_lys_cannot_be_asked()
-> Result<(), Box<dyn std::error::Error>> {
let mut diagnostics = Vec::new();
super::check_lys_schema(&mut diagnostics, None)?;
assert!(String::from_utf8(diagnostics)?.starts_with("lys_schema_unchecked"));
let directory = tempfile::tempdir()?;
let credential = directory.path().join("credential");
std::fs::write(&credential, "lys-app.haematite.never-shown\n")?;
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
let issuer = format!("http://{}/", listener.local_addr()?);
drop(listener);
let refused = super::check_lys_schema(&mut Vec::new(), Some((issuer, credential)))
.err()
.ok_or("an unreachable Lys passed the deploy check")?;
assert_eq!(refused.code, "bundle_lys_schema_refused");
assert!(
refused.message.starts_with("lys_could_not_be_asked"),
"{}",
refused.message
);
assert!(!refused.message.contains("never-shown"));
Ok(())
}
}
#[cfg(test)]
#[path = "cli_tests.rs"]
mod proof_tests;