From 2ccb36f4b9c6d144d1cb0cb3ac3fe0fab8b00145 Mon Sep 17 00:00:00 2001 From: techartdev Date: Mon, 14 Sep 2026 18:14:08 +0300 Subject: [PATCH 1/2] Add SSH shell file access when SFTP is unavailable --- crates/ssh-core/src/connection.rs | 2 +- crates/ssh-core/src/lib.rs | 2 + crates/ssh-core/src/shell_files.rs | 634 +++++++++++++++++++++++ crates/ssh-core/src/shell_files_tests.rs | 386 ++++++++++++++ docs/filesystem-contract.md | 13 + src-tauri/src/lib.rs | 29 +- 6 files changed, 1060 insertions(+), 6 deletions(-) create mode 100644 crates/ssh-core/src/shell_files.rs create mode 100644 crates/ssh-core/src/shell_files_tests.rs diff --git a/crates/ssh-core/src/connection.rs b/crates/ssh-core/src/connection.rs index 0a9e91d..0f44800 100644 --- a/crates/ssh-core/src/connection.rs +++ b/crates/ssh-core/src/connection.rs @@ -117,7 +117,7 @@ impl Connection { .await } - async fn connect_using( + pub(crate) async fn connect_using( options: &ConnectOptions, known_hosts: PathBuf, additional_known_hosts: Option, diff --git a/crates/ssh-core/src/lib.rs b/crates/ssh-core/src/lib.rs index b3cd748..f26a798 100644 --- a/crates/ssh-core/src/lib.rs +++ b/crates/ssh-core/src/lib.rs @@ -13,6 +13,8 @@ pub mod probe; pub mod profiles; pub mod provider; pub mod settings; +pub mod shell_files; +pub use shell_files::ShellFiles; mod sftp_transport; pub mod terminal; pub mod text; diff --git a/crates/ssh-core/src/shell_files.rs b/crates/ssh-core/src/shell_files.rs new file mode 100644 index 0000000..5454452 --- /dev/null +++ b/crates/ssh-core/src/shell_files.rs @@ -0,0 +1,634 @@ +// SPDX-License-Identifier: MPL-2.0 +//! Optional POSIX-shell file access over separate SSH exec channels. Never uses +//! the interactive terminal, installs remote helpers, or elevates privileges. +use crate::{Connection, OP_TIMEOUT}; +use anyhow::{bail, Context, Result}; +use async_trait::async_trait; +use base64::{engine::general_purpose::STANDARD, Engine}; +use russh::{client, Channel, ChannelMsg}; +use shellcanvas_services::*; +use std::{collections::VecDeque, sync::Arc}; +use tokio::time::timeout; + +#[cfg(all(test, any(windows, target_os = "linux")))] +#[path = "shell_files_tests.rs"] +mod tests; + +// GNU and BusyBox stat; include inode/device and high-resolution timestamps +// where supplied by the host. This is an optimistic revision, as with SFTP. +const STAT: &str = "stat -c '%f|%s|%Y|%d|%i|%y|%z' --"; +const PROBE: &str = + "cd -P . && printf 'SC1\\000%s\\000' \"$PWD\" && stat -c '%f|%s|%Y|%d|%i|%y|%z' -- ."; +const DOWNLOAD_PROBE: &str = "command -v cat >/dev/null && exec 3&1"; +const FIELD_LIMIT: usize = 64 * 1024; + +fn quote(value: &str) -> Result { + if value.contains('\0') { + bail!("A remote path cannot contain NUL"); + } + Ok(format!("'{}'", value.replace('\'', "'\\''"))) +} +fn absolute(path: &str) -> Result<&str> { + if !path.starts_with('/') || path.contains('\0') { + bail!("Shell file access requires an absolute POSIX path"); + } + Ok(path) +} +fn child(parent: &str, name: &str) -> Result { + absolute(parent)?; + if name.is_empty() || matches!(name, "." | "..") || name.contains(['/', '\0']) { + bail!("Invalid remote file name"); + } + Ok(format!("{}/{}", parent.trim_end_matches('/'), name)) +} +fn location(path: String) -> FileLocation { + crate::provider::sftp_location(path) +} +fn entry(path: String, metadata: &str) -> Result { + let fields: Vec<_> = metadata.split('|').collect(); + if fields.len() != 7 || fields.iter().any(|v| v.is_empty()) { + bail!("Unsupported shell stat response"); + } + let mode = u32::from_str_radix(fields[0], 16).context("Invalid file mode")?; + let size = fields[1].parse().context("Invalid file size")?; + let modified: i64 = fields[2].parse().context("Invalid modification time")?; + fields[3].parse::().context("Invalid device number")?; + fields[4].parse::().context("Invalid inode number")?; + let kind = match mode & 0xf000 { + 0x4000 => "directory", + 0x8000 => "file", + 0xa000 => "symlink", + _ => "special", + }; + Ok(FileEntry { + name: location(path.clone()).name, + path, + kind: kind.into(), + size, + modified: u32::try_from(modified).ok(), + revision: metadata.into(), + }) +} + +/// Owns the exec channel even while requests are pending, so timeout/cancellation +/// closes it. stdout is binary and bounded per delivery; stderr is bounded too. +struct Command { + channel: Option>, + pending: VecDeque, + stderr: Vec, + status: Option, + done: bool, +} +impl Command { + async fn start(connection: &Connection, script: &str) -> Result { + let channel = timeout(OP_TIMEOUT, connection.handle.channel_open_session()) + .await + .context("Shell file channel timed out")??; + let result = Self { + channel: Some(channel), + pending: VecDeque::new(), + stderr: vec![], + status: None, + done: false, + }; + timeout( + OP_TIMEOUT, + result + .channel + .as_ref() + .unwrap() + .exec(true, format!("LC_ALL=C; export LC_ALL; {script}")), + ) + .await + .context("Shell file command timed out")??; + Ok(result) + } + async fn read(&mut self) -> Result> { + if self.channel.is_none() { + bail!("Shell file channel is closed"); + } + timeout(OP_TIMEOUT, self.read_inner()) + .await + .context("Shell file read timed out")? + } + async fn read_inner(&mut self) -> Result> { + while self.pending.is_empty() && !self.done { + match self + .channel + .as_mut() + .context("Shell file channel is closed")? + .wait() + .await + { + Some(ChannelMsg::Data { data }) => self.pending.extend(data.iter()), + Some(ChannelMsg::ExtendedData { data, .. }) => { + let count = data.len().min(4096 - self.stderr.len()); + self.stderr.extend_from_slice(&data[..count]); + } + Some(ChannelMsg::ExitStatus { exit_status }) => self.status = Some(exit_status), + Some(ChannelMsg::Failure) => bail!("The host rejected shell file access"), + Some(ChannelMsg::Close) | None => self.done = true, + _ => {} + } + } + if self.done && self.pending.is_empty() && self.status != Some(0) { + bail!( + "Shell file command failed (status {:?}): {}", + self.status, + String::from_utf8_lossy(&self.stderr).trim() + ); + } + Ok(self + .pending + .drain(..self.pending.len().min(TRANSFER_CHUNK)) + .collect()) + } + async fn write(&self, bytes: &[u8]) -> Result<()> { + timeout( + OP_TIMEOUT, + self.channel + .as_ref() + .context("Shell file channel is closed")? + .data(bytes), + ) + .await + .context("Shell file write timed out")??; + Ok(()) + } + async fn close(&mut self) -> Result<()> { + if let Some(channel) = self.channel.take() { + timeout(OP_TIMEOUT, async { + let _ = channel.eof().await; + channel.close().await + }) + .await + .context("Shell file close timed out")??; + } + self.done = true; + self.pending.clear(); + Ok(()) + } +} +impl Drop for Command { + fn drop(&mut self) { + if let Some(channel) = self.channel.take() { + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + let _ = timeout(OP_TIMEOUT, async { + let _ = channel.eof().await; + let _ = channel.close().await; + }) + .await; + }); + } + } + } +} +async fn collect(connection: &Connection, script: &str, limit: usize) -> Result> { + timeout(OP_TIMEOUT, async { + let mut command = Command::start(connection, script).await?; + let mut bytes = Vec::new(); + loop { + let chunk = command.read().await?; + if chunk.is_empty() { + break; + } + if chunk.len() > limit.saturating_sub(bytes.len()) { + bail!("Shell file response exceeded its size limit"); + } + bytes.extend(chunk); + } + command.close().await?; + Ok(bytes) + }) + .await + .context("Shell file operation timed out")? +} + +pub struct ShellFiles { + connection: Arc, + home: String, + downloads: bool, + uploads: bool, +} +impl ShellFiles { + /// All probing is read-only. Incompatible/restricted shells fail closed. + pub async fn probe(connection: Arc) -> Result { + let bytes = collect(&connection, PROBE, FIELD_LIMIT).await?; + let mut fields = bytes.split(|b| *b == 0); + if fields.next() != Some(b"SC1".as_slice()) { + bail!("The host does not provide a compatible POSIX shell"); + } + let home = String::from_utf8(fields.next().context("Missing shell home")?.to_vec())?; + absolute(&home)?; + let metadata = std::str::from_utf8(fields.next().context("Missing shell stat")?)? + .trim_end_matches('\n'); + if fields.next().is_some() || entry(home.clone(), metadata)?.kind != "directory" { + bail!("The host does not provide compatible directory metadata"); + } + let downloads = collect(&connection, DOWNLOAD_PROBE, FIELD_LIMIT) + .await + .is_ok_and(|v| v == b"SCREAD"); + let uploads = collect(&connection, UPLOAD_PROBE, FIELD_LIMIT) + .await + .is_ok_and(|help| String::from_utf8_lossy(&help).contains("-T")); + Ok(Self { + connection, + home, + downloads, + uploads, + }) + } + pub fn home(&self) -> &str { + &self.home + } + pub fn can_upload(&self) -> bool { + self.uploads + } + pub fn can_download(&self) -> bool { + self.downloads + } + async fn stat(&self, path: &str) -> Result { + let bytes = collect( + &self.connection, + &format!("{STAT} {}", quote(absolute(path)?)?), + FIELD_LIMIT, + ) + .await?; + entry( + path.into(), + std::str::from_utf8(&bytes)?.trim_end_matches('\n'), + ) + } + async fn checked(&self, path: &str, revision: &str) -> Result { + let item = self.stat(path).await?; + if item.revision != revision { + bail!("The remote entry changed. Refresh and try again."); + } + Ok(item) + } + async fn directory_path(&self, path: &str) -> Result { + let script = format!( + "cd -P {} && printf '%s\\000' \"$PWD\"", + quote(absolute(path)?)? + ); + let bytes = collect(&self.connection, &script, FIELD_LIMIT).await?; + let path = std::str::from_utf8( + bytes + .strip_suffix(&[0]) + .context("Invalid shell directory response")?, + )?; + absolute(path)?; + Ok(path.into()) + } +} + +struct ShellDirectory { + command: Command, + metadata: Directory, + buffer: Vec, + active: bool, +} +impl ShellDirectory { + async fn field(&mut self) -> Result> { + loop { + if let Some(end) = self.buffer.iter().position(|b| *b == 0) { + if end > FIELD_LIMIT { + bail!("Shell directory field is too large"); + } + let field = String::from_utf8(self.buffer.drain(..=end).take(end).collect())?; + return Ok(Some(field)); + } + if self.buffer.len() > FIELD_LIMIT { + bail!("Shell directory field is too large"); + } + let chunk = self.command.read().await?; + if chunk.is_empty() { + if !self.buffer.is_empty() { + bail!("Truncated shell directory response"); + } + return Ok(None); + } + self.buffer.extend(chunk); + } + } + async fn page(&mut self) -> Result { + let mut directory = self.metadata.clone(); + while directory.entries.len() < DIRECTORY_PAGE { + let Some(name) = self.field().await? else { + return Ok(DirectoryPage { + directory, + done: true, + }); + }; + let metadata = self + .field() + .await? + .context("Truncated shell directory entry")?; + directory + .entries + .push(entry(child(&directory.path, &name)?, &metadata)?); + } + Ok(DirectoryPage { + directory, + done: false, + }) + } +} +#[async_trait] +impl DirectoryReader for ShellDirectory { + async fn next(&mut self) -> Result { + if !self.active { + bail!("Directory reader is closed"); + } + self.active = false; + let result = timeout(OP_TIMEOUT, self.page()) + .await + .context("Shell directory page timed out") + .and_then(|v| v); + match result { + Ok(page) => { + if page.done { + self.command.close().await?; + } else { + self.active = true; + } + Ok(page) + } + Err(error) => { + let _ = self.close().await; + Err(error) + } + } + } + async fn close(&mut self) -> Result<()> { + self.active = false; + self.buffer.clear(); + self.command.close().await + } +} +#[async_trait] +impl FileSystemProvider for ShellFiles { + async fn list(&self, path: Option<&str>) -> Result { + collect_directory(self.open(path).await?).await + } + async fn open_directory( + self: Arc, + path: Option<&str>, + ) -> Result> { + self.open(path).await + } + async fn locate(&self, path: &str) -> Result { + let item = self.stat(path).await?; + if item.kind == "directory" { + Ok(location(self.directory_path(path).await?)) + } else { + Ok(location(path.into())) + } + } + async fn preview(&self, path: &str) -> Result { + Ok(self.read_text(path).await?.text) + } +} +impl ShellFiles { + async fn open(&self, path: Option<&str>) -> Result> { + let path = self.directory_path(path.unwrap_or(&self.home)).await?; + let place = location(path.clone()); + // NUL framing preserves whitespace, newlines and shell metacharacters. + // Unmatched globs are skipped; never parse human-readable ls output. + let script = format!("cd {} || exit; [ -r . ] && [ -x . ] || exit 1; for f in .[!.]* ..?* *; do [ -e \"$f\" ] || [ -L \"$f\" ] || continue; m=$({STAT} \"./$f\") || exit; printf '%s\\000%s\\000' \"$f\" \"$m\"; done", quote(&path)?); + Ok(Box::new(ShellDirectory { + command: Command::start(&self.connection, &script).await?, + metadata: Directory { + path, + name: place.name, + parent: place.parent, + home: Some(FilePlace { + path: self.home.clone(), + name: location(self.home.clone()).name, + }), + roots: vec![FilePlace { + path: "/".into(), + name: "/".into(), + }], + entries: vec![], + }, + buffer: vec![], + active: true, + })) + } +} + +fn download_script(path: &str, revision: &str) -> Result { + let path = quote(absolute(path)?)?; + let revision = quote(revision)?; + // Bind the read to an opened regular file; Linux procfs lets stat verify + // the actual descriptor as well as the path before and after the stream. + Ok(format!("p={path}; r={revision}; [ ! -L \"$p\" ] && [ -f \"$p\" ] || exit 1; exec 3<\"$p\" || exit; [ \"$(stat -Lc '%f|%s|%Y|%d|%i|%y|%z' /proc/self/fd/3)\" = \"$r\" ] || exit 1; cat <&3 || exit; [ \"$({STAT} \"$p\")\" = \"$r\" ] && [ \"$(stat -Lc '%f|%s|%Y|%d|%i|%y|%z' /proc/self/fd/3)\" = \"$r\" ]")) +} +struct Download { + command: Command, + file: TransferFile, + read: u64, + eof: bool, +} +#[async_trait] +impl TransferReader for Download { + fn file(&self) -> TransferFile { + self.file.clone() + } + async fn read(&mut self) -> Result> { + let bytes = self.command.read().await?; + self.read += bytes.len() as u64; + if self.read > self.file.size { + bail!("Remote file grew during download"); + } + self.eof = bytes.is_empty(); + Ok(bytes) + } + async fn finish(&mut self) -> Result<()> { + if self.command.channel.is_none() { + bail!("Download is closed"); + } + if !self.eof && !self.read().await?.is_empty() { + bail!("Download has unread data"); + } + if self.read != self.file.size { + bail!("Remote file was truncated during download"); + } + self.command.close().await + } + async fn abort(&mut self) -> Result<()> { + self.command.close().await + } +} + +fn upload_script(parent: &str, name: &str, size: u64) -> Result { + let destination = quote(&child(parent, name)?)?; + let template = quote(&child(parent, ".shellcanvas-upload.XXXXXXXXXXXX")?)?; + // EOF is cancellation, not publication. Only an explicit commit after all + // chunks publishes via no-clobber hard link; -T also rejects directories. + // A shell EXIT trap removes only the private mktemp file on every path. + Ok(format!("umask 077; t=$(mktemp {template}) || exit; trap 'rm -f -- \"$t\" || exit 1' 0; trap 'exit 1' 1 2 15; printf 'SCREADY\\n'; while IFS= read -r chunk; do if [ \"$chunk\" = ABORT ]; then exit 0; fi; if [ \"$chunk\" = COMMIT ]; then [ \"$(stat -c %s -- \"$t\")\" = {size} ] || exit 1; ln -T -- \"$t\" {destination} || exit; exit 0; fi; printf '%s' \"$chunk\" | base64 -d >> \"$t\" || exit; done; exit 1")) +} +struct Upload { + command: Command, + destination: FileLocation, + size: u64, + written: u64, + active: bool, +} +#[async_trait] +impl TransferWriter for Upload { + async fn write(&mut self, bytes: &[u8]) -> Result<()> { + if !self.active + || bytes.len() > TRANSFER_CHUNK + || bytes.len() as u64 > self.size - self.written + { + bail!("Invalid shell upload chunk or closed upload"); + } + self.active = false; + let mut line = STANDARD.encode(bytes); + line.push('\n'); + self.command.write(line.as_bytes()).await?; + self.written += bytes.len() as u64; + self.active = true; + Ok(()) + } + async fn finish(&mut self) -> Result { + if !self.active || self.written != self.size { + bail!("Upload is incomplete or closed"); + } + self.active = false; + async { + self.command.write(b"COMMIT\n").await?; + if !self.command.read().await?.is_empty() { + bail!("Unexpected shell upload response"); + } + self.command.close().await + } + .await + .context( + "Upload completion could not be confirmed. Refresh the destination before retrying", + )?; + Ok(self.destination.clone()) + } + async fn abort(&mut self) -> Result<()> { + let cleanup = if self.active { + self.active = false; + async { + self.command.write(b"ABORT\n").await?; + if !self.command.read().await?.is_empty() { + bail!("Unexpected shell upload cleanup response"); + } + Ok::<_, anyhow::Error>(()) + } + .await + } else { + Ok(()) + }; + self.active = false; + let close = self.command.close().await; + cleanup.context("Shell upload cleanup could not be confirmed")?; + close + } +} +#[async_trait] +impl FileTransferService for ShellFiles { + async fn download( + self: Arc, + path: &str, + revision: &str, + ) -> Result> { + if !self.downloads { + bail!("Safe shell downloads are unavailable on this host"); + } + let item = self.checked(path, revision).await?; + if item.kind != "file" { + bail!("Shell transfers support regular files only"); + } + Ok(Box::new(Download { + command: Command::start(&self.connection, &download_script(path, revision)?).await?, + file: TransferFile { + location: location(path.into()), + size: item.size, + }, + read: 0, + eof: false, + })) + } + async fn upload( + self: Arc, + parent: &str, + name: &str, + size: u64, + ) -> Result> { + if !self.uploads { + bail!("Safe shell uploads are unavailable on this host"); + } + let parent = self.directory_path(parent).await?; + let mut command = + Command::start(&self.connection, &upload_script(&parent, name, size)?).await?; + let mut ready = Vec::new(); + timeout(OP_TIMEOUT, async { + while ready.len() < 8 { + let chunk = command.read().await?; + if chunk.is_empty() { + bail!("Shell upload did not start"); + } + ready.extend(chunk); + } + if ready != b"SCREADY\n" { + bail!("Unexpected shell upload handshake"); + } + Ok::<_, anyhow::Error>(()) + }) + .await + .context("Shell upload preparation timed out")??; + Ok(Box::new(Upload { + command, + destination: location(child(&parent, name)?), + size, + written: 0, + active: true, + })) + } +} +#[async_trait] +impl TextFileService for ShellFiles { + async fn read_text(&self, path: &str) -> Result { + if !self.downloads { + bail!("Text preview is unavailable on this host"); + } + let item = self.stat(path).await?; + if item.kind != "file" || item.size > 256 * 1024 { + bail!("Shell text preview supports regular files up to 256 KiB"); + } + let bytes = collect( + &self.connection, + &download_script(path, &item.revision)?, + 256 * 1024, + ) + .await?; + if bytes.len() as u64 != item.size || bytes.contains(&0) { + bail!("File changed or is not a text file"); + } + let place = location(path.into()); + Ok(TextDocument { + path: place.path, + name: place.name, + parent: place.parent, + text: String::from_utf8(bytes).context("File is not UTF-8 text")?, + revision: item.revision, + writable: false, + save_requires_confirmation: false, + }) + } + async fn create_text(&self, _: &str, _: &str, _: &str) -> Result { + bail!("Text editing is unavailable in shell file mode") + } + async fn save_text(&self, _: &str, _: &str, _: &str) -> Result { + bail!("Text editing is unavailable in shell file mode") + } +} diff --git a/crates/ssh-core/src/shell_files_tests.rs b/crates/ssh-core/src/shell_files_tests.rs new file mode 100644 index 0000000..7af7efe --- /dev/null +++ b/crates/ssh-core/src/shell_files_tests.rs @@ -0,0 +1,386 @@ +// SPDX-License-Identifier: MPL-2.0 +use super::*; +use crate::ConnectOptions; +use russh::{keys, server, ChannelId}; +use std::{collections::HashMap, path::PathBuf, process::Stdio, time::Duration}; +use tokio::{io::AsyncReadExt, net::TcpListener}; + +#[test] +fn filenames_and_metadata_are_not_shell_code() { + assert_eq!( + quote("a'$(touch /bad)\nb").unwrap(), + "'a'\\''$(touch /bad)\nb'" + ); + for name in ["", ".", "..", "a/b", "a\0b"] { + assert!(child("/tmp", name).is_err()); + } + assert!(quote("a\0b").is_err()); + assert!(absolute("relative").is_err()); + let item = entry("/tmp/a\nb".into(), "81a4|12|123|1|2|date|date").unwrap(); + assert_eq!(item.name, "a\nb"); + assert_eq!(item.kind, "file"); + assert_eq!(item.size, 12); + for metadata in [ + "banner", + "81a4|12|123|1|2|date", + "81a4|bad|123|1|2|date|date", + ] { + assert!(entry("/tmp/x".into(), metadata).is_err()); + } +} + +// A loopback SSH server rejecting SFTP and executing commands only inside a +// fresh test directory. No user host, credential, or interactive UI is used. +struct ShellServer { + channels: HashMap>, + directory: PathBuf, + restricted: bool, +} +impl server::Handler for ShellServer { + type Error = anyhow::Error; + async fn auth_password(&mut self, _: &str, _: &str) -> Result { + Ok(server::Auth::Accept) + } + async fn channel_open_session( + &mut self, + channel: Channel, + reply: server::ChannelOpenHandle, + _: &mut server::Session, + ) -> Result<()> { + self.channels.insert(channel.id(), channel); + reply.accept().await; + Ok(()) + } + async fn subsystem_request( + &mut self, + id: ChannelId, + _: &str, + session: &mut server::Session, + ) -> Result<()> { + session.channel_failure(id)?; + session.close(id)?; + self.channels.remove(&id); + Ok(()) + } + async fn exec_request( + &mut self, + id: ChannelId, + data: &[u8], + session: &mut server::Session, + ) -> Result<()> { + let channel = self.channels.remove(&id).unwrap(); + if self.restricted { + session.channel_failure(id)?; + session.close(id)?; + return Ok(()); + } + session.channel_success(id)?; + let shell = if cfg!(windows) { + "C:\\Program Files\\Git\\bin\\bash.exe" + } else { + "/bin/sh" + }; + let mut child = tokio::process::Command::new(shell) + .arg("-c") + .arg(std::str::from_utf8(data)?) + .current_dir(&self.directory) + .env("LC_ALL", "C") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn()?; + let handle = session.handle(); + tokio::spawn(async move { + let (mut input, mut output) = tokio::io::split(channel.into_stream()); + let mut stdin = child.stdin.take().unwrap(); + let mut stdout = child.stdout.take().unwrap(); + let mut stderr = child.stderr.take().unwrap(); + let input_task = tokio::spawn(async move { + let _ = tokio::io::copy(&mut input, &mut stdin).await; + }); + let output_task = tokio::spawn(async move { + let _ = tokio::io::copy(&mut stdout, &mut output).await; + }); + let error_task = tokio::spawn(async move { + let mut bytes = vec![]; + let _ = stderr.read_to_end(&mut bytes).await; + bytes + }); + let status = child.wait().await.unwrap(); + input_task.abort(); + output_task.await.unwrap(); + let errors = error_task.await.unwrap(); + if !errors.is_empty() { + let _ = handle.extended_data(id, 1, errors).await; + } + let _ = handle + .exit_status_request(id, status.code().unwrap_or(1) as u32) + .await; + let _ = handle.eof(id).await; + let _ = handle.close(id).await; + }); + Ok(()) + } +} +struct Fixture { + connection: Arc, + directory: tempfile::TempDir, + server: tokio::task::JoinHandle<()>, +} +impl Drop for Fixture { + fn drop(&mut self) { + self.server.abort(); + } +} +impl Fixture { + async fn new(restricted: bool) -> Self { + let directory = tempfile::tempdir().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let key = keys::PrivateKey::random(&mut rand::rng(), keys::Algorithm::Ed25519).unwrap(); + let known_hosts = directory.path().join("known_hosts"); + std::fs::write( + &known_hosts, + format!( + "[127.0.0.1]:{port} {}\n", + key.public_key().to_openssh().unwrap() + ), + ) + .unwrap(); + let handler = ShellServer { + channels: HashMap::new(), + directory: directory.path().into(), + restricted, + }; + let config = Arc::new(server::Config { + keys: vec![key], + auth_rejection_time: Duration::ZERO, + ..Default::default() + }); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let session = server::run_stream(config, stream, handler).await.unwrap(); + let _ = session.await; + }); + let connection = Connection::connect_using( + &ConnectOptions { + host: "127.0.0.1".into(), + port, + username: "fixture".into(), + password: Some("local-test-only".into()), + key_path: String::new(), + passphrase: None, + allow_legacy_mac: false, + }, + known_hosts, + None, + None, + ) + .await + .unwrap(); + Self { + connection: Arc::new(connection), + directory, + server, + } + } +} + +#[tokio::test] +async fn rejected_sftp_keeps_shell_browsing_and_transfers_usable() { + let fixture = Fixture::new(false).await; + let bytes: Vec = (0..100_001).map(|i| (i % 256) as u8).collect(); + std::fs::write(fixture.directory.path().join("binary"), &bytes).unwrap(); + std::fs::create_dir(fixture.directory.path().join("folder")).unwrap(); + for n in 0..140 { + std::fs::write(fixture.directory.path().join(format!("item {n}")), b"x").unwrap(); + } + assert!(fixture.connection.text_files().await.is_err()); + let service = Arc::new(ShellFiles::probe(fixture.connection.clone()).await.unwrap()); + assert!(service.can_upload()); + #[cfg(target_os = "linux")] + assert!( + service.can_download(), + "Linux downloads should be available" + ); + let mut reader = service.clone().open_directory(None).await.unwrap(); + let first = reader.next().await.unwrap(); + assert_eq!(first.directory.entries.len(), DIRECTORY_PAGE); + assert!(!first.done); + let second = reader.next().await.unwrap(); + assert!(second.done); + assert_eq!(second.directory.entries.len(), 15); + reader.close().await.unwrap(); + let folder = service + .list(Some(&child(service.home(), "folder").unwrap())) + .await + .unwrap(); + assert!(folder.entries.is_empty()); + assert_eq!(folder.parent.as_deref(), Some(service.home())); + + let name = "-quoted ' $(touch INJECTED) file"; + let mut upload = service + .clone() + .upload(service.home(), name, bytes.len() as u64) + .await + .unwrap(); + for chunk in bytes.chunks(TRANSFER_CHUNK) { + upload.write(chunk).await.unwrap(); + } + let uploaded = upload.finish().await.unwrap(); + assert_eq!( + std::fs::read(fixture.directory.path().join(name)).unwrap(), + bytes + ); + assert!(!fixture.directory.path().join("INJECTED").exists()); + let mut duplicate = service + .clone() + .upload(service.home(), name, 1) + .await + .unwrap(); + duplicate.write(b"x").await.unwrap(); + assert!(duplicate.finish().await.is_err()); + assert_eq!( + std::fs::read(fixture.directory.path().join(name)).unwrap(), + bytes + ); + let mut directory_collision = service + .clone() + .upload(service.home(), "folder", 0) + .await + .unwrap(); + assert!(directory_collision.finish().await.is_err()); + assert_eq!( + std::fs::read_dir(fixture.directory.path().join("folder")) + .unwrap() + .count(), + 0 + ); + + if service.can_download() { + let item = service.stat(&uploaded.path).await.unwrap(); + let mut download = service + .clone() + .download(&item.path, &item.revision) + .await + .unwrap(); + let mut received = vec![]; + loop { + let chunk = download.read().await.unwrap(); + if chunk.is_empty() { + break; + } + assert!(chunk.len() <= TRANSFER_CHUNK); + received.extend(chunk); + } + download.finish().await.unwrap(); + assert_eq!(received, bytes); + std::fs::write(fixture.directory.path().join(name), b"changed").unwrap(); + assert!(service + .clone() + .download(&item.path, &item.revision) + .await + .is_err()); + } + let mut cancelled = service + .clone() + .upload(service.home(), "cancelled", 10) + .await + .unwrap(); + cancelled.write(b"partial").await.unwrap(); + assert!(cancelled.finish().await.is_err()); + cancelled.abort().await.unwrap(); + assert!(!fixture.directory.path().join("cancelled").exists()); + let mut abandoned = service + .clone() + .upload(service.home(), "abandoned", 3) + .await + .unwrap(); + abandoned.write(b"all").await.unwrap(); + drop(abandoned); // All bytes without COMMIT must never publish. + timeout(Duration::from_secs(5), async { + loop { + if std::fs::read_dir(fixture.directory.path()) + .unwrap() + .all(|e| { + !e.unwrap() + .file_name() + .to_string_lossy() + .starts_with(".shellcanvas-upload.") + }) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("cancelled upload must clean its temporary file"); + assert!(!fixture.directory.path().join("abandoned").exists()); + assert_eq!( + fixture + .connection + .exec_readonly("printf terminal-still-works") + .await + .unwrap(), + "terminal-still-works" + ); +} + +#[tokio::test] +async fn restricted_shell_and_command_failures_fail_closed() { + let fixture = Fixture::new(true).await; + assert!(fixture.connection.text_files().await.is_err()); + assert!(ShellFiles::probe(fixture.connection.clone()).await.is_err()); + assert!(!fixture.connection.handle.is_closed()); + let fixture = Fixture::new(false).await; + assert!(collect(&fixture.connection, "printf partial; exit 1", 100) + .await + .is_err()); + assert!(collect(&fixture.connection, "printf oversized", 2) + .await + .is_err()); + assert_eq!( + collect(&fixture.connection, "printf '\\000\\377\\012 '", 100) + .await + .unwrap(), + vec![0, 255, 10, 32] + ); + let mut directory = ShellDirectory { + command: Command::start(&fixture.connection, "printf 'name\\000truncated'") + .await + .unwrap(), + metadata: Directory { + path: "/".into(), + name: "/".into(), + parent: None, + home: None, + roots: vec![], + entries: vec![], + }, + buffer: vec![], + active: true, + }; + assert!(directory.next().await.is_err()); + assert!(directory.next().await.is_err()); + for (script, size, succeeds) in [ + ("printf abc", 3, true), + ("printf abc", 4, false), + ("printf abc; exit 1", 3, false), + ] { + let mut download = Download { + command: Command::start(&fixture.connection, script).await.unwrap(), + file: TransferFile { + location: location("/fixture".into()), + size, + }, + read: 0, + eof: false, + }; + assert_eq!(download.read().await.unwrap(), b"abc"); + assert_eq!(download.finish().await.is_ok(), succeeds); + download.abort().await.unwrap(); + assert!(download.read().await.is_err()); + } +} diff --git a/docs/filesystem-contract.md b/docs/filesystem-contract.md index 4180b8f..8eec51e 100644 --- a/docs/filesystem-contract.md +++ b/docs/filesystem-contract.md @@ -20,6 +20,19 @@ Relocation mappings carry no text or replacement revision. Providers supporting The SFTP adapter currently implements POSIX SFTP conventions, including `/` and the server's canonical `.` directory. Those assumptions stay inside the adapter. This change does not add production Windows or appliance support. +## SSH hosts without SFTP + +If opening SFTP fails, the built-in SSH connection probes a POSIX shell on separate exec channels. Compatible Linux hosts can browse directories without an SFTP subsystem or installed helper. A failed shell probe leaves terminal access independent and reports why file access is unavailable. The fallback respects the account's existing permissions and cannot provide files through a forced-command or restricted account that forbids the required commands. + +Browsing requires shell builtins and GNU/BusyBox-compatible `stat -c`. Listings are NUL-framed and delivered in pages of 128 entries; names with whitespace or shell metacharacters are quoted, never parsed from `ls`. Non-UTF-8 names produce an explicit error. Commands/pages have 15-second timeouts and bounded output buffers. + +Individual-file download and read-only text preview additionally require `cat` and working Linux `/proc/self/fd` metadata. Downloads verify the opened descriptor and path against the selected revision before/after streaming, plus the byte count. Preview is limited to 256 KiB of UTF-8 text. Individual-file upload requires `mktemp`, `base64 -d`, `rm` and `ln -T`; capability checks are read-only, and actual directory permissions/filesystem hard-link support are checked by the operation. Uploads stream encoded chunks into a private temporary sibling and publish only on an explicit commit, without replacing an existing file, symlink or directory. Abort requests await temporary-file cleanup. Lost connections/forced termination can still leave a temporary file or an uncertain publication acknowledgement. + +This compatibility mode advertises only supported browsing, upload, download and file-copy capabilities. It does not advertise editing, rename/delete/move, directory creation, recursive folder transfers or local drive attachment. It does not change the full SFTP service. Transfer contents remain inside native Rust/SSH, outside JavaScript. + +Manual check on a host with SFTP disabled: connect and confirm the shell-mode notice; navigate Home/Parent/hidden folders; upload and download a binary file; compare hashes; repeat an upload with the same name and confirm the original survives; cancel a partial upload and check for temporary files. Confirm Terminal remains usable. Repeat with an account that also denies exec requests and check for a clear file-access notice. Automated loopback SSH tests reject SFTP and cover paging, quoting, transfer publication/cancellation, binary command output and restricted-shell failure. Linux descriptor-based downloads require a real Linux test environment; Windows Git Bash is not equivalent. + + ## Evidence The development fixture `/tests/fixtures/filesystems.html` supplies Unix paths, drive roots, and opaque volume/node/object IDs. Its provider rejects invented locations. Browser checks verified child/parent/root navigation, disabled Parent at roots, multiple drives, absent Home, editor titles independent of object IDs, Save As using provider parents, subsequent normal Save using the new object ID, and a new draft creating a file in the provider's default drive folder. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index efd0d5c..62c83c3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -260,11 +260,30 @@ async fn prepare_ssh( ]); Some(service) } - Err(error) => { - info.notices - .push(format!("File access unavailable: {error}")); - None - } + Err(error) => match ShellFiles::probe(connection.clone()).await { + Ok(service) => { + info.home = Some(service.home().into()); + info.capabilities.push("files.read".into()); + if service.can_download() { + info.capabilities.push("files.download".into()); + } + if service.can_upload() { + info.capabilities.push("files.upload".into()); + } + if service.can_download() && service.can_upload() { + info.capabilities.push("files.copy".into()); + } + info.notices.push("SFTP is unavailable. Using SSH shell file access: browsing and supported individual-file transfers. Editing, file management, folder transfers and local drive attachment are unavailable in this mode.".into()); + let service = Arc::new(service); + files = Some(service.clone()); + transfers = Some(service.clone()); + Some(service as Arc) + } + Err(shell_error) => { + info.notices.push(format!("File access unavailable: SFTP: {error}; SSH shell fallback: {shell_error}. The account must allow SFTP or compatible shell commands. Terminal access is independent.")); + None + } + }, }; let clock = shellcanvas_core::clock::SshHostClock::for_provider(connection.clone(), &info.provider); From 648534805a0ce1ba6dcbbf1114645f4ec1361aa2 Mon Sep 17 00:00:00 2001 From: techartdev Date: Mon, 14 Sep 2026 20:01:37 +0300 Subject: [PATCH 2/2] Prepare 0.1.8 release and verify shell fallback on Linux --- .github/workflows/ci.yml | 7 +++++++ CHANGELOG.md | 12 ++++++++++++ Cargo.lock | 6 +++--- crates/adapter-sdk/Cargo.toml | 2 +- crates/filesystem-sdk/Cargo.toml | 2 +- package-lock.json | 6 +++--- package.json | 2 +- packages/app-sdk/package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 10 files changed, 31 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d59884..22305c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,13 @@ concurrency: cancel-in-progress: true jobs: + ssh-core-linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Verify shell fallback against a Linux SSH fixture without SFTP + run: cargo test -p shellcanvas-core shell_files --lib --locked + verify: runs-on: windows-latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cccf02..a52da65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ Notable changes to ShellCanvas, newest first. Published SDK packages follow [semantic versioning](https://semver.org/), and desktop releases use the same version where practical. The project is pre-1.0, so a minor release may include breaking changes; those come with migration notes. +## [0.1.8] - 2026-09-14 + +### Added + +- Automatically try SSH shell file access when SFTP is unavailable. Compatible Linux hosts support paged browsing, individual-file uploads/downloads, file copy and read-only text preview without installing a remote helper. +- Probe each transfer capability independently, preserve existing destinations during upload, and clean up canceled temporary uploads. Restricted accounts receive a clear explanation when neither SFTP nor compatible shell commands are available; terminal access remains independent. + +### Known limitations + +- Shell mode requires permitted POSIX shell commands and compatible utilities. It does not support editing, rename/delete/move, folder creation or transfers, or local drive attachment. Downloads require Linux descriptor metadata; uploads require temporary-file and no-clobber hard-link support. See [SSH hosts without SFTP](docs/filesystem-contract.md#ssh-hosts-without-sftp). +- Manual verification on the reported restricted Linux host remains pending. + ## [0.1.7] - 2026-09-14 ### Added diff --git a/Cargo.lock b/Cargo.lock index 3efc326..3f16dc9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4978,7 +4978,7 @@ dependencies = [ [[package]] name = "shellcanvas" -version = "0.1.7" +version = "0.1.8" dependencies = [ "anyhow", "async-trait", @@ -5030,7 +5030,7 @@ dependencies = [ [[package]] name = "shellcanvas-adapter-sdk" -version = "0.1.7" +version = "0.1.8" dependencies = [ "anyhow", "async-trait", @@ -5068,7 +5068,7 @@ dependencies = [ [[package]] name = "shellcanvas-filesystem-sdk" -version = "0.1.7" +version = "0.1.8" dependencies = [ "async-trait", "serde", diff --git a/crates/adapter-sdk/Cargo.toml b/crates/adapter-sdk/Cargo.toml index 26bc6d1..b8cce5c 100644 --- a/crates/adapter-sdk/Cargo.toml +++ b/crates/adapter-sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shellcanvas-adapter-sdk" -version = "0.1.7" +version = "0.1.8" edition = "2021" license = "MPL-2.0" description = "Versioned process protocol and concurrent server for ShellCanvas connection adapters" diff --git a/crates/filesystem-sdk/Cargo.toml b/crates/filesystem-sdk/Cargo.toml index 915e040..d5399bc 100644 --- a/crates/filesystem-sdk/Cargo.toml +++ b/crates/filesystem-sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shellcanvas-filesystem-sdk" -version = "0.1.7" +version = "0.1.8" edition = "2021" license = "MPL-2.0" description = "Optional filesystem handles and native bridge protocol for ShellCanvas" diff --git a/package-lock.json b/package-lock.json index 4be9144..1f97461 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "shellcanvas", - "version": "0.1.7", + "version": "0.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shellcanvas", - "version": "0.1.7", + "version": "0.1.8", "hasInstallScript": true, "license": "MPL-2.0", "workspaces": [ @@ -2556,7 +2556,7 @@ }, "packages/app-sdk": { "name": "@techartdev/shellcanvas-app-sdk", - "version": "0.1.7", + "version": "0.1.8", "license": "MPL-2.0", "dependencies": { "esbuild": "0.25.12" diff --git a/package.json b/package.json index ac54aa6..39cc2cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "shellcanvas", - "version": "0.1.7", + "version": "0.1.8", "private": true, "type": "module", "license": "MPL-2.0", diff --git a/packages/app-sdk/package.json b/packages/app-sdk/package.json index 18fa7b8..3a431ca 100644 --- a/packages/app-sdk/package.json +++ b/packages/app-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@techartdev/shellcanvas-app-sdk", - "version": "0.1.7", + "version": "0.1.8", "description": "Typed runtime app API and app packaging tools for ShellCanvas", "keywords": ["shellcanvas", "desktop", "remote", "sdk", "extensions"], "homepage": "https://shellcanvas.com/docs/app-sdk/quickstart.html", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 25f96fb..978dfbf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shellcanvas" -version = "0.1.7" +version = "0.1.8" edition = "2021" license = "MPL-2.0" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index def38ac..c80c642 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ShellCanvas", - "version": "0.1.7", + "version": "0.1.8", "identifier": "dev.shellcanvas.client", "build": { "beforeDevCommand": "npm run dev",