oops wrong one

This commit is contained in:
Pat Nakajima 2026-05-18 21:12:32 -07:00
parent 16f20dbc2e
commit e1e6c99f08
6 changed files with 780 additions and 780 deletions

2
Cargo.lock generated
View File

@ -2394,7 +2394,7 @@ checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "toes-matter"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"async-io",
"embassy-futures",

View File

@ -1,6 +1,6 @@
[package]
name = "toes-matter"
version = "0.1.4"
version = "0.1.5"
edition = "2021"
license = "MIT OR Apache-2.0"
readme = "README.md"
@ -11,7 +11,7 @@ name = "toes-matter"
required-features = ["device"]
[[bin]]
name = "toes-matter-provision"
name = "toes-matter-credentials"
[[example]]
name = "basic"

View File

@ -1,7 +1,7 @@
[project]
name = "toes-matter"
auto-tag = true # detect Cargo.toml version, create/push git tag v<version> before releasing to git
# binary = "toes-matter" # defaults to project name
binary = "toes-matter" # defaults to project name
# package = "toes-matter" # optional workspace package override; auto-detected from binary when unique
# binaries = ["toes-matter", "toes-matter-cli"] # optional extra release assets
repo = "nakajima/toes-matter"

View File

@ -0,0 +1,17 @@
#![recursion_limit = "256"]
fn main() -> toes_matter::Result<()> {
env_logger::init_from_env(
env_logger::Env::default().filter_or(env_logger::DEFAULT_FILTER_ENV, "info"),
);
let mut args = std::env::args_os().skip(1);
let dir = args.next().unwrap_or_else(|| "./creds".into());
if args.next().is_some() {
eprintln!("Usage: toes-matter-creds [CREDS_DIR]");
std::process::exit(2);
}
futures_lite::future::block_on(toes_matter::generate_credentials(dir))
}

View File

@ -1,766 +0,0 @@
#![recursion_limit = "256"]
use std::ffi::OsString;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use rand::RngCore;
use rs_matter::dm::clusters::dev_att::DeviceAttestation;
use rs_matter::dm::devices::test::{TEST_DEV_ATT, TEST_DEV_DET};
use rs_matter::pairing::qr::{no_optional_data, CommFlowType, QrPayload};
use rs_matter::pairing::DiscoveryCapabilities;
use rs_matter::sc::pase::{Spake2pVerifierPassword, Spake2pVerifierPasswordRef};
use rs_matter::BasicCommData;
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
const DEFAULT_REMOTE_CREDS_DIR: &str = "/var/lib/toes-matter/creds";
const DEFAULT_REMOTE_STATE_DIR: &str = "/var/lib/toes-matter/state";
const DEFAULT_SERVICE: &str = "toes-matter.service";
const DEFAULT_BAUD: u32 = 115_200;
const DEFAULT_SERIAL_DELAY_MS: u64 = 15;
const DEFAULT_SERIAL_LOGIN_TIMEOUT_SECS: u64 = 30;
fn main() -> Result<()> {
let opts = Options::parse()?;
let generated = generate_credentials(&opts.creds_dir)?;
println!();
println!("==> Pairing info for device {}", opts.device_id);
println!("Manual code: {}", generated.manual_code);
println!("QR payload : {}", generated.qr_code);
render_qr(&generated.qr_code);
if let Some(serial) = opts.serial.as_deref() {
write_to_serial(serial, &opts)?;
} else if let Some(target) = opts.target.as_deref() {
copy_to_device(target, &opts)?;
} else {
println!();
println!("==> No target/serial specified; generated local creds only");
}
println!();
println!("==> Done");
println!("Local copy saved at: {}", opts.creds_dir.display());
if opts.target.is_some() || opts.serial.is_some() {
println!("Remote app env should use:");
println!(" TOES_MATTER_CREDS_DIR={}", opts.remote_creds_dir);
println!(" TOES_MATTER_STATE_DIR={}", opts.remote_state_dir);
}
Ok(())
}
#[derive(Debug)]
struct Options {
target: Option<String>,
serial: Option<String>,
device_id: String,
creds_dir: PathBuf,
remote_creds_dir: String,
remote_state_dir: String,
service: Option<String>,
ssh_opts: Vec<String>,
baud: u32,
serial_delay: Duration,
serial_login: Option<String>,
serial_password: Option<String>,
serial_prompts: Vec<String>,
serial_login_timeout: Duration,
}
impl Options {
fn parse() -> Result<Self> {
let mut args = std::env::args().skip(1);
let mut target = None;
let mut serial = std::env::var("SERIAL_PORT").ok();
let mut device_id = None;
let mut creds_dir = std::env::var_os("CREDS_DIR").map(PathBuf::from);
let mut remote_creds_dir =
std::env::var("REMOTE_CREDS_DIR").unwrap_or_else(|_| DEFAULT_REMOTE_CREDS_DIR.into());
let mut remote_state_dir =
std::env::var("REMOTE_STATE_DIR").unwrap_or_else(|_| DEFAULT_REMOTE_STATE_DIR.into());
let mut service = match std::env::var("SERVICE") {
Ok(value) if value.is_empty() => None,
Ok(value) => Some(value),
Err(_) => Some(DEFAULT_SERVICE.into()),
};
let mut ssh_opts = split_ssh_opts(std::env::var("SSH_OPTS").unwrap_or_default());
let mut baud = std::env::var("SERIAL_BAUD")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(DEFAULT_BAUD);
let mut serial_delay_ms = std::env::var("SERIAL_DELAY_MS")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(DEFAULT_SERIAL_DELAY_MS);
let mut serial_login = std::env::var("SERIAL_LOGIN").ok();
let mut serial_password = std::env::var("SERIAL_PASSWORD").ok();
let mut serial_prompts = std::env::var("SERIAL_PROMPT")
.ok()
.map(|prompt| vec![prompt])
.unwrap_or_else(|| vec!["# ".into(), "$ ".into()]);
let mut serial_login_timeout_secs = std::env::var("SERIAL_LOGIN_TIMEOUT_SECS")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(DEFAULT_SERIAL_LOGIN_TIMEOUT_SECS);
while let Some(arg) = args.next() {
match arg.as_str() {
"-h" | "--help" => {
print_usage();
std::process::exit(0);
}
"--creds-dir" => {
creds_dir = Some(PathBuf::from(next_arg(&mut args, "--creds-dir")?))
}
"--remote-creds-dir" => {
remote_creds_dir = next_arg(&mut args, "--remote-creds-dir")?
}
"--remote-state-dir" => {
remote_state_dir = next_arg(&mut args, "--remote-state-dir")?
}
"--service" => service = Some(next_arg(&mut args, "--service")?),
"--no-restart" => service = None,
"--device-id" => device_id = Some(next_arg(&mut args, "--device-id")?),
"--ssh-opt" => ssh_opts.push(next_arg(&mut args, "--ssh-opt")?),
"--serial" => serial = Some(next_arg(&mut args, "--serial")?),
"--baud" => baud = next_arg(&mut args, "--baud")?.parse()?,
"--serial-delay-ms" => {
serial_delay_ms = next_arg(&mut args, "--serial-delay-ms")?.parse()?
}
"--login" => serial_login = Some(next_arg(&mut args, "--login")?),
"--password" => serial_password = Some(next_arg(&mut args, "--password")?),
"--prompt" => serial_prompts.push(next_arg(&mut args, "--prompt")?),
"--login-timeout-secs" => {
serial_login_timeout_secs =
next_arg(&mut args, "--login-timeout-secs")?.parse()?
}
_ if arg.starts_with('-') => return Err(format!("unknown option: {arg}").into()),
_ => {
if target.is_none() {
target = Some(arg);
} else if device_id.is_none() {
device_id = Some(arg);
} else {
return Err("too many positional arguments".into());
}
}
}
}
if serial.is_some() && target.is_some() && device_id.is_none() {
device_id = target.take();
}
if serial.is_some() && target.is_some() {
return Err("use either SSH target or --serial, not both".into());
}
let device_id = device_id.unwrap_or_else(default_device_id);
let creds_dir = creds_dir.unwrap_or_else(|| {
PathBuf::from("manufacturing")
.join(&device_id)
.join("creds")
});
Ok(Self {
target,
serial,
device_id,
creds_dir,
remote_creds_dir,
remote_state_dir,
service,
ssh_opts,
baud,
serial_delay: Duration::from_millis(serial_delay_ms),
serial_login,
serial_password,
serial_prompts,
serial_login_timeout: Duration::from_secs(serial_login_timeout_secs),
})
}
}
fn print_usage() {
eprintln!(
"Usage: toes-matter-provision [OPTIONS] [user@device-host] [device-id]\n\n\
Generates Matter setup data, prints the QR/manual code, and optionally copies it to a Linux device over SSH or serial console.\n\n\
Options:\n\
--creds-dir DIR Local output dir [env: CREDS_DIR]\n\
--remote-creds-dir DIR Remote creds dir [default: {DEFAULT_REMOTE_CREDS_DIR}]\n\
--remote-state-dir DIR Remote state dir [default: {DEFAULT_REMOTE_STATE_DIR}]\n\
--service NAME Service to restart [default: {DEFAULT_SERVICE}]\n\
--no-restart Do not restart a remote service\n\
--device-id ID Device id for local-only generation\n\
--ssh-opt ARG Extra ssh/scp option; may be repeated [env: SSH_OPTS]\n\
--serial PORT Write install script to a serial shell [env: SERIAL_PORT]\n\
--baud BAUD Configure serial baud [default: 115200, env: SERIAL_BAUD]\n\
--serial-delay-ms MS Delay between serial lines [default: 15, env: SERIAL_DELAY_MS]\n\
--login USER Log in on serial before provisioning [env: SERIAL_LOGIN]\n\
--password PASS Serial login password [env: SERIAL_PASSWORD]\n\
--prompt PROMPT Shell prompt marker; may repeat [env: SERIAL_PROMPT]\n\
--login-timeout-secs N Serial login timeout [default: 30, env: SERIAL_LOGIN_TIMEOUT_SECS]\n\
-h, --help Show this help"
);
}
fn next_arg(args: &mut impl Iterator<Item = String>, option: &str) -> Result<String> {
args.next()
.ok_or_else(|| format!("{option} requires an argument").into())
}
fn default_device_id() -> String {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0);
format!("device-{secs}")
}
fn split_ssh_opts(value: String) -> Vec<String> {
value
.split_whitespace()
.filter(|part| !part.is_empty())
.map(str::to_owned)
.collect()
}
struct GeneratedCredentials {
manual_code: String,
qr_code: String,
}
fn generate_credentials(path: &Path) -> Result<GeneratedCredentials> {
std::fs::create_dir_all(path)?;
std::fs::write(path.join("dac.der"), TEST_DEV_ATT.dac())?;
std::fs::write(path.join("pai.der"), TEST_DEV_ATT.pai())?;
std::fs::write(
path.join("certification-declaration.der"),
TEST_DEV_ATT.cert_declaration(),
)?;
std::fs::write(
path.join("dac-private-key.raw"),
TEST_DEV_ATT.dac_priv_key().access(),
)?;
let comm = read_comm_data(path)?.unwrap_or_else(random_comm_data);
let payload = QrPayload::new_from_basic_info(
DiscoveryCapabilities::BLE,
CommFlowType::Standard,
comm.clone(),
&TEST_DEV_DET,
no_optional_data,
);
let mut qr_buf = [0_u8; 512];
let (qr_text, _) = payload.as_str(&mut qr_buf)?;
let manual_code = comm.compute_pretty_pairing_code().to_string();
let qr_code = qr_text.to_owned();
let setup = format!(
"setup_passcode={}\n\
discriminator={}\n\
manual_code={}\n\
qr_code={}\n",
comm_passcode(&comm),
comm.discriminator,
manual_code,
qr_code,
);
std::fs::write(path.join("setup.txt"), setup)?;
Ok(GeneratedCredentials {
manual_code,
qr_code,
})
}
fn read_comm_data(path: &Path) -> Result<Option<BasicCommData>> {
let setup_path = path.join("setup.txt");
let setup = match std::fs::read_to_string(setup_path) {
Ok(setup) => setup,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err.into()),
};
let passcode = parse_setup_u32(&setup, "setup_passcode")?;
let discriminator = parse_setup_u32(&setup, "discriminator")?;
if discriminator > 0x0fff || !valid_setup_passcode(passcode) {
return Err("invalid setup.txt passcode/discriminator".into());
}
Ok(Some(make_comm_data(passcode, discriminator as u16)))
}
fn parse_setup_u32(setup: &str, key: &str) -> Result<u32> {
let value = setup
.lines()
.find_map(|line| line.strip_prefix(key)?.strip_prefix('='))
.ok_or_else(|| format!("setup.txt missing {key}"))?;
value
.parse()
.map_err(|err| format!("invalid {key}: {err}").into())
}
fn random_comm_data() -> BasicCommData {
let mut rng = rand::thread_rng();
let passcode = loop {
let passcode = (rng.next_u32() % 99_999_998) + 1;
if valid_setup_passcode(passcode) {
break passcode;
}
};
let discriminator = (rng.next_u32() & 0x0fff) as u16;
make_comm_data(passcode, discriminator)
}
fn make_comm_data(passcode: u32, discriminator: u16) -> BasicCommData {
BasicCommData {
password: Spake2pVerifierPassword::new_from_ref(Spake2pVerifierPasswordRef::new(
&passcode.to_le_bytes(),
)),
discriminator,
}
}
fn comm_passcode(comm: &BasicCommData) -> u32 {
u32::from_le_bytes(*comm.password.access())
}
fn valid_setup_passcode(passcode: u32) -> bool {
const INVALID: &[u32] = &[
0, 11111111, 22222222, 33333333, 44444444, 55555555, 66666666, 77777777, 88888888,
99999999, 12345678, 87654321,
];
(1..=99_999_998).contains(&passcode) && !INVALID.contains(&passcode)
}
fn render_qr(qr_code: &str) {
let status = Command::new("qrencode")
.arg("-t")
.arg("ANSIUTF8")
.arg(qr_code)
.status();
match status {
Ok(status) if status.success() => {}
Ok(_) | Err(_) => {
println!();
println!("Tip: install qrencode to render the QR in this terminal:");
println!(" macOS: brew install qrencode");
println!(" Debian/Ubuntu: sudo apt install qrencode");
}
}
}
fn write_to_serial(port: &str, opts: &Options) -> Result<()> {
configure_serial(port, opts.baud)?;
let script = build_remote_install_script(opts)?;
println!();
println!(
"==> Writing install script to serial port {port} at {} baud",
opts.baud
);
if opts.serial_login.is_some() {
println!(" Will log in on the serial console first.");
} else {
println!(" This assumes the serial console is already at a shell prompt.");
}
if cfg!(target_os = "macos") && port.starts_with("/dev/tty.") {
println!(
" Tip: on macOS, /dev/cu.* is usually better than /dev/tty.* for outbound serial."
);
}
let mut serial = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(port)?;
serial.write_all(b"\n")?;
serial.flush()?;
if opts.serial_login.is_some() {
login_to_serial(&mut serial, opts)?;
}
for line in script.lines() {
serial.write_all(line.as_bytes())?;
serial.write_all(b"\n")?;
serial.flush()?;
std::thread::sleep(opts.serial_delay);
}
println!("==> Serial install script sent");
Ok(())
}
fn configure_serial(port: &str, baud: u32) -> Result<()> {
let port_flag = if cfg!(any(
target_os = "macos",
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd"
)) {
"-f"
} else {
"-F"
};
let status = Command::new("stty")
.arg(port_flag)
.arg(port)
.arg(baud.to_string())
.arg("raw")
.arg("-echo")
.arg("-ixon")
.arg("-ixoff")
.arg("min")
.arg("0")
.arg("time")
.arg("5")
.status()?;
if status.success() {
Ok(())
} else {
Err(format!("stty failed for {port} with status {status}").into())
}
}
fn login_to_serial(serial: &mut std::fs::File, opts: &Options) -> Result<()> {
let login = opts
.serial_login
.as_deref()
.ok_or("serial login requested without a username")?;
println!("==> Waiting for serial login prompt or shell prompt...");
serial.write_all(b"\n")?;
serial.flush()?;
let mut initial_needles = vec![b"login:".to_vec(), b"Login:".to_vec()];
initial_needles.extend(
opts.serial_prompts
.iter()
.map(|prompt| prompt.as_bytes().to_vec()),
);
let initial = wait_for_any(serial, &initial_needles, opts.serial_login_timeout)?;
if initial >= 2 {
println!("==> Serial console already appears to be at a shell prompt");
return Ok(());
}
println!("==> Sending serial login username: {login}");
serial.write_all(login.as_bytes())?;
serial.write_all(b"\n")?;
serial.flush()?;
let mut after_user_needles = vec![b"Password:".to_vec(), b"password:".to_vec()];
after_user_needles.extend(
opts.serial_prompts
.iter()
.map(|prompt| prompt.as_bytes().to_vec()),
);
let after_user = wait_for_any(serial, &after_user_needles, opts.serial_login_timeout)?;
if after_user < 2 {
let password = opts
.serial_password
.as_deref()
.ok_or("serial password prompt detected; pass --password or set SERIAL_PASSWORD")?;
println!("==> Sending serial login password");
serial.write_all(password.as_bytes())?;
serial.write_all(b"\n")?;
serial.flush()?;
let prompt_needles = opts
.serial_prompts
.iter()
.map(|prompt| prompt.as_bytes().to_vec())
.collect::<Vec<_>>();
wait_for_any(serial, &prompt_needles, opts.serial_login_timeout)?;
}
println!("==> Serial login complete");
Ok(())
}
fn wait_for_any(
serial: &mut std::fs::File,
needles: &[Vec<u8>],
timeout: Duration,
) -> Result<usize> {
let start = std::time::Instant::now();
let mut seen = Vec::<u8>::new();
let mut buf = [0_u8; 256];
while start.elapsed() < timeout {
match serial.read(&mut buf) {
Ok(0) => {
std::thread::sleep(Duration::from_millis(25));
}
Ok(n) => {
std::io::stdout().write_all(&buf[..n])?;
std::io::stdout().flush()?;
seen.extend_from_slice(&buf[..n]);
if seen.len() > 8192 {
let keep_from = seen.len() - 4096;
seen.drain(..keep_from);
}
if let Some(index) = find_needle(&seen, needles) {
return Ok(index);
}
}
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {}
Err(err) => return Err(err.into()),
}
}
let tail = String::from_utf8_lossy(&seen);
Err(format!("timed out waiting for serial prompt; recent output: {tail:?}").into())
}
fn find_needle(haystack: &[u8], needles: &[Vec<u8>]) -> Option<usize> {
needles.iter().position(|needle| {
!needle.is_empty()
&& haystack
.windows(needle.len())
.any(|window| window == needle.as_slice())
})
}
fn build_remote_install_script(opts: &Options) -> Result<String> {
let mut script = String::new();
script.push_str("set -e\n");
script.push_str(&format!(
"REMOTE_CREDS_DIR={}\nREMOTE_STATE_DIR={}\n",
shell_quote(&opts.remote_creds_dir),
shell_quote(&opts.remote_state_dir),
));
script.push_str("sudo rm -rf \"$REMOTE_CREDS_DIR\"\n");
script.push_str("sudo mkdir -p \"$REMOTE_CREDS_DIR\" \"$REMOTE_STATE_DIR\"\n");
for file in [
"dac.der",
"pai.der",
"certification-declaration.der",
"dac-private-key.raw",
"setup.txt",
] {
let path = opts.creds_dir.join(file);
let data = std::fs::read(&path)?;
let marker = format!(
"TOES_MATTER_{}",
file.replace(['.', '-'], "_").to_uppercase()
);
script.push_str(&format!(
"base64 -d <<'{marker}' | sudo tee \"$REMOTE_CREDS_DIR/{file}\" >/dev/null\n"
));
script.push_str(&base64_wrapped(&data));
script.push_str(&format!("\n{marker}\n"));
}
script.push_str("sudo chmod -R go-rwx \"$REMOTE_CREDS_DIR\" \"$REMOTE_STATE_DIR\"\n");
if let Some(service) = opts.service.as_deref() {
script.push_str(&format!(
"if command -v systemctl >/dev/null 2>&1 && \\\n (systemctl list-unit-files {service} >/dev/null 2>&1 || systemctl status {service} >/dev/null 2>&1); then\n\
sudo systemctl restart {service}\n\
else\n\
echo 'Service {service_display} not found; skipping restart'\n\
fi\n",
service = shell_quote(service),
service_display = service,
));
}
script.push_str("echo 'toes-matter serial provisioning complete'\n");
Ok(script)
}
fn base64_wrapped(data: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
let mut line_len = 0_usize;
for chunk in data.chunks(3) {
let b0 = chunk[0];
let b1 = *chunk.get(1).unwrap_or(&0);
let b2 = *chunk.get(2).unwrap_or(&0);
let encoded = [
TABLE[(b0 >> 2) as usize] as char,
TABLE[(((b0 & 0b0000_0011) << 4) | (b1 >> 4)) as usize] as char,
if chunk.len() > 1 {
TABLE[(((b1 & 0b0000_1111) << 2) | (b2 >> 6)) as usize] as char
} else {
'='
},
if chunk.len() > 2 {
TABLE[(b2 & 0b0011_1111) as usize] as char
} else {
'='
},
];
for ch in encoded {
if line_len == 76 {
out.push('\n');
line_len = 0;
}
out.push(ch);
line_len += 1;
}
}
out
}
fn copy_to_device(target: &str, opts: &Options) -> Result<()> {
let remote_tmp = format!(
"/tmp/toes-matter-creds-{}-{}",
sanitize_path_component(&opts.device_id),
std::process::id(),
);
println!();
println!("==> Copying creds to {target}:{}", opts.remote_creds_dir);
run_status(
"ssh",
command_with_args(
"ssh",
opts.ssh_opts.iter().map(OsString::from).chain([
OsString::from(target),
OsString::from(format!(
"rm -rf {} && mkdir -p {}",
shell_quote(&remote_tmp),
shell_quote(&remote_tmp),
)),
]),
),
)?;
let mut scp_args = opts.ssh_opts.iter().map(OsString::from).collect::<Vec<_>>();
scp_args.push(OsString::from("-r"));
scp_args.push(opts.creds_dir.join(".").into_os_string());
scp_args.push(OsString::from(format!("{target}:{remote_tmp}/")));
run_status("scp", command_with_args("scp", scp_args))?;
let parent = remote_parent(&opts.remote_creds_dir);
let mut remote_script = format!(
"set -e\n\
sudo rm -rf {remote_creds}\n\
sudo mkdir -p {parent} {remote_state}\n\
sudo mv {remote_tmp} {remote_creds}\n\
sudo chmod -R go-rwx {remote_creds} {remote_state}\n",
remote_creds = shell_quote(&opts.remote_creds_dir),
parent = shell_quote(&parent),
remote_state = shell_quote(&opts.remote_state_dir),
remote_tmp = shell_quote(&remote_tmp),
);
if let Some(service) = opts.service.as_deref() {
remote_script.push_str(&format!(
"if systemctl list-unit-files {service} >/dev/null 2>&1 || systemctl status {service} >/dev/null 2>&1; then\n\
sudo systemctl restart {service}\n\
else\n\
echo 'Service {service_display} not found; skipping restart'\n\
fi\n",
service = shell_quote(service),
service_display = service,
));
}
run_status(
"ssh",
command_with_args(
"ssh",
opts.ssh_opts
.iter()
.map(OsString::from)
.chain([OsString::from(target), OsString::from(remote_script)]),
),
)?;
Ok(())
}
fn command_with_args(program: &str, args: impl IntoIterator<Item = OsString>) -> Command {
let mut command = Command::new(program);
command.args(args);
command
}
fn run_status(label: &str, mut command: Command) -> Result<()> {
let status = command.status()?;
if status.success() {
Ok(())
} else {
Err(format!("{label} failed with status {status}").into())
}
}
fn sanitize_path_component(value: &str) -> String {
value
.chars()
.map(|ch| match ch {
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => ch,
_ => '_',
})
.collect()
}
fn remote_parent(path: &str) -> String {
path.rsplit_once('/')
.map(|(parent, _)| if parent.is_empty() { "/" } else { parent })
.unwrap_or(".")
.to_owned()
}
fn shell_quote(value: &str) -> String {
let mut quoted = String::with_capacity(value.len() + 2);
quoted.push('\'');
for ch in value.chars() {
if ch == '\'' {
quoted.push_str("'\\''");
} else {
quoted.push(ch);
}
}
quoted.push('\'');
quoted
}

View File

@ -1,17 +1,766 @@
#![recursion_limit = "256"]
fn main() -> toes_matter::Result<()> {
env_logger::init_from_env(
env_logger::Env::default().filter_or(env_logger::DEFAULT_FILTER_ENV, "info"),
);
use std::ffi::OsString;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
let mut args = std::env::args_os().skip(1);
let dir = args.next().unwrap_or_else(|| "./creds".into());
use rand::RngCore;
if args.next().is_some() {
eprintln!("Usage: toes-matter-creds [CREDS_DIR]");
std::process::exit(2);
use rs_matter::dm::clusters::dev_att::DeviceAttestation;
use rs_matter::dm::devices::test::{TEST_DEV_ATT, TEST_DEV_DET};
use rs_matter::pairing::qr::{no_optional_data, CommFlowType, QrPayload};
use rs_matter::pairing::DiscoveryCapabilities;
use rs_matter::sc::pase::{Spake2pVerifierPassword, Spake2pVerifierPasswordRef};
use rs_matter::BasicCommData;
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
const DEFAULT_REMOTE_CREDS_DIR: &str = "/var/lib/toes-matter/creds";
const DEFAULT_REMOTE_STATE_DIR: &str = "/var/lib/toes-matter/state";
const DEFAULT_SERVICE: &str = "toes-matter.service";
const DEFAULT_BAUD: u32 = 115_200;
const DEFAULT_SERIAL_DELAY_MS: u64 = 15;
const DEFAULT_SERIAL_LOGIN_TIMEOUT_SECS: u64 = 30;
fn main() -> Result<()> {
let opts = Options::parse()?;
let generated = generate_credentials(&opts.creds_dir)?;
println!();
println!("==> Pairing info for device {}", opts.device_id);
println!("Manual code: {}", generated.manual_code);
println!("QR payload : {}", generated.qr_code);
render_qr(&generated.qr_code);
if let Some(serial) = opts.serial.as_deref() {
write_to_serial(serial, &opts)?;
} else if let Some(target) = opts.target.as_deref() {
copy_to_device(target, &opts)?;
} else {
println!();
println!("==> No target/serial specified; generated local creds only");
}
futures_lite::future::block_on(toes_matter::generate_credentials(dir))
println!();
println!("==> Done");
println!("Local copy saved at: {}", opts.creds_dir.display());
if opts.target.is_some() || opts.serial.is_some() {
println!("Remote app env should use:");
println!(" TOES_MATTER_CREDS_DIR={}", opts.remote_creds_dir);
println!(" TOES_MATTER_STATE_DIR={}", opts.remote_state_dir);
}
Ok(())
}
#[derive(Debug)]
struct Options {
target: Option<String>,
serial: Option<String>,
device_id: String,
creds_dir: PathBuf,
remote_creds_dir: String,
remote_state_dir: String,
service: Option<String>,
ssh_opts: Vec<String>,
baud: u32,
serial_delay: Duration,
serial_login: Option<String>,
serial_password: Option<String>,
serial_prompts: Vec<String>,
serial_login_timeout: Duration,
}
impl Options {
fn parse() -> Result<Self> {
let mut args = std::env::args().skip(1);
let mut target = None;
let mut serial = std::env::var("SERIAL_PORT").ok();
let mut device_id = None;
let mut creds_dir = std::env::var_os("CREDS_DIR").map(PathBuf::from);
let mut remote_creds_dir =
std::env::var("REMOTE_CREDS_DIR").unwrap_or_else(|_| DEFAULT_REMOTE_CREDS_DIR.into());
let mut remote_state_dir =
std::env::var("REMOTE_STATE_DIR").unwrap_or_else(|_| DEFAULT_REMOTE_STATE_DIR.into());
let mut service = match std::env::var("SERVICE") {
Ok(value) if value.is_empty() => None,
Ok(value) => Some(value),
Err(_) => Some(DEFAULT_SERVICE.into()),
};
let mut ssh_opts = split_ssh_opts(std::env::var("SSH_OPTS").unwrap_or_default());
let mut baud = std::env::var("SERIAL_BAUD")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(DEFAULT_BAUD);
let mut serial_delay_ms = std::env::var("SERIAL_DELAY_MS")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(DEFAULT_SERIAL_DELAY_MS);
let mut serial_login = std::env::var("SERIAL_LOGIN").ok();
let mut serial_password = std::env::var("SERIAL_PASSWORD").ok();
let mut serial_prompts = std::env::var("SERIAL_PROMPT")
.ok()
.map(|prompt| vec![prompt])
.unwrap_or_else(|| vec!["# ".into(), "$ ".into()]);
let mut serial_login_timeout_secs = std::env::var("SERIAL_LOGIN_TIMEOUT_SECS")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(DEFAULT_SERIAL_LOGIN_TIMEOUT_SECS);
while let Some(arg) = args.next() {
match arg.as_str() {
"-h" | "--help" => {
print_usage();
std::process::exit(0);
}
"--creds-dir" => {
creds_dir = Some(PathBuf::from(next_arg(&mut args, "--creds-dir")?))
}
"--remote-creds-dir" => {
remote_creds_dir = next_arg(&mut args, "--remote-creds-dir")?
}
"--remote-state-dir" => {
remote_state_dir = next_arg(&mut args, "--remote-state-dir")?
}
"--service" => service = Some(next_arg(&mut args, "--service")?),
"--no-restart" => service = None,
"--device-id" => device_id = Some(next_arg(&mut args, "--device-id")?),
"--ssh-opt" => ssh_opts.push(next_arg(&mut args, "--ssh-opt")?),
"--serial" => serial = Some(next_arg(&mut args, "--serial")?),
"--baud" => baud = next_arg(&mut args, "--baud")?.parse()?,
"--serial-delay-ms" => {
serial_delay_ms = next_arg(&mut args, "--serial-delay-ms")?.parse()?
}
"--login" => serial_login = Some(next_arg(&mut args, "--login")?),
"--password" => serial_password = Some(next_arg(&mut args, "--password")?),
"--prompt" => serial_prompts.push(next_arg(&mut args, "--prompt")?),
"--login-timeout-secs" => {
serial_login_timeout_secs =
next_arg(&mut args, "--login-timeout-secs")?.parse()?
}
_ if arg.starts_with('-') => return Err(format!("unknown option: {arg}").into()),
_ => {
if target.is_none() {
target = Some(arg);
} else if device_id.is_none() {
device_id = Some(arg);
} else {
return Err("too many positional arguments".into());
}
}
}
}
if serial.is_some() && target.is_some() && device_id.is_none() {
device_id = target.take();
}
if serial.is_some() && target.is_some() {
return Err("use either SSH target or --serial, not both".into());
}
let device_id = device_id.unwrap_or_else(default_device_id);
let creds_dir = creds_dir.unwrap_or_else(|| {
PathBuf::from("manufacturing")
.join(&device_id)
.join("creds")
});
Ok(Self {
target,
serial,
device_id,
creds_dir,
remote_creds_dir,
remote_state_dir,
service,
ssh_opts,
baud,
serial_delay: Duration::from_millis(serial_delay_ms),
serial_login,
serial_password,
serial_prompts,
serial_login_timeout: Duration::from_secs(serial_login_timeout_secs),
})
}
}
fn print_usage() {
eprintln!(
"Usage: toes-matter-provision [OPTIONS] [user@device-host] [device-id]\n\n\
Generates Matter setup data, prints the QR/manual code, and optionally copies it to a Linux device over SSH or serial console.\n\n\
Options:\n\
--creds-dir DIR Local output dir [env: CREDS_DIR]\n\
--remote-creds-dir DIR Remote creds dir [default: {DEFAULT_REMOTE_CREDS_DIR}]\n\
--remote-state-dir DIR Remote state dir [default: {DEFAULT_REMOTE_STATE_DIR}]\n\
--service NAME Service to restart [default: {DEFAULT_SERVICE}]\n\
--no-restart Do not restart a remote service\n\
--device-id ID Device id for local-only generation\n\
--ssh-opt ARG Extra ssh/scp option; may be repeated [env: SSH_OPTS]\n\
--serial PORT Write install script to a serial shell [env: SERIAL_PORT]\n\
--baud BAUD Configure serial baud [default: 115200, env: SERIAL_BAUD]\n\
--serial-delay-ms MS Delay between serial lines [default: 15, env: SERIAL_DELAY_MS]\n\
--login USER Log in on serial before provisioning [env: SERIAL_LOGIN]\n\
--password PASS Serial login password [env: SERIAL_PASSWORD]\n\
--prompt PROMPT Shell prompt marker; may repeat [env: SERIAL_PROMPT]\n\
--login-timeout-secs N Serial login timeout [default: 30, env: SERIAL_LOGIN_TIMEOUT_SECS]\n\
-h, --help Show this help"
);
}
fn next_arg(args: &mut impl Iterator<Item = String>, option: &str) -> Result<String> {
args.next()
.ok_or_else(|| format!("{option} requires an argument").into())
}
fn default_device_id() -> String {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0);
format!("device-{secs}")
}
fn split_ssh_opts(value: String) -> Vec<String> {
value
.split_whitespace()
.filter(|part| !part.is_empty())
.map(str::to_owned)
.collect()
}
struct GeneratedCredentials {
manual_code: String,
qr_code: String,
}
fn generate_credentials(path: &Path) -> Result<GeneratedCredentials> {
std::fs::create_dir_all(path)?;
std::fs::write(path.join("dac.der"), TEST_DEV_ATT.dac())?;
std::fs::write(path.join("pai.der"), TEST_DEV_ATT.pai())?;
std::fs::write(
path.join("certification-declaration.der"),
TEST_DEV_ATT.cert_declaration(),
)?;
std::fs::write(
path.join("dac-private-key.raw"),
TEST_DEV_ATT.dac_priv_key().access(),
)?;
let comm = read_comm_data(path)?.unwrap_or_else(random_comm_data);
let payload = QrPayload::new_from_basic_info(
DiscoveryCapabilities::BLE,
CommFlowType::Standard,
comm.clone(),
&TEST_DEV_DET,
no_optional_data,
);
let mut qr_buf = [0_u8; 512];
let (qr_text, _) = payload.as_str(&mut qr_buf)?;
let manual_code = comm.compute_pretty_pairing_code().to_string();
let qr_code = qr_text.to_owned();
let setup = format!(
"setup_passcode={}\n\
discriminator={}\n\
manual_code={}\n\
qr_code={}\n",
comm_passcode(&comm),
comm.discriminator,
manual_code,
qr_code,
);
std::fs::write(path.join("setup.txt"), setup)?;
Ok(GeneratedCredentials {
manual_code,
qr_code,
})
}
fn read_comm_data(path: &Path) -> Result<Option<BasicCommData>> {
let setup_path = path.join("setup.txt");
let setup = match std::fs::read_to_string(setup_path) {
Ok(setup) => setup,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err.into()),
};
let passcode = parse_setup_u32(&setup, "setup_passcode")?;
let discriminator = parse_setup_u32(&setup, "discriminator")?;
if discriminator > 0x0fff || !valid_setup_passcode(passcode) {
return Err("invalid setup.txt passcode/discriminator".into());
}
Ok(Some(make_comm_data(passcode, discriminator as u16)))
}
fn parse_setup_u32(setup: &str, key: &str) -> Result<u32> {
let value = setup
.lines()
.find_map(|line| line.strip_prefix(key)?.strip_prefix('='))
.ok_or_else(|| format!("setup.txt missing {key}"))?;
value
.parse()
.map_err(|err| format!("invalid {key}: {err}").into())
}
fn random_comm_data() -> BasicCommData {
let mut rng = rand::thread_rng();
let passcode = loop {
let passcode = (rng.next_u32() % 99_999_998) + 1;
if valid_setup_passcode(passcode) {
break passcode;
}
};
let discriminator = (rng.next_u32() & 0x0fff) as u16;
make_comm_data(passcode, discriminator)
}
fn make_comm_data(passcode: u32, discriminator: u16) -> BasicCommData {
BasicCommData {
password: Spake2pVerifierPassword::new_from_ref(Spake2pVerifierPasswordRef::new(
&passcode.to_le_bytes(),
)),
discriminator,
}
}
fn comm_passcode(comm: &BasicCommData) -> u32 {
u32::from_le_bytes(*comm.password.access())
}
fn valid_setup_passcode(passcode: u32) -> bool {
const INVALID: &[u32] = &[
0, 11111111, 22222222, 33333333, 44444444, 55555555, 66666666, 77777777, 88888888,
99999999, 12345678, 87654321,
];
(1..=99_999_998).contains(&passcode) && !INVALID.contains(&passcode)
}
fn render_qr(qr_code: &str) {
let status = Command::new("qrencode")
.arg("-t")
.arg("ANSIUTF8")
.arg(qr_code)
.status();
match status {
Ok(status) if status.success() => {}
Ok(_) | Err(_) => {
println!();
println!("Tip: install qrencode to render the QR in this terminal:");
println!(" macOS: brew install qrencode");
println!(" Debian/Ubuntu: sudo apt install qrencode");
}
}
}
fn write_to_serial(port: &str, opts: &Options) -> Result<()> {
configure_serial(port, opts.baud)?;
let script = build_remote_install_script(opts)?;
println!();
println!(
"==> Writing install script to serial port {port} at {} baud",
opts.baud
);
if opts.serial_login.is_some() {
println!(" Will log in on the serial console first.");
} else {
println!(" This assumes the serial console is already at a shell prompt.");
}
if cfg!(target_os = "macos") && port.starts_with("/dev/tty.") {
println!(
" Tip: on macOS, /dev/cu.* is usually better than /dev/tty.* for outbound serial."
);
}
let mut serial = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(port)?;
serial.write_all(b"\n")?;
serial.flush()?;
if opts.serial_login.is_some() {
login_to_serial(&mut serial, opts)?;
}
for line in script.lines() {
serial.write_all(line.as_bytes())?;
serial.write_all(b"\n")?;
serial.flush()?;
std::thread::sleep(opts.serial_delay);
}
println!("==> Serial install script sent");
Ok(())
}
fn configure_serial(port: &str, baud: u32) -> Result<()> {
let port_flag = if cfg!(any(
target_os = "macos",
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd"
)) {
"-f"
} else {
"-F"
};
let status = Command::new("stty")
.arg(port_flag)
.arg(port)
.arg(baud.to_string())
.arg("raw")
.arg("-echo")
.arg("-ixon")
.arg("-ixoff")
.arg("min")
.arg("0")
.arg("time")
.arg("5")
.status()?;
if status.success() {
Ok(())
} else {
Err(format!("stty failed for {port} with status {status}").into())
}
}
fn login_to_serial(serial: &mut std::fs::File, opts: &Options) -> Result<()> {
let login = opts
.serial_login
.as_deref()
.ok_or("serial login requested without a username")?;
println!("==> Waiting for serial login prompt or shell prompt...");
serial.write_all(b"\n")?;
serial.flush()?;
let mut initial_needles = vec![b"login:".to_vec(), b"Login:".to_vec()];
initial_needles.extend(
opts.serial_prompts
.iter()
.map(|prompt| prompt.as_bytes().to_vec()),
);
let initial = wait_for_any(serial, &initial_needles, opts.serial_login_timeout)?;
if initial >= 2 {
println!("==> Serial console already appears to be at a shell prompt");
return Ok(());
}
println!("==> Sending serial login username: {login}");
serial.write_all(login.as_bytes())?;
serial.write_all(b"\n")?;
serial.flush()?;
let mut after_user_needles = vec![b"Password:".to_vec(), b"password:".to_vec()];
after_user_needles.extend(
opts.serial_prompts
.iter()
.map(|prompt| prompt.as_bytes().to_vec()),
);
let after_user = wait_for_any(serial, &after_user_needles, opts.serial_login_timeout)?;
if after_user < 2 {
let password = opts
.serial_password
.as_deref()
.ok_or("serial password prompt detected; pass --password or set SERIAL_PASSWORD")?;
println!("==> Sending serial login password");
serial.write_all(password.as_bytes())?;
serial.write_all(b"\n")?;
serial.flush()?;
let prompt_needles = opts
.serial_prompts
.iter()
.map(|prompt| prompt.as_bytes().to_vec())
.collect::<Vec<_>>();
wait_for_any(serial, &prompt_needles, opts.serial_login_timeout)?;
}
println!("==> Serial login complete");
Ok(())
}
fn wait_for_any(
serial: &mut std::fs::File,
needles: &[Vec<u8>],
timeout: Duration,
) -> Result<usize> {
let start = std::time::Instant::now();
let mut seen = Vec::<u8>::new();
let mut buf = [0_u8; 256];
while start.elapsed() < timeout {
match serial.read(&mut buf) {
Ok(0) => {
std::thread::sleep(Duration::from_millis(25));
}
Ok(n) => {
std::io::stdout().write_all(&buf[..n])?;
std::io::stdout().flush()?;
seen.extend_from_slice(&buf[..n]);
if seen.len() > 8192 {
let keep_from = seen.len() - 4096;
seen.drain(..keep_from);
}
if let Some(index) = find_needle(&seen, needles) {
return Ok(index);
}
}
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {}
Err(err) => return Err(err.into()),
}
}
let tail = String::from_utf8_lossy(&seen);
Err(format!("timed out waiting for serial prompt; recent output: {tail:?}").into())
}
fn find_needle(haystack: &[u8], needles: &[Vec<u8>]) -> Option<usize> {
needles.iter().position(|needle| {
!needle.is_empty()
&& haystack
.windows(needle.len())
.any(|window| window == needle.as_slice())
})
}
fn build_remote_install_script(opts: &Options) -> Result<String> {
let mut script = String::new();
script.push_str("set -e\n");
script.push_str(&format!(
"REMOTE_CREDS_DIR={}\nREMOTE_STATE_DIR={}\n",
shell_quote(&opts.remote_creds_dir),
shell_quote(&opts.remote_state_dir),
));
script.push_str("sudo rm -rf \"$REMOTE_CREDS_DIR\"\n");
script.push_str("sudo mkdir -p \"$REMOTE_CREDS_DIR\" \"$REMOTE_STATE_DIR\"\n");
for file in [
"dac.der",
"pai.der",
"certification-declaration.der",
"dac-private-key.raw",
"setup.txt",
] {
let path = opts.creds_dir.join(file);
let data = std::fs::read(&path)?;
let marker = format!(
"TOES_MATTER_{}",
file.replace(['.', '-'], "_").to_uppercase()
);
script.push_str(&format!(
"base64 -d <<'{marker}' | sudo tee \"$REMOTE_CREDS_DIR/{file}\" >/dev/null\n"
));
script.push_str(&base64_wrapped(&data));
script.push_str(&format!("\n{marker}\n"));
}
script.push_str("sudo chmod -R go-rwx \"$REMOTE_CREDS_DIR\" \"$REMOTE_STATE_DIR\"\n");
if let Some(service) = opts.service.as_deref() {
script.push_str(&format!(
"if command -v systemctl >/dev/null 2>&1 && \\\n (systemctl list-unit-files {service} >/dev/null 2>&1 || systemctl status {service} >/dev/null 2>&1); then\n\
sudo systemctl restart {service}\n\
else\n\
echo 'Service {service_display} not found; skipping restart'\n\
fi\n",
service = shell_quote(service),
service_display = service,
));
}
script.push_str("echo 'toes-matter serial provisioning complete'\n");
Ok(script)
}
fn base64_wrapped(data: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
let mut line_len = 0_usize;
for chunk in data.chunks(3) {
let b0 = chunk[0];
let b1 = *chunk.get(1).unwrap_or(&0);
let b2 = *chunk.get(2).unwrap_or(&0);
let encoded = [
TABLE[(b0 >> 2) as usize] as char,
TABLE[(((b0 & 0b0000_0011) << 4) | (b1 >> 4)) as usize] as char,
if chunk.len() > 1 {
TABLE[(((b1 & 0b0000_1111) << 2) | (b2 >> 6)) as usize] as char
} else {
'='
},
if chunk.len() > 2 {
TABLE[(b2 & 0b0011_1111) as usize] as char
} else {
'='
},
];
for ch in encoded {
if line_len == 76 {
out.push('\n');
line_len = 0;
}
out.push(ch);
line_len += 1;
}
}
out
}
fn copy_to_device(target: &str, opts: &Options) -> Result<()> {
let remote_tmp = format!(
"/tmp/toes-matter-creds-{}-{}",
sanitize_path_component(&opts.device_id),
std::process::id(),
);
println!();
println!("==> Copying creds to {target}:{}", opts.remote_creds_dir);
run_status(
"ssh",
command_with_args(
"ssh",
opts.ssh_opts.iter().map(OsString::from).chain([
OsString::from(target),
OsString::from(format!(
"rm -rf {} && mkdir -p {}",
shell_quote(&remote_tmp),
shell_quote(&remote_tmp),
)),
]),
),
)?;
let mut scp_args = opts.ssh_opts.iter().map(OsString::from).collect::<Vec<_>>();
scp_args.push(OsString::from("-r"));
scp_args.push(opts.creds_dir.join(".").into_os_string());
scp_args.push(OsString::from(format!("{target}:{remote_tmp}/")));
run_status("scp", command_with_args("scp", scp_args))?;
let parent = remote_parent(&opts.remote_creds_dir);
let mut remote_script = format!(
"set -e\n\
sudo rm -rf {remote_creds}\n\
sudo mkdir -p {parent} {remote_state}\n\
sudo mv {remote_tmp} {remote_creds}\n\
sudo chmod -R go-rwx {remote_creds} {remote_state}\n",
remote_creds = shell_quote(&opts.remote_creds_dir),
parent = shell_quote(&parent),
remote_state = shell_quote(&opts.remote_state_dir),
remote_tmp = shell_quote(&remote_tmp),
);
if let Some(service) = opts.service.as_deref() {
remote_script.push_str(&format!(
"if systemctl list-unit-files {service} >/dev/null 2>&1 || systemctl status {service} >/dev/null 2>&1; then\n\
sudo systemctl restart {service}\n\
else\n\
echo 'Service {service_display} not found; skipping restart'\n\
fi\n",
service = shell_quote(service),
service_display = service,
));
}
run_status(
"ssh",
command_with_args(
"ssh",
opts.ssh_opts
.iter()
.map(OsString::from)
.chain([OsString::from(target), OsString::from(remote_script)]),
),
)?;
Ok(())
}
fn command_with_args(program: &str, args: impl IntoIterator<Item = OsString>) -> Command {
let mut command = Command::new(program);
command.args(args);
command
}
fn run_status(label: &str, mut command: Command) -> Result<()> {
let status = command.status()?;
if status.success() {
Ok(())
} else {
Err(format!("{label} failed with status {status}").into())
}
}
fn sanitize_path_component(value: &str) -> String {
value
.chars()
.map(|ch| match ch {
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => ch,
_ => '_',
})
.collect()
}
fn remote_parent(path: &str) -> String {
path.rsplit_once('/')
.map(|(parent, _)| if parent.is_empty() { "/" } else { parent })
.unwrap_or(".")
.to_owned()
}
fn shell_quote(value: &str) -> String {
let mut quoted = String::with_capacity(value.len() + 2);
quoted.push('\'');
for ch in value.chars() {
if ch == '\'' {
quoted.push_str("'\\''");
} else {
quoted.push(ch);
}
}
quoted.push('\'');
quoted
}