Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
ed6d619
Use ssh2 instead of std::process::Command
Feb 12, 2025
c5811d8
Append ssh port if not already there
Feb 12, 2025
61d992c
Add missing G_SLICE env variable
Feb 12, 2025
a566f08
Use process::Command to construct shell command
Feb 12, 2025
ee2828c
Set G_SLICE in acap-ssh-utils instead
Feb 13, 2025
327dc82
Handle the exit_status of remote program
Feb 13, 2025
7bc38a0
Channels can't be reused so create it only when needed
Feb 13, 2025
c236986
Use bail! macro
Feb 13, 2025
38a275d
Return Vec<u8> from exec_capture_output
Feb 13, 2025
537e846
Make session blocking
Feb 13, 2025
1cbac37
Change println! to debug!
Feb 13, 2025
1d8fd32
Handle root/acap user differently
Feb 14, 2025
ede5781
Change unwrap to expect for clarity
Feb 14, 2025
193268f
Remove unnecessary assignment
Feb 14, 2025
6cb7f19
Always append port 22 to host
Feb 14, 2025
f832a11
Handle directories with sftp
Feb 17, 2025
5580324
Handle arguments to remote programs when using 'su'
Feb 17, 2025
6df6689
Add contexts to fallible sftp calls
Feb 17, 2025
67ad2e5
Fix lint
Feb 17, 2025
ac11ef6
Merge remote-tracking branch 'upstream/main' into rust-ssh
Mar 27, 2025
d1f8363
Update docstrings
Mar 27, 2025
1f2dd63
Remove sshpass from system requirements
Mar 27, 2025
5e8ff7a
Use is_ok_and instead of expect
Mar 27, 2025
feecdbb
Merge remote-tracking branch 'upstream/main' into rust-ssh
Apr 16, 2025
ecd277e
Change `as_root` to `as_package_user`
Apr 16, 2025
29aa98b
Handle symlinks
Apr 16, 2025
3020e38
Change CWD when running ACAP
Apr 16, 2025
7afa257
Use `with_context` instead of `context`
Apr 16, 2025
1757a27
Avoid unnecessary `unwrap`s
Apr 16, 2025
020cfb9
Pass arguments correctly + test
Apr 16, 2025
94ee183
Reformat
Apr 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 89 additions & 31 deletions crates/acap-ssh-utils/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ use std::{
path::{Path, PathBuf},
};

use anyhow::bail;
use anyhow::{bail, Context};
use flate2::read::GzDecoder;
use tar::Archive;

use ssh2::{Channel, FileStat, Session};
use ssh2::{FileStat, Session};

use crate::acap::Manifest;

Expand All @@ -21,14 +21,14 @@ struct RemoteCommand {

impl RemoteCommand {
pub fn new(
user: Option<&str>,
user: Option<impl AsRef<str>>,
env: Option<&[(impl AsRef<str>, impl AsRef<str>)]>,
executable: &str,
args: Option<&[&str]>,
) -> Self {
let mut cmd = if let Some(user) = user {
let mut cmd = std::process::Command::new("su");
cmd.arg(user);
cmd.arg(user.as_ref());
cmd
} else {
std::process::Command::new("sh")
Expand All @@ -39,9 +39,11 @@ impl RemoteCommand {
if let Some(env) = env {
cmd.envs(env.iter().map(|(k, v)| (k.as_ref(), v.as_ref())));
}
cmd.env("G_SLICE", "always-malloc");

cmd.arg(executable);
if let Some(args) = args {
cmd.arg("--"); // They should be passed to the process, not to 'su'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you tested this?

I don't have a camera at hand, but su --help on Debian does not mention that -- can be used and experimenting seems to confirm this:

$ su --help 
Usage:
 su [options] [-] [<user> [<argument>...]]
...

$ env --help
env --help
Usage: env [OPTION]... [-] [NAME=VALUE]... [COMMAND [ARG]...]
...

$ su root -c env -- --help
SHELL=/bin/bash
...

I understand the camera might differ, so this is just to double check.

I will make sure to get a camera set up in the next week so that I can check things like this myself and not have to bother you.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. When I was running the tests it was complaining since we pass --test-threads=1 to the test. Adding the '--' suppressed this but that's not the correct way to do it. We need to pass the executable with its arguments as a whole, i.e. as a string:

su root -c "env --help"

I've fixed that now and added a test in inspect_env to make sure this stays working.

cmd.args(args);
}

Expand All @@ -50,7 +52,10 @@ impl RemoteCommand {
}
}

pub fn exec(&self, channel: &mut Channel) -> Result<(), anyhow::Error> {
pub fn exec(&self, session: &Session) -> Result<(), anyhow::Error> {
let mut channel = session.channel_session()?;
channel.handle_extended_data(ssh2::ExtendedData::Merge)?;

channel.exec(&self.cmd)?;
let mut stdout = channel.stream(0);
let mut buf = [0; 4096];
Expand All @@ -63,15 +68,35 @@ impl RemoteCommand {
stdout.flush()?;
}

channel.wait_eof()?;
channel.wait_close()?;
let code = channel.exit_status()?;

if code != 0 {
bail!("{} exited with status {}", self.cmd, code);
}

Ok(())
}

pub fn exec_capture_stdout(&self, channel: &mut Channel) -> Result<String, anyhow::Error> {
pub fn exec_capture_stdout(&self, session: &Session) -> Result<Vec<u8>, anyhow::Error> {
let mut channel = session.channel_session()?;
channel.handle_extended_data(ssh2::ExtendedData::Merge)?;

channel.exec(&self.cmd)?;
let mut stdout = channel.stream(0);
let mut buf = [0; 4096];
let n = stdout.read(&mut buf)?;
Ok(std::str::from_utf8(&buf[..n])?.to_string())
let mut output = Vec::new();
stdout.read_to_end(&mut output)?;

channel.wait_eof()?;
channel.wait_close()?;
let code = channel.exit_status()?;

if code != 0 {
bail!("{} exited with status {}", self.cmd, code);
}

Ok(output)
}
}

Expand All @@ -92,16 +117,32 @@ pub fn run_other<S: AsRef<str>>(
env: &[(S, S)],
args: &[&str],
) -> anyhow::Result<()> {
let sftp = session.sftp()?;
let mut channel = session.channel_session()?;
let tmp = RemoteCommand::new(None::<&str>, None::<&[(&str, &str)]>, "mktemp -u", None)
.exec_capture_stdout(session)?;

let tmp = RemoteCommand::new(None, None::<&[(&str, &str)]>, "mktemp", None)
.exec_capture_stdout(&mut channel)?;
// The output from `mktemp -u` contains a trailing '\n'
let path = std::str::from_utf8(&tmp)?.strip_suffix('\n').unwrap();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is essentially parsing input from a source that we don't control, so I don't think we should panic if it doesn't conform to our expectations.


sftp.create(Path::new(&tmp))?
.write_all(&std::fs::read(prog)?)?;
{
let path = Path::new(&path);

RemoteCommand::new(None, Some(env), &tmp, Some(args)).exec(&mut channel)
let sftp = session.sftp().context("Creating sftp session")?;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! I'm usually too lazy to add context, but it really does help when troubleshooting.

sftp.create(path)
.context(format!("Creating {:?}", path))?
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using with_context to avoid creating these strings eagerly.

.write_all(&std::fs::read(prog)?)
.context(format!("Writing {:?}", prog))?;
let mut stat = sftp
.stat(path)
.context(format!("Running `stat` on {:?}", path))?;
// `sftp.create` creates a new file with write-only permissions,
// but since we expect to run this program we need to mark it executable
// for the user
stat.perm = Some(0o100744);
sftp.setstat(path, stat)
.context(format!("Updating stat on {:?}", path))?;
}

RemoteCommand::new(None::<&str>, Some(env), path, Some(args)).exec(session)
}

// TODO: Consider abstracting away the difference between devices that support developer mode, and
Expand All @@ -123,17 +164,16 @@ pub fn run_package<S: AsRef<str>>(
package: &str,
env: &[(S, S)],
args: &[&str],
as_root: bool,
) -> anyhow::Result<()> {
let mut channel = session.channel_session()?;
let cmd = RemoteCommand::new(
Some(&format!("acap-{package}")),
as_root.then(|| format!("acap-{package}")),
Some(env),
&format!("/usr/local/packages/{package}/{package}"),
Some(args),
);
channel.handle_extended_data(ssh2::ExtendedData::Merge)?;

cmd.exec(&mut channel)
cmd.exec(session)
}

/// Update ACAP app on device without installing it
Expand All @@ -151,10 +191,13 @@ pub fn patch_package(package: &Path, session: &Session) -> anyhow::Result<()> {
let mut full = Archive::new(GzDecoder::new(File::open(package)?));
let mut entries = full.entries()?;

let app_name = if let Some(entry) = entries
.by_ref()
.find(|e| e.as_ref().unwrap().path().unwrap_or_default() == Path::new("manifest.json"))
{
let app_name = if let Some(entry) = entries.by_ref().find(|e| {
e.as_ref()
.expect("The entry should be valid")
.path()
.unwrap_or_default()
== Path::new("manifest.json")
}) {
let mut manifest = String::new();
entry?.read_to_string(&mut manifest)?;
let manifest: Manifest = serde_json::from_str(&manifest)?;
Expand All @@ -164,9 +207,9 @@ pub fn patch_package(package: &Path, session: &Session) -> anyhow::Result<()> {
};

let package_dir = PathBuf::from("/usr/local/packages").join(app_name);
let sftp = session.sftp()?;
let sftp = session.sftp().context("Creating sftp session")?;
if sftp.stat(&package_dir).is_err() {
return Err(anyhow::anyhow!("Package doesn't exist"));
bail!("Package doesn't exist!");
}

let mut full = Archive::new(GzDecoder::new(File::open(package)?));
Expand All @@ -186,11 +229,26 @@ pub fn patch_package(package: &Path, session: &Session) -> anyhow::Result<()> {
mtime: Some(header.mtime()?),
size: None,
};
_ = entry.read_to_end(&mut buf)?;
println!("Writing file: {:?}", entry.path()?);
let mut file = sftp.create(&package_dir.join(&entry.path()?))?;
file.write_all(&buf)?;
file.setstat(stat)?;

if header.entry_type().is_dir() {
// If the directory can't be opened, then it doesn't exist so we need to create it.
// TODO: What if permissions has changed?
if sftp.opendir(entry.path()?).is_err() {
sftp.mkdir(&header.path()?, header.mode()? as i32)
.context(format!("Creating directory {:?}", entry.path()?))?;
}

continue;
}

entry.read_to_end(&mut buf)?;
let mut file = sftp
.create(&package_dir.join(&entry.path()?))
.context(format!("Creating {:?}", entry.path()?))?;
file.write_all(&buf)
.context(format!("Writing to {:?}", entry.path()?))?;
file.setstat(stat)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this work if the app contains files that are symlinks?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inspect_env has a couple of symlinks, so that could be useful for testing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does not. I'll fix that.

.context(format!("Updating stat on {:?}", entry.path()?))?;
}
}

Expand Down
15 changes: 6 additions & 9 deletions crates/acap-ssh-utils/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ struct Cli {
}

impl Cli {
fn exec(self, session: &Session) -> anyhow::Result<()> {
fn exec(self, session: &Session, as_root: bool) -> anyhow::Result<()> {
match self.command {
Command::Patch(cmd) => cmd.exec(session),
Command::RunApp(cmd) => cmd.exec(session),
Command::RunApp(cmd) => cmd.exec(session, as_root),
Command::RunOther(cmd) => cmd.exec(session),
}
}
Expand Down Expand Up @@ -93,12 +93,13 @@ struct RunApp {
}

impl RunApp {
fn exec(self, session: &Session) -> anyhow::Result<()> {
fn exec(self, session: &Session, as_root: bool) -> anyhow::Result<()> {
run_package(
session,
&self.package,
&self.environment,
&self.args.iter().map(String::as_str).collect::<Vec<_>>(),
as_root,
)
}
}
Expand Down Expand Up @@ -151,10 +152,7 @@ fn main() -> anyhow::Result<()> {

let cli = Cli::parse();

let mut host = cli.netloc.host.to_string();
if !host.contains(":") {
host.push_str(":22");
}
let host = format!("{}:22", cli.netloc.host);

let tcp = TcpStream::connect(host).unwrap();
let mut sess = Session::new().unwrap();
Expand All @@ -163,9 +161,8 @@ fn main() -> anyhow::Result<()> {

sess.userauth_password(&cli.netloc.user, &cli.netloc.pass)
.unwrap();
sess.set_blocking(false);

match Cli::parse().exec(&sess) {
match Cli::parse().exec(&sess, cli.netloc.user == "root") {
Copy link

Copilot AI Apr 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CLI arguments are parsed twice; consider reusing the 'cli' variable that is already stored to avoid potential inconsistencies.

Suggested change
match Cli::parse().exec(&sess, cli.netloc.user == "root") {
match cli.exec(&sess, cli.netloc.user == "root") {

Copilot uses AI. Check for mistakes.

Ok(()) => Ok(()),
Err(e) => {
if let Some(log_file) = log_file {
Expand Down
18 changes: 6 additions & 12 deletions crates/cargo-acap-sdk/src/commands/run_command.rs
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I try this, it doesn't work. Are you able to reproduce the error I see?

$ git checkout 5e8ff7a967a604fc16938de5daedb95e25498cc8
Previous HEAD position was efc9365 Provide a futures_lite::Stream implementation for event subscription (#174)
HEAD is now at 5e8ff7a Use is_ok_and instead of expect
$ cargo build --bin cargo-acap-sdk
# ...
$ ./target/debug/cargo-acap-sdk run -- -p inspect_env
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.50s
Error: G_SLICE="always-malloc" RUST_LOG="debug" RUST_LOG_STYLE="always" "su" "acap-inspect_env" "-c" "/usr/local/packages/inspect_env/inspect_env" "--" exited with status 1

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hope the above blunt feedback does not discourage you ...

Of course not, I agree this review got a bit out of hand. If you want I can try and split it up into easier to digest PRs.

When I try this, it doesn't work. Are you able to reproduce the error I see?

No I'm not, however I have another issue: there's no output from the program. As you rightly pointed, patch_package didn't work with symlinks.

Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,8 @@ impl RunCommand {
pass: password,
} = deploy_options;

let mut host = address.to_string();
if !host.contains(":") {
host.push_str(":22");
}
let host = format!("{}:22", address);

let tcp = TcpStream::connect(host).unwrap();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this function returns anyhow::Result we should either return an Err or unwrap and leave a comment explaining why the unwrap will not panic.

let mut session = Session::new().unwrap();
session.set_tcp_stream(tcp);
Expand All @@ -43,18 +41,14 @@ impl RunCommand {
.args(args)
.execute()?;
for artifact in artifacts {
let envs = [
("RUST_LOG", "debug"),
("RUST_LOG_STYLE", "always"),
("G_SLICE", "always-malloc"),
];
let envs = [("RUST_LOG", "debug"), ("RUST_LOG_STYLE", "always")];
match artifact {
Artifact::Eap { path, name } => {
// TODO: Install instead of patch when needed
println!("Patching app {name}");
debug!("Patching app {name}");
acap_ssh_utils::patch_package(&path, &session)?;
println!("Running app {name}");
acap_ssh_utils::run_package(&session, &name, &envs, &[])?
debug!("Running app {name}");
acap_ssh_utils::run_package(&session, &name, &envs, &[], username == "root")?
}
Artifact::Exe { path } => {
debug!(
Expand Down
16 changes: 9 additions & 7 deletions crates/cargo-acap-sdk/src/commands/test_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ impl TestCommand {

build_args.push("--tests".to_string());

let tcp = TcpStream::connect(address.to_string()).unwrap();
let tcp = TcpStream::connect(format!("{}:22", address)).unwrap();
let mut session = Session::new().unwrap();
session.set_tcp_stream(tcp);
session.handshake().unwrap();
Expand All @@ -46,19 +46,21 @@ impl TestCommand {

for artifact in artifacts {
debug!("Running {:?}", artifact);
let envs = [
("RUST_LOG", "debug"),
("RUST_LOG_STYLE", "always"),
("G_SLICE", "always-malloc"),
];
let envs = [("RUST_LOG", "debug"), ("RUST_LOG_STYLE", "always")];
let test_args = ["--test-threads=1"];
match artifact {
Artifact::Eap { path, name } => {
// TODO: Install instead of patch when needed
debug!("Patching app {name}");
acap_ssh_utils::patch_package(&path, &session)?;
debug!("Running app {name}");
acap_ssh_utils::run_package(&session, &name, &envs, &test_args)?
acap_ssh_utils::run_package(
&session,
&name,
&envs,
&test_args,
username == "root",
)?
}
Artifact::Exe { path } => {
debug!(
Expand Down