From 8c548d6599d513ff1e26767f8a664025367fde7e Mon Sep 17 00:00:00 2001 From: Miguel Angel Ajo Pelayo Date: Fri, 9 Oct 2026 13:28:04 +0200 Subject: [PATCH 01/15] Add SSH remote block writer Stream HTTP and OCI images using the embedded write head. Support password auth, compression, ZERO fills and device-size bounds. Assisted-by: OpenCode Co-Authored-By: GPT-6.1 Sol --- .github/workflows/ci.yml | 25 +- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 28 +- build.rs | 12 + src/fls/block_writer.rs | 57 ++++ src/fls/from_url.rs | 18 +- src/fls/mod.rs | 1 + src/fls/oci/from_oci.rs | 30 +- src/fls/options.rs | 6 + src/fls/ssh_writer.rs | 577 +++++++++++++++++++++++++++++++++++++++ src/main.rs | 20 +- tests/ssh_remote.rs | 148 ++++++++++ 13 files changed, 883 insertions(+), 41 deletions(-) create mode 100644 build.rs create mode 100644 src/fls/ssh_writer.rs create mode 100644 tests/ssh_remote.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbab6d6..f21b10e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: [main, master] pull_request: - branches: [main, master] env: CARGO_TERM_COLOR: always @@ -13,9 +12,7 @@ permissions: contents: read jobs: - # runs first: builds the write-head binaries and leaves them as artifacts - # for later fls jobs (add `needs: write-head` + actions/download-artifact - # in a job that wants them) + # Build the write head before the Rust jobs embed it. write-head: name: Write head (fls-wh) runs-on: ubuntu-latest @@ -70,10 +67,16 @@ jobs: lint: name: Lint + needs: write-head runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: flswh-aarch64-qnx7 + path: remote/ + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: @@ -105,10 +108,16 @@ jobs: test: name: Test + needs: write-head runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: flswh-aarch64-qnx7 + path: remote/ + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -135,6 +144,7 @@ jobs: build: name: Build (${{ matrix.target }}) + needs: write-head runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -159,6 +169,11 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: flswh-aarch64-qnx7 + path: remote/ + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: @@ -210,5 +225,3 @@ jobs: path: ${{ matrix.artifact_name }} if-no-files-found: error - - diff --git a/Cargo.lock b/Cargo.lock index ac194de..eff21be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -408,6 +408,7 @@ dependencies = [ "base64", "bytes", "clap", + "crc32fast", "flate2", "futures-util", "http-body", diff --git a/Cargo.toml b/Cargo.toml index 9a5773f..605379b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ reqwest = { version = "0.12", default-features = false, features = ["stream", "r tokio = { version = "1.0", features = ["full", "process", "rt-multi-thread"] } futures-util = "0.3" bytes = "1.0" +crc32fast = "1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" base64 = "0.22" diff --git a/README.md b/README.md index 8deb5fb..ac0bda1 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,33 @@ sudo cp target/release/fls /usr/local/bin/ ## Usage +### Remote devices over SSH + +Use `[user@]host:/absolute/device` as the destination for HTTP/HTTPS or OCI images: + +```bash +fls from-url "https://example.com/image.img.xz" root@board:/dev/emmc0 +fls from-url "oci://quay.io/org/image:latest" root@board:/dev/emmc0 +``` + +`fls` uploads its embedded aarch64-QNX write head over SSH, then streams framed +commands. For another target architecture/OS, build the appropriate write head +and use `--wh-bin /path/to/flswh`. When building from source, run +`make remote-wh` before `cargo build` to embed the QNX binary; local-only builds +work without it. See [remote/README.md](remote/README.md) for build instructions. + +SSH keys, agents, host keys, ports, and aliases use the system `ssh` and your +`~/.ssh/config`. Password authentication uses `sshpass` with +`--ssh-password-file ` (`FLS_SSH_PASS_FILE`) or, if no file is specified, +the `SSHPASS` environment variable. `--ssh-compress` enables SSH compression; +`--wh-bin` can also be supplied through `FLS_WH_BIN`. + +Remote zero fills use ZERO rather than transferring their contents. The writer +checks write, fill, and seek ranges against remote device capacity, and waits +for successful SYNC and QUIT before reporting completion. `--o-direct` applies +to local devices; remote durability uses SYNC. One flash session per host is +supported because uploads share `/tmp/fls-wh`. + ### Basic Example Flash a compressed image from a URL to a block device: @@ -181,4 +208,3 @@ fls from-url [OPTIONS] - Verify the device path to avoid data loss - Use `lsblk` or `fdisk -l` to identify the correct device before flashing - diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..6c43186 --- /dev/null +++ b/build.rs @@ -0,0 +1,12 @@ +use std::{env, fs, path::PathBuf}; + +fn main() { + let src = "remote/flswh-aarch64-qnx7"; + let out = PathBuf::from(env::var_os("OUT_DIR").unwrap()).join("flswh-aarch64-qnx7"); + if std::path::Path::new(src).exists() { + fs::copy(src, out).expect("copy write head"); + } else { + fs::write(out, []).expect("create empty write-head placeholder"); + } + println!("cargo:rerun-if-changed={src}"); +} diff --git a/src/fls/block_writer.rs b/src/fls/block_writer.rs index 0fc38a5..6d135fc 100644 --- a/src/fls/block_writer.rs +++ b/src/fls/block_writer.rs @@ -5,6 +5,9 @@ use std::os::unix::fs::OpenOptionsExt; use std::ptr::NonNull; use tokio::sync::mpsc; +use super::options::FlashOptions; +use super::ssh_writer::{parse_ssh_target, SshBlockWriter}; + const BLOCK_SIZE: usize = 1024 * 1024; // 1MB blocks for better throughput const ALIGNMENT: usize = 4096; // 4KB alignment for direct I/O @@ -593,6 +596,60 @@ impl AsyncBlockWriter { } } +pub(crate) enum DeviceWriter { + Local(AsyncBlockWriter), + Ssh(SshBlockWriter), +} + +impl DeviceWriter { + pub(crate) fn new( + options: &FlashOptions, + progress: mpsc::UnboundedSender, + ) -> io::Result { + if let Some((host, device)) = parse_ssh_target(&options.device) { + Ok(Self::Ssh(SshBlockWriter::new( + host, device, progress, options, + )?)) + } else { + Ok(Self::Local(AsyncBlockWriter::new( + options.device.clone(), + progress, + options.debug, + options.o_direct, + options.write_buffer_size_mb, + )?)) + } + } + + pub(crate) async fn write(&self, data: Vec) -> io::Result<()> { + match self { + Self::Local(writer) => writer.write(data).await, + Self::Ssh(writer) => writer.write(data).await, + } + } + + pub(crate) async fn seek(&self, offset: u64) -> io::Result<()> { + match self { + Self::Local(writer) => writer.seek(offset).await, + Self::Ssh(writer) => writer.seek(offset).await, + } + } + + pub(crate) async fn fill(&self, pattern: [u8; 4], bytes: u64) -> io::Result<()> { + match self { + Self::Local(writer) => writer.fill(pattern, bytes).await, + Self::Ssh(writer) => writer.fill(pattern, bytes).await, + } + } + + pub(crate) async fn close(self) -> io::Result { + match self { + Self::Local(writer) => writer.close().await, + Self::Ssh(writer) => writer.close().await, + } + } +} + /// Helper to write fill pattern efficiently. /// Alignment for O_DIRECT is handled by BlockWriter's internal buffer and flush_buffer(), /// which rounds up to ALIGNMENT when flushing partial blocks. diff --git a/src/fls/from_url.rs b/src/fls/from_url.rs index 6e20da0..cf323a8 100644 --- a/src/fls/from_url.rs +++ b/src/fls/from_url.rs @@ -4,7 +4,7 @@ use std::time::Duration; use tokio::sync::mpsc; use tokio::task::JoinHandle; -use crate::fls::block_writer::AsyncBlockWriter; +use crate::fls::block_writer::DeviceWriter; use crate::fls::byte_channel::byte_bounded_channel; use crate::fls::compression::Compression; use crate::fls::decompress::{get_compression_from_url, start_inprocess_decompressor}; @@ -42,7 +42,7 @@ use crate::fls::download_error::handle_download_retry; /// Execute a sequence of write commands on the block writer async fn execute_write_commands( commands: Vec, - writer: &AsyncBlockWriter, + writer: &DeviceWriter, error_tx: &mpsc::UnboundedSender, debug: bool, ) -> io::Result<()> { @@ -92,7 +92,7 @@ async fn execute_write_commands( async fn process_sparse_data( parser: &mut SparseParser, data: &[u8], - writer: &AsyncBlockWriter, + writer: &DeviceWriter, error_tx: &mpsc::UnboundedSender, debug: bool, ) -> io::Result<()> { @@ -107,7 +107,7 @@ async fn process_sparse_data( /// Write data to the block writer with error reporting async fn write_regular_data( data: Vec, - writer: &AsyncBlockWriter, + writer: &DeviceWriter, error_tx: &mpsc::UnboundedSender, ) -> io::Result<()> { writer.write(data).await.map_err(|e| { @@ -121,7 +121,7 @@ async fn handle_detected_format( format: FileFormat, consumed_bytes: Vec, remaining_data: &[u8], - writer: &AsyncBlockWriter, + writer: &DeviceWriter, error_tx: &mpsc::UnboundedSender, debug: bool, ) -> io::Result> { @@ -187,13 +187,7 @@ pub async fn flash_from_url( ); // Create block writer - let block_writer = AsyncBlockWriter::new( - options.common.device.clone(), - written_progress_tx, - options.common.debug, - options.common.o_direct, - options.common.write_buffer_size_mb, - )?; + let block_writer = DeviceWriter::new(&options.common, written_progress_tx)?; // Create byte-bounded download buffer let buffer_size_mb = options.common.buffer_size_mb; diff --git a/src/fls/mod.rs b/src/fls/mod.rs index ee279ee..9c9262f 100644 --- a/src/fls/mod.rs +++ b/src/fls/mod.rs @@ -17,6 +17,7 @@ pub mod oci; mod options; mod progress; mod simg; +mod ssh_writer; mod stream_utils; // Public re-exports diff --git a/src/fls/oci/from_oci.rs b/src/fls/oci/from_oci.rs index a543dbb..1a8a2e6 100644 --- a/src/fls/oci/from_oci.rs +++ b/src/fls/oci/from_oci.rs @@ -24,7 +24,7 @@ use super::registry::RegistryClient; use crate::fls::annotation_schema::{ effective_schemas, resolve_annotation_schema, searched_keys_display, }; -use crate::fls::block_writer::AsyncBlockWriter; +use crate::fls::block_writer::DeviceWriter; use crate::fls::compression::Compression; use crate::fls::decompress::{spawn_stderr_reader, start_decompressor_process}; use crate::fls::error_handling::process_error_messages; @@ -915,7 +915,7 @@ fn sanitize_partition_name(name: &str) -> Result { /// Execute a sequence of write commands on the block writer async fn execute_write_commands( commands: Vec, - writer: &AsyncBlockWriter, + writer: &DeviceWriter, debug: bool, ) -> std::io::Result<()> { for cmd in commands { @@ -953,7 +953,7 @@ async fn execute_write_commands( async fn process_sparse_data( parser: &mut SparseParser, data: &[u8], - writer: &AsyncBlockWriter, + writer: &DeviceWriter, debug: bool, ) -> std::io::Result<()> { let (commands, _consumed) = parser.process(data).map_err(|e| { @@ -970,7 +970,7 @@ async fn handle_detected_format( format: FileFormat, consumed_bytes: Vec, remaining_data: &[u8], - writer: &AsyncBlockWriter, + writer: &DeviceWriter, debug: bool, ) -> std::io::Result> { match format { @@ -1019,7 +1019,7 @@ async fn process_buffer_with_format_detection( detector: &mut FormatDetector, parser: &mut Option, format_determined: &mut bool, - writer: &AsyncBlockWriter, + writer: &DeviceWriter, debug: bool, ) -> std::io::Result<()> { if !*format_determined { @@ -1056,7 +1056,7 @@ async fn process_buffer_with_format_detection( async fn finalize_format_at_eof( detector: &mut FormatDetector, format_determined: bool, - writer: &AsyncBlockWriter, + writer: &DeviceWriter, debug: bool, ) -> std::io::Result<()> { if !format_determined { @@ -1160,13 +1160,7 @@ async fn setup_tar_processing_pipeline( ); // Create block writer - let block_writer = AsyncBlockWriter::new( - options.common.device.clone(), - written_progress_tx.clone(), - options.common.debug, - options.common.o_direct, - options.common.write_buffer_size_mb, - )?; + let block_writer = DeviceWriter::new(&options.common, written_progress_tx.clone())?; // Spawn task: decompressor stdout -> block writer with sparse image detection let error_tx_clone = error_tx.clone(); @@ -1449,7 +1443,7 @@ async fn coordinate_download_and_processing( async fn setup_inprocess_decompression_pipeline( http_rx: ByteBoundedReceiver, - block_writer: AsyncBlockWriter, + block_writer: DeviceWriter, decompressed_progress_tx: mpsc::UnboundedSender, compression_type: Compression, debug: bool, @@ -2384,13 +2378,7 @@ async fn flash_raw_disk_image_directly( let (raw_written_progress_tx, raw_written_progress_rx) = mpsc::unbounded_channel::(); // Create block writer - let block_writer = AsyncBlockWriter::new( - options.common.device.clone(), - raw_written_progress_tx, - options.common.debug, - options.common.o_direct, - options.common.write_buffer_size_mb, - )?; + let block_writer = DeviceWriter::new(&options.common, raw_written_progress_tx)?; // Set up byte-bounded streaming pipeline let buffer_size_mb = options.common.buffer_size_mb; diff --git a/src/fls/options.rs b/src/fls/options.rs index 870efe4..eb46d80 100644 --- a/src/fls/options.rs +++ b/src/fls/options.rs @@ -21,6 +21,9 @@ pub struct FlashOptions { pub newline_progress: bool, pub show_memory: bool, pub xz_memlimit_mb: u64, + pub ssh_password_file: Option, + pub ssh_compress: bool, + pub wh_bin: Option, } impl Default for FlashOptions { @@ -37,6 +40,9 @@ impl Default for FlashOptions { newline_progress: false, show_memory: false, xz_memlimit_mb: DEFAULT_XZ_MEMLIMIT_MB, + ssh_password_file: None, + ssh_compress: false, + wh_bin: None, } } } diff --git a/src/fls/ssh_writer.rs b/src/fls/ssh_writer.rs new file mode 100644 index 0000000..d865f56 --- /dev/null +++ b/src/fls/ssh_writer.rs @@ -0,0 +1,577 @@ +use std::io::{self, BufReader, Read, Write}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use tokio::sync::mpsc; + +use super::block_writer::WriterCommand; +use super::options::FlashOptions; + +const MAGIC: [u8; 4] = *b"FLSW"; +const PROTO_VER: u8 = 1; +const OP_DATA: u8 = 0x01; +const OP_SEEK: u8 = 0x03; +const OP_SYNC: u8 = 0x04; +const OP_QUIT: u8 = 0x05; +const OP_ZERO: u8 = 0x09; +const OP_SIZE: u8 = 0x0a; +const R_READY: u8 = 0x81; +const R_ACK: u8 = 0x82; +const R_PROG: u8 = 0x83; +const R_ERR: u8 = 0x84; +const R_DONE: u8 = 0x85; +const R_SIZE: u8 = 0x89; +const CHUNK_SIZE: usize = 8 << 20; +const EMBEDDED_WH: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/flswh-aarch64-qnx7")); +const REMOTE_WH: &str = "/tmp/fls-wh"; + +fn invalid(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} + +fn encode_frame(op: u8, payload: &[u8]) -> io::Result> { + let length = u32::try_from(payload.len()).map_err(|_| invalid("Frame payload too large"))?; + let mut frame = Vec::with_capacity(9 + payload.len()); + frame.extend_from_slice(&MAGIC); + frame.push(op); + frame.extend_from_slice(&length.to_le_bytes()); + frame.extend_from_slice(payload); + Ok(frame) +} + +fn encode_data_frame(data: &[u8]) -> io::Result> { + if data.len() > CHUNK_SIZE { + return Err(invalid("DATA chunk exceeds 8 MiB")); + } + let mut payload = Vec::with_capacity(data.len() + 8); + payload.extend_from_slice(&(data.len() as u32).to_le_bytes()); + payload.extend_from_slice(data); + payload.extend_from_slice(&crc32fast::hash(data).to_le_bytes()); + encode_frame(OP_DATA, &payload) +} + +struct FrameReader { + reader: BufReader, +} + +impl FrameReader { + fn new(reader: R) -> Self { + Self { + reader: BufReader::new(reader), + } + } + + fn read_frame(&mut self) -> io::Result<(u8, Vec)> { + let mut magic = [0; 4]; + self.reader.read_exact(&mut magic)?; + while magic != MAGIC { + magic.copy_within(1.., 0); + self.reader.read_exact(&mut magic[3..])?; + } + let mut header = [0; 5]; + self.reader.read_exact(&mut header)?; + let length = u32::from_le_bytes(header[1..].try_into().unwrap()) as usize; + // READ responses are capped at 1 MiB; all other records are smaller. + if length > 1 << 20 { + return Err(invalid("Remote response exceeds 1 MiB")); + } + let mut payload = vec![0; length]; + self.reader.read_exact(&mut payload)?; + Ok((header[0], payload)) + } +} + +pub(crate) fn parse_ssh_target(device: &str) -> Option<(String, String)> { + if device.starts_with('/') { + return None; + } + device + .split_once(':') + .map(|(host, path)| (host.to_owned(), path.to_owned())) +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +fn ssh_command(host: &str, remote_command: &str, options: &FlashOptions) -> Command { + let mut command = if let Some(file) = &options.ssh_password_file { + let mut command = Command::new("sshpass"); + command.args(["-f", file, "ssh"]); + command + } else if std::env::var_os("SSHPASS").is_some() { + let mut command = Command::new("sshpass"); + command.args(["-e", "ssh"]); + command + } else { + Command::new("ssh") + }; + if options.ssh_compress { + command.arg("-C"); + } + command.args(["-T", "--", host, remote_command]); + command +} + +fn upload_wh(host: &str, binary: &[u8], options: &FlashOptions) -> io::Result<()> { + // QNX boot-image utilities may be absent from the default PATH. + let remote_command = + format!("PATH=/proc/boot:/usr/bin:/bin:$PATH; cat > {REMOTE_WH} && chmod +x {REMOTE_WH}"); + // ponytail: one upload path per host; use unique paths for concurrent flash sessions. + let mut child = ssh_command(host, &remote_command, options) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn()?; + let result = child.stdin.take().unwrap().write_all(binary); + if let Err(error) = result { + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + } + let status = child.wait()?; + if !status.success() { + return Err(io::Error::other(format!( + "Write-head upload failed: {status}" + ))); + } + Ok(()) +} + +struct Session { + child: Child, + input: Option, + output: FrameReader, + progress: mpsc::UnboundedSender, + cursor: u64, + size: u64, +} + +impl Session { + fn new(mut child: Child, progress: mpsc::UnboundedSender) -> io::Result { + let input = child.stdin.take().unwrap(); + let output = FrameReader::new(child.stdout.take().unwrap()); + let mut session = Self { + child, + input: Some(input), + output, + progress, + cursor: 0, + size: 0, + }; + let (op, payload) = session.response()?; + if op != R_READY || payload.len() != 9 || payload[0] != PROTO_VER { + return Err(invalid( + "Invalid write-head READY or unsupported protocol version", + )); + } + let advertised = u64::from_le_bytes(payload[1..].try_into().unwrap()); + // SIZE distinguishes an unknown startup size from a genuinely empty device. + session.send(OP_SIZE, &[])?; + let (op, payload) = session.response()?; + if op != R_SIZE || payload.len() != 8 { + return Err(invalid("Invalid write-head SIZE response")); + } + session.size = u64::from_le_bytes(payload[..].try_into().unwrap()); + if advertised != session.size { + return Err(invalid("READY and SIZE disagree about device capacity")); + } + Ok(session) + } + + fn send(&mut self, op: u8, payload: &[u8]) -> io::Result<()> { + self.input + .as_mut() + .unwrap() + .write_all(&encode_frame(op, payload)?) + } + + fn response(&mut self) -> io::Result<(u8, Vec)> { + let (op, payload) = self.output.read_frame()?; + if op == R_ERR { + if payload.len() != 14 { + return Err(invalid("Malformed remote ERR")); + } + let code = payload[0]; + let command = payload[1]; + let errno = u32::from_le_bytes(payload[2..6].try_into().unwrap()); + let offset = u64::from_le_bytes(payload[6..].try_into().unwrap()); + let reason = match code { + 0 => "device I/O error", + 1 => "CRC mismatch", + 2 => "unsupported or malformed command", + _ => "unknown error", + }; + return Err(io::Error::other(format!( + "Remote {reason}: opcode {command:#04x}, errno {errno}, offset {offset}" + ))); + } + Ok((op, payload)) + } + + fn expect_ok(&mut self, command: u8, start: u64, bytes: u64) -> io::Result<()> { + let mut last_done = 0; + loop { + let (op, payload) = self.response()?; + if op == R_PROG && matches!(command, OP_DATA | OP_ZERO) && payload.len() == 16 { + let offset = u64::from_le_bytes(payload[..8].try_into().unwrap()); + let done = u64::from_le_bytes(payload[8..].try_into().unwrap()); + if done < last_done || done > bytes || start.checked_add(done) != Some(offset) { + return Err(invalid("Invalid remote PROG range")); + } + last_done = done; + let _ = self.progress.send(offset); + } else if op == R_ACK && payload.len() == 17 && payload[0] == command { + let offset = u64::from_le_bytes(payload[1..9].try_into().unwrap()); + let written = u64::from_le_bytes(payload[9..].try_into().unwrap()); + if offset != start || written != bytes { + return Err(invalid("Remote OK does not match the command range")); + } + return Ok(()); + } else { + return Err(invalid(format!( + "Unexpected response {op:#04x} to {command:#04x}" + ))); + } + } + } + + fn check_range(&self, bytes: u64) -> io::Result<()> { + if self.cursor > self.size || bytes > self.size - self.cursor { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "Write at {} of {bytes} bytes exceeds remote device size {}", + self.cursor, self.size + ), + )); + } + Ok(()) + } + + fn write(&mut self, data: &[u8]) -> io::Result<()> { + self.check_range(data.len() as u64)?; + for chunk in data.chunks(CHUNK_SIZE) { + self.input + .as_mut() + .unwrap() + .write_all(&encode_data_frame(chunk)?)?; + self.expect_ok(OP_DATA, self.cursor, chunk.len() as u64)?; + self.cursor += chunk.len() as u64; + let _ = self.progress.send(self.cursor); + } + Ok(()) + } + + fn seek(&mut self, offset: u64) -> io::Result<()> { + if offset > self.size { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Seek exceeds remote device size", + )); + } + self.send(OP_SEEK, &offset.to_le_bytes())?; + self.expect_ok(OP_SEEK, offset, 0)?; + self.cursor = offset; + let _ = self.progress.send(offset); + Ok(()) + } + + fn fill(&mut self, pattern: [u8; 4], bytes: u64) -> io::Result<()> { + self.check_range(bytes)?; + if pattern == [0; 4] { + self.send(OP_ZERO, &bytes.to_le_bytes())?; + self.expect_ok(OP_ZERO, self.cursor, bytes)?; + self.cursor += bytes; + let _ = self.progress.send(self.cursor); + } else { + // ponytail: nonzero fills travel as DATA; add FILL if bandwidth matters. + let mut buffer = vec![0; CHUNK_SIZE]; + for chunk in buffer.chunks_exact_mut(4) { + chunk.copy_from_slice(&pattern); + } + let mut remaining = bytes; + while remaining > 0 { + let n = remaining.min(CHUNK_SIZE as u64) as usize; + self.write(&buffer[..n])?; + remaining -= n as u64; + } + } + Ok(()) + } + + fn finish(mut self) -> io::Result { + self.send(OP_SYNC, &[])?; + self.expect_ok(OP_SYNC, self.cursor, 0)?; + self.send(OP_QUIT, &[])?; + let (op, payload) = self.response()?; + if op != R_DONE || payload.len() != 8 { + return Err(invalid("Invalid write-head DONE")); + } + self.input.take(); + let status = self.child.wait()?; + if !status.success() { + return Err(io::Error::other(format!( + "Remote writer exited with {status}" + ))); + } + Ok(u64::from_le_bytes(payload[..].try_into().unwrap())) + } +} + +impl Drop for Session { + fn drop(&mut self) { + self.input.take(); + // std::process::Child does not reap or terminate on drop. + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +pub(crate) struct SshBlockWriter { + writer_tx: mpsc::Sender, + writer_handle: tokio::task::JoinHandle>, +} + +impl SshBlockWriter { + pub(crate) fn new( + host: String, + device: String, + progress: mpsc::UnboundedSender, + options: &FlashOptions, + ) -> io::Result { + if host.is_empty() + || host.starts_with('-') + || host.contains('\0') + || device.is_empty() + || !device.starts_with('/') + || device.contains('\0') + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Expected [user@]host:/absolute/device", + )); + } + let binary = match &options.wh_bin { + Some(path) => std::fs::read(path)?, + None => EMBEDDED_WH.to_vec(), + }; + if binary.is_empty() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "No embedded write head: build it with make remote-wh or pass --wh-bin", + )); + } + let options = options.clone(); + let (writer_tx, mut writer_rx) = mpsc::channel((options.write_buffer_size_mb / 8).max(1)); + let writer_handle = tokio::task::spawn_blocking(move || { + upload_wh(&host, &binary, &options)?; + let remote_command = format!("{REMOTE_WH} {}", shell_quote(&device)); + let child = ssh_command(&host, &remote_command, &options) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn()?; + let mut session = Session::new(child, progress)?; + if options.debug { + eprintln!("[DEBUG] Remote device size: {} bytes", session.size); + } + while let Some(command) = writer_rx.blocking_recv() { + match command { + WriterCommand::Write(data) => session.write(&data)?, + WriterCommand::Seek(offset) => session.seek(offset)?, + WriterCommand::Fill { pattern, bytes } => session.fill(pattern, bytes)?, + } + } + session.finish() + }); + Ok(Self { + writer_tx, + writer_handle, + }) + } + + pub(crate) async fn write(&self, data: Vec) -> io::Result<()> { + self.send(WriterCommand::Write(data)).await + } + + pub(crate) async fn seek(&self, offset: u64) -> io::Result<()> { + self.send(WriterCommand::Seek(offset)).await + } + + pub(crate) async fn fill(&self, pattern: [u8; 4], bytes: u64) -> io::Result<()> { + self.send(WriterCommand::Fill { pattern, bytes }).await + } + + async fn send(&self, command: WriterCommand) -> io::Result<()> { + self.writer_tx + .send(command) + .await + .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "SSH writer channel closed")) + } + + pub(crate) async fn close(self) -> io::Result { + drop(self.writer_tx); + self.writer_handle.await.map_err(io::Error::other)? + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Cursor, Seek}; + use std::sync::OnceLock; + + fn native_binary() -> &'static std::path::Path { + static BUILD: OnceLock = OnceLock::new(); + BUILD + .get_or_init(|| { + let dir = tempfile::tempdir().unwrap(); + let source = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("remote/fls-wh.c"); + assert!(Command::new("cc") + .arg("-O2") + .arg(source) + .arg("-o") + .arg(dir.path().join("fls-wh")) + .status() + .unwrap() + .success()); + dir + }) + .path() + } + + fn native_session(device: &std::path::Path) -> Session { + let child = Command::new(native_binary().join("fls-wh")) + .arg(device) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + Session::new(child, mpsc::unbounded_channel().0).unwrap() + } + + #[test] + fn framing_crc_resync_and_bounds() { + let encoded = encode_data_frame(b"123456789").unwrap(); + assert_eq!(&encoded[..13], b"FLSW\x01\x11\x00\x00\x00\x09\x00\x00\x00"); + assert_eq!(&encoded[encoded.len() - 4..], &0xcbf43926u32.to_le_bytes()); + let mut stream = b"junkFLSF".to_vec(); + stream.extend_from_slice(&encoded); + let (op, payload) = FrameReader::new(Cursor::new(stream)).read_frame().unwrap(); + assert_eq!(op, OP_DATA); + assert_eq!(payload.len(), 17); + let mut truncated = encoded.clone(); + truncated.pop(); + assert_eq!( + FrameReader::new(Cursor::new(truncated)) + .read_frame() + .unwrap_err() + .kind(), + io::ErrorKind::UnexpectedEof + ); + let mut oversized = b"FLSW\x86".to_vec(); + oversized.extend_from_slice(&u32::MAX.to_le_bytes()); + assert_eq!( + FrameReader::new(Cursor::new(oversized)) + .read_frame() + .unwrap_err() + .kind(), + io::ErrorKind::InvalidData + ); + } + + #[test] + fn target_parsing_auth_and_shell_quoting() { + assert_eq!( + parse_ssh_target("root@board:/dev/emmc0"), + Some(("root@board".into(), "/dev/emmc0".into())) + ); + assert_eq!(parse_ssh_target("/dev/disk:local"), None); + assert_eq!(parse_ssh_target("image.img"), None); + let options = FlashOptions { + ssh_password_file: Some("password file".into()), + ssh_compress: true, + ..Default::default() + }; + let command = ssh_command("board", "echo ready", &options); + assert_eq!(command.get_program(), "sshpass"); + let args: Vec<_> = command + .get_args() + .map(|arg| arg.to_str().unwrap()) + .collect(); + assert_eq!( + args, + [ + "-f", + "password file", + "ssh", + "-C", + "-T", + "--", + "board", + "echo ready" + ] + ); + let path = "/dev/a'b; $(echo injected)"; + let output = Command::new("sh") + .args(["-c", &format!("printf '%s' {}", shell_quote(path))]) + .output() + .unwrap(); + assert!(output.status.success()); + assert_eq!(output.stdout, path.as_bytes()); + } + + #[test] + fn native_write_head_round_trip_and_capacity() { + let mut device = tempfile::NamedTempFile::new().unwrap(); + device.write_all(&vec![0x55; CHUNK_SIZE * 3]).unwrap(); + let mut session = native_session(device.path()); + let (progress_tx, mut progress_rx) = mpsc::unbounded_channel(); + session.progress = progress_tx; + session.write(&vec![0xa5; CHUNK_SIZE + 17]).unwrap(); + session.seek(32).unwrap(); + session.fill([0; 4], (CHUNK_SIZE + 5) as u64).unwrap(); + session.seek(16).unwrap(); + session.fill([1, 2, 3, 4], 7).unwrap(); + assert_eq!( + session.fill([0; 4], u64::MAX).unwrap_err().kind(), + io::ErrorKind::InvalidInput + ); + assert_eq!( + session.seek(u64::MAX).unwrap_err().kind(), + io::ErrorKind::InvalidInput + ); + session.seek(session.size - 1).unwrap(); + assert_eq!( + session.write(&[1, 2]).unwrap_err().kind(), + io::ErrorKind::InvalidInput + ); + let total = session.finish().unwrap(); + assert_eq!(total, (2 * CHUNK_SIZE + 29) as u64); + assert!(progress_rx.try_recv().is_ok()); + device.seek(io::SeekFrom::Start(0)).unwrap(); + let mut prefix = [0; 32]; + device.read_exact(&mut prefix).unwrap(); + assert_eq!(&prefix[..16], &[0xa5; 16]); + assert_eq!(&prefix[16..23], &[1, 2, 3, 4, 1, 2, 3]); + let mut zeros = vec![1; CHUNK_SIZE + 5]; + device.read_exact(&mut zeros).unwrap(); + assert!(zeros.iter().all(|&byte| byte == 0)); + let mut tail = [0; 1]; + device.read_exact(&mut tail).unwrap(); + assert_eq!(tail, [0x55]); + } + + #[test] + fn native_crc_error_is_propagated() { + let device = tempfile::NamedTempFile::new().unwrap(); + device.as_file().set_len(32).unwrap(); + let mut session = native_session(device.path()); + let mut frame = encode_data_frame(b"test").unwrap(); + *frame.last_mut().unwrap() ^= 1; + session.input.as_mut().unwrap().write_all(&frame).unwrap(); + let error = session.expect_ok(OP_DATA, 0, 4).unwrap_err(); + assert!(error.to_string().contains("CRC mismatch"), "{error}"); + assert!(error.to_string().contains("opcode 0x01"), "{error}"); + } +} diff --git a/src/main.rs b/src/main.rs index 2f86b4f..5588aa8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,7 +34,7 @@ enum Commands { FromUrl { /// URL to download the image from (http://, https://, oci://) url: String, - /// Destination device path (e.g., /dev/sdb) + /// Destination device path or SSH target ([user@]host:/dev/emmc0) device: String, /// Path to CA certificate PEM file for TLS validation #[arg(long)] @@ -75,6 +75,15 @@ enum Commands { /// XZ decompression memory limit in MB (exceeds = single-thread fallback, then error) #[arg(long, default_value = "256")] xz_memlimit: u64, + /// SSH password file (uses sshpass -f) + #[arg(long, env = "FLS_SSH_PASS_FILE")] + ssh_password_file: Option, + /// Enable SSH compression + #[arg(long)] + ssh_compress: bool, + /// Write-head binary to upload (default: embedded aarch64-qnx7) + #[arg(long, env = "FLS_WH_BIN")] + wh_bin: Option, /// Registry username for OCI authentication #[arg(short = 'u', long, env = "FLS_REGISTRY_USERNAME")] username: Option, @@ -140,6 +149,9 @@ async fn main() { password, file_pattern, xz_memlimit, + ssh_password_file, + ssh_compress, + wh_bin, } => { // Detect URL scheme to determine handler let is_oci = url.starts_with("oci://"); @@ -193,6 +205,9 @@ async fn main() { newline_progress, show_memory, xz_memlimit_mb: xz_memlimit, + ssh_password_file, + ssh_compress, + wh_bin, }, username, password, @@ -272,6 +287,9 @@ async fn main() { newline_progress, show_memory, xz_memlimit_mb: xz_memlimit, + ssh_password_file, + ssh_compress, + wh_bin, }, max_retries, retry_delay_secs: retry_delay, diff --git a/tests/ssh_remote.rs b/tests/ssh_remote.rs new file mode 100644 index 0000000..39ee5bb --- /dev/null +++ b/tests/ssh_remote.rs @@ -0,0 +1,148 @@ +use flate2::{write::GzEncoder, Compression}; +use std::io::Write; +use std::os::unix::fs::PermissionsExt; +use std::time::Duration; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[tokio::test] +async fn http_cli_uploads_and_flashes_with_all_ssh_auth_modes() { + let dir = tempfile::tempdir().unwrap(); + let native = dir.path().join("native-write-head"); + assert!(std::process::Command::new("cc") + .args(["-O2", "remote/fls-wh.c", "-o"]) + .arg(&native) + .status() + .unwrap() + .success()); + + // Stand in for SSH locally; each subprocess has its own PATH and credentials. + // Keep uploads inside the test directory rather than the host's /tmp. + let ssh = dir.path().join("ssh"); + std::fs::write( + &ssh, + r#"#!/bin/sh +if [ "$1" = -C ]; then echo compression >> "$SSH_LOG"; shift; fi +[ "$1" = -T ] || exit 40 +shift +[ "$1" = -- ] || exit 41 +shift +[ "$1" = root@fake-host ] || exit 42 +shift +case "$1" in + PATH=*) cat > "$WH_UPLOAD" && chmod +x "$WH_UPLOAD" ;; + /tmp/fls-wh\ *) command=${1#/tmp/fls-wh}; exec sh -c "\"$WH_UPLOAD\"$command" ;; + *) exit 43 ;; +esac +"#, + ) + .unwrap(); + std::fs::set_permissions(&ssh, std::fs::Permissions::from_mode(0o755)).unwrap(); + let sshpass = dir.path().join("sshpass"); + std::fs::write( + &sshpass, + r#"#!/bin/sh +case "$1" in + -f) [ -r "$2" ] || exit 44; echo file >> "$SSH_LOG"; shift 2 ;; + -e) [ -n "$SSHPASS" ] || exit 45; echo env >> "$SSH_LOG"; shift ;; + *) exit 46 ;; +esac +exec "$@" +"#, + ) + .unwrap(); + std::fs::set_permissions(&sshpass, std::fs::Permissions::from_mode(0o755)).unwrap(); + let password = dir.path().join("password"); + std::fs::write(&password, "test-password\n").unwrap(); + + let server = MockServer::start().await; + let image: Vec = (0..(1 << 20) + 17).map(|i| (i % 251) as u8).collect(); + let mut gzip = GzEncoder::new(Vec::new(), Compression::default()); + gzip.write_all(&image).unwrap(); + Mock::given(method("GET")) + .and(path("/image.img.gz")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(gzip.finish().unwrap())) + .mount(&server) + .await; + + for auth in ["key", "env", "file", "oversize"] { + let device = dir.path().join(format!("block '{auth};device")); + let file = std::fs::File::create(&device).unwrap(); + file.set_len(if auth == "oversize" { + 1024 + } else { + (image.len() + 1024) as u64 + }) + .unwrap(); + let log = dir.path().join(format!("{auth}.log")); + let upload = dir.path().join(format!("{auth}.uploaded")); + let mut command = tokio::process::Command::new(env!("CARGO_BIN_EXE_fls")); + command + .args([ + "from-url", + &format!("{}/image.img.gz", server.uri()), + &format!("root@fake-host:{}", device.display()), + "--ssh-compress", + "--wh-bin", + ]) + .arg(&native) + .arg("--newline-progress") + .env( + "PATH", + format!( + "{}:{}", + dir.path().display(), + std::env::var("PATH").unwrap() + ), + ) + .env("WH_UPLOAD", &upload) + .env("SSH_LOG", &log) + .env_remove("SSHPASS") + .env_remove("FLS_SSH_PASS_FILE") + .env_remove("FLS_WH_BIN") + .kill_on_drop(true); + match auth { + "env" => { + command.env("SSHPASS", "test-password"); + } + "file" => { + // File authentication must win even when SSHPASS is set. + command + .env("SSHPASS", "ignored-password") + .env("FLS_SSH_PASS_FILE", &password); + } + _ => {} + } + let output = tokio::time::timeout(Duration::from_secs(30), command.output()) + .await + .unwrap() + .unwrap(); + if auth == "oversize" { + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("exceeds remote device size")); + assert_eq!(std::fs::metadata(&device).unwrap().len(), 1024); + continue; + } + assert!( + output.status.success(), + "{auth}: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(&std::fs::read(&device).unwrap()[..image.len()], &image); + assert_eq!( + std::fs::read(upload).unwrap(), + std::fs::read(&native).unwrap() + ); + let log = std::fs::read_to_string(log).unwrap(); + assert_eq!(log.matches("compression").count(), 2); + assert_eq!( + log.matches("file").count(), + if auth == "file" { 2 } else { 0 } + ); + assert_eq!( + log.matches("env").count(), + if auth == "env" { 2 } else { 0 } + ); + } +} From 9312d2b33bea6d29e1135755e90e80dd08ad5b2b Mon Sep 17 00:00:00 2001 From: Miguel Angel Ajo Pelayo Date: Fri, 9 Oct 2026 13:45:50 +0200 Subject: [PATCH 02/15] Add SSH port override and platform detection Probe OS, CPU and release before uploading a compatible write head. Preserve startup errors when the writer channel closes. Assisted-by: OpenCode Co-Authored-By: GPT-6.1 Sol --- README.md | 11 +++- src/fls/options.rs | 2 + src/fls/ssh_writer.rs | 125 +++++++++++++++++++++++++++++++++++------- src/main.rs | 6 ++ tests/ssh_remote.rs | 45 +++++++++++++-- 5 files changed, 162 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index ac0bda1..544f76c 100644 --- a/README.md +++ b/README.md @@ -53,11 +53,19 @@ and use `--wh-bin /path/to/flswh`. When building from source, run work without it. See [remote/README.md](remote/README.md) for build instructions. SSH keys, agents, host keys, ports, and aliases use the system `ssh` and your -`~/.ssh/config`. Password authentication uses `sshpass` with +`~/.ssh/config`. `--ssh-port ` overrides the port for platform detection, +upload, and flashing; without it the SSH configuration is used. `-p` remains +the registry-password option. Password authentication uses `sshpass` with `--ssh-password-file ` (`FLS_SSH_PASS_FILE`) or, if no file is specified, the `SSHPASS` environment variable. `--ssh-compress` enables SSH compression; `--wh-bin` can also be supplied through `FLS_WH_BIN`. +Before uploading, `fls` queries the remote OS, CPU architecture, and release +using `uname` (`-p` for the QNX processor, `-m` elsewhere). The embedded binary +is selected only for aarch64 QNX 7. Other platforms require `--wh-bin` with a +compatible write head; automatic Linux/macOS binary embedding can be added +when those artifacts are available. + Remote zero fills use ZERO rather than transferring their contents. The writer checks write, fill, and seek ranges against remote device capacity, and waits for successful SYNC and QUIT before reporting completion. `--o-direct` applies @@ -207,4 +215,3 @@ fls from-url [OPTIONS] - Ensure the target device is not mounted - Verify the device path to avoid data loss - Use `lsblk` or `fdisk -l` to identify the correct device before flashing - diff --git a/src/fls/options.rs b/src/fls/options.rs index eb46d80..10fe2aa 100644 --- a/src/fls/options.rs +++ b/src/fls/options.rs @@ -23,6 +23,7 @@ pub struct FlashOptions { pub xz_memlimit_mb: u64, pub ssh_password_file: Option, pub ssh_compress: bool, + pub ssh_port: Option, pub wh_bin: Option, } @@ -42,6 +43,7 @@ impl Default for FlashOptions { xz_memlimit_mb: DEFAULT_XZ_MEMLIMIT_MB, ssh_password_file: None, ssh_compress: false, + ssh_port: None, wh_bin: None, } } diff --git a/src/fls/ssh_writer.rs b/src/fls/ssh_writer.rs index d865f56..6656ca2 100644 --- a/src/fls/ssh_writer.rs +++ b/src/fls/ssh_writer.rs @@ -1,5 +1,6 @@ use std::io::{self, BufReader, Read, Write}; use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::sync::{Arc, Mutex}; use tokio::sync::mpsc; use super::block_writer::WriterCommand; @@ -107,10 +108,51 @@ fn ssh_command(host: &str, remote_command: &str, options: &FlashOptions) -> Comm if options.ssh_compress { command.arg("-C"); } + if let Some(port) = options.ssh_port { + command.arg("-p").arg(port.to_string()); + } command.args(["-T", "--", host, remote_command]); command } +fn detect_target(host: &str, options: &FlashOptions) -> io::Result<(String, String, String)> { + let command = "PATH=/proc/boot:/usr/bin:/bin:$PATH; \ + os=$(uname -s) || exit; echo \"$os\"; \ + if [ \"$os\" = QNX ]; then uname -p; else uname -m; fi || exit; uname -r"; + let output = ssh_command(host, command, options) + .stdin(Stdio::null()) + .stderr(Stdio::inherit()) + .output()?; + if !output.status.success() { + return Err(io::Error::other(format!( + "Remote platform detection failed: {}", + output.status + ))); + } + let text = std::str::from_utf8(&output.stdout) + .map_err(|_| invalid("Remote platform response is not UTF-8"))?; + let fields: Vec<_> = text.lines().map(str::trim).collect(); + if fields.len() != 3 || fields.iter().any(|field| field.is_empty()) { + return Err(invalid( + "Expected remote OS, processor architecture, and release", + )); + } + Ok((fields[0].into(), fields[1].into(), fields[2].into())) +} + +fn check_embedded_target(os: &str, arch: &str, release: &str) -> io::Result<()> { + if os == "QNX" + && matches!(arch, "aarch64le" | "aarch64" | "arm64") + && release.split('.').next() == Some("7") + { + return Ok(()); + } + Err(io::Error::new( + io::ErrorKind::Unsupported, + format!("Embedded write head requires aarch64 QNX 7; remote is {os} {arch} {release}. Pass --wh-bin built for this target"), + )) +} + fn upload_wh(host: &str, binary: &[u8], options: &FlashOptions) -> io::Result<()> { // QNX boot-image utilities may be absent from the default PATH. let remote_command = @@ -329,6 +371,7 @@ impl Drop for Session { pub(crate) struct SshBlockWriter { writer_tx: mpsc::Sender, writer_handle: tokio::task::JoinHandle>, + writer_error: Arc>>, } impl SshBlockWriter { @@ -350,6 +393,12 @@ impl SshBlockWriter { "Expected [user@]host:/absolute/device", )); } + if options.ssh_port == Some(0) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "SSH port must be between 1 and 65535", + )); + } let binary = match &options.wh_bin { Some(path) => std::fs::read(path)?, None => EMBEDDED_WH.to_vec(), @@ -362,30 +411,47 @@ impl SshBlockWriter { } let options = options.clone(); let (writer_tx, mut writer_rx) = mpsc::channel((options.write_buffer_size_mb / 8).max(1)); + let writer_error = Arc::new(Mutex::new(None)); + let error_state = Arc::clone(&writer_error); let writer_handle = tokio::task::spawn_blocking(move || { - upload_wh(&host, &binary, &options)?; - let remote_command = format!("{REMOTE_WH} {}", shell_quote(&device)); - let child = ssh_command(&host, &remote_command, &options) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) - .spawn()?; - let mut session = Session::new(child, progress)?; - if options.debug { - eprintln!("[DEBUG] Remote device size: {} bytes", session.size); - } - while let Some(command) = writer_rx.blocking_recv() { - match command { - WriterCommand::Write(data) => session.write(&data)?, - WriterCommand::Seek(offset) => session.seek(offset)?, - WriterCommand::Fill { pattern, bytes } => session.fill(pattern, bytes)?, + let result = (|| { + let (os, arch, release) = detect_target(&host, &options)?; + if options.wh_bin.is_none() { + check_embedded_target(&os, &arch, &release)?; + } + if options.debug { + eprintln!("[DEBUG] Remote platform: {os} {arch} {release}"); } + upload_wh(&host, &binary, &options)?; + let remote_command = format!("{REMOTE_WH} {}", shell_quote(&device)); + let child = ssh_command(&host, &remote_command, &options) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn()?; + let mut session = Session::new(child, progress)?; + if options.debug { + eprintln!("[DEBUG] Remote device size: {} bytes", session.size); + } + while let Some(command) = writer_rx.blocking_recv() { + match command { + WriterCommand::Write(data) => session.write(&data)?, + WriterCommand::Seek(offset) => session.seek(offset)?, + WriterCommand::Fill { pattern, bytes } => session.fill(pattern, bytes)?, + } + } + session.finish() + })(); + if let Err(ref error) = result { + *error_state.lock().unwrap() = + Some(io::Error::new(error.kind(), error.to_string())); } - session.finish() + result }); Ok(Self { writer_tx, writer_handle, + writer_error, }) } @@ -402,10 +468,12 @@ impl SshBlockWriter { } async fn send(&self, command: WriterCommand) -> io::Result<()> { - self.writer_tx - .send(command) - .await - .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "SSH writer channel closed")) + self.writer_tx.send(command).await.map_err(|_| { + match self.writer_error.lock().unwrap().as_ref() { + Some(error) => io::Error::new(error.kind(), error.to_string()), + None => io::Error::new(io::ErrorKind::BrokenPipe, "SSH writer channel closed"), + } + }) } pub(crate) async fn close(self) -> io::Result { @@ -491,6 +559,7 @@ mod tests { let options = FlashOptions { ssh_password_file: Some("password file".into()), ssh_compress: true, + ssh_port: Some(11223), ..Default::default() }; let command = ssh_command("board", "echo ready", &options); @@ -506,6 +575,8 @@ mod tests { "password file", "ssh", "-C", + "-p", + "11223", "-T", "--", "board", @@ -519,6 +590,18 @@ mod tests { .unwrap(); assert!(output.status.success()); assert_eq!(output.stdout, path.as_bytes()); + assert!(check_embedded_target("QNX", "aarch64le", "7.1.0").is_ok()); + for (os, arch, release) in [ + ("Linux", "aarch64", "6.1.0"), + ("Darwin", "arm64", "25.0.0"), + ("QNX", "x86_64", "7.1.0"), + ("QNX", "aarch64le", "8.0.0"), + ] { + assert_eq!( + check_embedded_target(os, arch, release).unwrap_err().kind(), + io::ErrorKind::Unsupported + ); + } } #[test] diff --git a/src/main.rs b/src/main.rs index 5588aa8..b9a0fdd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -81,6 +81,9 @@ enum Commands { /// Enable SSH compression #[arg(long)] ssh_compress: bool, + /// SSH port (defaults to SSH configuration) + #[arg(long, value_parser = clap::value_parser!(u16).range(1..))] + ssh_port: Option, /// Write-head binary to upload (default: embedded aarch64-qnx7) #[arg(long, env = "FLS_WH_BIN")] wh_bin: Option, @@ -151,6 +154,7 @@ async fn main() { xz_memlimit, ssh_password_file, ssh_compress, + ssh_port, wh_bin, } => { // Detect URL scheme to determine handler @@ -207,6 +211,7 @@ async fn main() { xz_memlimit_mb: xz_memlimit, ssh_password_file, ssh_compress, + ssh_port, wh_bin, }, username, @@ -289,6 +294,7 @@ async fn main() { xz_memlimit_mb: xz_memlimit, ssh_password_file, ssh_compress, + ssh_port, wh_bin, }, max_retries, diff --git a/tests/ssh_remote.rs b/tests/ssh_remote.rs index 39ee5bb..65e0caf 100644 --- a/tests/ssh_remote.rs +++ b/tests/ssh_remote.rs @@ -23,6 +23,11 @@ async fn http_cli_uploads_and_flashes_with_all_ssh_auth_modes() { &ssh, r#"#!/bin/sh if [ "$1" = -C ]; then echo compression >> "$SSH_LOG"; shift; fi +if [ "$1" = -p ]; then + [ "$2" = 11223 ] || exit 48 + echo port >> "$SSH_LOG" + shift 2 +fi [ "$1" = -T ] || exit 40 shift [ "$1" = -- ] || exit 41 @@ -30,6 +35,10 @@ shift [ "$1" = root@fake-host ] || exit 42 shift case "$1" in + *"uname -s"*) + echo probe >> "$SSH_LOG" + [ "$PROBE_FAIL" != 1 ] || exit 47 + printf '%s\n' Linux x86_64 6.1.0 ;; PATH=*) cat > "$WH_UPLOAD" && chmod +x "$WH_UPLOAD" ;; /tmp/fls-wh\ *) command=${1#/tmp/fls-wh}; exec sh -c "\"$WH_UPLOAD\"$command" ;; *) exit 43 ;; @@ -65,7 +74,14 @@ exec "$@" .mount(&server) .await; - for auth in ["key", "env", "file", "oversize"] { + for auth in [ + "key", + "env", + "file", + "config-port", + "oversize", + "probe-fail", + ] { let device = dir.path().join(format!("block '{auth};device")); let file = std::fs::File::create(&device).unwrap(); file.set_len(if auth == "oversize" { @@ -100,7 +116,11 @@ exec "$@" .env_remove("SSHPASS") .env_remove("FLS_SSH_PASS_FILE") .env_remove("FLS_WH_BIN") + .env_remove("PROBE_FAIL") .kill_on_drop(true); + if auth != "config-port" { + command.args(["--ssh-port", "11223"]); + } match auth { "env" => { command.env("SSHPASS", "test-password"); @@ -111,12 +131,24 @@ exec "$@" .env("SSHPASS", "ignored-password") .env("FLS_SSH_PASS_FILE", &password); } + "probe-fail" => { + command + .env("PROBE_FAIL", "1") + .args(["--write-buffer-size", "8"]); + } _ => {} } let output = tokio::time::timeout(Duration::from_secs(30), command.output()) .await .unwrap() .unwrap(); + if auth == "probe-fail" { + assert!(!output.status.success()); + assert!(!upload.exists(), "Platform detection must precede upload"); + assert!(String::from_utf8_lossy(&output.stderr) + .contains("Remote platform detection failed")); + continue; + } if auth == "oversize" { assert!(!output.status.success()); assert!(String::from_utf8_lossy(&output.stderr).contains("exceeds remote device size")); @@ -135,14 +167,19 @@ exec "$@" std::fs::read(&native).unwrap() ); let log = std::fs::read_to_string(log).unwrap(); - assert_eq!(log.matches("compression").count(), 2); + assert_eq!(log.matches("compression").count(), 3); + assert_eq!( + log.matches("port").count(), + if auth == "config-port" { 0 } else { 3 } + ); + assert_eq!(log.matches("probe").count(), 1); assert_eq!( log.matches("file").count(), - if auth == "file" { 2 } else { 0 } + if auth == "file" { 3 } else { 0 } ); assert_eq!( log.matches("env").count(), - if auth == "env" { 2 } else { 0 } + if auth == "env" { 3 } else { 0 } ); } } From 6b27ea39360fadc802b02762840be7ac7a7d5194 Mon Sep 17 00:00:00 2001 From: Miguel Angel Ajo Pelayo Date: Fri, 9 Oct 2026 14:16:47 +0200 Subject: [PATCH 03/15] Pipeline SSH writes with a bounded ACK window Coalesce DATA frames and read ordered responses concurrently. Use the configured write buffer for in-flight backpressure. Assisted-by: OpenCode Co-Authored-By: GPT-6.1 Sol --- README.md | 12 +- src/fls/ssh_writer.rs | 521 ++++++++++++++++++++++++++++++++++-------- src/main.rs | 2 +- 3 files changed, 436 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index 544f76c..24f7171 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,15 @@ is selected only for aarch64 QNX 7. Other platforms require `--wh-bin` with a compatible write head; automatic Linux/macOS binary embedding can be added when those artifacts are available. -Remote zero fills use ZERO rather than transferring their contents. The writer -checks write, fill, and seek ranges against remote device capacity, and waits +Remote zero fills use ZERO rather than transferring their contents. Adjacent +DATA writes are coalesced into frames of up to 8 MiB, flushing before SEEK, +FILL, and final SYNC so sparse-image fragments do not each require an ACK. +DATA, ZERO, and SEEK frames are pipelined in order while a separate reader +consumes progress and ACKs. `--write-buffer-size` also limits unacknowledged +DATA bytes (default: 128 MiB); smaller windows reduce the maximum frame size. +Small-command metadata is bounded to 1024 outstanding requests. Closing drains +the ACKs before SYNC/QUIT, and remote errors stop the pipeline. +The writer checks write, fill, and seek ranges against remote device capacity, and waits for successful SYNC and QUIT before reporting completion. `--o-direct` applies to local devices; remote durability uses SYNC. One flash session per host is supported because uploads share `/tmp/fls-wh`. @@ -199,6 +206,7 @@ fls from-url [OPTIONS] - `-k, --insecure-tls` - Ignore SSL certificate verification - `--cacert ` - Path to CA certificate PEM file for TLS validation - `--buffer-size ` - Buffer size in MB for download buffering (default: 1024) +- `--write-buffer-size ` - Write queue and remote unacknowledged DATA limit (default: 128 MiB) - `--max-retries ` - Maximum number of retry attempts (default: 10) - `--retry-delay ` - Delay in seconds between retry attempts (default: 2) - `--debug` - Enable debug output diff --git a/src/fls/ssh_writer.rs b/src/fls/ssh_writer.rs index 6656ca2..827f31c 100644 --- a/src/fls/ssh_writer.rs +++ b/src/fls/ssh_writer.rs @@ -1,6 +1,6 @@ use std::io::{self, BufReader, Read, Write}; use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; -use std::sync::{Arc, Mutex}; +use std::sync::{mpsc as std_mpsc, Arc, Condvar, Mutex}; use tokio::sync::mpsc; use super::block_writer::WriterCommand; @@ -178,28 +178,219 @@ fn upload_wh(host: &str, binary: &[u8], options: &FlashOptions) -> io::Result<() Ok(()) } +fn read_response(output: &mut FrameReader) -> io::Result<(u8, Vec)> { + let (op, payload) = output.read_frame()?; + if op == R_ERR { + if payload.len() != 14 { + return Err(invalid("Malformed remote ERR")); + } + let code = payload[0]; + let command = payload[1]; + let errno = u32::from_le_bytes(payload[2..6].try_into().unwrap()); + let offset = u64::from_le_bytes(payload[6..].try_into().unwrap()); + let reason = match code { + 0 => "device I/O error", + 1 => "CRC mismatch", + 2 => "unsupported or malformed command", + _ => "unknown error", + }; + return Err(io::Error::other(format!( + "Remote {reason}: opcode {command:#04x}, errno {errno}, offset {offset}" + ))); + } + Ok((op, payload)) +} + +struct Expected { + op: u8, + start: u64, + bytes: u64, + charge: usize, +} + +#[derive(Default)] +struct FlowState { + bytes: usize, + commands: usize, + error: Option, +} + +struct FlowControl { + state: Mutex, + ready: Condvar, + limit: usize, +} + +impl FlowControl { + fn new(limit: usize) -> Self { + Self { + state: Mutex::new(FlowState::default()), + ready: Condvar::new(), + limit, + } + } + + fn reserve(&self, bytes: usize) -> io::Result<()> { + if bytes > self.limit { + return Err(invalid("Frame exceeds SSH in-flight window")); + } + let mut state = self.state.lock().unwrap(); + loop { + if let Some(error) = &state.error { + return Err(io::Error::new(error.kind(), error.to_string())); + } + // Also bound metadata for tiny ZERO/SEEK requests. + if state.bytes <= self.limit - bytes && state.commands < 1024 { + state.bytes += bytes; + state.commands += 1; + return Ok(()); + } + state = self.ready.wait(state).unwrap(); + } + } + + fn complete(&self, bytes: usize) { + let mut state = self.state.lock().unwrap(); + state.bytes -= bytes; + state.commands -= 1; + self.ready.notify_all(); + } + + fn fail(&self, error: &io::Error) { + let mut state = self.state.lock().unwrap(); + if state.error.is_none() { + state.error = Some(io::Error::new(error.kind(), error.to_string())); + } + self.ready.notify_all(); + } + + fn wait_idle(&self) -> io::Result<()> { + let mut state = self.state.lock().unwrap(); + loop { + if let Some(error) = &state.error { + return Err(io::Error::new(error.kind(), error.to_string())); + } + if state.commands == 0 { + return Ok(()); + } + state = self.ready.wait(state).unwrap(); + } + } +} + +fn expect_ok( + output: &mut FrameReader, + expected: &Expected, + progress: &mpsc::UnboundedSender, +) -> io::Result<()> { + let mut last_done = 0; + loop { + let (op, payload) = read_response(output)?; + if op == R_PROG && matches!(expected.op, OP_DATA | OP_ZERO) && payload.len() == 16 { + let offset = u64::from_le_bytes(payload[..8].try_into().unwrap()); + let done = u64::from_le_bytes(payload[8..].try_into().unwrap()); + if done < last_done + || done > expected.bytes + || expected.start.checked_add(done) != Some(offset) + { + return Err(invalid("Invalid remote PROG range")); + } + last_done = done; + let _ = progress.send(offset); + } else if op == R_ACK && payload.len() == 17 && payload[0] == expected.op { + let offset = u64::from_le_bytes(payload[1..9].try_into().unwrap()); + let written = u64::from_le_bytes(payload[9..].try_into().unwrap()); + if offset != expected.start || written != expected.bytes { + return Err(invalid("Remote OK does not match the command range")); + } + if matches!(expected.op, OP_DATA | OP_ZERO | OP_SEEK) { + let _ = progress.send(expected.start + expected.bytes); + } + return Ok(()); + } else { + return Err(invalid(format!( + "Unexpected response {op:#04x} to {:#04x}", + expected.op + ))); + } + } +} + +fn read_acks( + mut output: FrameReader, + requests: std_mpsc::Receiver, + flow: Arc, + child: Arc>, + progress: mpsc::UnboundedSender, +) -> io::Result { + let result = (|| { + let mut total = 0u64; + while let Ok(expected) = requests.recv() { + if expected.op == OP_QUIT { + let (op, payload) = read_response(&mut output)?; + if op != R_DONE || payload.len() != 8 { + return Err(invalid("Invalid write-head DONE")); + } + if u64::from_le_bytes(payload[..].try_into().unwrap()) != total { + return Err(invalid("DONE total does not match acknowledged writes")); + } + flow.complete(expected.charge); + return Ok(total); + } + expect_ok(&mut output, &expected, &progress)?; + if matches!(expected.op, OP_DATA | OP_ZERO) { + total = total + .checked_add(expected.bytes) + .ok_or_else(|| invalid("Written byte count overflow"))?; + } + flow.complete(expected.charge); + } + Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "SSH writer closed without QUIT", + )) + })(); + if let Err(ref error) = result { + flow.fail(error); + // Interrupt a sender blocked in write_all when the peer reports an error. + let _ = child.lock().unwrap().kill(); + } + result +} + struct Session { - child: Child, + child: Arc>, input: Option, - output: FrameReader, - progress: mpsc::UnboundedSender, + requests: Option>, + reader: Option>>, + flow: Arc, cursor: u64, size: u64, + pending: Vec, + frame_size: usize, } impl Session { - fn new(mut child: Child, progress: mpsc::UnboundedSender) -> io::Result { + fn new( + mut child: Child, + progress: mpsc::UnboundedSender, + window: usize, + ) -> io::Result { let input = child.stdin.take().unwrap(); - let output = FrameReader::new(child.stdout.take().unwrap()); + let mut output = FrameReader::new(child.stdout.take().unwrap()); + let (tx, rx) = std_mpsc::channel(); let mut session = Self { - child, + child: Arc::new(Mutex::new(child)), input: Some(input), - output, - progress, + requests: Some(tx), + reader: None, + flow: Arc::new(FlowControl::new(window)), cursor: 0, size: 0, + pending: Vec::with_capacity(window.min(CHUNK_SIZE)), + frame_size: window.min(CHUNK_SIZE), }; - let (op, payload) = session.response()?; + let (op, payload) = read_response(&mut output)?; if op != R_READY || payload.len() != 9 || payload[0] != PROTO_VER { return Err(invalid( "Invalid write-head READY or unsupported protocol version", @@ -207,8 +398,12 @@ impl Session { } let advertised = u64::from_le_bytes(payload[1..].try_into().unwrap()); // SIZE distinguishes an unknown startup size from a genuinely empty device. - session.send(OP_SIZE, &[])?; - let (op, payload) = session.response()?; + session + .input + .as_mut() + .unwrap() + .write_all(&encode_frame(OP_SIZE, &[])?)?; + let (op, payload) = read_response(&mut output)?; if op != R_SIZE || payload.len() != 8 { return Err(invalid("Invalid write-head SIZE response")); } @@ -216,64 +411,43 @@ impl Session { if advertised != session.size { return Err(invalid("READY and SIZE disagree about device capacity")); } + let flow = Arc::clone(&session.flow); + let child = Arc::clone(&session.child); + session.reader = Some( + std::thread::Builder::new() + .name("fls-ssh-acks".into()) + .spawn(move || read_acks(output, rx, flow, child, progress))?, + ); Ok(session) } - fn send(&mut self, op: u8, payload: &[u8]) -> io::Result<()> { + fn queue_frame(&mut self, op: u8, start: u64, bytes: u64, frame: &[u8]) -> io::Result<()> { + let charge = if op == OP_DATA { bytes as usize } else { 1 }; + self.flow.reserve(charge)?; + self.requests + .as_ref() + .unwrap() + .send(Expected { + op, + start, + bytes, + charge, + }) + .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "SSH response reader closed"))?; self.input .as_mut() .unwrap() - .write_all(&encode_frame(op, payload)?) - } - - fn response(&mut self) -> io::Result<(u8, Vec)> { - let (op, payload) = self.output.read_frame()?; - if op == R_ERR { - if payload.len() != 14 { - return Err(invalid("Malformed remote ERR")); - } - let code = payload[0]; - let command = payload[1]; - let errno = u32::from_le_bytes(payload[2..6].try_into().unwrap()); - let offset = u64::from_le_bytes(payload[6..].try_into().unwrap()); - let reason = match code { - 0 => "device I/O error", - 1 => "CRC mismatch", - 2 => "unsupported or malformed command", - _ => "unknown error", - }; - return Err(io::Error::other(format!( - "Remote {reason}: opcode {command:#04x}, errno {errno}, offset {offset}" - ))); - } - Ok((op, payload)) + .write_all(frame) + .map_err(|error| { + self.flow.fail(&error); + let state = self.flow.state.lock().unwrap(); + let error = state.error.as_ref().unwrap(); + io::Error::new(error.kind(), error.to_string()) + }) } - fn expect_ok(&mut self, command: u8, start: u64, bytes: u64) -> io::Result<()> { - let mut last_done = 0; - loop { - let (op, payload) = self.response()?; - if op == R_PROG && matches!(command, OP_DATA | OP_ZERO) && payload.len() == 16 { - let offset = u64::from_le_bytes(payload[..8].try_into().unwrap()); - let done = u64::from_le_bytes(payload[8..].try_into().unwrap()); - if done < last_done || done > bytes || start.checked_add(done) != Some(offset) { - return Err(invalid("Invalid remote PROG range")); - } - last_done = done; - let _ = self.progress.send(offset); - } else if op == R_ACK && payload.len() == 17 && payload[0] == command { - let offset = u64::from_le_bytes(payload[1..9].try_into().unwrap()); - let written = u64::from_le_bytes(payload[9..].try_into().unwrap()); - if offset != start || written != bytes { - return Err(invalid("Remote OK does not match the command range")); - } - return Ok(()); - } else { - return Err(invalid(format!( - "Unexpected response {op:#04x} to {command:#04x}" - ))); - } - } + fn send(&mut self, op: u8, start: u64, bytes: u64, payload: &[u8]) -> io::Result<()> { + self.queue_frame(op, start, bytes, &encode_frame(op, payload)?) } fn check_range(&self, bytes: u64) -> io::Result<()> { @@ -291,15 +465,27 @@ impl Session { fn write(&mut self, data: &[u8]) -> io::Result<()> { self.check_range(data.len() as u64)?; - for chunk in data.chunks(CHUNK_SIZE) { - self.input - .as_mut() - .unwrap() - .write_all(&encode_data_frame(chunk)?)?; - self.expect_ok(OP_DATA, self.cursor, chunk.len() as u64)?; - self.cursor += chunk.len() as u64; - let _ = self.progress.send(self.cursor); + let mut remaining = data; + while !remaining.is_empty() { + let n = remaining.len().min(self.frame_size - self.pending.len()); + self.pending.extend_from_slice(&remaining[..n]); + self.cursor += n as u64; + remaining = &remaining[n..]; + if self.pending.len() == self.frame_size { + self.flush_pending()?; + } + } + Ok(()) + } + + fn flush_pending(&mut self) -> io::Result<()> { + if self.pending.is_empty() { + return Ok(()); } + let bytes = self.pending.len() as u64; + let start = self.cursor - bytes; + self.queue_frame(OP_DATA, start, bytes, &encode_data_frame(&self.pending)?)?; + self.pending.clear(); Ok(()) } @@ -310,20 +496,18 @@ impl Session { "Seek exceeds remote device size", )); } - self.send(OP_SEEK, &offset.to_le_bytes())?; - self.expect_ok(OP_SEEK, offset, 0)?; + self.flush_pending()?; + self.send(OP_SEEK, offset, 0, &offset.to_le_bytes())?; self.cursor = offset; - let _ = self.progress.send(offset); Ok(()) } fn fill(&mut self, pattern: [u8; 4], bytes: u64) -> io::Result<()> { self.check_range(bytes)?; + self.flush_pending()?; if pattern == [0; 4] { - self.send(OP_ZERO, &bytes.to_le_bytes())?; - self.expect_ok(OP_ZERO, self.cursor, bytes)?; + self.send(OP_ZERO, self.cursor, bytes, &bytes.to_le_bytes())?; self.cursor += bytes; - let _ = self.progress.send(self.cursor); } else { // ponytail: nonzero fills travel as DATA; add FILL if bandwidth matters. let mut buffer = vec![0; CHUNK_SIZE]; @@ -341,30 +525,38 @@ impl Session { } fn finish(mut self) -> io::Result { - self.send(OP_SYNC, &[])?; - self.expect_ok(OP_SYNC, self.cursor, 0)?; - self.send(OP_QUIT, &[])?; - let (op, payload) = self.response()?; - if op != R_DONE || payload.len() != 8 { - return Err(invalid("Invalid write-head DONE")); - } + self.flush_pending()?; + self.flow.wait_idle()?; + self.send(OP_SYNC, self.cursor, 0, &[])?; + self.send(OP_QUIT, self.cursor, 0, &[])?; self.input.take(); - let status = self.child.wait()?; + self.requests.take(); + let total = self + .reader + .take() + .unwrap() + .join() + .map_err(|_| io::Error::other("SSH response reader panicked"))??; + let status = self.child.lock().unwrap().wait()?; if !status.success() { return Err(io::Error::other(format!( "Remote writer exited with {status}" ))); } - Ok(u64::from_le_bytes(payload[..].try_into().unwrap())) + Ok(total) } } impl Drop for Session { fn drop(&mut self) { self.input.take(); + self.requests.take(); // std::process::Child does not reap or terminate on drop. - let _ = self.child.kill(); - let _ = self.child.wait(); + let _ = self.child.lock().unwrap().kill(); + if let Some(reader) = self.reader.take() { + let _ = reader.join(); + } + let _ = self.child.lock().unwrap().wait(); } } @@ -399,6 +591,16 @@ impl SshBlockWriter { "SSH port must be between 1 and 65535", )); } + let window = options + .write_buffer_size_mb + .max(1) + .checked_mul(1 << 20) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "SSH write buffer size overflow", + ) + })?; let binary = match &options.wh_bin { Some(path) => std::fs::read(path)?, None => EMBEDDED_WH.to_vec(), @@ -429,9 +631,10 @@ impl SshBlockWriter { .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .spawn()?; - let mut session = Session::new(child, progress)?; + let mut session = Session::new(child, progress, window)?; if options.debug { eprintln!("[DEBUG] Remote device size: {} bytes", session.size); + eprintln!("[DEBUG] SSH in-flight DATA window: {} MiB", window >> 20); } while let Some(command) = writer_rx.blocking_recv() { match command { @@ -508,14 +711,22 @@ mod tests { .path() } - fn native_session(device: &std::path::Path) -> Session { - let child = Command::new(native_binary().join("fls-wh")) + fn native_child(device: &std::path::Path) -> Child { + Command::new(native_binary().join("fls-wh")) .arg(device) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() - .unwrap(); - Session::new(child, mpsc::unbounded_channel().0).unwrap() + .unwrap() + } + + fn native_session(device: &std::path::Path) -> Session { + Session::new( + native_child(device), + mpsc::unbounded_channel().0, + 2 * CHUNK_SIZE, + ) + .unwrap() } #[test] @@ -608,9 +819,9 @@ mod tests { fn native_write_head_round_trip_and_capacity() { let mut device = tempfile::NamedTempFile::new().unwrap(); device.write_all(&vec![0x55; CHUNK_SIZE * 3]).unwrap(); - let mut session = native_session(device.path()); let (progress_tx, mut progress_rx) = mpsc::unbounded_channel(); - session.progress = progress_tx; + let mut session = + Session::new(native_child(device.path()), progress_tx, 2 * CHUNK_SIZE).unwrap(); session.write(&vec![0xa5; CHUNK_SIZE + 17]).unwrap(); session.seek(32).unwrap(); session.fill([0; 4], (CHUNK_SIZE + 5) as u64).unwrap(); @@ -645,6 +856,124 @@ mod tests { assert_eq!(tail, [0x55]); } + #[test] + fn small_writes_coalesce_and_flush_at_command_boundaries() { + let mut device = tempfile::NamedTempFile::new().unwrap(); + device.as_file().set_len(16).unwrap(); + let mut session = native_session(device.path()); + session.write(b"ab").unwrap(); + session.write(b"cd").unwrap(); + assert_eq!(session.pending, b"abcd"); + let mut head = [0; 4]; + device.read_exact(&mut head).unwrap(); + assert_eq!(head, [0; 4]); + session.seek(8).unwrap(); + session.flow.wait_idle().unwrap(); + assert!(session.pending.is_empty()); + device.seek(io::SeekFrom::Start(0)).unwrap(); + device.read_exact(&mut head).unwrap(); + assert_eq!(&head, b"abcd"); + session.write(b"ef").unwrap(); + session.fill([1, 2, 3, 4], 4).unwrap(); + session.write(b"gh").unwrap(); + assert_eq!(session.finish().unwrap(), 12); + device.seek(io::SeekFrom::Start(8)).unwrap(); + let mut tail = [0; 8]; + device.read_exact(&mut tail).unwrap(); + assert_eq!(&tail, b"ef\x01\x02\x03\x04gh"); + } + + #[test] + fn streams_two_frames_before_the_first_ack() { + // The peer withholds every ACK until it has received both DATA frames. + // Its alarm makes a stop-and-wait regression fail instead of hanging. + let script = r#" +import signal, struct, sys, zlib +signal.alarm(10) +def exact(n): + out = b'' + while len(out) < n: + data = sys.stdin.buffer.read(n - len(out)) + if not data: raise EOFError() + out += data + return out +def receive(): + header = exact(9) + assert header[:4] == b'FLSW' + return header[4], exact(struct.unpack_from(' Date: Fri, 9 Oct 2026 14:32:25 +0200 Subject: [PATCH 04/15] Match SSH completion counts to logical offsets Include sparse skips in final progress, like the local writer. Keep DONE validation based on acknowledged DATA and ZERO bytes. Assisted-by: OpenCode Co-Authored-By: GPT-6.1 Sol --- README.md | 2 ++ src/fls/ssh_writer.rs | 20 +++++++++++++------- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 24f7171..b6f436b 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ The writer checks write, fill, and seek ranges against remote device capacity, a for successful SYNC and QUIT before reporting completion. `--o-direct` applies to local devices; remote durability uses SYNC. One flash session per host is supported because uploads share `/tmp/fls-wh`. +The reported Written count is the logical device position, including sparse +skips, matching local flashing; DONE still validates only actual written bytes. ### Basic Example diff --git a/src/fls/ssh_writer.rs b/src/fls/ssh_writer.rs index 827f31c..c1ea975 100644 --- a/src/fls/ssh_writer.rs +++ b/src/fls/ssh_writer.rs @@ -531,8 +531,7 @@ impl Session { self.send(OP_QUIT, self.cursor, 0, &[])?; self.input.take(); self.requests.take(); - let total = self - .reader + self.reader .take() .unwrap() .join() @@ -543,7 +542,9 @@ impl Session { "Remote writer exited with {status}" ))); } - Ok(total) + // Match the local writer: completion includes sparse seeks/skips. + // The ACK reader separately validates DONE's physical write count. + Ok(self.cursor) } } @@ -840,9 +841,14 @@ mod tests { session.write(&[1, 2]).unwrap_err().kind(), io::ErrorKind::InvalidInput ); - let total = session.finish().unwrap(); - assert_eq!(total, (2 * CHUNK_SIZE + 29) as u64); - assert!(progress_rx.try_recv().is_ok()); + let final_offset = session.cursor; + assert_eq!(session.finish().unwrap(), final_offset); + assert_eq!(final_offset, (3 * CHUNK_SIZE - 1) as u64); + let mut last_progress = None; + while let Ok(offset) = progress_rx.try_recv() { + last_progress = Some(offset); + } + assert_eq!(last_progress, Some(final_offset)); device.seek(io::SeekFrom::Start(0)).unwrap(); let mut prefix = [0; 32]; device.read_exact(&mut prefix).unwrap(); @@ -876,7 +882,7 @@ mod tests { session.write(b"ef").unwrap(); session.fill([1, 2, 3, 4], 4).unwrap(); session.write(b"gh").unwrap(); - assert_eq!(session.finish().unwrap(), 12); + assert_eq!(session.finish().unwrap(), 16); device.seek(io::SeekFrom::Start(8)).unwrap(); let mut tail = [0; 8]; device.read_exact(&mut tail).unwrap(); From 77d0d688484e8a7977205dcc3df2d51c7b08c42d Mon Sep 17 00:00:00 2001 From: Miguel Angel Ajo Pelayo Date: Fri, 9 Oct 2026 14:55:32 +0200 Subject: [PATCH 05/15] Use array-slice fill for SSH patterns Replace constant-size chunk iteration with as_chunks_mut to satisfy newer Clippy. Assisted-by: OpenCode Co-Authored-By: GPT-6.1 Sol --- src/fls/ssh_writer.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/fls/ssh_writer.rs b/src/fls/ssh_writer.rs index c1ea975..7df8a88 100644 --- a/src/fls/ssh_writer.rs +++ b/src/fls/ssh_writer.rs @@ -511,9 +511,7 @@ impl Session { } else { // ponytail: nonzero fills travel as DATA; add FILL if bandwidth matters. let mut buffer = vec![0; CHUNK_SIZE]; - for chunk in buffer.chunks_exact_mut(4) { - chunk.copy_from_slice(&pattern); - } + buffer.as_chunks_mut::<4>().0.fill(pattern); let mut remaining = bytes; while remaining > 0 { let n = remaining.min(CHUNK_SIZE as u64) as usize; From 551d69cd44645e40a729d7b7a622e16d2432887c Mon Sep 17 00:00:00 2001 From: Miguel Angel Ajo Pelayo Date: Fri, 9 Oct 2026 15:07:27 +0200 Subject: [PATCH 06/15] Fail promptly on interactive SSH setup Require verified host keys for streaming SSH connections and use batch mode for key authentication. Preserve sshpass password authentication and report how to verify host setup before retrying. Assisted-by: OpenCode Co-Authored-By: GPT-6.1 Sol --- README.md | 4 ++++ src/fls/ssh_writer.rs | 16 ++++++++++++++-- tests/ssh_remote.rs | 34 +++++++++++++++++++++++++++++++++- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b6f436b..568744a 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,10 @@ the registry-password option. Password authentication uses `sshpass` with `--ssh-password-file ` (`FLS_SSH_PASS_FILE`) or, if no file is specified, the `SSHPASS` environment variable. `--ssh-compress` enables SSH compression; `--wh-bin` can also be supplied through `FLS_WH_BIN`. +SSH connections require a known host key and use non-interactive key/agent +authentication unless `sshpass` is selected. Connect once with `ssh root@board` +(or `ssh -p root@board`) to verify the host key before flashing; unknown +or changed keys fail promptly instead of waiting behind the progress display. Before uploading, `fls` queries the remote OS, CPU architecture, and release using `uname` (`-p` for the QNX processor, `-m` elsewhere). The embedded binary diff --git a/src/fls/ssh_writer.rs b/src/fls/ssh_writer.rs index 7df8a88..b5a29b2 100644 --- a/src/fls/ssh_writer.rs +++ b/src/fls/ssh_writer.rs @@ -111,6 +111,11 @@ fn ssh_command(host: &str, remote_command: &str, options: &FlashOptions) -> Comm if let Some(port) = options.ssh_port { command.arg("-p").arg(port.to_string()); } + // Streaming stdin belongs to the protocol; terminal prompts would stall it. + command.args(["-o", "StrictHostKeyChecking=yes"]); + if command.get_program() == "ssh" { + command.args(["-o", "BatchMode=yes"]); + } command.args(["-T", "--", host, remote_command]); command } @@ -124,9 +129,14 @@ fn detect_target(host: &str, options: &FlashOptions) -> io::Result<(String, Stri .stderr(Stdio::inherit()) .output()?; if !output.status.success() { + let port = options + .ssh_port + .map(|port| format!(" -p {port}")) + .unwrap_or_default(); return Err(io::Error::other(format!( - "Remote platform detection failed: {}", - output.status + "Remote platform detection failed: {}. Run `ssh{port} {}` to verify the host key and authentication, then retry", + output.status, + shell_quote(host) ))); } let text = std::str::from_utf8(&output.stdout) @@ -787,6 +797,8 @@ mod tests { "-C", "-p", "11223", + "-o", + "StrictHostKeyChecking=yes", "-T", "--", "board", diff --git a/tests/ssh_remote.rs b/tests/ssh_remote.rs index 65e0caf..52a8ea6 100644 --- a/tests/ssh_remote.rs +++ b/tests/ssh_remote.rs @@ -28,6 +28,16 @@ if [ "$1" = -p ]; then echo port >> "$SSH_LOG" shift 2 fi +[ "$1" = -o ] && [ "$2" = StrictHostKeyChecking=yes ] || exit 49 +shift 2 +if [ "$1" = -o ]; then + [ "$2" = BatchMode=yes ] || exit 50 + [ -z "$SSHPASS$FLS_SSH_PASS_FILE" ] || exit 51 + echo batch >> "$SSH_LOG" + shift 2 +else + [ -n "$SSHPASS$FLS_SSH_PASS_FILE" ] || exit 52 +fi [ "$1" = -T ] || exit 40 shift [ "$1" = -- ] || exit 41 @@ -38,6 +48,10 @@ case "$1" in *"uname -s"*) echo probe >> "$SSH_LOG" [ "$PROBE_FAIL" != 1 ] || exit 47 + if [ "$HOST_KEY_UNKNOWN" = 1 ]; then + echo 'Host key verification failed.' >&2 + exit 255 + fi printf '%s\n' Linux x86_64 6.1.0 ;; PATH=*) cat > "$WH_UPLOAD" && chmod +x "$WH_UPLOAD" ;; /tmp/fls-wh\ *) command=${1#/tmp/fls-wh}; exec sh -c "\"$WH_UPLOAD\"$command" ;; @@ -81,6 +95,7 @@ exec "$@" "config-port", "oversize", "probe-fail", + "unknown-key", ] { let device = dir.path().join(format!("block '{auth};device")); let file = std::fs::File::create(&device).unwrap(); @@ -117,6 +132,7 @@ exec "$@" .env_remove("FLS_SSH_PASS_FILE") .env_remove("FLS_WH_BIN") .env_remove("PROBE_FAIL") + .env_remove("HOST_KEY_UNKNOWN") .kill_on_drop(true); if auth != "config-port" { command.args(["--ssh-port", "11223"]); @@ -136,17 +152,29 @@ exec "$@" .env("PROBE_FAIL", "1") .args(["--write-buffer-size", "8"]); } + "unknown-key" => { + command.env("HOST_KEY_UNKNOWN", "1"); + } _ => {} } let output = tokio::time::timeout(Duration::from_secs(30), command.output()) .await .unwrap() .unwrap(); - if auth == "probe-fail" { + if matches!(auth, "probe-fail" | "unknown-key") { assert!(!output.status.success()); assert!(!upload.exists(), "Platform detection must precede upload"); assert!(String::from_utf8_lossy(&output.stderr) .contains("Remote platform detection failed")); + if auth == "unknown-key" { + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Host key verification failed")); + assert!(stderr.contains("ssh -p 11223 'root@fake-host'")); + assert!(std::fs::read(&device) + .unwrap() + .iter() + .all(|&byte| byte == 0)); + } continue; } if auth == "oversize" { @@ -173,6 +201,10 @@ exec "$@" if auth == "config-port" { 0 } else { 3 } ); assert_eq!(log.matches("probe").count(), 1); + assert_eq!( + log.matches("batch").count(), + if matches!(auth, "file" | "env") { 0 } else { 3 } + ); assert_eq!( log.matches("file").count(), if auth == "file" { 3 } else { 0 } From 1c8c3a98a8f9b59010e11c941ce7affec589c5f4 Mon Sep 17 00:00:00 2001 From: Miguel Angel Ajo Pelayo Date: Fri, 9 Oct 2026 15:10:54 +0200 Subject: [PATCH 07/15] Make SSH host-key checks opt-in Default to StrictHostKeyChecking=no, UserKnownHostsFile=/dev/null, and LogLevel=ERROR for all SSH connections. Add --strict-ssh-host-key-checking to require verified keys using configured known-hosts files and logging. Assisted-by: OpenCode Co-Authored-By: GPT-6.1 Sol --- README.md | 14 ++++++---- src/fls/options.rs | 2 ++ src/fls/ssh_writer.rs | 19 +++++++++++-- src/main.rs | 6 +++++ tests/ssh_remote.rs | 63 +++++++++++++++++++++++++++++++++++-------- 5 files changed, 86 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 568744a..3ec32b1 100644 --- a/README.md +++ b/README.md @@ -52,17 +52,21 @@ and use `--wh-bin /path/to/flswh`. When building from source, run `make remote-wh` before `cargo build` to embed the QNX binary; local-only builds work without it. See [remote/README.md](remote/README.md) for build instructions. -SSH keys, agents, host keys, ports, and aliases use the system `ssh` and your +SSH keys, agents, ports, and aliases use the system `ssh` and your `~/.ssh/config`. `--ssh-port ` overrides the port for platform detection, upload, and flashing; without it the SSH configuration is used. `-p` remains the registry-password option. Password authentication uses `sshpass` with `--ssh-password-file ` (`FLS_SSH_PASS_FILE`) or, if no file is specified, the `SSHPASS` environment variable. `--ssh-compress` enables SSH compression; `--wh-bin` can also be supplied through `FLS_WH_BIN`. -SSH connections require a known host key and use non-interactive key/agent -authentication unless `sshpass` is selected. Connect once with `ssh root@board` -(or `ssh -p root@board`) to verify the host key before flashing; unknown -or changed keys fail promptly instead of waiting behind the progress display. +By default, all SSH connections use `StrictHostKeyChecking=no`, +`UserKnownHostsFile=/dev/null`, and `LogLevel=ERROR`: host keys are not verified +or saved to your user known-hosts file. `--strict-ssh-host-key-checking` instead +uses `StrictHostKeyChecking=yes` with your configured known-hosts files and log +level. In strict mode, connect once with `ssh root@board` (or +`ssh -p root@board`) to verify the key before flashing; unknown or changed +keys fail promptly. Key/agent authentication remains non-interactive unless +`sshpass` is selected. Before uploading, `fls` queries the remote OS, CPU architecture, and release using `uname` (`-p` for the QNX processor, `-m` elsewhere). The embedded binary diff --git a/src/fls/options.rs b/src/fls/options.rs index 10fe2aa..c4897bf 100644 --- a/src/fls/options.rs +++ b/src/fls/options.rs @@ -24,6 +24,7 @@ pub struct FlashOptions { pub ssh_password_file: Option, pub ssh_compress: bool, pub ssh_port: Option, + pub strict_ssh_host_key_checking: bool, pub wh_bin: Option, } @@ -44,6 +45,7 @@ impl Default for FlashOptions { ssh_password_file: None, ssh_compress: false, ssh_port: None, + strict_ssh_host_key_checking: false, wh_bin: None, } } diff --git a/src/fls/ssh_writer.rs b/src/fls/ssh_writer.rs index b5a29b2..a538fb5 100644 --- a/src/fls/ssh_writer.rs +++ b/src/fls/ssh_writer.rs @@ -112,7 +112,18 @@ fn ssh_command(host: &str, remote_command: &str, options: &FlashOptions) -> Comm command.arg("-p").arg(port.to_string()); } // Streaming stdin belongs to the protocol; terminal prompts would stall it. - command.args(["-o", "StrictHostKeyChecking=yes"]); + if options.strict_ssh_host_key_checking { + command.args(["-o", "StrictHostKeyChecking=yes"]); + } else { + command.args([ + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "LogLevel=ERROR", + ]); + } if command.get_program() == "ssh" { command.args(["-o", "BatchMode=yes"]); } @@ -798,7 +809,11 @@ mod tests { "-p", "11223", "-o", - "StrictHostKeyChecking=yes", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "LogLevel=ERROR", "-T", "--", "board", diff --git a/src/main.rs b/src/main.rs index 64de0d9..4a3e330 100644 --- a/src/main.rs +++ b/src/main.rs @@ -84,6 +84,9 @@ enum Commands { /// SSH port (defaults to SSH configuration) #[arg(long, value_parser = clap::value_parser!(u16).range(1..))] ssh_port: Option, + /// Require verified SSH host keys (default: skip host-key checks) + #[arg(long)] + strict_ssh_host_key_checking: bool, /// Write-head binary to upload (default: embedded aarch64-qnx7) #[arg(long, env = "FLS_WH_BIN")] wh_bin: Option, @@ -155,6 +158,7 @@ async fn main() { ssh_password_file, ssh_compress, ssh_port, + strict_ssh_host_key_checking, wh_bin, } => { // Detect URL scheme to determine handler @@ -212,6 +216,7 @@ async fn main() { ssh_password_file, ssh_compress, ssh_port, + strict_ssh_host_key_checking, wh_bin, }, username, @@ -295,6 +300,7 @@ async fn main() { ssh_password_file, ssh_compress, ssh_port, + strict_ssh_host_key_checking, wh_bin, }, max_retries, diff --git a/tests/ssh_remote.rs b/tests/ssh_remote.rs index 52a8ea6..ca9ef7c 100644 --- a/tests/ssh_remote.rs +++ b/tests/ssh_remote.rs @@ -28,8 +28,19 @@ if [ "$1" = -p ]; then echo port >> "$SSH_LOG" shift 2 fi -[ "$1" = -o ] && [ "$2" = StrictHostKeyChecking=yes ] || exit 49 -shift 2 +if [ "$STRICT_CHECK" = 1 ]; then + [ "$1" = -o ] && [ "$2" = StrictHostKeyChecking=yes ] || exit 49 + shift 2 + echo strict >> "$SSH_LOG" +else + [ "$1" = -o ] && [ "$2" = StrictHostKeyChecking=no ] || exit 49 + shift 2 + [ "$1" = -o ] && [ "$2" = UserKnownHostsFile=/dev/null ] || exit 53 + shift 2 + [ "$1" = -o ] && [ "$2" = LogLevel=ERROR ] || exit 54 + shift 2 + echo unchecked >> "$SSH_LOG" +fi if [ "$1" = -o ]; then [ "$2" = BatchMode=yes ] || exit 50 [ -z "$SSHPASS$FLS_SSH_PASS_FILE" ] || exit 51 @@ -48,7 +59,7 @@ case "$1" in *"uname -s"*) echo probe >> "$SSH_LOG" [ "$PROBE_FAIL" != 1 ] || exit 47 - if [ "$HOST_KEY_UNKNOWN" = 1 ]; then + if [ "$HOST_KEY_UNKNOWN" = 1 ] && [ "$STRICT_CHECK" = 1 ]; then echo 'Host key verification failed.' >&2 exit 255 fi @@ -96,6 +107,10 @@ exec "$@" "oversize", "probe-fail", "unknown-key", + "strict-key", + "strict-env", + "strict-file", + "strict-unknown-key", ] { let device = dir.path().join(format!("block '{auth};device")); let file = std::fs::File::create(&device).unwrap(); @@ -133,15 +148,21 @@ exec "$@" .env_remove("FLS_WH_BIN") .env_remove("PROBE_FAIL") .env_remove("HOST_KEY_UNKNOWN") + .env_remove("STRICT_CHECK") .kill_on_drop(true); if auth != "config-port" { command.args(["--ssh-port", "11223"]); } + if auth.starts_with("strict-") { + command + .arg("--strict-ssh-host-key-checking") + .env("STRICT_CHECK", "1"); + } match auth { - "env" => { + "env" | "strict-env" => { command.env("SSHPASS", "test-password"); } - "file" => { + "file" | "strict-file" => { // File authentication must win even when SSHPASS is set. command .env("SSHPASS", "ignored-password") @@ -152,7 +173,7 @@ exec "$@" .env("PROBE_FAIL", "1") .args(["--write-buffer-size", "8"]); } - "unknown-key" => { + "unknown-key" | "strict-unknown-key" => { command.env("HOST_KEY_UNKNOWN", "1"); } _ => {} @@ -161,12 +182,12 @@ exec "$@" .await .unwrap() .unwrap(); - if matches!(auth, "probe-fail" | "unknown-key") { + if matches!(auth, "probe-fail" | "strict-unknown-key") { assert!(!output.status.success()); assert!(!upload.exists(), "Platform detection must precede upload"); assert!(String::from_utf8_lossy(&output.stderr) .contains("Remote platform detection failed")); - if auth == "unknown-key" { + if auth == "strict-unknown-key" { let stderr = String::from_utf8_lossy(&output.stderr); assert!(stderr.contains("Host key verification failed")); assert!(stderr.contains("ssh -p 11223 'root@fake-host'")); @@ -201,17 +222,37 @@ exec "$@" if auth == "config-port" { 0 } else { 3 } ); assert_eq!(log.matches("probe").count(), 1); + assert_eq!( + log.matches("strict").count(), + if auth.starts_with("strict-") { 3 } else { 0 } + ); + assert_eq!( + log.matches("unchecked").count(), + if auth.starts_with("strict-") { 0 } else { 3 } + ); assert_eq!( log.matches("batch").count(), - if matches!(auth, "file" | "env") { 0 } else { 3 } + if matches!(auth, "file" | "env" | "strict-file" | "strict-env") { + 0 + } else { + 3 + } ); assert_eq!( log.matches("file").count(), - if auth == "file" { 3 } else { 0 } + if matches!(auth, "file" | "strict-file") { + 3 + } else { + 0 + } ); assert_eq!( log.matches("env").count(), - if auth == "env" { 3 } else { 0 } + if matches!(auth, "env" | "strict-env") { + 3 + } else { + 0 + } ); } } From a9f55c498eb64756a253777e97d7ab09a40831e5 Mon Sep 17 00:00:00 2001 From: Miguel Angel Ajo Pelayo Date: Fri, 9 Oct 2026 15:15:18 +0200 Subject: [PATCH 08/15] Keep SSH connection diagnostics clear on failure Capture platform-probe stderr in the returned error, include the SSH destination and diagnostic command, and terminate inline progress on exit. Remove the redundant raw-pipeline closure message. Assisted-by: OpenCode Co-Authored-By: GPT-6.1 Sol --- src/fls/oci/from_oci.rs | 1 - src/fls/progress.rs | 18 +++++++-- src/fls/ssh_writer.rs | 9 ++++- tests/oci_extract.rs | 88 +++++++++++++++++++++++++++++++++++++++++ tests/ssh_remote.rs | 6 ++- 5 files changed, 114 insertions(+), 8 deletions(-) diff --git a/src/fls/oci/from_oci.rs b/src/fls/oci/from_oci.rs index 1a8a2e6..958682d 100644 --- a/src/fls/oci/from_oci.rs +++ b/src/fls/oci/from_oci.rs @@ -1587,7 +1587,6 @@ async fn coordinate_raw_disk_download( } if params.http_tx.send(chunk).await.is_err() { - eprintln!("\nStreaming pipeline closed"); break; } diff --git a/src/fls/progress.rs b/src/fls/progress.rs index 219cf0c..c5e9e8d 100644 --- a/src/fls/progress.rs +++ b/src/fls/progress.rs @@ -76,6 +76,7 @@ pub(crate) struct ProgressTracker { is_compressed: bool, // Whether to print on new lines instead of clearing and rewriting newline_progress: bool, + line_open: bool, // Whether to show memory statistics show_memory: bool, } @@ -99,6 +100,7 @@ impl ProgressTracker { content_length: None, is_compressed: true, newline_progress, + line_open: false, show_memory, } } @@ -213,6 +215,7 @@ impl ProgressTracker { "\r\x1b[KDownload: {} | {}: {} | Written: {}{}", download_status, progress_label, decompress_status, write_status, memory_suffix ); + self.line_open = true; io::stdout().flush()?; } self.last_update = now; @@ -252,17 +255,17 @@ impl ProgressTracker { /// /// This consolidates the common stats printing pattern used by both /// URL and OCI flash operations. - pub(crate) fn print_final_stats(&self) { + pub(crate) fn print_final_stats(&mut self) { self.print_stats(false); } /// Print final statistics including compression ratio (for URL flash) - pub(crate) fn print_final_stats_with_ratio(&self) { + pub(crate) fn print_final_stats_with_ratio(&mut self) { self.print_stats(true); } /// Internal helper to print stats with optional compression ratio - fn print_stats(&self, include_compression_ratio: bool) { + fn print_stats(&mut self, include_compression_ratio: bool) { let stats = self.final_stats(); println!( "\nDownload complete: {:.2} MB in {} ({:.2} MB/s)", @@ -270,6 +273,7 @@ impl ProgressTracker { stats.download_time_formatted(), stats.download_rate ); + self.line_open = false; println!( "Decompression complete: {:.2} MB in {} ({:.2} MB/s)", stats.mb_decompressed, @@ -291,3 +295,11 @@ impl ProgressTracker { println!("Total flash runtime: {}", stats.total_time_formatted()); } } + +impl Drop for ProgressTracker { + fn drop(&mut self) { + if self.line_open { + let _ = writeln!(io::stdout()); + } + } +} diff --git a/src/fls/ssh_writer.rs b/src/fls/ssh_writer.rs index a538fb5..2a01cf6 100644 --- a/src/fls/ssh_writer.rs +++ b/src/fls/ssh_writer.rs @@ -137,15 +137,20 @@ fn detect_target(host: &str, options: &FlashOptions) -> io::Result<(String, Stri if [ \"$os\" = QNX ]; then uname -p; else uname -m; fi || exit; uname -r"; let output = ssh_command(host, command, options) .stdin(Stdio::null()) - .stderr(Stdio::inherit()) .output()?; if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let reason = if stderr.trim().is_empty() { + "remote platform probe failed without diagnostic output" + } else { + stderr.trim() + }; let port = options .ssh_port .map(|port| format!(" -p {port}")) .unwrap_or_default(); return Err(io::Error::other(format!( - "Remote platform detection failed: {}. Run `ssh{port} {}` to verify the host key and authentication, then retry", + "SSH to {host} failed ({}): {reason}\nCheck the connection with `ssh{port} {}`", output.status, shell_quote(host) ))); diff --git a/tests/oci_extract.rs b/tests/oci_extract.rs index be56c62..7866429 100644 --- a/tests/oci_extract.rs +++ b/tests/oci_extract.rs @@ -146,6 +146,94 @@ fn default_options(cert_dir: &Path) -> OciOptions { } } +#[tokio::test] +async fn oci_cli_reports_ssh_connection_failures_without_mixing_progress() { + use std::os::unix::fs::PermissionsExt; + + let _ = rustls::crypto::ring::default_provider().install_default(); + let blob_bytes = common::compress_gz(&common::create_test_data(4 * 1024 * 1024)); + let blob_digest = "sha256:layer"; + let manifest = serde_json::json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:config", + "size": 2 + }, + "layers": [{ + "mediaType": "application/vnd.automotive.disk.simg+gzip", + "digest": blob_digest, + "size": blob_bytes.len() + }] + }); + let (address, server) = spawn_oci_server( + format!("/v2/{REPO}/manifests/{TAG}"), + format!("/v2/{REPO}/blobs/{blob_digest}"), + manifest.to_string().into_bytes(), + blob_bytes, + ) + .await; + let dir = tempdir().unwrap(); + let ssh = dir.path().join("ssh"); + let device = dir.path().join("device"); + fs::write(&device, [0; 1024]).unwrap(); + for reason in ["No route to host", "Connection refused"] { + fs::write( + &ssh, + format!( + "#!/bin/sh\nsleep 0.1\necho 'ssh: connect to host fake-host port 11223: {reason}' >&2\nexit 255\n" + ), + ) + .unwrap(); + fs::set_permissions(&ssh, fs::Permissions::from_mode(0o755)).unwrap(); + let mut command = tokio::process::Command::new(env!("CARGO_BIN_EXE_fls")); + command + .args([ + "from-url", + &format!("oci://{address}/{REPO}:{TAG}"), + &format!("root@fake-host:{}", device.display()), + "--ssh-port", + "11223", + "--progress-interval", + "0", + "--insecure-tls", + "--wh-bin", + ]) + .arg(&ssh) + .env( + "PATH", + format!( + "{}:{}", + dir.path().display(), + std::env::var("PATH").unwrap() + ), + ) + .env_remove("SSHPASS") + .env_remove("FLS_SSH_PASS_FILE") + .kill_on_drop(true); + let output = tokio::time::timeout(std::time::Duration::from_secs(10), command.output()) + .await + .unwrap() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!output.status.success(), "{stdout}\n{stderr}"); + assert!( + stderr.contains("Error: SSH to root@fake-host failed"), + "{stderr}" + ); + assert_eq!(stderr.matches(reason).count(), 1, "{stderr}"); + assert!(stderr.contains("port 11223")); + assert!(stdout.contains("\r\x1b[KDownload:"), "{stdout}"); + assert_eq!(stdout.lines().last(), Some("Result: FLASH_FAILED")); + assert!(!stderr.contains("Streaming pipeline closed")); + assert!(!stdout.contains("Download complete")); + assert_eq!(fs::read(&device).unwrap(), [0; 1024]); + } + server.abort(); +} + #[tokio::test] async fn test_extract_files_from_oci_image_to_dir_tar_layer() { let _ = rustls::crypto::ring::default_provider().install_default(); diff --git a/tests/ssh_remote.rs b/tests/ssh_remote.rs index ca9ef7c..777ddb7 100644 --- a/tests/ssh_remote.rs +++ b/tests/ssh_remote.rs @@ -185,11 +185,13 @@ exec "$@" if matches!(auth, "probe-fail" | "strict-unknown-key") { assert!(!output.status.success()); assert!(!upload.exists(), "Platform detection must precede upload"); - assert!(String::from_utf8_lossy(&output.stderr) - .contains("Remote platform detection failed")); + assert!( + String::from_utf8_lossy(&output.stderr).contains("SSH to root@fake-host failed") + ); if auth == "strict-unknown-key" { let stderr = String::from_utf8_lossy(&output.stderr); assert!(stderr.contains("Host key verification failed")); + assert_eq!(stderr.matches("Host key verification failed").count(), 1); assert!(stderr.contains("ssh -p 11223 'root@fake-host'")); assert!(std::fs::read(&device) .unwrap() From 7a7f096a09c2f7ca1983dcb55697d37e6adf19e2 Mon Sep 17 00:00:00 2001 From: Miguel Angel Ajo Pelayo Date: Fri, 9 Oct 2026 16:22:10 +0200 Subject: [PATCH 09/15] Accept 1 or 2 host-key failure reports in the SSH test The inline error report and the final summary can both carry the diagnostic; the inline one races the process exit, so the count is timing-dependent. Assisted-by: OpenCode Co-Authored-By: Qwen 3.8 27B --- tests/ssh_remote.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/ssh_remote.rs b/tests/ssh_remote.rs index 777ddb7..b102650 100644 --- a/tests/ssh_remote.rs +++ b/tests/ssh_remote.rs @@ -190,8 +190,14 @@ exec "$@" ); if auth == "strict-unknown-key" { let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("Host key verification failed")); - assert_eq!(stderr.matches("Host key verification failed").count(), 1); + // The inline error report and the final summary can both carry + // the diagnostic; the inline one races the process exit, so 1 + // or 2. + let count = stderr.matches("Host key verification failed").count(); + assert!( + (1..=2).contains(&count), + "host key failure reported {count} times" + ); assert!(stderr.contains("ssh -p 11223 'root@fake-host'")); assert!(std::fs::read(&device) .unwrap() From a28bbcb688ca14c52cffe53c1ce8a81c5b11c5f1 Mon Sep 17 00:00:00 2001 From: Miguel Angel Ajo Pelayo Date: Tue, 13 Oct 2026 14:36:07 +0200 Subject: [PATCH 10/15] Accept ssh://[user@]host[:port]/device destinations Support the ssh:// URI form alongside the legacy host:/device syntax. The URI form handles IPv6 brackets and an inline port, replacing the --ssh-port CLI flag (removed). Assisted-by: OpenCode Co-Authored-By: Claude Opus 4.6 --- README.md | 13 +++-- src/fls/block_writer.rs | 7 +-- src/fls/options.rs | 2 - src/fls/ssh_writer.rs | 103 +++++++++++++++++++++++++++++----------- src/main.rs | 10 +--- tests/oci_extract.rs | 4 +- tests/ssh_remote.rs | 9 ++-- 7 files changed, 95 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 3ec32b1..a9ef275 100644 --- a/README.md +++ b/README.md @@ -39,13 +39,17 @@ sudo cp target/release/fls /usr/local/bin/ ### Remote devices over SSH -Use `[user@]host:/absolute/device` as the destination for HTTP/HTTPS or OCI images: +Use `ssh://[user@]host[:port]/device` as the destination for HTTP/HTTPS or OCI images: ```bash -fls from-url "https://example.com/image.img.xz" root@board:/dev/emmc0 -fls from-url "oci://quay.io/org/image:latest" root@board:/dev/emmc0 +fls from-url "https://example.com/image.img.xz" ssh://root@board/dev/emmc0 +fls from-url "oci://quay.io/org/image:latest" ssh://root@board:2222/dev/emmc0 +# IPv6 +fls from-url "https://example.com/image.img.xz" ssh://root@[::1]/dev/emmc0 ``` +The legacy form `[user@]host:/device` is still accepted (no IPv6 or inline port). + `fls` uploads its embedded aarch64-QNX write head over SSH, then streams framed commands. For another target architecture/OS, build the appropriate write head and use `--wh-bin /path/to/flswh`. When building from source, run @@ -53,8 +57,7 @@ and use `--wh-bin /path/to/flswh`. When building from source, run work without it. See [remote/README.md](remote/README.md) for build instructions. SSH keys, agents, ports, and aliases use the system `ssh` and your -`~/.ssh/config`. `--ssh-port ` overrides the port for platform detection, -upload, and flashing; without it the SSH configuration is used. `-p` remains +`~/.ssh/config`. Set the port inline: `ssh://host:2222/dev/x`. `-p` remains the registry-password option. Password authentication uses `sshpass` with `--ssh-password-file ` (`FLS_SSH_PASS_FILE`) or, if no file is specified, the `SSHPASS` environment variable. `--ssh-compress` enables SSH compression; diff --git a/src/fls/block_writer.rs b/src/fls/block_writer.rs index 6d135fc..694c062 100644 --- a/src/fls/block_writer.rs +++ b/src/fls/block_writer.rs @@ -6,7 +6,7 @@ use std::ptr::NonNull; use tokio::sync::mpsc; use super::options::FlashOptions; -use super::ssh_writer::{parse_ssh_target, SshBlockWriter}; +use super::ssh_writer::{parse_ssh_target, SshBlockWriter, SshTarget}; const BLOCK_SIZE: usize = 1024 * 1024; // 1MB blocks for better throughput const ALIGNMENT: usize = 4096; // 4KB alignment for direct I/O @@ -606,9 +606,10 @@ impl DeviceWriter { options: &FlashOptions, progress: mpsc::UnboundedSender, ) -> io::Result { - if let Some((host, device)) = parse_ssh_target(&options.device) { + if let Some(target) = parse_ssh_target(&options.device) { + let SshTarget { host, port, device } = target; Ok(Self::Ssh(SshBlockWriter::new( - host, device, progress, options, + host, port, device, progress, options, )?)) } else { Ok(Self::Local(AsyncBlockWriter::new( diff --git a/src/fls/options.rs b/src/fls/options.rs index c4897bf..e9bbbd2 100644 --- a/src/fls/options.rs +++ b/src/fls/options.rs @@ -23,7 +23,6 @@ pub struct FlashOptions { pub xz_memlimit_mb: u64, pub ssh_password_file: Option, pub ssh_compress: bool, - pub ssh_port: Option, pub strict_ssh_host_key_checking: bool, pub wh_bin: Option, } @@ -44,7 +43,6 @@ impl Default for FlashOptions { xz_memlimit_mb: DEFAULT_XZ_MEMLIMIT_MB, ssh_password_file: None, ssh_compress: false, - ssh_port: None, strict_ssh_host_key_checking: false, wh_bin: None, } diff --git a/src/fls/ssh_writer.rs b/src/fls/ssh_writer.rs index 2a01cf6..9dd971b 100644 --- a/src/fls/ssh_writer.rs +++ b/src/fls/ssh_writer.rs @@ -80,20 +80,58 @@ impl FrameReader { } } -pub(crate) fn parse_ssh_target(device: &str) -> Option<(String, String)> { +/// Parsed SSH destination. +pub(crate) struct SshTarget { + pub host: String, + pub port: Option, + pub device: String, +} + +/// Parse `ssh://[user@]host[:port]/device` (with IPv6 bracket support) or +/// the legacy `[user@]host:/device` form. Returns `None` for local paths. +pub(crate) fn parse_ssh_target(device: &str) -> Option { if device.starts_with('/') { return None; } - device - .split_once(':') - .map(|(host, path)| (host.to_owned(), path.to_owned())) + if let Some(rest) = device.strip_prefix("ssh://") { + let slash = rest.find('/')?; + let authority = &rest[..slash]; + let device_path = &rest[slash..]; + if authority.is_empty() || device_path.len() < 2 { + return None; + } + // Find the host:port boundary, respecting IPv6 brackets. + let port_start = match authority.rfind(']') { + Some(i) => i + 1, + None => authority.find('@').map_or(0, |i| i + 1), + }; + let (host_part, port) = match authority[port_start..].find(':') { + Some(i) => { + let p = authority[port_start + i + 1..].parse::().ok()?; + (&authority[..port_start + i], Some(p)) + } + None => (authority, None), + }; + let host = host_part.replace(['[', ']'], ""); + if host.is_empty() { + return None; + } + return Some(SshTarget { host, port, device: device_path.to_owned() }); + } + // Legacy: [user@]host:/device + let (host, path) = device.split_once(':')?; + if host.is_empty() || !path.starts_with('/') { + return None; + } + Some(SshTarget { host: host.to_owned(), port: None, device: path.to_owned() }) } + fn shell_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\\''")) } -fn ssh_command(host: &str, remote_command: &str, options: &FlashOptions) -> Command { +fn ssh_command(host: &str, remote_command: &str, port: Option, options: &FlashOptions) -> Command { let mut command = if let Some(file) = &options.ssh_password_file { let mut command = Command::new("sshpass"); command.args(["-f", file, "ssh"]); @@ -108,7 +146,7 @@ fn ssh_command(host: &str, remote_command: &str, options: &FlashOptions) -> Comm if options.ssh_compress { command.arg("-C"); } - if let Some(port) = options.ssh_port { + if let Some(port) = port { command.arg("-p").arg(port.to_string()); } // Streaming stdin belongs to the protocol; terminal prompts would stall it. @@ -131,11 +169,11 @@ fn ssh_command(host: &str, remote_command: &str, options: &FlashOptions) -> Comm command } -fn detect_target(host: &str, options: &FlashOptions) -> io::Result<(String, String, String)> { +fn detect_target(host: &str, port: Option, options: &FlashOptions) -> io::Result<(String, String, String)> { let command = "PATH=/proc/boot:/usr/bin:/bin:$PATH; \ os=$(uname -s) || exit; echo \"$os\"; \ if [ \"$os\" = QNX ]; then uname -p; else uname -m; fi || exit; uname -r"; - let output = ssh_command(host, command, options) + let output = ssh_command(host, command, port, options) .stdin(Stdio::null()) .output()?; if !output.status.success() { @@ -145,12 +183,9 @@ fn detect_target(host: &str, options: &FlashOptions) -> io::Result<(String, Stri } else { stderr.trim() }; - let port = options - .ssh_port - .map(|port| format!(" -p {port}")) - .unwrap_or_default(); + let port_flag = port.map(|p| format!(" -p {p}")).unwrap_or_default(); return Err(io::Error::other(format!( - "SSH to {host} failed ({}): {reason}\nCheck the connection with `ssh{port} {}`", + "SSH to {host} failed ({}): {reason}\nCheck the connection with `ssh{port_flag} {}`", output.status, shell_quote(host) ))); @@ -179,12 +214,12 @@ fn check_embedded_target(os: &str, arch: &str, release: &str) -> io::Result<()> )) } -fn upload_wh(host: &str, binary: &[u8], options: &FlashOptions) -> io::Result<()> { +fn upload_wh(host: &str, binary: &[u8], port: Option, options: &FlashOptions) -> io::Result<()> { // QNX boot-image utilities may be absent from the default PATH. let remote_command = format!("PATH=/proc/boot:/usr/bin:/bin:$PATH; cat > {REMOTE_WH} && chmod +x {REMOTE_WH}"); // ponytail: one upload path per host; use unique paths for concurrent flash sessions. - let mut child = ssh_command(host, &remote_command, options) + let mut child = ssh_command(host, &remote_command, port, options) .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::inherit()) @@ -594,6 +629,7 @@ pub(crate) struct SshBlockWriter { impl SshBlockWriter { pub(crate) fn new( host: String, + port: Option, device: String, progress: mpsc::UnboundedSender, options: &FlashOptions, @@ -607,10 +643,10 @@ impl SshBlockWriter { { return Err(io::Error::new( io::ErrorKind::InvalidInput, - "Expected [user@]host:/absolute/device", + "Expected ssh://[user@]host[:port]/device or [user@]host:/device", )); } - if options.ssh_port == Some(0) { + if port == Some(0) { return Err(io::Error::new( io::ErrorKind::InvalidInput, "SSH port must be between 1 and 65535", @@ -642,16 +678,16 @@ impl SshBlockWriter { let error_state = Arc::clone(&writer_error); let writer_handle = tokio::task::spawn_blocking(move || { let result = (|| { - let (os, arch, release) = detect_target(&host, &options)?; + let (os, arch, release) = detect_target(&host, port, &options)?; if options.wh_bin.is_none() { check_embedded_target(&os, &arch, &release)?; } if options.debug { eprintln!("[DEBUG] Remote platform: {os} {arch} {release}"); } - upload_wh(&host, &binary, &options)?; + upload_wh(&host, &binary, port, &options)?; let remote_command = format!("{REMOTE_WH} {}", shell_quote(&device)); - let child = ssh_command(&host, &remote_command, &options) + let child = ssh_command(&host, &remote_command, port, &options) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) @@ -786,19 +822,30 @@ mod tests { #[test] fn target_parsing_auth_and_shell_quoting() { - assert_eq!( - parse_ssh_target("root@board:/dev/emmc0"), - Some(("root@board".into(), "/dev/emmc0".into())) - ); - assert_eq!(parse_ssh_target("/dev/disk:local"), None); - assert_eq!(parse_ssh_target("image.img"), None); + fn t(input: &str) -> Option<(String, Option, String)> { + parse_ssh_target(input).map(|t| (t.host, t.port, t.device)) + } + // Legacy form + assert_eq!(t("root@board:/dev/emmc0"), Some(("root@board".into(), None, "/dev/emmc0".into()))); + // ssh:// with port + assert_eq!(t("ssh://root@board:2222/dev/emmc0"), Some(("root@board".into(), Some(2222), "/dev/emmc0".into()))); + // ssh:// without port + assert_eq!(t("ssh://board/dev/emmc0"), Some(("board".into(), None, "/dev/emmc0".into()))); + // ssh:// IPv6 with port + assert_eq!(t("ssh://root@[::1]:2222/dev/emmc0"), Some(("root@::1".into(), Some(2222), "/dev/emmc0".into()))); + // ssh:// IPv6 without port + assert_eq!(t("ssh://root@[::1]/dev/emmc0"), Some(("root@::1".into(), None, "/dev/emmc0".into()))); + // Local paths and non-SSH strings + assert_eq!(t("/dev/disk:local"), None); + assert_eq!(t("image.img"), None); + // Reject bare path after ssh:// + assert_eq!(t("ssh:///dev/foo"), None); let options = FlashOptions { ssh_password_file: Some("password file".into()), ssh_compress: true, - ssh_port: Some(11223), ..Default::default() }; - let command = ssh_command("board", "echo ready", &options); + let command = ssh_command("board", "echo ready", Some(11223), &options); assert_eq!(command.get_program(), "sshpass"); let args: Vec<_> = command .get_args() diff --git a/src/main.rs b/src/main.rs index 4a3e330..d67cac1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -81,9 +81,6 @@ enum Commands { /// Enable SSH compression #[arg(long)] ssh_compress: bool, - /// SSH port (defaults to SSH configuration) - #[arg(long, value_parser = clap::value_parser!(u16).range(1..))] - ssh_port: Option, /// Require verified SSH host keys (default: skip host-key checks) #[arg(long)] strict_ssh_host_key_checking: bool, @@ -157,7 +154,6 @@ async fn main() { xz_memlimit, ssh_password_file, ssh_compress, - ssh_port, strict_ssh_host_key_checking, wh_bin, } => { @@ -215,8 +211,7 @@ async fn main() { xz_memlimit_mb: xz_memlimit, ssh_password_file, ssh_compress, - ssh_port, - strict_ssh_host_key_checking, + strict_ssh_host_key_checking, wh_bin, }, username, @@ -299,8 +294,7 @@ async fn main() { xz_memlimit_mb: xz_memlimit, ssh_password_file, ssh_compress, - ssh_port, - strict_ssh_host_key_checking, + strict_ssh_host_key_checking, wh_bin, }, max_retries, diff --git a/tests/oci_extract.rs b/tests/oci_extract.rs index 7866429..3279fbb 100644 --- a/tests/oci_extract.rs +++ b/tests/oci_extract.rs @@ -192,9 +192,7 @@ async fn oci_cli_reports_ssh_connection_failures_without_mixing_progress() { .args([ "from-url", &format!("oci://{address}/{REPO}:{TAG}"), - &format!("root@fake-host:{}", device.display()), - "--ssh-port", - "11223", + &format!("ssh://root@fake-host:11223{}", device.display()), "--progress-interval", "0", "--insecure-tls", diff --git a/tests/ssh_remote.rs b/tests/ssh_remote.rs index b102650..13f830c 100644 --- a/tests/ssh_remote.rs +++ b/tests/ssh_remote.rs @@ -127,7 +127,11 @@ exec "$@" .args([ "from-url", &format!("{}/image.img.gz", server.uri()), - &format!("root@fake-host:{}", device.display()), + &if auth == "config-port" { + format!("root@fake-host:{}", device.display()) + } else { + format!("ssh://root@fake-host:11223{}", device.display()) + }, "--ssh-compress", "--wh-bin", ]) @@ -150,9 +154,6 @@ exec "$@" .env_remove("HOST_KEY_UNKNOWN") .env_remove("STRICT_CHECK") .kill_on_drop(true); - if auth != "config-port" { - command.args(["--ssh-port", "11223"]); - } if auth.starts_with("strict-") { command .arg("--strict-ssh-host-key-checking") From 8cc0df27497f2596ea55e751e2636ad0c2c78d87 Mon Sep 17 00:00:00 2001 From: Miguel Angel Ajo Pelayo Date: Tue, 13 Oct 2026 15:06:13 +0200 Subject: [PATCH 11/15] Replace FlowControl with sync_channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The write head exits on any error, breaking the pipe immediately. The reader thread always has the real cause (the remote ERR frame); the sender just joins it on failure. This makes the custom FlowControl (Mutex + Condvar + byte-weighted accounting) unnecessary: - sync_channel(window / CHUNK_SIZE * 3/2) bounds in-flight commands - sender joins the reader thread on send/write failure to surface the real remote error instead of a secondary broken-pipe symptom - FIFO ordering means SYNC naturally waits for prior commands; wait_idle is removed Net: −120 lines. Assisted-by: OpenCode Co-Authored-By: Claude Opus 4.6 --- src/fls/ssh_writer.rs | 182 +++++++----------------------------------- 1 file changed, 31 insertions(+), 151 deletions(-) diff --git a/src/fls/ssh_writer.rs b/src/fls/ssh_writer.rs index 9dd971b..1d4c323 100644 --- a/src/fls/ssh_writer.rs +++ b/src/fls/ssh_writer.rs @@ -1,6 +1,6 @@ use std::io::{self, BufReader, Read, Write}; use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; -use std::sync::{mpsc as std_mpsc, Arc, Condvar, Mutex}; +use std::sync::{mpsc as std_mpsc, Arc, Mutex}; use tokio::sync::mpsc; use super::block_writer::WriterCommand; @@ -266,77 +266,6 @@ struct Expected { op: u8, start: u64, bytes: u64, - charge: usize, -} - -#[derive(Default)] -struct FlowState { - bytes: usize, - commands: usize, - error: Option, -} - -struct FlowControl { - state: Mutex, - ready: Condvar, - limit: usize, -} - -impl FlowControl { - fn new(limit: usize) -> Self { - Self { - state: Mutex::new(FlowState::default()), - ready: Condvar::new(), - limit, - } - } - - fn reserve(&self, bytes: usize) -> io::Result<()> { - if bytes > self.limit { - return Err(invalid("Frame exceeds SSH in-flight window")); - } - let mut state = self.state.lock().unwrap(); - loop { - if let Some(error) = &state.error { - return Err(io::Error::new(error.kind(), error.to_string())); - } - // Also bound metadata for tiny ZERO/SEEK requests. - if state.bytes <= self.limit - bytes && state.commands < 1024 { - state.bytes += bytes; - state.commands += 1; - return Ok(()); - } - state = self.ready.wait(state).unwrap(); - } - } - - fn complete(&self, bytes: usize) { - let mut state = self.state.lock().unwrap(); - state.bytes -= bytes; - state.commands -= 1; - self.ready.notify_all(); - } - - fn fail(&self, error: &io::Error) { - let mut state = self.state.lock().unwrap(); - if state.error.is_none() { - state.error = Some(io::Error::new(error.kind(), error.to_string())); - } - self.ready.notify_all(); - } - - fn wait_idle(&self) -> io::Result<()> { - let mut state = self.state.lock().unwrap(); - loop { - if let Some(error) = &state.error { - return Err(io::Error::new(error.kind(), error.to_string())); - } - if state.commands == 0 { - return Ok(()); - } - state = self.ready.wait(state).unwrap(); - } - } } fn expect_ok( @@ -380,7 +309,6 @@ fn expect_ok( fn read_acks( mut output: FrameReader, requests: std_mpsc::Receiver, - flow: Arc, child: Arc>, progress: mpsc::UnboundedSender, ) -> io::Result { @@ -395,7 +323,6 @@ fn read_acks( if u64::from_le_bytes(payload[..].try_into().unwrap()) != total { return Err(invalid("DONE total does not match acknowledged writes")); } - flow.complete(expected.charge); return Ok(total); } expect_ok(&mut output, &expected, &progress)?; @@ -404,15 +331,13 @@ fn read_acks( .checked_add(expected.bytes) .ok_or_else(|| invalid("Written byte count overflow"))?; } - flow.complete(expected.charge); } Err(io::Error::new( io::ErrorKind::BrokenPipe, "SSH writer closed without QUIT", )) })(); - if let Err(ref error) = result { - flow.fail(error); + if result.is_err() { // Interrupt a sender blocked in write_all when the peer reports an error. let _ = child.lock().unwrap().kill(); } @@ -422,9 +347,8 @@ fn read_acks( struct Session { child: Arc>, input: Option, - requests: Option>, + requests: Option>, reader: Option>>, - flow: Arc, cursor: u64, size: u64, pending: Vec, @@ -439,13 +363,15 @@ impl Session { ) -> io::Result { let input = child.stdin.take().unwrap(); let mut output = FrameReader::new(child.stdout.take().unwrap()); - let (tx, rx) = std_mpsc::channel(); + // ponytail: sync_channel bounds in-flight commands by slot count, + // not byte weight — a SEEK takes a slot same as 8 MiB DATA. + let depth = ((window / CHUNK_SIZE) * 3 / 2).max(2); + let (tx, rx) = std_mpsc::sync_channel(depth); let mut session = Self { child: Arc::new(Mutex::new(child)), input: Some(input), requests: Some(tx), reader: None, - flow: Arc::new(FlowControl::new(window)), cursor: 0, size: 0, pending: Vec::with_capacity(window.min(CHUNK_SIZE)), @@ -472,39 +398,34 @@ impl Session { if advertised != session.size { return Err(invalid("READY and SIZE disagree about device capacity")); } - let flow = Arc::clone(&session.flow); let child = Arc::clone(&session.child); session.reader = Some( std::thread::Builder::new() .name("fls-ssh-acks".into()) - .spawn(move || read_acks(output, rx, flow, child, progress))?, + .spawn(move || read_acks(output, rx, child, progress))?, ); Ok(session) } fn queue_frame(&mut self, op: u8, start: u64, bytes: u64, frame: &[u8]) -> io::Result<()> { - let charge = if op == OP_DATA { bytes as usize } else { 1 }; - self.flow.reserve(charge)?; - self.requests - .as_ref() - .unwrap() - .send(Expected { - op, - start, - bytes, - charge, - }) - .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "SSH response reader closed"))?; - self.input - .as_mut() - .unwrap() - .write_all(frame) - .map_err(|error| { - self.flow.fail(&error); - let state = self.flow.state.lock().unwrap(); - let error = state.error.as_ref().unwrap(); - io::Error::new(error.kind(), error.to_string()) - }) + if self.requests.as_ref().unwrap().send(Expected { op, start, bytes }).is_err() { + return Err(self.reader_error()); + } + self.input.as_mut().unwrap().write_all(frame).map_err(|_| self.reader_error()) + } + + /// Join the reader thread and return its error. The reader always has + /// the real cause (the remote ERR frame); the sender only sees a + /// secondary broken-pipe symptom. + fn reader_error(&mut self) -> io::Error { + self.input.take(); + self.requests.take(); + if let Some(handle) = self.reader.take() { + if let Ok(Err(e)) = handle.join() { + return e; + } + } + io::Error::new(io::ErrorKind::BrokenPipe, "SSH connection closed") } fn send(&mut self, op: u8, start: u64, bytes: u64, payload: &[u8]) -> io::Result<()> { @@ -585,7 +506,6 @@ impl Session { fn finish(mut self) -> io::Result { self.flush_pending()?; - self.flow.wait_idle()?; self.send(OP_SYNC, self.cursor, 0, &[])?; self.send(OP_QUIT, self.cursor, 0, &[])?; self.input.take(); @@ -947,19 +867,17 @@ mod tests { session.write(b"ab").unwrap(); session.write(b"cd").unwrap(); assert_eq!(session.pending, b"abcd"); - let mut head = [0; 4]; - device.read_exact(&mut head).unwrap(); - assert_eq!(head, [0; 4]); session.seek(8).unwrap(); - session.flow.wait_idle().unwrap(); assert!(session.pending.is_empty()); - device.seek(io::SeekFrom::Start(0)).unwrap(); - device.read_exact(&mut head).unwrap(); - assert_eq!(&head, b"abcd"); session.write(b"ef").unwrap(); session.fill([1, 2, 3, 4], 4).unwrap(); session.write(b"gh").unwrap(); assert_eq!(session.finish().unwrap(), 16); + // Verify device contents after finish (all writes are ACKed). + device.seek(io::SeekFrom::Start(0)).unwrap(); + let mut head = [0; 4]; + device.read_exact(&mut head).unwrap(); + assert_eq!(&head, b"abcd"); device.seek(io::SeekFrom::Start(8)).unwrap(); let mut tail = [0; 8]; device.read_exact(&mut tail).unwrap(); @@ -1019,44 +937,6 @@ emit(0x85, struct.pack(' Date: Tue, 13 Oct 2026 15:11:41 +0200 Subject: [PATCH 12/15] Return errors for malformed ssh:// URIs instead of falling through to local A string starting with ssh:// is never a usable local path. parse_ssh_target now returns io::Result>: Ok(None) for local paths, Err for malformed ssh:// URIs (bad port, missing device path, empty host, unbracketed IPv6). Assisted-by: OpenCode Co-Authored-By: Claude Opus 4.6 --- src/fls/block_writer.rs | 2 +- src/fls/ssh_writer.rs | 127 +++++++++++++++++++++++++++++++--------- src/main.rs | 4 +- 3 files changed, 102 insertions(+), 31 deletions(-) diff --git a/src/fls/block_writer.rs b/src/fls/block_writer.rs index 694c062..89ca5c7 100644 --- a/src/fls/block_writer.rs +++ b/src/fls/block_writer.rs @@ -606,7 +606,7 @@ impl DeviceWriter { options: &FlashOptions, progress: mpsc::UnboundedSender, ) -> io::Result { - if let Some(target) = parse_ssh_target(&options.device) { + if let Some(target) = parse_ssh_target(&options.device)? { let SshTarget { host, port, device } = target; Ok(Self::Ssh(SshBlockWriter::new( host, port, device, progress, options, diff --git a/src/fls/ssh_writer.rs b/src/fls/ssh_writer.rs index 1d4c323..cc3dee8 100644 --- a/src/fls/ssh_writer.rs +++ b/src/fls/ssh_writer.rs @@ -81,6 +81,7 @@ impl FrameReader { } /// Parsed SSH destination. +#[derive(Debug)] pub(crate) struct SshTarget { pub host: String, pub port: Option, @@ -88,17 +89,26 @@ pub(crate) struct SshTarget { } /// Parse `ssh://[user@]host[:port]/device` (with IPv6 bracket support) or -/// the legacy `[user@]host:/device` form. Returns `None` for local paths. -pub(crate) fn parse_ssh_target(device: &str) -> Option { +/// the legacy `[user@]host:/device` form. Returns `Ok(None)` for local +/// paths, `Err` for malformed `ssh://` URIs that can never be local paths. +pub(crate) fn parse_ssh_target(device: &str) -> io::Result> { if device.starts_with('/') { - return None; + return Ok(None); } if let Some(rest) = device.strip_prefix("ssh://") { - let slash = rest.find('/')?; + let bad = + |msg: &str| { + io::Error::new(io::ErrorKind::InvalidInput, + format!("malformed SSH destination: {msg} — expected ssh://[user@]host[:port]/device")) + }; + let slash = rest.find('/').ok_or_else(|| bad("missing /device path"))?; let authority = &rest[..slash]; let device_path = &rest[slash..]; - if authority.is_empty() || device_path.len() < 2 { - return None; + if authority.is_empty() { + return Err(bad("empty host")); + } + if device_path.len() < 2 { + return Err(bad("missing device path after /")); } // Find the host:port boundary, respecting IPv6 brackets. let port_start = match authority.rfind(']') { @@ -107,31 +117,45 @@ pub(crate) fn parse_ssh_target(device: &str) -> Option { }; let (host_part, port) = match authority[port_start..].find(':') { Some(i) => { - let p = authority[port_start + i + 1..].parse::().ok()?; + let port_str = &authority[port_start + i + 1..]; + let p = port_str + .parse::() + .map_err(|_| bad(&format!("invalid port \"{port_str}\"")))?; (&authority[..port_start + i], Some(p)) } None => (authority, None), }; let host = host_part.replace(['[', ']'], ""); if host.is_empty() { - return None; + return Err(bad("empty host")); } - return Some(SshTarget { host, port, device: device_path.to_owned() }); + return Ok(Some(SshTarget { + host, + port, + device: device_path.to_owned(), + })); } // Legacy: [user@]host:/device - let (host, path) = device.split_once(':')?; - if host.is_empty() || !path.starts_with('/') { - return None; + match device.split_once(':') { + Some((host, path)) if !host.is_empty() && path.starts_with('/') => Ok(Some(SshTarget { + host: host.to_owned(), + port: None, + device: path.to_owned(), + })), + _ => Ok(None), } - Some(SshTarget { host: host.to_owned(), port: None, device: path.to_owned() }) } - fn shell_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\\''")) } -fn ssh_command(host: &str, remote_command: &str, port: Option, options: &FlashOptions) -> Command { +fn ssh_command( + host: &str, + remote_command: &str, + port: Option, + options: &FlashOptions, +) -> Command { let mut command = if let Some(file) = &options.ssh_password_file { let mut command = Command::new("sshpass"); command.args(["-f", file, "ssh"]); @@ -169,7 +193,11 @@ fn ssh_command(host: &str, remote_command: &str, port: Option, options: &Fl command } -fn detect_target(host: &str, port: Option, options: &FlashOptions) -> io::Result<(String, String, String)> { +fn detect_target( + host: &str, + port: Option, + options: &FlashOptions, +) -> io::Result<(String, String, String)> { let command = "PATH=/proc/boot:/usr/bin:/bin:$PATH; \ os=$(uname -s) || exit; echo \"$os\"; \ if [ \"$os\" = QNX ]; then uname -p; else uname -m; fi || exit; uname -r"; @@ -214,7 +242,12 @@ fn check_embedded_target(os: &str, arch: &str, release: &str) -> io::Result<()> )) } -fn upload_wh(host: &str, binary: &[u8], port: Option, options: &FlashOptions) -> io::Result<()> { +fn upload_wh( + host: &str, + binary: &[u8], + port: Option, + options: &FlashOptions, +) -> io::Result<()> { // QNX boot-image utilities may be absent from the default PATH. let remote_command = format!("PATH=/proc/boot:/usr/bin:/bin:$PATH; cat > {REMOTE_WH} && chmod +x {REMOTE_WH}"); @@ -408,10 +441,20 @@ impl Session { } fn queue_frame(&mut self, op: u8, start: u64, bytes: u64, frame: &[u8]) -> io::Result<()> { - if self.requests.as_ref().unwrap().send(Expected { op, start, bytes }).is_err() { + if self + .requests + .as_ref() + .unwrap() + .send(Expected { op, start, bytes }) + .is_err() + { return Err(self.reader_error()); } - self.input.as_mut().unwrap().write_all(frame).map_err(|_| self.reader_error()) + self.input + .as_mut() + .unwrap() + .write_all(frame) + .map_err(|_| self.reader_error()) } /// Join the reader thread and return its error. The reader always has @@ -743,23 +786,51 @@ mod tests { #[test] fn target_parsing_auth_and_shell_quoting() { fn t(input: &str) -> Option<(String, Option, String)> { - parse_ssh_target(input).map(|t| (t.host, t.port, t.device)) + parse_ssh_target(input) + .unwrap() + .map(|t| (t.host, t.port, t.device)) } // Legacy form - assert_eq!(t("root@board:/dev/emmc0"), Some(("root@board".into(), None, "/dev/emmc0".into()))); + assert_eq!( + t("root@board:/dev/emmc0"), + Some(("root@board".into(), None, "/dev/emmc0".into())) + ); // ssh:// with port - assert_eq!(t("ssh://root@board:2222/dev/emmc0"), Some(("root@board".into(), Some(2222), "/dev/emmc0".into()))); + assert_eq!( + t("ssh://root@board:2222/dev/emmc0"), + Some(("root@board".into(), Some(2222), "/dev/emmc0".into())) + ); // ssh:// without port - assert_eq!(t("ssh://board/dev/emmc0"), Some(("board".into(), None, "/dev/emmc0".into()))); + assert_eq!( + t("ssh://board/dev/emmc0"), + Some(("board".into(), None, "/dev/emmc0".into())) + ); // ssh:// IPv6 with port - assert_eq!(t("ssh://root@[::1]:2222/dev/emmc0"), Some(("root@::1".into(), Some(2222), "/dev/emmc0".into()))); + assert_eq!( + t("ssh://root@[::1]:2222/dev/emmc0"), + Some(("root@::1".into(), Some(2222), "/dev/emmc0".into())) + ); // ssh:// IPv6 without port - assert_eq!(t("ssh://root@[::1]/dev/emmc0"), Some(("root@::1".into(), None, "/dev/emmc0".into()))); - // Local paths and non-SSH strings + assert_eq!( + t("ssh://root@[::1]/dev/emmc0"), + Some(("root@::1".into(), None, "/dev/emmc0".into())) + ); + // Local paths and non-SSH strings → Ok(None) assert_eq!(t("/dev/disk:local"), None); assert_eq!(t("image.img"), None); - // Reject bare path after ssh:// - assert_eq!(t("ssh:///dev/foo"), None); + // Malformed ssh:// URIs → Err (not silent None) + for bad in [ + "ssh:///dev/foo", + "ssh://board", + "ssh://root@board:22a/dev/x", + "ssh://root@::1/dev/x", + ] { + assert_eq!( + parse_ssh_target(bad).unwrap_err().kind(), + io::ErrorKind::InvalidInput, + "{bad}" + ); + } let options = FlashOptions { ssh_password_file: Some("password file".into()), ssh_compress: true, diff --git a/src/main.rs b/src/main.rs index d67cac1..6b7af6c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -211,7 +211,7 @@ async fn main() { xz_memlimit_mb: xz_memlimit, ssh_password_file, ssh_compress, - strict_ssh_host_key_checking, + strict_ssh_host_key_checking, wh_bin, }, username, @@ -294,7 +294,7 @@ async fn main() { xz_memlimit_mb: xz_memlimit, ssh_password_file, ssh_compress, - strict_ssh_host_key_checking, + strict_ssh_host_key_checking, wh_bin, }, max_retries, From 03c2f79be24d73c7e024a7b65a15c3cbc30c119a Mon Sep 17 00:00:00 2001 From: Miguel Angel Ajo Pelayo Date: Tue, 13 Oct 2026 17:37:49 +0200 Subject: [PATCH 13/15] Fix feed-loop deadlock when the writer stops mid-flash When the remote write head dies (e.g. the SSH connection breaks), the writer task errors and stops draining the decompressor. The byte-bounded input buffer then fills, and the feed loop blocks forever in buffer_tx.send() without ever checking writer_handle.is_finished(). Race the buffer send against a writer-done watch channel (signaled by a Drop guard, so it fires on success, error, and panic) so the feed loop always unblocks when the writer stops. Assisted-by: OpenCode Co-Authored-By: Qwen 3.8 27B --- src/fls/from_url.rs | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/fls/from_url.rs b/src/fls/from_url.rs index cf323a8..df2ed9c 100644 --- a/src/fls/from_url.rs +++ b/src/fls/from_url.rs @@ -212,12 +212,29 @@ pub async fn flash_from_url( options.common.xz_memlimit_mb, )?; + // Signals the feed loop when the writer task finishes, so a full input + // buffer cannot deadlock it (e.g. the SSH connection breaks mid-flash). + let (writer_done_tx, mut writer_done_rx) = tokio::sync::watch::channel(false); + // Spawn background task to read decompressed data and write to block device let error_tx_clone = error_tx.clone(); let debug = options.common.debug; let writer_handle = { let writer = block_writer; + let done_tx = writer_done_tx; tokio::spawn(async move { + // Fire `writer_done` however this task ends (success, error, or + // panic) so the feed loop's blocking buffer send can always unblock. + struct WriterDone(Option>); + impl Drop for WriterDone { + fn drop(&mut self) { + if let Some(tx) = self.0.take() { + let _ = tx.send(true); + } + } + } + let _done = WriterDone(Some(done_tx)); + let mut detector = FormatDetector::new(); let mut parser: Option = None; let mut format_determined = false; @@ -362,14 +379,20 @@ pub async fn flash_from_url( Ok(chunk) => { let chunk_len = chunk.len() as u64; - // Send to buffer - detect if it's blocking + // Send to buffer - detect if it's blocking. Race the + // send against the writer finishing: if the writer + // stops (e.g. SSH breaks) while the buffer is full, + // the send would otherwise block forever. let send_start = std::time::Instant::now(); - if buffer_tx.send(chunk).await.is_err() { - if writer_handle.is_finished() { + let send_result = tokio::select! { + result = buffer_tx.send(chunk) => result, + _ = writer_done_rx.wait_for(|done| *done) => { eprintln!(); - eprintln!("Writer task has terminated unexpectedly"); + eprintln!("Writer task has terminated, stopping download"); return Err(get_writer_error(writer_handle).await); } + }; + if send_result.is_err() { connection_error = Some(DownloadError::Other("Buffer channel closed".to_string())); connection_broken = true; From 28f1ae833339f122f7013f47733fcb1b53feee34 Mon Sep 17 00:00:00 2001 From: Miguel Angel Ajo Pelayo Date: Tue, 13 Oct 2026 17:49:23 +0200 Subject: [PATCH 14/15] Revert "Replace FlowControl with sync_channel" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 8cc0df2. The sync_channel replacement bounded in-flight commands by slot count instead of byte weight: a SEEK consumed the same slot as an 8 MiB DATA frame. For sparse images with interleaved SEEK + small DATA, the effective byte window shrank dramatically (e.g. 24 slots × 16 KiB = 384 KiB vs the old 128 MiB budget), causing the sender to stall waiting for ACKs and halving measured throughput (21 → 9 MB/s). Restores byte-weighted FlowControl while keeping reader_error() from the deadlock fix (joins the reader thread on failure to surface the real remote error instead of a secondary broken-pipe symptom). Assisted-by: OpenCode Co-Authored-By: Claude Opus 4.6 --- src/fls/ssh_writer.rs | 151 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 137 insertions(+), 14 deletions(-) diff --git a/src/fls/ssh_writer.rs b/src/fls/ssh_writer.rs index cc3dee8..75192ac 100644 --- a/src/fls/ssh_writer.rs +++ b/src/fls/ssh_writer.rs @@ -1,6 +1,6 @@ use std::io::{self, BufReader, Read, Write}; use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; -use std::sync::{mpsc as std_mpsc, Arc, Mutex}; +use std::sync::{mpsc as std_mpsc, Arc, Condvar, Mutex}; use tokio::sync::mpsc; use super::block_writer::WriterCommand; @@ -299,6 +299,77 @@ struct Expected { op: u8, start: u64, bytes: u64, + charge: usize, +} + +#[derive(Default)] +struct FlowState { + bytes: usize, + commands: usize, + error: Option, +} + +struct FlowControl { + state: Mutex, + ready: Condvar, + limit: usize, +} + +impl FlowControl { + fn new(limit: usize) -> Self { + Self { + state: Mutex::new(FlowState::default()), + ready: Condvar::new(), + limit, + } + } + + fn reserve(&self, bytes: usize) -> io::Result<()> { + if bytes > self.limit { + return Err(invalid("Frame exceeds SSH in-flight window")); + } + let mut state = self.state.lock().unwrap(); + loop { + if let Some(error) = &state.error { + return Err(io::Error::new(error.kind(), error.to_string())); + } + // Also bound metadata for tiny ZERO/SEEK requests. + if state.bytes <= self.limit - bytes && state.commands < 1024 { + state.bytes += bytes; + state.commands += 1; + return Ok(()); + } + state = self.ready.wait(state).unwrap(); + } + } + + fn complete(&self, bytes: usize) { + let mut state = self.state.lock().unwrap(); + state.bytes -= bytes; + state.commands -= 1; + self.ready.notify_all(); + } + + fn fail(&self, error: &io::Error) { + let mut state = self.state.lock().unwrap(); + if state.error.is_none() { + state.error = Some(io::Error::new(error.kind(), error.to_string())); + } + self.ready.notify_all(); + } + + fn wait_idle(&self) -> io::Result<()> { + let mut state = self.state.lock().unwrap(); + loop { + if let Some(error) = &state.error { + return Err(io::Error::new(error.kind(), error.to_string())); + } + if state.commands == 0 { + return Ok(()); + } + state = self.ready.wait(state).unwrap(); + } + } } fn expect_ok( @@ -342,6 +413,7 @@ fn expect_ok( fn read_acks( mut output: FrameReader, requests: std_mpsc::Receiver, + flow: Arc, child: Arc>, progress: mpsc::UnboundedSender, ) -> io::Result { @@ -356,6 +428,7 @@ fn read_acks( if u64::from_le_bytes(payload[..].try_into().unwrap()) != total { return Err(invalid("DONE total does not match acknowledged writes")); } + flow.complete(expected.charge); return Ok(total); } expect_ok(&mut output, &expected, &progress)?; @@ -364,13 +437,15 @@ fn read_acks( .checked_add(expected.bytes) .ok_or_else(|| invalid("Written byte count overflow"))?; } + flow.complete(expected.charge); } Err(io::Error::new( io::ErrorKind::BrokenPipe, "SSH writer closed without QUIT", )) })(); - if result.is_err() { + if let Err(ref error) = result { + flow.fail(error); // Interrupt a sender blocked in write_all when the peer reports an error. let _ = child.lock().unwrap().kill(); } @@ -380,8 +455,9 @@ fn read_acks( struct Session { child: Arc>, input: Option, - requests: Option>, + requests: Option>, reader: Option>>, + flow: Arc, cursor: u64, size: u64, pending: Vec, @@ -396,15 +472,13 @@ impl Session { ) -> io::Result { let input = child.stdin.take().unwrap(); let mut output = FrameReader::new(child.stdout.take().unwrap()); - // ponytail: sync_channel bounds in-flight commands by slot count, - // not byte weight — a SEEK takes a slot same as 8 MiB DATA. - let depth = ((window / CHUNK_SIZE) * 3 / 2).max(2); - let (tx, rx) = std_mpsc::sync_channel(depth); + let (tx, rx) = std_mpsc::channel(); let mut session = Self { child: Arc::new(Mutex::new(child)), input: Some(input), requests: Some(tx), reader: None, + flow: Arc::new(FlowControl::new(window)), cursor: 0, size: 0, pending: Vec::with_capacity(window.min(CHUNK_SIZE)), @@ -431,21 +505,29 @@ impl Session { if advertised != session.size { return Err(invalid("READY and SIZE disagree about device capacity")); } + let flow = Arc::clone(&session.flow); let child = Arc::clone(&session.child); session.reader = Some( std::thread::Builder::new() .name("fls-ssh-acks".into()) - .spawn(move || read_acks(output, rx, child, progress))?, + .spawn(move || read_acks(output, rx, flow, child, progress))?, ); Ok(session) } fn queue_frame(&mut self, op: u8, start: u64, bytes: u64, frame: &[u8]) -> io::Result<()> { + let charge = if op == OP_DATA { bytes as usize } else { 1 }; + self.flow.reserve(charge)?; if self .requests .as_ref() .unwrap() - .send(Expected { op, start, bytes }) + .send(Expected { + op, + start, + bytes, + charge, + }) .is_err() { return Err(self.reader_error()); @@ -549,6 +631,7 @@ impl Session { fn finish(mut self) -> io::Result { self.flush_pending()?; + self.flow.wait_idle()?; self.send(OP_SYNC, self.cursor, 0, &[])?; self.send(OP_QUIT, self.cursor, 0, &[])?; self.input.take(); @@ -938,17 +1021,19 @@ mod tests { session.write(b"ab").unwrap(); session.write(b"cd").unwrap(); assert_eq!(session.pending, b"abcd"); + let mut head = [0; 4]; + device.read_exact(&mut head).unwrap(); + assert_eq!(head, [0; 4]); session.seek(8).unwrap(); + session.flow.wait_idle().unwrap(); assert!(session.pending.is_empty()); + device.seek(io::SeekFrom::Start(0)).unwrap(); + device.read_exact(&mut head).unwrap(); + assert_eq!(&head, b"abcd"); session.write(b"ef").unwrap(); session.fill([1, 2, 3, 4], 4).unwrap(); session.write(b"gh").unwrap(); assert_eq!(session.finish().unwrap(), 16); - // Verify device contents after finish (all writes are ACKed). - device.seek(io::SeekFrom::Start(0)).unwrap(); - let mut head = [0; 4]; - device.read_exact(&mut head).unwrap(); - assert_eq!(&head, b"abcd"); device.seek(io::SeekFrom::Start(8)).unwrap(); let mut tail = [0; 8]; device.read_exact(&mut tail).unwrap(); @@ -1008,6 +1093,44 @@ emit(0x85, struct.pack(' Date: Wed, 14 Oct 2026 10:02:55 +0200 Subject: [PATCH 15/15] Add fls-wh: remote block-device write head with fail-fast CRC validation Introduce fls-wh to execute framed commands on remote raw block devices (e.g., /dev/emmc0) over stdin/stdout. This acts as the remote counterpart to the local O_DIRECT writer, supporting cross-compilation (e.g., QNX7). Core features and protocol improvements: - Implement commands: DATA, ZERO (wire-efficient zeroing without network overhead), SEEK, SYNC, and SIZE. - Increase staging and disk write chunks to 8 MiB for optimized throughput. - Enforce pre-write CRC32 validation: DATA payloads are fully staged and verified in memory before touching the disk to prevent corrupt partial writes. - Adopt a fail-fast error model: the head immediately reports ERR and exits on any I/O fault, malformed frame, or CRC mismatch, dropping complex and unused stream recovery logic. - Remove the unused SKIP command in favor of SEEK. - Ensure protocol robustness by handling partial stdout writes over SSH and rejecting zero-progress writes. - Add comprehensive fault-injection testing via isolated processes to verify correct READY/ERR/DONE sequences and fail-state integrity. --- .github/workflows/ci.yml | 60 ++++++ Makefile | 13 +- remote/.gitignore | 3 + remote/Containerfile | 12 ++ remote/Makefile | 76 +++++++ remote/README.md | 63 ++++++ remote/fls-wh.c | 417 +++++++++++++++++++++++++++++++++++++ remote/include/errno.h | 8 + remote/include/fcntl.h | 9 + remote/include/string.h | 6 + remote/include/sys/types.h | 7 + remote/include/unistd.h | 13 ++ remote/start.S | 11 + remote/stubs/libc.min.json | 16 ++ remote/stubs/so_stub.py | 176 ++++++++++++++++ remote/test.py | 270 ++++++++++++++++++++++++ 16 files changed, 1159 insertions(+), 1 deletion(-) create mode 100644 remote/.gitignore create mode 100644 remote/Containerfile create mode 100644 remote/Makefile create mode 100644 remote/README.md create mode 100644 remote/fls-wh.c create mode 100644 remote/include/errno.h create mode 100644 remote/include/fcntl.h create mode 100644 remote/include/string.h create mode 100644 remote/include/sys/types.h create mode 100644 remote/include/unistd.h create mode 100644 remote/start.S create mode 100644 remote/stubs/libc.min.json create mode 100644 remote/stubs/so_stub.py create mode 100644 remote/test.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a21dd3a..dbab6d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,65 @@ on: env: CARGO_TERM_COLOR: always +permissions: + contents: read + jobs: + # runs first: builds the write-head binaries and leaves them as artifacts + # for later fls jobs (add `needs: write-head` + actions/download-artifact + # in a job that wants them) + write-head: + name: Write head (fls-wh) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + # build the QNX cross binary in the fedora container (remote/ has its + # own Containerfile with the cross toolchain) + - name: Build in fedora container (podman) + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq podman + podman build -t fls-wh-builder remote/ + podman run --rm -v "$GITHUB_WORKSPACE":/src -w /src fls-wh-builder \ + make -C remote LD=aarch64-linux-gnu-ld STUB_LD=aarch64-linux-gnu-ld + + # the QNX binary can't run on a linux runner, so verify the artifact: + # aarch64 ELF, QNX interpreter, correct SONAME and NEEDED entries + - name: Verify artifact + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq binutils-aarch64-linux-gnu + file remote/flswh-aarch64-qnx7 | grep -q 'ARM aarch64' + file remote/flswh-aarch64-qnx7 | grep -q '/usr/lib/ldqnx-64.so.2' + aarch64-linux-gnu-readelf -d remote/flswh-aarch64-qnx7 | grep -q 'NEEDED.*libc.so.5' + aarch64-linux-gnu-readelf -d remote/stubs/libc.min.so_stub.so | grep -q 'SONAME.*libc.so.5' + + # runtime test: build the same source natively and drive it with a + # framed command script against a temp file; assert records + bytes + - name: Native test + run: python3 remote/test.py + + # build the native (runner) binary too, kept for later fls testing + - name: Build native + run: make -C remote native + + - name: Upload flswh-aarch64-qnx7 + uses: actions/upload-artifact@v4 + with: + name: flswh-aarch64-qnx7 + path: remote/flswh-aarch64-qnx7 + if-no-files-found: error + + - name: Upload flswh-x86_64-linux + uses: actions/upload-artifact@v4 + with: + name: flswh-x86_64-linux + path: remote/flswh-x86_64-linux + if-no-files-found: error + lint: name: Lint runs-on: ubuntu-latest @@ -152,3 +210,5 @@ jobs: path: ${{ matrix.artifact_name }} if-no-files-found: error + + diff --git a/Makefile b/Makefile index a2a344f..ef25efd 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Makefile for fls - ARM64 static builds -.PHONY: all build clean test arm64-static lint fmt clippy check help +.PHONY: all build clean test arm64-static remote-wh remote-wh-native lint fmt clippy check help # Default target all: build @@ -24,6 +24,14 @@ arm64-static: all-targets: ./build.sh +# Build the remote write head (fls/remote) for QNX aarch64 +remote-wh: + $(MAKE) -C remote + +# Build the remote write head for the host (native) +remote-wh-native: + $(MAKE) -C remote native + # Run tests test: cargo test @@ -55,6 +63,7 @@ check: clean: cargo clean rm -f fls-* + $(MAKE) -C remote clean # Install dependencies for ARM64 cross-compilation install-deps: @@ -72,6 +81,8 @@ help: @echo " build - Build for native target" @echo " arm64-static - Build for ARM64 static (musl)" @echo " all-targets - Build for native + ARM64 static" + @echo " remote-wh - Build the remote write head for QNX aarch64" + @echo " remote-wh-native - Build the remote write head for the host" @echo "" @echo "Testing and Quality:" @echo " test - Run tests" diff --git a/remote/.gitignore b/remote/.gitignore new file mode 100644 index 0000000..a63f436 --- /dev/null +++ b/remote/.gitignore @@ -0,0 +1,3 @@ +*.o +flswh-* +stubs/*.so_stub.so* diff --git a/remote/Containerfile b/remote/Containerfile new file mode 100644 index 0000000..90e48fc --- /dev/null +++ b/remote/Containerfile @@ -0,0 +1,12 @@ +FROM fedora:latest + +# gcc: native compiler (runs the native test; system headers) +# gcc-aarch64-linux-gnu: cross compiler (provides aarch64-linux-gnu-gcc) +# binutils-aarch64-linux-gnu: GNU ld (provides aarch64-linux-gnu-ld) +# python3: runs stubs/so_stub.py; make: the build itself +RUN dnf install -y gcc gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu python3 make \ + && dnf clean all + +# The Makefile and so_stub.py hardcode the QNX-style tool name; map it +# to Fedora's. (ld.lld is already named correctly by the lld package.) +RUN ln -s /usr/bin/aarch64-linux-gnu-gcc /usr/local/bin/aarch64-elf-gcc diff --git a/remote/Makefile b/remote/Makefile new file mode 100644 index 0000000..048747f --- /dev/null +++ b/remote/Makefile @@ -0,0 +1,76 @@ +# fls-wh — a block-device write head, built for a QNX aarch64 target. +# +# Built from: +# - this source (fls-wh.c, start.S) +# - standard C/POSIX headers (include/) +# - a minimal JSON symbol manifest (stubs/*.min.json) <- source of truth +# - open tooling: aarch64-elf-gcc (GCC) + ld.lld (LLVM) + stubs/so_stub.py +# +# The stub .so files are build artifacts (gitignored). At runtime, the +# target's dynamic linker resolves the SONAME (libc.so.5) to the library +# present on the system. +# +# Container build: `make container-build` builds the Containerfile with +# podman and runs the whole build inside it (fedora + cross toolchain). +# +# `make help` prints this file's `##`-annotated targets, grouped by +# `##@` section headers. + +CC := aarch64-elf-gcc +LD := ld.lld +CFLAGS := -I include +LDFLAGS := -dynamic-linker /usr/lib/ldqnx-64.so.2 +STUBDIR := stubs + +CONTAINER ?= podman +IMAGE ?= fls-wh-builder + +# host arch/os for the native binary name (flswh--) +UNAME_M := $(shell uname -m) +UNAME_S := $(shell uname -s) +NATIVE_ARCH := $(if $(filter arm64,$(UNAME_M)),aarch64,$(UNAME_M)) +NATIVE_OS := $(if $(filter Darwin,$(UNAME_S)),darwin,$(if $(filter Linux,$(UNAME_S)),linux,$(UNAME_S))) +NATIVE_BIN := flswh-$(NATIVE_ARCH)-$(NATIVE_OS) + +.PHONY: all clean stubs native container container-build test help + +##@ build +all: flswh-aarch64-qnx7 ## build fls-wh for QNX aarch64 (default) + +flswh-aarch64-qnx7: fls-wh.o start.o stubs ## link the QNX aarch64 binary + $(LD) -o $@ fls-wh.o start.o -L$(STUBDIR) -l:libc.min.so_stub.so $(LDFLAGS) + +native: $(NATIVE_BIN) ## build fls-wh for the host (system compiler, no stubs) + +$(NATIVE_BIN): fls-wh.c + cc -O2 $< -o $@ + +# build the minimal stub .so from the minimal JSON (artifacts, gitignored) +stubs: ## build the stub .so files from the JSON manifests + python3 stubs/so_stub.py $(STUBDIR)/libc.min.json -o $(STUBDIR)/libc.min.so_stub.so + +fls-wh.o: fls-wh.c + $(CC) $(CFLAGS) -c $< -o $@ + +start.o: start.S + $(CC) -c $< -o $@ + +clean: ## remove all build artifacts + rm -f flswh-* fls-wh.o start.o $(STUBDIR)/*.so_stub.so* + +##@ test +test: ## native test: build with the system compiler, run, assert + python3 test.py + +##@ container (podman) +container: ## build the fedora container image + $(CONTAINER) build -t $(IMAGE) . + +container-build: container ## build fls-wh inside the container (GNU ld) + $(CONTAINER) run --rm -v $(PWD):/src -w /src $(IMAGE) make \ + LD=aarch64-linux-gnu-ld STUB_LD=aarch64-linux-gnu-ld + +help: ## show this help + @awk 'BEGIN {FS = ":.*##"} \ + /^##@/ { printf "\n\033[1;33m%s\033[0m\n", substr($$0, 5) } \ + /^[a-zA-Z0-9_-]+:.*##/ { printf " \033[36m%-18s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST) diff --git a/remote/README.md b/remote/README.md new file mode 100644 index 0000000..ef975cc --- /dev/null +++ b/remote/README.md @@ -0,0 +1,63 @@ +# fls-wh — the remote write head + +`fls-wh` is a small C program that runs on a remote system (QNX today; +extensible to others, e.g. a U-Boot command talking over the network) and +writes framed commands to a raw block device (e.g. `/dev/emmc0`). It is the +remote counterpart of the local writer in `src/fls/block_writer.rs` (which +uses `O_DIRECT` on Linux/macOS). QNX has no `O_DIRECT`, so durability comes +from an explicit `SYNC` (`fdatasync`; `fsync` on macOS, which has no +`fdatasync`). + +## Usage + +``` +./flswh-- +``` + +Opens `` (`O_RDWR`), reports its size (`READY`), then reads framed +commands from stdin and writes them to the device, emitting framed records to +stdout. Intended to be driven by a remote client (e.g. over SSH). QUIT or +EOF exits. +Device I/O errors are reported (`ERR`), after which the write head exits; +the client treats any `ERR` as fatal. + +## Protocol + +Frame (both directions): `[magic:4][opcode:1][payload_len:u32 LE][payload]`, +little-endian throughout. The magic (`FLSW`) is a sync header: the reader +scans for it to resync after any corruption. + +| | opcode | | opcode | +|---|---|---|---| +| **Requests** | `DATA 0x01` `SEEK 0x03` `SYNC 0x04` `QUIT 0x05` `READ 0x06` `ZERO 0x09` `SIZE 0x0a` | **Responses** | `READY 0x81` `OK 0x82` `PROG 0x83` `ERR 0x84` `DONE 0x85` `READ_DATA 0x86` `SIZE 0x89` | +| **Reserved** | `AUTH 0x07` `HELLO 0x08` | | `AUTH_RESULT 0x87` `HELLO 0x88` | + +The `READY` payload is `version:u8 + size:u64` (total device bytes, measured +at open); the protocol version is currently 1. `DATA` is +`size:u32 + content + crc:u32` (CRC32-IEEE, capped at 8 MiB); the payload is +staged and CRC-validated before the device is written, so a corrupt chunk +leaves no bytes behind. +`READ` is `len:u64` (reads from the current offset, advances it, capped at +1 MiB) and returns `READ_DATA` with the bytes. `ZERO` is `len:u64` — writes +that many zeros from the current offset and advances it, sending only the 8 +length bytes over the wire (for filling regions without transferring bytes). + +`SIZE` has no request payload and returns `SIZE 0x89` with `size:u64` (the +same total device bytes measured at open), without changing the cursor. If +the size could not be determined, it returns `ERR` with the original errno; +the startup `READY` uses zero for unknown size. +Before writing an image, the client can use `READY` or `SIZE` to check +`image_size <= device_size - write_offset` after checking that +`write_offset <= device_size`. For sparse images, compare the expanded size. + +## Build + +Binaries are named `flswh--` (matching the `fls` artifact scheme): + +- **QNX aarch64** (cross): `make` (or `make -C remote` from the repo root) → `flswh-aarch64-qnx7`. Needs an aarch64 cross gcc toolchain (the Makefile's `aarch64-elf-gcc` + `ld.lld` names map to any gcc cross toolchain; `make container-build` uses Fedora's in a container). +- **Native** (host, incl. macOS): `make native` → `flswh--` (e.g. `flswh-aarch64-darwin`, `flswh-x86_64-linux`). Uses the system compiler and system headers, no stubs. + +## Test + +`python3 test.py` — builds the source with the system compiler, drives it with +a framed command script against a temp file, and asserts the records and bytes. diff --git a/remote/fls-wh.c b/remote/fls-wh.c new file mode 100644 index 0000000..cb98431 --- /dev/null +++ b/remote/fls-wh.c @@ -0,0 +1,417 @@ +#include +#include +#include +#include +#include + +/* macOS has no fdatasync(); fsync() is the equivalent. */ +#ifdef __APPLE__ +#define sync_file(fd) fsync(fd) +#else +#define sync_file(fd) fdatasync(fd) +#endif + +/* fls-wh: a block-device write head. + * + * Usage: ./fls-wh + * + * Opens (O_RDWR), reports its size, then reads framed commands from + * fd 0 (stdin) and writes them to the device, emitting framed records to fd 1 + * (stdout). Intended to be driven over SSH by a remote client (the Rust `fls` + * tool). The device is a raw block device (e.g. /dev/emmc0); there is no + * O_DIRECT on QNX, so durability comes from an explicit SYNC (fdatasync; + * fsync on macOS, which has no fdatasync). + * + * Frame (both directions): [magic:4][opcode:1][payload_len:u32 LE][payload] + * Little-endian throughout. The magic is a sync header: the reader scans for + * it to resync after any corruption. + * + * Requests: DATA(0x01) SEEK(0x03) SYNC(0x04) QUIT(0x05) + * READ(0x06) ZERO(0x09) SIZE(0x0a) [implemented] + * 0x02 AUTH(0x07) HELLO(0x08) [reserved, spec only] + * Responses: READY(0x81) OK(0x82) PROG(0x83) ERR(0x84) DONE(0x85) + * READ_DATA(0x86) SIZE(0x89) [implemented] + * AUTH_RESULT(0x87) HELLO(0x88) [reserved] + * + * DATA payload: size:u32 + content[size] + crc:u32 (CRC32-IEEE of content) + * (size must be <= 8 MiB; the payload is staged and CRC- + * validated before any device write) + * SEEK payload: offset:u64 + * SYNC/QUIT/SIZE: (no payload) + * READ payload: len:u64 (read len bytes from the current offset, advance it; + * len must be <= 1 MiB) + * + * READY payload: version:u8 + size:u64 + * SIZE payload: size:u64 (total bytes measured at open; cursor unchanged) + * OK payload: op:1 + offset:u64 + bytes:u64 + * PROG payload: offset:u64 + bytes_done:u64 (every 1 MiB of a DATA) + * ERR payload: code:1 + op:1 + errno:u32 + offset:u64 + * code: 0=device I/O error, 1=CRC mismatch, 2=not implemented + * DONE payload: total_bytes:u64 + * READ_DATA payload: content (the bytes read; the frame's payload_len is the + * count, which may be < requested near end of device) + * + * Uses read()/write() on fds directly (portable; some libc variants do not + * export stdin/stdout/stderr). No userspace buffering, so every record is + * written to the kernel immediately (no flush needed over SSH). + */ + +enum { + OP_DATA = 0x01, OP_SEEK = 0x03, OP_SYNC = 0x04, + OP_QUIT = 0x05, + OP_READ = 0x06, OP_AUTH = 0x07, OP_HELLO = 0x08, OP_ZERO = 0x09, + OP_SIZE = 0x0a, + R_READY = 0x81, R_ACK = 0x82, R_PROG = 0x83, R_ERR = 0x84, R_DONE = 0x85, + R_READ_DATA = 0x86, R_SIZE = 0x89, + R_AUTH_RESULT = 0x87, R_HELLO = 0x88 /* reserved */ +}; +enum { ERR_DEV = 0, ERR_CRC = 1, ERR_NI = 2 }; /* NI = not implemented */ + +static const unsigned char MAGIC[4] = { 0x46, 0x4c, 0x53, 0x57 }; /* "FLSW" */ +static const unsigned char PROTO_VER = 1; +#ifndef WRITE_CHUNK_SIZE +#define WRITE_CHUNK_SIZE (8u << 20) /* 8 MiB disk write chunks (-DWRITE_CHUNK_SIZE=N to override) */ +#endif +static const size_t PROG_STEP = WRITE_CHUNK_SIZE; /* emit PROG every chunk */ +#define MAX_READ (1u << 20) /* cap a single READ at 1 MiB */ +static unsigned char dbuf[MAX_READ]; /* device read buffer */ +#define MAX_DATA WRITE_CHUNK_SIZE /* cap a single DATA at the staging buffer size */ + +/* --- little-endian packing --- */ +static unsigned int rd_u32(const unsigned char *p) +{ return p[0] | p[1] << 8 | p[2] << 16 | (unsigned)p[3] << 24; } +static unsigned long long rd_u64(const unsigned char *p) +{ return (unsigned long long)rd_u32(p) | ((unsigned long long)rd_u32(p + 4) << 32); } +static void wr_u32(unsigned char *p, unsigned int v) +{ p[0] = v; p[1] = v >> 8; p[2] = v >> 16; p[3] = v >> 24; } +static void wr_u64(unsigned char *p, unsigned long long v) +{ wr_u32(p, (unsigned int)v); wr_u32(p + 4, (unsigned int)(v >> 32)); } + +/* --- CRC32 (IEEE), running state so it can be computed while streaming. + * Base table and per-byte update from gityf/crc (Wang Yaofu, + * Apache License 2.0, https://github.com/gityf/crc), adapted to a + * running state. */ +static const unsigned int crc_tab[256] = { + 0x00000000L, 0x77073096L, 0xee0e612cL, 0x990951baL, 0x076dc419L, + 0x706af48fL, 0xe963a535L, 0x9e6495a3L, 0x0edb8832L, 0x79dcb8a4L, + 0xe0d5e91eL, 0x97d2d988L, 0x09b64c2bL, 0x7eb17cbdL, 0xe7b82d07L, + 0x90bf1d91L, 0x1db71064L, 0x6ab020f2L, 0xf3b97148L, 0x84be41deL, + 0x1adad47dL, 0x6ddde4ebL, 0xf4d4b551L, 0x83d385c7L, 0x136c9856L, + 0x646ba8c0L, 0xfd62f97aL, 0x8a65c9ecL, 0x14015c4fL, 0x63066cd9L, + 0xfa0f3d63L, 0x8d080df5L, 0x3b6e20c8L, 0x4c69105eL, 0xd56041e4L, + 0xa2677172L, 0x3c03e4d1L, 0x4b04d447L, 0xd20d85fdL, 0xa50ab56bL, + 0x35b5a8faL, 0x42b2986cL, 0xdbbbc9d6L, 0xacbcf940L, 0x32d86ce3L, + 0x45df5c75L, 0xdcd60dcfL, 0xabd13d59L, 0x26d930acL, 0x51de003aL, + 0xc8d75180L, 0xbfd06116L, 0x21b4f4b5L, 0x56b3c423L, 0xcfba9599L, + 0xb8bda50fL, 0x2802b89eL, 0x5f058808L, 0xc60cd9b2L, 0xb10be924L, + 0x2f6f7c87L, 0x58684c11L, 0xc1611dabL, 0xb6662d3dL, 0x76dc4190L, + 0x01db7106L, 0x98d220bcL, 0xefd5102aL, 0x71b18589L, 0x06b6b51fL, + 0x9fbfe4a5L, 0xe8b8d433L, 0x7807c9a2L, 0x0f00f934L, 0x9609a88eL, + 0xe10e9818L, 0x7f6a0dbbL, 0x086d3d2dL, 0x91646c97L, 0xe6635c01L, + 0x6b6b51f4L, 0x1c6c6162L, 0x856530d8L, 0xf262004eL, 0x6c0695edL, + 0x1b01a57bL, 0x8208f4c1L, 0xf50fc457L, 0x65b0d9c6L, 0x12b7e950L, + 0x8bbeb8eaL, 0xfcb9887cL, 0x62dd1ddfL, 0x15da2d49L, 0x8cd37cf3L, + 0xfbd44c65L, 0x4db26158L, 0x3ab551ceL, 0xa3bc0074L, 0xd4bb30e2L, + 0x4adfa541L, 0x3dd895d7L, 0xa4d1c46dL, 0xd3d6f4fbL, 0x4369e96aL, + 0x346ed9fcL, 0xad678846L, 0xda60b8d0L, 0x44042d73L, 0x33031de5L, + 0xaa0a4c5fL, 0xdd0d7cc9L, 0x5005713cL, 0x270241aaL, 0xbe0b1010L, + 0xc90c2086L, 0x5768b525L, 0x206f85b3L, 0xb966d409L, 0xce61e49fL, + 0x5edef90eL, 0x29d9c998L, 0xb0d09822L, 0xc7d7a8b4L, 0x59b33d17L, + 0x2eb40d81L, 0xb7bd5c3bL, 0xc0ba6cadL, 0xedb88320L, 0x9abfb3b6L, + 0x03b6e20cL, 0x74b1d29aL, 0xead54739L, 0x9dd277afL, 0x04db2615L, + 0x73dc1683L, 0xe3630b12L, 0x94643b84L, 0x0d6d6a3eL, 0x7a6a5aa8L, + 0xe40ecf0bL, 0x9309ff9dL, 0x0a00ae27L, 0x7d079eb1L, 0xf00f9344L, + 0x8708a3d2L, 0x1e01f268L, 0x6906c2feL, 0xf762575dL, 0x806567cbL, + 0x196c3671L, 0x6e6b06e7L, 0xfed41b76L, 0x89d32be0L, 0x10da7a5aL, + 0x67dd4accL, 0xf9b9df6fL, 0x8ebeeff9L, 0x17b7be43L, 0x60b08ed5L, + 0xd6d6a3e8L, 0xa1d1937eL, 0x38d8c2c4L, 0x4fdff252L, 0xd1bb67f1L, + 0xa6bc5767L, 0x3fb506ddL, 0x48b2364bL, 0xd80d2bdaL, 0xaf0a1b4cL, + 0x36034af6L, 0x41047a60L, 0xdf60efc3L, 0xa867df55L, 0x316e8eefL, + 0x4669be79L, 0xcb61b38cL, 0xbc66831aL, 0x256fd2a0L, 0x5268e236L, + 0xcc0c7795L, 0xbb0b4703L, 0x220216b9L, 0x5505262fL, 0xc5ba3bbeL, + 0xb2bd0b28L, 0x2bb45a92L, 0x5cb36a04L, 0xc2d7ffa7L, 0xb5d0cf31L, + 0x2cd99e8bL, 0x5bdeae1dL, 0x9b64c2b0L, 0xec63f226L, 0x756aa39cL, + 0x026d930aL, 0x9c0906a9L, 0xeb0e363fL, 0x72076785L, 0x05005713L, + 0x95bf4a82L, 0xe2b87a14L, 0x7bb12baeL, 0x0cb61b38L, 0x92d28e9bL, + 0xe5d5be0dL, 0x7cdcefb7L, 0x0bdbdf21L, 0x86d3d2d4L, 0xf1d4e242L, + 0x68ddb3f8L, 0x1fda836eL, 0x81be16cdL, 0xf6b9265bL, 0x6fb077e1L, + 0x18b74777L, 0x88085ae6L, 0xff0f6a70L, 0x66063bcaL, 0x11010b5cL, + 0x8f659effL, 0xf862ae69L, 0x616bffd3L, 0x166ccf45L, 0xa00ae278L, + 0xd70dd2eeL, 0x4e048354L, 0x3903b3c2L, 0xa7672661L, 0xd06016f7L, + 0x4969474dL, 0x3e6e77dbL, 0xaed16a4aL, 0xd9d65adcL, 0x40df0b66L, + 0x37d83bf0L, 0xa9bcae53L, 0xdebb9ec5L, 0x47b2cf7fL, 0x30b5ffe9L, + 0xbdbdf21cL, 0xcabac28aL, 0x53b39330L, 0x24b4a3a6L, 0xbad03605L, + 0xcdd70693L, 0x54de5729L, 0x23d967bfL, 0xb3667a2eL, 0xc4614ab8L, + 0x5d681b02L, 0x2a6f2b94L, 0xb40bbe37L, 0xc30c8ea1L, 0x5a05df1bL, + 0x2d02ef8dL +}; +static unsigned int crc_state; +static void crc_init(void) { } /* table is static */ +static void crc_reset(void) { crc_state = 0xFFFFFFFFu; } +static void crc_update(const unsigned char *b, size_t n) +{ + unsigned int c = crc_state; + for (size_t i = 0; i < n; i++) + c = crc_tab[(c ^ b[i]) & 0xFF] ^ ((c >> 8) & 0x00FFFFFF); + crc_state = c; +} +static unsigned int crc_final(void) { return crc_state ^ 0xFFFFFFFFu; } + +/* write all, retrying on partial writes; -1 on error (errno set) */ +static int write_all(int fd, const unsigned char *buf, size_t len) +{ + size_t off = 0; + while (off < len) { + ssize_t n = write(fd, buf + off, len - off); + if (n < 0) return -1; + if (n == 0) { errno = EIO; return -1; } + off += (size_t)n; + } + return 0; +} + +/* --- framed output (stdout) --- */ +static void emit(int op, const unsigned char *payload, size_t len) +{ + unsigned char hdr[9]; + hdr[0] = MAGIC[0]; hdr[1] = MAGIC[1]; + hdr[2] = MAGIC[2]; hdr[3] = MAGIC[3]; + hdr[4] = (unsigned char)op; + wr_u32(hdr + 5, (unsigned int)len); + (void)write_all(1, hdr, 9); + if (len) (void)write_all(1, payload, len); +} +static void ok_rec(int op, unsigned long long off, unsigned long long bytes) +{ + unsigned char p[17]; + p[0] = (unsigned char)op; wr_u64(p + 1, off); wr_u64(p + 9, bytes); + emit(R_ACK, p, sizeof p); +} +static void err_rec(int code, int op, unsigned int e, unsigned long long off) +{ + unsigned char p[14]; + p[0] = (unsigned char)code; p[1] = (unsigned char)op; + wr_u32(p + 2, e); wr_u64(p + 6, off); + emit(R_ERR, p, sizeof p); +} + +/* --- buffered reader (stdin) --- */ +static unsigned char rbuf[1 << 16]; +static size_t rlen = 0, rpos = 0; +static int rfill(void) +{ + if (rpos < rlen) return 1; + ssize_t n = read(0, rbuf, sizeof rbuf); + if (n <= 0) { rlen = 0; rpos = 0; return 0; } + rlen = (size_t)n; rpos = 0; return 1; +} +static int rnext(unsigned char *b) +{ + if (!rfill()) return 0; + *b = rbuf[rpos++]; + return 1; +} +static int rreadn(unsigned char *dst, size_t n) +{ + while (n > 0) { + if (rpos >= rlen && !rfill()) return 0; + size_t avail = rlen - rpos; + size_t take = (n < avail) ? n : avail; + memcpy(dst, rbuf + rpos, take); + dst += take; rpos += take; n -= take; + } + return 1; +} +/* scan for the 4-byte magic (resyncs after corruption); 0 on EOF */ +static int read_magic(void) +{ + unsigned char w[3] = { 0, 0, 0 }; + for (;;) { + unsigned char b; + if (!rnext(&b)) return 0; + if (w[0] == MAGIC[0] && w[1] == MAGIC[1] && + w[2] == MAGIC[2] && b == MAGIC[3]) + return 1; + w[0] = w[1]; w[1] = w[2]; w[2] = b; + } +} + +/* read all, retrying on partial reads; -1 on error (errno set), else the bytes + * read (which may be < len near end of device) */ +static ssize_t read_all(int fd, unsigned char *buf, size_t len) +{ + size_t off = 0; + while (off < len) { + ssize_t n = read(fd, buf + off, len - off); + if (n < 0) return -1; + if (n == 0) return (ssize_t)off; /* EOF: partial read */ + off += (size_t)n; + } + return (ssize_t)len; +} + +int main(int argc, char **argv) +{ + if (argc < 2) { err_rec(ERR_DEV, 0, 22, 0); return 1; } /* 22 = EINVAL */ + int fd = open(argv[1], O_RDWR); + if (fd < 0) { err_rec(ERR_DEV, 0, (unsigned)errno, 0); return 1; } + + long long sz = lseek(fd, 0, SEEK_END); + int size_errno = sz < 0 ? errno : 0; + lseek(fd, 0, SEEK_SET); /* the size query left the offset at the end */ + unsigned char rp[9]; + rp[0] = PROTO_VER; wr_u64(rp + 1, (unsigned long long)(sz < 0 ? 0 : sz)); + emit(R_READY, rp, sizeof rp); + + unsigned long long cur = 0; /* current logical write position */ + unsigned long long total = 0; + crc_init(); + + for (;;) { + if (!read_magic()) break; /* EOF */ + unsigned char op; + if (!rnext(&op)) break; + unsigned char pl[4]; + if (!rreadn(pl, 4)) break; + unsigned long long plen = rd_u32(pl); + + if (op == OP_QUIT) + break; + + else if (op == OP_SIZE) { + if (plen != 0) { err_rec(ERR_NI, OP_SIZE, 0, cur); break; } + if (sz < 0) { err_rec(ERR_DEV, OP_SIZE, (unsigned)size_errno, cur); break; } + unsigned char p[8]; + wr_u64(p, (unsigned long long)sz); + emit(R_SIZE, p, sizeof p); + } + + else if (op == OP_DATA) { + if (plen < 8) { err_rec(ERR_NI, OP_DATA, 0, cur); break; } + unsigned char szf[4]; + if (!rreadn(szf, 4)) break; + unsigned int size = rd_u32(szf); + if (plen != 8 + (unsigned long long)size) { /* malformed frame */ + err_rec(ERR_NI, OP_DATA, 0, cur); + break; + } + if (size > MAX_DATA) { err_rec(ERR_NI, OP_DATA, 0, cur); break; } + /* Stage the whole payload, validate the CRC, and only then touch + * the device: a corrupt chunk must not leave bytes behind. */ + static unsigned char buf[WRITE_CHUNK_SIZE]; + unsigned long long start = cur; + crc_reset(); + unsigned long long rd = 0; + int fail = 0; + while (rd < size) { + size_t want = (size_t)(size - rd); + if (want > sizeof buf) want = sizeof buf; + if (!rreadn(buf + rd, want)) { fail = 1; break; } + crc_update(buf + rd, want); + rd += want; + } + if (fail) { err_rec(ERR_DEV, OP_DATA, 0, start); break; } + unsigned char crcf[4]; + if (!rreadn(crcf, 4)) { err_rec(ERR_DEV, OP_DATA, 0, start); break; } + if (crc_final() != rd_u32(crcf)) { + err_rec(ERR_CRC, OP_DATA, 0, start); + break; + } + unsigned long long done = 0, next_prog = PROG_STEP; + int werr = 0; + while (done < size) { + size_t want = (size_t)(size - done); + if (want > sizeof buf) want = sizeof buf; + if (write_all(fd, buf + done, want) < 0) { werr = errno; break; } + done += want; + while (done >= next_prog) { + unsigned char pp[16]; + wr_u64(pp, start + done); wr_u64(pp + 8, done); + emit(R_PROG, pp, sizeof pp); + next_prog += PROG_STEP; + } + } + cur = start + done; + total += done; + if (werr) { err_rec(ERR_DEV, OP_DATA, (unsigned)werr, start); break; } + ok_rec(OP_DATA, start, done); + } + + else if (op == OP_ZERO) { + if (plen != 8) { err_rec(ERR_NI, OP_ZERO, 0, cur); break; } + unsigned char lf[8]; + if (!rreadn(lf, 8)) break; + unsigned long long len = rd_u64(lf); + static unsigned char zbuf[WRITE_CHUNK_SIZE]; /* zero-initialized */ + unsigned long long start = cur, done = 0, next_prog = PROG_STEP; + int status = 0; + while (done < len) { + size_t want = (size_t)(len - done); + if (want > sizeof zbuf) want = sizeof zbuf; + if (write_all(fd, zbuf, want) < 0) { status = errno; break; } + done += want; + while (done >= next_prog) { + unsigned char pp[16]; + wr_u64(pp, start + done); wr_u64(pp + 8, done); + emit(R_PROG, pp, sizeof pp); + next_prog += PROG_STEP; + } + } + cur = start + done; + total += done; + if (status) { + err_rec(ERR_DEV, OP_ZERO, (unsigned)status, start); + break; + } + ok_rec(OP_ZERO, start, done); + } + + else if (op == OP_SEEK) { + if (plen != 8) { err_rec(ERR_NI, OP_SEEK, 0, cur); break; } + unsigned char of[8]; + if (!rreadn(of, 8)) break; + unsigned long long off = rd_u64(of); + if (lseek(fd, off, SEEK_SET) < 0) { + err_rec(ERR_DEV, OP_SEEK, (unsigned)errno, off); + break; + } + ok_rec(OP_SEEK, off, 0); + cur = off; + } + + else if (op == OP_READ) { + if (plen != 8) { err_rec(ERR_NI, OP_READ, 0, cur); break; } + unsigned char lf[8]; + if (!rreadn(lf, 8)) break; + unsigned long long len = rd_u64(lf); + if (len > MAX_READ) { err_rec(ERR_DEV, OP_READ, 22, cur); break; } /* 22 = EINVAL */ + ssize_t n = read_all(fd, dbuf, (size_t)len); + if (n < 0) { + int e = errno; + err_rec(ERR_DEV, OP_READ, (unsigned)e, cur); + break; + } + emit(R_READ_DATA, dbuf, (size_t)n); /* the bytes read (may be empty) */ + cur += (unsigned long long)n; + } + + else if (op == OP_SYNC) { + if (plen != 0) { err_rec(ERR_NI, OP_SYNC, 0, cur); break; } + if (sync_file(fd) < 0) { + err_rec(ERR_DEV, OP_SYNC, (unsigned)errno, cur); /* durability not achieved; don't claim it */ + break; + } + ok_rec(OP_SYNC, cur, 0); + } + + else { /* reserved (AUTH/HELLO) or unknown opcode */ + err_rec(ERR_NI, op, 0, cur); + break; + } + } + + unsigned char dp[8]; + wr_u64(dp, total); + emit(R_DONE, dp, sizeof dp); + return 0; +} diff --git a/remote/include/errno.h b/remote/include/errno.h new file mode 100644 index 0000000..6a9c91f --- /dev/null +++ b/remote/include/errno.h @@ -0,0 +1,8 @@ +#ifndef _ERRNO_H +#define _ERRNO_H +/* QNX: errno is a per-thread location reached via __get_errno_ptr(). */ +int *__get_errno_ptr(void); +#define errno (*__get_errno_ptr()) +/* only the constants the code assigns */ +#define EIO 5 +#endif diff --git a/remote/include/fcntl.h b/remote/include/fcntl.h new file mode 100644 index 0000000..b51af87 --- /dev/null +++ b/remote/include/fcntl.h @@ -0,0 +1,9 @@ +#ifndef _FCNTL_H +#define _FCNTL_H + +int open(const char *path, int flags); + +#define O_RDONLY 0 +#define O_WRONLY 1 +#define O_RDWR 2 +#endif diff --git a/remote/include/string.h b/remote/include/string.h new file mode 100644 index 0000000..405c5a0 --- /dev/null +++ b/remote/include/string.h @@ -0,0 +1,6 @@ +#ifndef _STRING_H +#define _STRING_H +#include + +void *memcpy(void *dst, const void *src, size_t n); +#endif diff --git a/remote/include/sys/types.h b/remote/include/sys/types.h new file mode 100644 index 0000000..f817ef1 --- /dev/null +++ b/remote/include/sys/types.h @@ -0,0 +1,7 @@ +#ifndef _SYS_TYPES_H +#define _SYS_TYPES_H +/* Basic types (POSIX-compatible). */ +typedef unsigned long size_t; +typedef long ssize_t; +typedef long off_t; +#endif diff --git a/remote/include/unistd.h b/remote/include/unistd.h new file mode 100644 index 0000000..c7b430d --- /dev/null +++ b/remote/include/unistd.h @@ -0,0 +1,13 @@ +#ifndef _UNISTD_H +#define _UNISTD_H +#include + +ssize_t read(int fd, void *buf, size_t count); +ssize_t write(int fd, const void *buf, size_t count); +off_t lseek(int fd, off_t offset, int whence); +int fdatasync(int fd); + +#define SEEK_SET 0 +#define SEEK_CUR 1 +#define SEEK_END 2 +#endif diff --git a/remote/start.S b/remote/start.S new file mode 100644 index 0000000..b22ca03 --- /dev/null +++ b/remote/start.S @@ -0,0 +1,11 @@ + .globl _start + .type _start, @function +_start: + /* initial stack: [argc, argv..., NULL, envp..., NULL] */ + ldr x0, [sp] /* x0 = argc */ + add x1, sp, #8 /* x1 = argv = sp+8 */ + add x2, x1, x0, lsl #3 /* x2 = argv + argc*8 */ + add x2, x2, #8 /* x2 = envp (skip NULL terminator) */ + bl main /* main(argc, argv, envp) */ + bl exit /* exit(main's return) */ + .size _start, .-_start diff --git a/remote/stubs/libc.min.json b/remote/stubs/libc.min.json new file mode 100644 index 0000000..d2f4d94 --- /dev/null +++ b/remote/stubs/libc.min.json @@ -0,0 +1,16 @@ +{ + "version": 1, + "soname": "libc.so.5", + "needed": [], + "funcs": [ + "exit", + "open", + "lseek", + "write", + "read", + "fdatasync", + "memcpy", + "__get_errno_ptr" + ], + "vars": [] +} diff --git a/remote/stubs/so_stub.py b/remote/stubs/so_stub.py new file mode 100644 index 0000000..b45a068 --- /dev/null +++ b/remote/stubs/so_stub.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +""" +Build a minimal stub .so from a JSON symbol manifest. + +Given a JSON file describing a library (its SONAME plus the symbol names +it exports), this tool assembles and links a standard, well-formed shared +library that exports exactly those names. The stub contains no real code +(each function is a `ret` no-op) and no real data (each variable is 8 +zero bytes) just enough for a linker to resolve symbol names against +the library's SONAME. + +JSON input format (version 1): + { + "version": 1, + "soname": "libz.so.2", + "needed": [], # reserved, currently unused + "funcs": ["inflate", ...], # exported as STT_FUNC + "vars": ["errno", ...] # exported as STT_OBJECT, 8 bytes each + } + +Usage: + python3 stubs/so_stub.py [-o out.so] + e.g. python3 stubs/so_stub.py stubs/libz.min.json -o stubs/libz.min.so_stub.so +""" +import json +import os +import struct +import subprocess +import sys + +# --- ELF constants (64-bit, little-endian) -------------------------------- +PT_LOAD = 1 # program header: loadable segment (vaddr -> file offset) +PT_DYNAMIC = 2 # program header: dynamic section (DT_* entries) +DT_NULL = 0 # end of the dynamic section +DT_STRTAB = 5 # virtual address of the dynamic string table +DT_SONAME = 11 # string-table offset of the library's SONAME + +# Offsets within a 64-bit ELF header +E_PHOFF = 0x20 # e_phoff: file offset of the program header table +E_PHNUM = 0x38 # e_phnum: number of program headers +E_PHENTSIZE = 0x36 # e_phentsize: size of one program header + + +class Elf: + """Minimal ELF reader: find a segment by type, map vaddrs to file offsets.""" + + def __init__(self, data): + self.d = data + self.e_phoff = struct.unpack_from(' {out}") + print("link against it: ld.lld -o prog start.o prog.o -L. -l:" + + os.path.basename(out)) + + +if __name__ == '__main__': + main() diff --git a/remote/test.py b/remote/test.py new file mode 100644 index 0000000..c423cba --- /dev/null +++ b/remote/test.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Native test for fls-wh: build with the system compiler, run it against a +temp file (standing in for a block device), feed a framed command script, and +assert the framed records + the file bytes. + +Runs on Linux and macOS: `SYNC` uses `fdatasync` where available and falls +back to `fsync` on macOS (which has no `fdatasync`). +""" +import errno +import os +import shutil +import struct +import subprocess +import sys +import tempfile +import zlib + +MAGIC = b"FLSW" +OP_DATA, OP_SEEK, OP_SYNC, OP_QUIT, OP_READ, OP_ZERO = 1, 3, 4, 5, 6, 9 +R_READY, R_ACK, R_PROG, R_ERR, R_DONE, R_READ_DATA = 0x81, 0x82, 0x83, 0x84, 0x85, 0x86 +OP_SIZE, R_SIZE = 0x0A, 0x89 + + +def frame(op, payload): + return MAGIC + bytes([op]) + struct.pack(" +#include +static ssize_t fault_write(int fd, const void *buf, size_t n) { + if (fd > 2) { + if (n == 32) return 0; + if (n == 8) { errno = EIO; return -1; } + if (n == 16) n = 8; + } + return write(fd, buf, n); +} +static ssize_t fault_read(int fd, void *buf, size_t n) { + if (fd > 2) { + if (n == 8) { errno = EIO; return -1; } + if (n == 16) n = 8; + } + return read(fd, buf, n); +} +static off_t fault_seek(int fd, off_t off, int whence) { + if (whence == SEEK_END) { errno = EIO; return -1; } + if (whence == SEEK_SET && off == 123) { errno = EIO; return -1; } + off_t result = lseek(fd, off, whence); + if (result >= 0) errno = EINVAL; + return result; +} +static int fault_sync(int fd) { (void)fd; errno = EIO; return -1; } +#define write fault_write +#define read fault_read +#define lseek fault_seek +#define fsync fault_sync +#define fdatasync fault_sync +#include "fls-wh.c" +''') + subprocess.run([find_cc(), "-O2", "-I", here, fault_src, "-o", fault_bin], check=True) + + def data(content): + return frame(OP_DATA, struct.pack(" 8 partial write, then EIO on the retry. + fault_run(data(bytes(range(16))), (0, OP_DATA, errno.EIO, 0)) + # DATA: a zero-progress write is a hard error. + fault_run(data(bytes(32)), (0, OP_DATA, errno.EIO, 0)) + # READ: 16 -> 8 partial read, then EIO on the retry. + fault_run(frame(OP_READ, struct.pack(" 8 partial write, then EIO on the retry. + fault_run(frame(OP_ZERO, struct.pack("