use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use anyhow::{Context, Result, bail};
use serde_json::Value;
mod docker;
mod native;
pub use docker::Docker;
pub(crate) use docker::{START_DOCKER, docker_reachable, image_present};
pub use native::Native;
pub const SEQUENCER_FEATURES: &str = "standalone,prove";
pub const LEZ_REPO: &str = "https://github.com/logos-blockchain/logos-execution-zone.git";
const SEQUENCER_LOG: &str = "info,kameo=warn";
fn sequencer_env(dev_mode: bool) -> [(&'static str, &'static str); 2] {
[
("RUST_LOG", SEQUENCER_LOG),
(
crate::envfile::DEV_MODE_VAR,
crate::envfile::dev_mode_value(dev_mode),
),
]
}
pub enum WalletArg {
Text(String),
File(PathBuf),
}
impl From<&str> for WalletArg {
fn from(text: &str) -> Self {
Self::Text(text.to_owned())
}
}
impl From<String> for WalletArg {
fn from(text: String) -> Self {
Self::Text(text)
}
}
pub trait Runtime {
fn describe(&self) -> String;
fn prepare(&self, rebuild: bool) -> Result<()>;
fn upstream_config(&self) -> Result<Value>;
fn chain_home(&self) -> String;
fn wallet_sequencer_url(&self) -> String;
fn start(&self) -> Result<()>;
fn stop(&self) -> Result<()>;
fn running(&self) -> bool;
fn wipe_chain(&self) -> Result<()>;
fn wallet(&self, home: &Path, args: Vec<WalletArg>) -> Result<String>;
fn check_wallet_file(&self, _path: &Path) -> Result<()> {
Ok(())
}
fn wallet_batch(&self, home: &Path, runs: &[Vec<String>]) -> Result<Vec<String>>;
fn logs(&self, follow: bool) -> Result<()>;
}
pub(crate) fn stdout_of(cmd: &mut Command) -> Result<String> {
let output = run(cmd)?;
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
pub(crate) fn all_output_of(cmd: &mut Command) -> Result<String> {
run(cmd).map(|output| combined(&output))
}
fn run(cmd: &mut Command) -> Result<Output> {
let output = cmd
.stdin(Stdio::null())
.output()
.with_context(|| format!("could not run {:?}", cmd.get_program()))?;
if !output.status.success() {
bail!(
"{:?} failed ({}):\n{}",
cmd.get_program(),
output.status,
combined(&output).trim_end()
);
}
Ok(output)
}
pub(crate) fn run_building(cmd: &mut Command) -> Result<()> {
run_attached(cmd.stdout(std::io::stderr()))
}
pub(crate) fn run_attached(cmd: &mut Command) -> Result<()> {
let status = cmd
.status()
.with_context(|| format!("could not run {:?}", cmd.get_program()))?;
if !status.success() {
bail!("{:?} failed ({status})", cmd.get_program());
}
Ok(())
}
const RUN_MARK: &str = "@@logos-devnet-wallet-run@@";
const FAIL_MARK: &str = "@@logos-devnet-wallet-failed@@";
pub(crate) fn batch_script(wallet: &str, runs: &[Vec<String>]) -> String {
let mut script = String::new();
for run in runs {
let args: Vec<String> = run.iter().map(|a| sh_quote(a)).collect();
script += &format!(
"printf '\\n%s\\n' '{RUN_MARK}'; {} {} </dev/null 2>&1 || printf '\\n%s\\n' '{FAIL_MARK}'\n",
sh_quote(wallet),
args.join(" ")
);
}
script
}
pub(crate) const BATCH_CHUNK: usize = 200;
pub(crate) fn split_batch(output: &str, runs: usize) -> Result<Vec<String>> {
let mut outputs: Vec<String> = Vec::new();
for line in output.lines() {
if line == RUN_MARK {
outputs.push(String::new());
} else if let Some(current) = outputs.last_mut() {
current.push_str(line);
current.push('\n');
}
}
if let Some(i) = outputs
.iter()
.position(|o| o.lines().any(|l| l == FAIL_MARK))
{
bail!(
"wallet command {} of {runs} failed:\n{}",
i + 1,
redact_recovery_phrase(outputs[i].trim_end())
);
}
if outputs.len() != runs {
bail!("the wallet batch ran {} of {runs} commands", outputs.len());
}
Ok(outputs
.iter()
.map(|o| {
let o = o.trim_end_matches('\n');
redact_recovery_phrase(&if o.is_empty() {
String::new()
} else {
format!("{o}\n")
})
})
.collect())
}
pub(crate) fn redact_recovery_phrase(text: &str) -> String {
const MARK: &str = "Recovery phrase:";
let mut out = String::with_capacity(text.len());
let mut hiding = false;
for line in text.split_inclusive('\n') {
if hiding {
if line.trim().is_empty() {
hiding = false;
out.push_str(line);
}
continue;
}
if let Some(at) = line.find(MARK) {
out.push_str(&line[..at + MARK.len()]);
out.push_str(" <hidden>\n");
hiding = true;
continue;
}
out.push_str(line);
}
out
}
pub(crate) fn redacted(e: anyhow::Error) -> anyhow::Error {
anyhow::anyhow!(redact_recovery_phrase(&format!("{e:#}")))
}
pub(crate) fn sh_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', r"'\''"))
}
fn combined(output: &Output) -> String {
let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
text.push_str(&String::from_utf8_lossy(&output.stderr));
text
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_stdout_apart_from_stderr() {
let out = stdout_of(Command::new("sh").args(["-c", "echo out; echo warn >&2"])).unwrap();
assert_eq!(out, "out\n");
let all = all_output_of(Command::new("sh").args(["-c", "echo out; echo err >&2"])).unwrap();
assert_eq!(all, "out\nerr\n");
}
#[test]
fn a_failure_carries_both_streams() {
let err = stdout_of(Command::new("sh").args(["-c", "echo out; echo err >&2; exit 3"]))
.unwrap_err()
.to_string();
assert!(err.contains("out") && err.contains("err"), "{err}");
}
#[test]
fn hides_the_recovery_phrase() {
let out = "Persistent storage not found\nRecovery phrase:\n amused river fossil\n\nGenerated new account with account_id Public/X\n";
let hidden = redact_recovery_phrase(out);
assert!(!hidden.contains("amused"), "{hidden}");
assert!(hidden.contains("Generated new account with account_id Public/X"));
for out in [
"Recovery phrase: amused river fossil\n",
"Recovery phrase:\n amused river\n fossil words\n\nnext\n",
] {
let hidden = redact_recovery_phrase(out);
assert!(
!hidden.contains("amused") && !hidden.contains("fossil"),
"{hidden}"
);
}
assert_eq!(
redact_recovery_phrase("nothing secret\n"),
"nothing secret\n"
);
}
#[test]
fn runs_a_batch_and_splits_its_output() {
let runs = vec![
vec!["account".to_owned(), "new".to_owned(), "public".to_owned()],
vec!["it's".to_owned()],
];
let script = batch_script("echo", &runs);
let out = stdout_of(Command::new("sh").arg("-c").arg(&script)).unwrap();
let outputs = split_batch(&out, 2).unwrap();
assert_eq!(outputs, ["account new public\n", "it's\n"]);
}
#[test]
fn a_run_without_a_final_newline_doesnt_hide_the_next_marker() {
let runs = [
vec!["-c".to_owned(), "printf x; exit 1".to_owned()],
vec!["-c".to_owned(), "printf y".to_owned()],
];
let out = stdout_of(Command::new("sh").arg("-c").arg(batch_script("sh", &runs))).unwrap();
let err = split_batch(&out, 2).unwrap_err().to_string();
assert!(err.contains("command 1 of 2"), "{err}");
}
#[test]
fn a_full_chunk_fits_one_linux_argument() {
let run = vec!["account".to_owned(), "new".to_owned(), "public".to_owned()];
let script = batch_script("/a/long/path/to/the/wallet/binary", &vec![run; BATCH_CHUNK]);
assert!(script.len() < 128 * 1024 / 2, "{} bytes", script.len());
}
#[test]
fn a_failed_batch_run_is_named_and_redacted() {
let runs = [
vec!["-c".to_owned(), "true".to_owned()],
vec![
"-c".to_owned(),
"echo 'Recovery phrase:'; echo secret words; exit 3".to_owned(),
],
];
let script = batch_script("sh", &runs);
let out = stdout_of(Command::new("sh").arg("-c").arg(&script)).unwrap();
let err = split_batch(&out, runs.len()).unwrap_err().to_string();
assert!(err.contains("command 2 of 2"), "{err}");
assert!(!err.contains("secret words"), "{err}");
}
#[test]
fn the_image_and_native_builds_agree() {
let dockerfile = docker::DOCKERFILE;
assert!(dockerfile.contains(LEZ_REPO));
assert!(dockerfile.contains(&format!("FEATURES={SEQUENCER_FEATURES}")));
}
}