From 2be4b6f790b469a3d7152174c076393886f485eb Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Fri, 22 May 2026 12:00:53 +0800 Subject: [PATCH 1/2] feat(vela): complete firmware flash-to-block-device capability - Add vela-flasher crate with BlockDeviceWriter and FpkInstaller - Refactor vela-slotmgr SlotManager: generic over SlotProvider, real write_slot - Wire vela-lifecycle Installing phase with actual firmware flashing - Fix vela-ffi #[no_mangle] -> #[unsafe(no_mangle)] for Rust 2024 edition Closes #222 --- src/vela/vela-core/Cargo.toml | 1 + src/vela/vela-core/crates/vela-ffi/src/lib.rs | 12 +- .../vela-core/crates/vela-flasher/Cargo.toml | 22 ++ .../crates/vela-flasher/src/direct_writer.rs | 374 ++++++++++++++++++ .../crates/vela-flasher/src/fpk_installer.rs | 343 ++++++++++++++++ .../vela-core/crates/vela-flasher/src/lib.rs | 122 ++++++ .../crates/vela-lifecycle/Cargo.toml | 6 + .../crates/vela-lifecycle/src/engine.rs | 243 +++++++++++- .../crates/vela-lifecycle/src/lib.rs | 34 +- .../vela-core/crates/vela-slotmgr/Cargo.toml | 3 +- .../vela-core/crates/vela-slotmgr/src/lib.rs | 3 + .../crates/vela-slotmgr/src/manager.rs | 332 ++++++++++++++-- 12 files changed, 1435 insertions(+), 60 deletions(-) create mode 100644 src/vela/vela-core/crates/vela-flasher/Cargo.toml create mode 100644 src/vela/vela-core/crates/vela-flasher/src/direct_writer.rs create mode 100644 src/vela/vela-core/crates/vela-flasher/src/fpk_installer.rs create mode 100644 src/vela/vela-core/crates/vela-flasher/src/lib.rs diff --git a/src/vela/vela-core/Cargo.toml b/src/vela/vela-core/Cargo.toml index 8be48442..1003e511 100644 --- a/src/vela/vela-core/Cargo.toml +++ b/src/vela/vela-core/Cargo.toml @@ -2,6 +2,7 @@ members = [ "crates/vela-crypto", "crates/vela-flashpack", + "crates/vela-flasher", "crates/vela-attestation", "crates/vela-lifecycle", "crates/vela-slotmgr", diff --git a/src/vela/vela-core/crates/vela-ffi/src/lib.rs b/src/vela/vela-core/crates/vela-ffi/src/lib.rs index 95c52295..bc132c07 100644 --- a/src/vela/vela-core/crates/vela-ffi/src/lib.rs +++ b/src/vela/vela-core/crates/vela-ffi/src/lib.rs @@ -23,7 +23,7 @@ pub struct EngineHandle { /// Get the last error message. Caller must free the returned string. /// Returns null if no error recorded. -#[no_mangle] +#[unsafe(no_mangle)] pub unsafe extern "C" fn vela_last_error() -> *mut std::os::raw::c_char { let err = LAST_ERROR.lock().ok().and_then(|g| g.clone()); match err { @@ -36,7 +36,7 @@ pub unsafe extern "C" fn vela_last_error() -> *mut std::os::raw::c_char { } /// Clear the last error. -#[no_mangle] +#[unsafe(no_mangle)] pub unsafe extern "C" fn vela_clear_error() { if let Ok(mut guard) = LAST_ERROR.lock() { *guard = None; @@ -44,7 +44,7 @@ pub unsafe extern "C" fn vela_clear_error() { } /// Initialize the Vela Core engine. Returns engine handle or null on error. -#[no_mangle] +#[unsafe(no_mangle)] pub unsafe extern "C" fn vela_init() -> *mut EngineHandle { trace!("FFI: vela_init called"); info!("Vela Core engine initialized"); @@ -52,7 +52,7 @@ pub unsafe extern "C" fn vela_init() -> *mut EngineHandle { } /// Shut down and release the engine handle. -#[no_mangle] +#[unsafe(no_mangle)] pub unsafe extern "C" fn vela_shutdown(handle: *mut EngineHandle) -> i32 { if handle.is_null() { trace!("FFI: vela_shutdown called with null handle"); @@ -64,7 +64,7 @@ pub unsafe extern "C" fn vela_shutdown(handle: *mut EngineHandle) -> i32 { } /// Open a FlashPack file. Returns handle or null on error. -#[no_mangle] +#[unsafe(no_mangle)] pub unsafe extern "C" fn vela_fpk_open(path: *const std::os::raw::c_char) -> *mut FpkHandle { let path_str = match unsafe { std::ffi::CStr::from_ptr(path) }.to_str() { Ok(s) => s, @@ -80,7 +80,7 @@ pub unsafe extern "C" fn vela_fpk_open(path: *const std::os::raw::c_char) -> *mu } /// Close a FlashPack handle and release resources. -#[no_mangle] +#[unsafe(no_mangle)] pub unsafe extern "C" fn vela_fpk_close(handle: *mut FpkHandle) -> i32 { if handle.is_null() { return 1; diff --git a/src/vela/vela-core/crates/vela-flasher/Cargo.toml b/src/vela/vela-core/crates/vela-flasher/Cargo.toml new file mode 100644 index 00000000..2b125f6d --- /dev/null +++ b/src/vela/vela-core/crates/vela-flasher/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "vela-flasher" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +tracing.workspace = true +thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio = { workspace = true, features = ["fs", "io-util", "rt"] } +sha2.workspace = true +hex.workspace = true +flate2.workspace = true +tar.workspace = true +vela-flashpack = { path = "../vela-flashpack" } +vela-crypto = { path = "../vela-crypto" } + +[dev-dependencies] +tempfile.workspace = true diff --git a/src/vela/vela-core/crates/vela-flasher/src/direct_writer.rs b/src/vela/vela-core/crates/vela-flasher/src/direct_writer.rs new file mode 100644 index 00000000..a7dc498e --- /dev/null +++ b/src/vela/vela-core/crates/vela-flasher/src/direct_writer.rs @@ -0,0 +1,374 @@ +//! Block-device writer: chunked writes with fsync, read-back verify, and SHA-256 tracking. +//! +//! `BlockDeviceWriter` is the low-level I/O layer for writing firmware images +//! to raw block devices (or regular files during testing). It guarantees: +//! +//! - **Chunked writes** — configurable chunk size (default 1 MiB) to limit the +//! amount of data in flight. +//! - **fsync** — optional per-chunk `sync_all()` to flush OS buffers. +//! - **Read-back verification** — optional per-chunk read-and-compare to detect +//! silent data corruption. +//! - **SHA-256 tracking** — incremental hash of all bytes written, available +//! for post-write integrity checks. + +use std::fs::{File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::Path; + +use sha2::{Digest, Sha256}; +use tracing::{debug, error, info, instrument, trace}; + +use crate::{FlashConfig, FlasherError, FlasherResult, ProgressCallback}; + +/// Writes firmware images to a block device with chunked I/O, +/// fsync, read-back verification, and SHA-256 tracking. +pub struct BlockDeviceWriter { + config: FlashConfig, + file: Option, + bytes_written: u64, + hasher: Sha256, + device_size: u64, +} + +impl BlockDeviceWriter { + /// Create a new writer with the given configuration. + /// + /// The device is lazily opened on the first `write_image` call. + pub fn new(config: FlashConfig) -> Self { + Self { + config, + file: None, + bytes_written: 0, + hasher: Sha256::new(), + device_size: 0, + } + } + + /// Write a firmware payload to the configured block device. + /// + /// Returns the total number of bytes written. + /// + /// # Errors + /// + /// Returns `FlasherError::OpenFailed` if the device cannot be opened. + /// Returns `FlasherError::DeviceTooSmall` if the payload exceeds the device capacity. + /// Returns `FlasherError::WriteFailed` if a write or read-back verification fails. + #[instrument(skip(self, payload, progress))] + pub fn write_image( + &mut self, + payload: &[u8], + progress: Option<&ProgressCallback>, + ) -> FlasherResult { + if self.config.device_path.is_empty() { + return Err(FlasherError::OpenFailed { + device: "".into(), + source: std::io::Error::new( + std::io::ErrorKind::NotFound, + "device path is empty", + ), + }); + } + + let _ = self.open_device()?; + self.verify_capacity(payload.len() as u64)?; + + // Take ownership of the file handle to avoid double-borrow across iterations + let mut file = self.file.take().unwrap(); + let total = payload.len() as u64; + let chunk_size = self.config.chunk_size; + + for (i, chunk) in payload.chunks(chunk_size).enumerate() { + let offset = (i * chunk_size) as u64; + self.write_chunk_inner(&mut file, offset, chunk)?; + + if let Some(cb) = progress { + cb(self.bytes_written, total); + } + } + + // Final fsync to flush all pending writes + file.sync_all().map_err(FlasherError::Io)?; + + // Return the file handle + self.file = Some(file); + + info!(bytes = self.bytes_written, "Image write completed"); + Ok(self.bytes_written) + } + + /// Return the SHA-256 hex string of all data written so far. + /// + /// Returns `None` if no data has been written yet. + pub fn sha256_checksum(&self) -> Option { + if self.bytes_written == 0 { + return None; + } + let hash = self.hasher.clone().finalize(); + Some(hex::encode(hash)) + } + + /// Verify that the written data matches the expected SHA-256 hex checksum. + pub fn verify_checksum(&self, expected: &str) -> bool { + match self.sha256_checksum() { + Some(computed) => computed == expected, + None => false, + } + } + + /// Return the total number of bytes written so far. + pub fn bytes_written(&self) -> u64 { + self.bytes_written + } + + // ── Private helpers ──────────────────────────────────────────────── + + /// Open the block device (or regular file) for read-write access. + /// Sets `self.device_size` from the file metadata. + fn open_device(&mut self) -> FlasherResult<()> { + if self.file.is_some() { + return Ok(()); + } + + let path = Path::new(&self.config.device_path); + debug!(device = %self.config.device_path, "Opening device for writing"); + + let file = OpenOptions::new() + .write(true) + .read(true) + .open(path) + .map_err(|e| FlasherError::OpenFailed { + device: self.config.device_path.clone(), + source: e, + })?; + + let metadata = file.metadata().map_err(FlasherError::Io)?; + self.device_size = metadata.len(); + + trace!(size = self.device_size, "Device opened successfully"); + self.file = Some(file); + Ok(()) + } + + /// Check that the device has enough capacity for the payload. + fn verify_capacity(&self, required: u64) -> FlasherResult<()> { + // A size of 0 means the device size is unknown (e.g., a special + // block device that doesn't report size). We allow writes in that case + // and rely on the kernel to enforce limits. + if self.device_size > 0 && required > self.device_size { + return Err(FlasherError::DeviceTooSmall { + device: self.config.device_path.clone(), + required, + available: self.device_size, + }); + } + debug!( + required, + available = self.device_size, + "Capacity verification passed" + ); + Ok(()) + } + + /// Write a single chunk to the device at the given offset. + /// Updates the internal SHA-256 hasher and bytes_written counter. + /// Optionally fsyncs and read-back verifies, depending on config. + fn write_chunk_inner(&mut self, file: &mut File, offset: u64, data: &[u8]) -> FlasherResult<()> { + file.seek(SeekFrom::Start(offset)).map_err(FlasherError::Io)?; + + let written = file.write(data).map_err(|e| FlasherError::WriteFailed { + device: self.config.device_path.clone(), + offset, + source: e, + })?; + + if written != data.len() { + return Err(FlasherError::ShortWrite { + expected: data.len(), + actual: written, + }); + } + + // Update the incremental hasher + self.hasher.update(data); + self.bytes_written += written as u64; + + // Optional per-chunk fsync + if self.config.sync_after_chunk { + file.sync_all().map_err(FlasherError::Io)?; + } + + // Optional read-back verification + if self.config.verify_after_write { + self.verify_chunk(file, offset, data)?; + } + + trace!(offset, size = written, "Chunk written successfully"); + Ok(()) + } + + /// Read back a chunk from the device and compare it with the expected data. + fn verify_chunk(&self, file: &mut File, offset: u64, expected: &[u8]) -> FlasherResult<()> { + let mut buf = vec![0u8; expected.len()]; + + file.seek(SeekFrom::Start(offset)).map_err(FlasherError::Io)?; + file.read_exact(&mut buf).map_err(FlasherError::Io)?; + + if buf != expected { + error!( + offset, + size = expected.len(), + "Read-back verification failed: data mismatch" + ); + return Err(FlasherError::WriteFailed { + device: self.config.device_path.clone(), + offset, + source: std::io::Error::new( + std::io::ErrorKind::Other, + "read-back data mismatch", + ), + }); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::NamedTempFile; + + /// Create a writer configured to use a temporary file as the "device". + fn writer_for_temp(file: &NamedTempFile) -> BlockDeviceWriter { + let config = FlashConfig::new(file.path().to_string_lossy().as_ref()) + .sync_after_chunk(false) + .verify_after_write(false); + BlockDeviceWriter::new(config) + } + + #[test] + fn test_write_small_payload() { + let tmp = NamedTempFile::new().unwrap(); + let mut writer = writer_for_temp(&tmp); + + let payload = b"Hello, Vela OTA! This is a firmware payload."; + let written = writer.write_image(payload, None).unwrap(); + assert_eq!(written, payload.len() as u64); + assert_eq!(writer.bytes_written(), payload.len() as u64); + } + + #[test] + fn test_write_with_progress() { + let tmp = NamedTempFile::new().unwrap(); + let mut writer = writer_for_temp(&tmp); + + let payload = vec![0xAAu8; 5000]; + let mut callback_calls: Vec<(u64, u64)> = Vec::new(); + let cb: ProgressCallback = Box::new(move |written, total| { + callback_calls.push((written, total)); + }); + + writer.write_image(&payload, Some(&cb)).unwrap(); + assert!(!callback_calls.is_empty(), "Progress callback was never called"); + } + + #[test] + fn test_write_with_readback_verify() { + let tmp = NamedTempFile::new().unwrap(); + let config = FlashConfig::new(tmp.path().to_string_lossy().as_ref()) + .sync_after_chunk(false) + .verify_after_write(true) + .chunk_size(64); + let mut writer = BlockDeviceWriter::new(config); + + let payload = vec![0xBBu8; 256]; + let written = writer.write_image(&payload, None).unwrap(); + assert_eq!(written, 256); + } + + #[test] + fn test_device_too_small() { + let tmp = NamedTempFile::new().unwrap(); + // Write a small file to get a known size + std::fs::write(tmp.path(), b"tiny").unwrap(); + + let config = FlashConfig::new(tmp.path().to_string_lossy().as_ref()); + let mut writer = BlockDeviceWriter::new(config); + + let payload = vec![0u8; 1024 * 1024]; // 1 MiB, definitely larger than 4 bytes + let result = writer.write_image(&payload, None); + assert!(result.is_err()); + assert!( + matches!(result.unwrap_err(), FlasherError::DeviceTooSmall { .. }), + "Expected DeviceTooSmall error" + ); + } + + #[test] + fn test_sha256_tracking() { + let tmp = NamedTempFile::new().unwrap(); + let mut writer = writer_for_temp(&tmp); + + let payload = b"consistency check payload for sha256"; + writer.write_image(payload, None).unwrap(); + + let checksum = writer.sha256_checksum().expect("should have a checksum"); + assert!(!checksum.is_empty()); + + // Verify that verify_checksum works + assert!(writer.verify_checksum(&checksum)); + assert!(!writer.verify_checksum("0000000000000000000000000000000000000000000000000000000000000000")); + } + + #[test] + fn test_empty_device_path_is_error() { + let mut writer = BlockDeviceWriter::new(FlashConfig::default()); + let result = writer.write_image(b"test", None); + assert!(result.is_err()); + } + + #[test] + fn test_sha256_none_before_write() { + let tmp = NamedTempFile::new().unwrap(); + let writer = writer_for_temp(&tmp); + assert!(writer.sha256_checksum().is_none()); + assert!(!writer.verify_checksum("anything")); // no data yet + } + + #[test] + fn test_large_chunked_write() { + let tmp = NamedTempFile::new().unwrap(); + let config = FlashConfig::new(tmp.path().to_string_lossy().as_ref()) + .sync_after_chunk(false) + .verify_after_write(false) + .chunk_size(64); // small chunks to exercise chunking + let mut writer = BlockDeviceWriter::new(config); + + let payload = vec![0xCCu8; 1000]; // 1000 bytes in 64-byte chunks + let written = writer.write_image(&payload, None).unwrap(); + assert_eq!(written, 1000); + } + + #[test] + fn test_write_preserves_payload() { + let tmp = NamedTempFile::new().unwrap(); + let config = FlashConfig::new(tmp.path().to_string_lossy().as_ref()) + .sync_after_chunk(false) + .verify_after_write(false); + let mut writer = BlockDeviceWriter::new(config); + + let payload: Vec = (0..255).collect(); // 0, 1, 2, ..., 254 + writer.write_image(&payload, None).unwrap(); + + // Read back from the file and verify + let written_data = std::fs::read(tmp.path()).unwrap(); + assert_eq!(&written_data[..payload.len()], payload.as_slice()); + } + + #[test] + fn test_nonexistent_device() { + let config = FlashConfig::new("/nonexistent/device/path_xyz_123"); + let mut writer = BlockDeviceWriter::new(config); + let result = writer.write_image(b"test", None); + assert!(result.is_err()); + } +} diff --git a/src/vela/vela-core/crates/vela-flasher/src/fpk_installer.rs b/src/vela/vela-core/crates/vela-flasher/src/fpk_installer.rs new file mode 100644 index 00000000..ea10e9fa --- /dev/null +++ b/src/vela/vela-core/crates/vela-flasher/src/fpk_installer.rs @@ -0,0 +1,343 @@ +//! FpkInstaller: reads `.fpk` archives, verifies checksums, decompresses payloads, +//! flashes firmware to block devices, and verifies the written hash. +//! +//! The `FpkInstaller` orchestrates the full installation pipeline: +//! +//! 1. Open and parse the `.fpk` archive via `vela_flashpack::FlashPackReader`. +//! 2. Verify the checksums of all archive components. +//! 3. Decompress the gzipped payload. +//! 4. Compute the SHA-256 hash of the decompressed payload. +//! 5. Flash the payload to the target device using `BlockDeviceWriter`. +//! 6. Verify the hash of the written data matches the decompressed payload hash. +//! +//! A convenience function `install_fpk()` bundles all of these steps +//! into a single call for quick integration. + +use std::io::Read; +use std::path::Path; + +use sha2::{Digest, Sha256}; +use tracing::{error, info, instrument, trace}; + +use crate::direct_writer::BlockDeviceWriter; +use crate::{FlashConfig, FlasherError, FlasherResult, ProgressCallback}; +use vela_flashpack::FlashPackReader; + +/// Installs a `.fpk` firmware bundle onto a target block device. +/// +/// Owns a `BlockDeviceWriter` for the actual I/O and a path to the `.fpk` file. +pub struct FpkInstaller { + fpk_path: String, + writer: BlockDeviceWriter, +} + +impl FpkInstaller { + /// Create a new installer for the given `.fpk` file and block device writer. + pub fn new(fpk_path: impl Into, writer: BlockDeviceWriter) -> Self { + Self { + fpk_path: fpk_path.into(), + writer, + } + } + + /// Execute the full installation pipeline. + /// + /// 1. Open and parse the `.fpk`. + /// 2. Verify archive checksums. + /// 3. Decompress the gzipped payload. + /// 4. Compute the SHA-256 hash of the decompressed data. + /// 5. Flash the decompressed payload to the target device. + /// 6. Verify that the hash of the written data matches. + /// + /// Returns the number of decompressed bytes written. + #[instrument(skip(self, progress), fields(fpk = %self.fpk_path))] + pub fn install(&mut self, progress: Option<&ProgressCallback>) -> FlasherResult { + info!("Starting FPK installation pipeline"); + + // 1. Open and parse the .fpk archive + let fpk_path = Path::new(&self.fpk_path); + let reader = FlashPackReader::open(fpk_path)?; + trace!( + bundle = %reader.header.bundle_name, + version = %reader.header.bundle_version, + "FlashPack opened" + ); + + // 2. Verify archive checksums (header + payload) + let _bundle_hash = reader.verify_checksums()?; + info!("FlashPack checksum verification passed"); + + // 3. Decompress the gzipped payload + let payload_reader = reader.payload_reader()?; + let decompressed = Self::decompress_payload(payload_reader)?; + trace!( + decompressed_size = decompressed.len(), + "Payload decompressed" + ); + + // 4. Compute SHA-256 of the decompressed payload + let decompressed_hash = { + let mut hasher = Sha256::new(); + hasher.update(&decompressed); + hex::encode(hasher.finalize()) + }; + trace!(hash = %&decompressed_hash[..16], "Decompressed payload hash computed"); + + // 5. Flash the decompressed payload to the device + let bytes_written = self.writer.write_image(&decompressed, progress)?; + info!(bytes = bytes_written, "Payload written to device"); + + // 6. Verify that the written data hash matches the decompressed payload hash + let written_hash = self.writer.sha256_checksum().unwrap_or_default(); + if written_hash != decompressed_hash { + error!( + expected = %&decompressed_hash[..16], + actual = %&written_hash[..16], + "Post-write hash verification failed" + ); + return Err(FlasherError::HashMismatch { + expected: decompressed_hash, + actual: written_hash, + }); + } + + info!( + bytes = bytes_written, + "FPK installation completed successfully" + ); + Ok(bytes_written) + } + + /// Decompress a gzip-compressed payload reader into a byte vector. + fn decompress_payload(reader: R) -> FlasherResult> { + use flate2::read::GzDecoder; + + let mut decoder = GzDecoder::new(reader); + let mut buf = Vec::new(); + decoder.read_to_end(&mut buf).map_err(FlasherError::Io)?; + Ok(buf) + } +} + +/// Convenience function to install a `.fpk` to a block device in one call. +/// +/// This bundles `FlashConfig` construction, `BlockDeviceWriter` creation, +/// and `FpkInstaller::install()` into a single function for easy integration. +pub fn install_fpk( + fpk_path: &str, + device_path: &str, + progress: Option<&ProgressCallback>, +) -> FlasherResult { + let config = FlashConfig::new(device_path); + let writer = BlockDeviceWriter::new(config); + let mut installer = FpkInstaller::new(fpk_path, writer); + installer.install(progress) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + use vela_flashpack::header::{FpkHeader, PayloadType}; + + /// Build a minimal valid `.fpk` file containing gzip-compressed payload data. + fn build_test_fpk(payload_data: &[u8]) -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let fpk_path = dir.path().join("test.fpk"); + + let file = File::create(&fpk_path).unwrap(); + let mut archive = tar::Builder::new(file); + + // 1. Compress the payload + use flate2::write::GzEncoder; + use flate2::Compression; + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(payload_data).unwrap(); + let compressed = encoder.finish().unwrap(); + + let payload_sha256 = { + let mut h = Sha256::new(); + h.update(&compressed); + hex::encode(h.finalize()) + }; + + // 2. Build the header + let header = FpkHeader { + format_version: "1.0.0".into(), + min_reader_version: "1.0.0".into(), + bundle_name: "test-install-bundle".into(), + bundle_version: "2.0.0".into(), + compatible_slots: vec!["test-slot".into()], + payload_type: PayloadType::FullImage, + payload_size: compressed.len() as u64, + requires_version: "1.0.0".into(), + created_at: "2026-05-22T00:00:00Z".into(), + builder_id: "test-ci".into(), + compat_flags: vec![], + }; + let header_json = serde_json::to_vec_pretty(&header).unwrap(); + let header_sha256 = { + let mut h = Sha256::new(); + h.update(&header_json); + hex::encode(h.finalize()) + }; + + // 3. Add entries to the tar archive + let mut hdr = tar::Header::new_gnu(); + hdr.set_path("fpk-header.json").unwrap(); + hdr.set_size(header_json.len() as u64); + hdr.set_mode(0o644); + hdr.set_cksum(); + archive.append(&hdr, header_json.as_slice()).unwrap(); + + let mut payload_hdr = tar::Header::new_gnu(); + payload_hdr.set_path("payload/data.gz").unwrap(); + payload_hdr.set_size(compressed.len() as u64); + payload_hdr.set_mode(0o644); + payload_hdr.set_cksum(); + archive.append(&payload_hdr, compressed.as_slice()).unwrap(); + + let checksums_content = format!( + "SHA256(fpk-header.json)= {header_sha256}\nSHA256(payload/data.gz)= {payload_sha256}\n" + ); + let mut cs_hdr = tar::Header::new_gnu(); + cs_hdr.set_path("checksums.sha256").unwrap(); + cs_hdr.set_size(checksums_content.len() as u64); + cs_hdr.set_mode(0o644); + cs_hdr.set_cksum(); + archive + .append(&cs_hdr, checksums_content.as_bytes()) + .unwrap(); + + let sig_data = b"PLACEHOLDER_SIGNATURE"; + let mut sig_hdr = tar::Header::new_gnu(); + sig_hdr.set_path("signature.p7s").unwrap(); + sig_hdr.set_size(sig_data.len() as u64); + sig_hdr.set_mode(0o644); + sig_hdr.set_cksum(); + archive.append(&sig_hdr, sig_data.as_slice()).unwrap(); + + archive.finish().unwrap(); + (dir, fpk_path) + } + + /// Create a writer pointed at a temp file for the "device". + fn writer_for_temp(path: &std::path::Path) -> BlockDeviceWriter { + let config = FlashConfig::new(path.to_string_lossy().as_ref()) + .sync_after_chunk(false) + .verify_after_write(false); + BlockDeviceWriter::new(config) + } + + #[test] + fn test_install_full_pipeline() { + let payload = b"Vela OTA firmware payload for full pipeline test!"; + let (_dir, fpk_path) = build_test_fpk(payload); + + let device_file = NamedTempFile::new().unwrap(); + let writer = writer_for_temp(device_file.path()); + let mut installer = FpkInstaller::new(fpk_path.to_string_lossy().as_ref(), writer); + + let bytes = installer.install(None).unwrap(); + assert_eq!(bytes, payload.len() as u64); + + // Verify the file contents + let written = std::fs::read(device_file.path()).unwrap(); + assert_eq!(&written[..payload.len()], payload); + } + + #[test] + fn test_install_with_progress() { + let payload = b"Progress tracking test payload!"; + let (_dir, fpk_path) = build_test_fpk(payload); + + let device_file = NamedTempFile::new().unwrap(); + let writer = writer_for_temp(device_file.path()); + let mut installer = FpkInstaller::new(fpk_path.to_string_lossy().as_ref(), writer); + + let mut calls = Vec::new(); + let cb: ProgressCallback = Box::new(move |w, t| { + calls.push((w, t)); + }); + + let bytes = installer.install(Some(&cb)).unwrap(); + assert_eq!(bytes, payload.len() as u64); + } + + #[test] + fn test_convenience_function() { + let payload = b"Convenience install_fpk test data!"; + let (_dir, fpk_path) = build_test_fpk(payload); + + let device_file = NamedTempFile::new().unwrap(); + let bytes = install_fpk( + fpk_path.to_str().unwrap(), + device_file.path().to_str().unwrap(), + None, + ) + .unwrap(); + assert_eq!(bytes, payload.len() as u64); + } + + #[test] + fn test_corrupt_fpk_rejected() { + let payload = b"Corrupt test payload..."; + let (_dir, fpk_path) = build_test_fpk(payload); + + // Corrupt the .fpk by truncating it + let size = std::fs::metadata(&fpk_path).unwrap().len(); + let file = std::fs::OpenOptions::new() + .write(true) + .open(&fpk_path) + .unwrap(); + file.set_len(size / 2).unwrap(); // truncate to half size + + let device_file = NamedTempFile::new().unwrap(); + let writer = writer_for_temp(device_file.path()); + let mut installer = FpkInstaller::new(fpk_path.to_string_lossy().as_ref(), writer); + + let result = installer.install(None); + assert!(result.is_err(), "Corrupt fpk should be rejected"); + } + + #[test] + fn test_missing_fpk_rejected() { + let device_file = NamedTempFile::new().unwrap(); + let writer = writer_for_temp(device_file.path()); + let mut installer = FpkInstaller::new("/nonexistent/file.fpk", writer); + + let result = installer.install(None); + assert!(result.is_err(), "Missing fpk should be rejected"); + } + + #[test] + fn test_empty_payload() { + let payload: &[u8] = &[]; + let (_dir, fpk_path) = build_test_fpk(payload); + + let device_file = NamedTempFile::new().unwrap(); + let writer = writer_for_temp(device_file.path()); + let mut installer = FpkInstaller::new(fpk_path.to_string_lossy().as_ref(), writer); + + let bytes = installer.install(None).unwrap(); + assert_eq!(bytes, 0); + } + + #[test] + fn test_large_payload_roundtrip() { + // 128 KiB of pseudo-random data + let payload: Vec = (0..128 * 1024).map(|i| (i % 251) as u8).collect(); + let (_dir, fpk_path) = build_test_fpk(&payload); + + let device_file = NamedTempFile::new().unwrap(); + let writer = writer_for_temp(device_file.path()); + let mut installer = FpkInstaller::new(fpk_path.to_string_lossy().as_ref(), writer); + + let bytes = installer.install(None).unwrap(); + assert_eq!(bytes, payload.len() as u64); + + let written = std::fs::read(device_file.path()).unwrap(); + assert_eq!(&written[..payload.len()], payload.as_slice()); + } +} diff --git a/src/vela/vela-core/crates/vela-flasher/src/lib.rs b/src/vela/vela-core/crates/vela-flasher/src/lib.rs new file mode 100644 index 00000000..c2b10d32 --- /dev/null +++ b/src/vela/vela-core/crates/vela-flasher/src/lib.rs @@ -0,0 +1,122 @@ +#![forbid(unsafe_code)] +#![doc = "Firmware flash-to-block-device module for Vela OTA."] +#![doc = ""] +#![doc = "Provides `BlockDeviceWriter` for chunked writes to raw block devices"] +#![doc = "and `FpkInstaller` for installing `.fpk` bundles to devices."] + +use std::io; +use thiserror::Error; + +pub mod direct_writer; +pub mod fpk_installer; + +pub use direct_writer::BlockDeviceWriter; +pub use fpk_installer::FpkInstaller; + +/// Errors that can occur during flash operations. +#[derive(Error, Debug)] +pub enum FlasherError { + #[error("Failed to open device {device}: {source}")] + OpenFailed { + device: String, + #[source] + source: io::Error, + }, + + #[error("Write to device {device} at offset {offset} failed: {source}")] + WriteFailed { + device: String, + offset: u64, + #[source] + source: io::Error, + }, + + #[error("Short write: expected {expected} bytes, wrote {actual}")] + ShortWrite { expected: usize, actual: usize }, + + #[error("Device {device} too small: need {required} bytes, have {available}")] + DeviceTooSmall { + device: String, + required: u64, + available: u64, + }, + + #[error("Hash mismatch after write: expected {expected}, got {actual}")] + HashMismatch { expected: String, actual: String }, + + #[error("FlashPack error: {0}")] + FpkError(#[from] vela_flashpack::FlashPackError), + + #[error("IO error: {0}")] + Io(#[from] io::Error), +} + +/// Result type alias for flash operations. +pub type FlasherResult = Result; + +/// Configuration for flash operations. +/// +/// The `device_path` field specifies the target block device. +/// Chunked writes with fsync and optional read-back verification +/// provide integrity guarantees suitable for OTA updates. +#[derive(Debug, Clone)] +pub struct FlashConfig { + /// Path to the block device (e.g., `/dev/mmcblk0p3`). + pub device_path: String, + /// Whether to call `fsync` after each chunk write. + pub sync_after_chunk: bool, + /// Size of each write chunk in bytes (default: 1 MiB). + pub chunk_size: usize, + /// Whether to read back and verify each chunk after writing. + pub verify_after_write: bool, +} + +impl Default for FlashConfig { + fn default() -> Self { + Self { + device_path: String::new(), + sync_after_chunk: true, + chunk_size: 1024 * 1024, // 1 MiB + verify_after_write: true, + } + } +} + +impl FlashConfig { + /// Create a new `FlashConfig` with the given device path. + pub fn new(device_path: impl Into) -> Self { + Self { + device_path: device_path.into(), + ..Default::default() + } + } + + /// Builder: set the device path. + pub fn device_path(mut self, path: impl Into) -> Self { + self.device_path = path.into(); + self + } + + /// Builder: enable or disable fsync after each chunk. + pub fn sync_after_chunk(mut self, sync: bool) -> Self { + self.sync_after_chunk = sync; + self + } + + /// Builder: set the chunk size for writes. + pub fn chunk_size(mut self, size: usize) -> Self { + self.chunk_size = size; + self + } + + /// Builder: enable or disable read-back verification after each chunk. + pub fn verify_after_write(mut self, verify: bool) -> Self { + self.verify_after_write = verify; + self + } +} + +/// Callback for progress reporting during flash operations. +/// +/// Arguments: `(bytes_written, total_bytes)`. +pub type ProgressCallback = Box; diff --git a/src/vela/vela-core/crates/vela-lifecycle/Cargo.toml b/src/vela/vela-core/crates/vela-lifecycle/Cargo.toml index bf1307be..8d3f9c0c 100644 --- a/src/vela/vela-core/crates/vela-lifecycle/Cargo.toml +++ b/src/vela/vela-core/crates/vela-lifecycle/Cargo.toml @@ -15,8 +15,14 @@ chrono.workspace = true uuid.workspace = true vela-flashpack = { path = "../vela-flashpack" } vela-slotmgr = { path = "../vela-slotmgr" } +vela-flasher = { path = "../vela-flasher" } vela-crypto = { path = "../vela-crypto" } [dev-dependencies] tempfile.workspace = true mockall.workspace = true +sha2.workspace = true +hex.workspace = true +flate2.workspace = true +tar.workspace = true +serde_json.workspace = true diff --git a/src/vela/vela-core/crates/vela-lifecycle/src/engine.rs b/src/vela/vela-core/crates/vela-lifecycle/src/engine.rs index 62314d36..5eb6d9af 100644 --- a/src/vela/vela-core/crates/vela-lifecycle/src/engine.rs +++ b/src/vela/vela-core/crates/vela-lifecycle/src/engine.rs @@ -3,13 +3,13 @@ //! The engine manages strict unidirectional phase transitions with //! per-phase timeout enforcement and structured tracing. -use std::sync::Arc; +use std::path::Path; use std::time::Duration; use tracing::{error, info, info_span, instrument, trace, warn}; use crate::{ - LifecycleConfig, LifecycleContext, LifecycleError, LifecycleMetrics, LifecycleOutcome, + LifecycleConfig, LifecycleContext, LifecycleError, LifecycleOutcome, LifecycleResult, PhaseTimer, UpdatePhase, }; @@ -68,12 +68,12 @@ impl LifecycleEngine { return Ok(UpdatePhase::Idle); } UpdatePhase::FallbackRecovery => { - warn!("Entering fallback recovery �?attempting to restore"); + warn!("Entering fallback recovery — attempting to restore"); self.handle_fallback_recovery(ctx).await?; return Ok(UpdatePhase::Idle); } UpdatePhase::Idle => { - trace!("Entering idle �?waiting for next poll trigger"); + trace!("Entering idle — waiting for next poll trigger"); return Ok(UpdatePhase::Polling); } phase => { @@ -106,6 +106,11 @@ impl LifecycleEngine { } /// Handle a non-terminal phase transition. + /// + /// Each phase has real logic wired up: + /// - `Validating`: Opens the `.fpk` file and runs `verify_checksums()`. + /// - `Installing`: Flashes the decompressed payload via `FpkInstaller`. + /// - `FallbackRecovery`: Clears the FPK path and restores system state. async fn handle_phase( &self, phase: UpdatePhase, @@ -125,15 +130,61 @@ impl LifecycleEngine { Ok(UpdatePhase::Validating) } UpdatePhase::Validating => { - trace!("Validating FlashPack"); + info!("Validating FlashPack bundle"); let start = std::time::Instant::now(); - // Validation logic would go here + + // Get the .fpk path from context + let fpk_path = ctx + .fpk_path + .lock() + .map_err(|_| LifecycleError::FpkNotAvailable)? + .clone() + .ok_or(LifecycleError::FpkNotAvailable)?; + + // Open and verify the FlashPack + let reader = vela_flashpack::FlashPackReader::open(Path::new(&fpk_path)) + .map_err(|e| LifecycleError::InstallError(format!("FPK open failed: {e}")))?; + + let _bundle_hash = reader + .verify_checksums() + .map_err(|e| LifecycleError::InstallError(format!("Checksum verification failed: {e}")))?; + + info!( + bundle = %reader.header.bundle_name, + version = %reader.header.bundle_version, + "FlashPack validation passed" + ); + ctx.record_validation_time(start.elapsed().as_millis() as u64); Ok(UpdatePhase::Installing) } UpdatePhase::Installing => { - trace!("Installing to alternate slot"); - ctx.record_bytes_written(0); // placeholder + info!("Installing firmware to target device"); + + let fpk_path = ctx + .fpk_path + .lock() + .map_err(|_| LifecycleError::FpkNotAvailable)? + .clone() + .ok_or(LifecycleError::FpkNotAvailable)?; + + let target_device = ctx + .target_device + .lock() + .map_err(|_| LifecycleError::NoTargetDevice)? + .clone() + .ok_or(LifecycleError::NoTargetDevice)?; + + let config = vela_flasher::FlashConfig::new(&target_device); + let writer = vela_flasher::BlockDeviceWriter::new(config); + let mut installer = vela_flasher::FpkInstaller::new(&fpk_path, writer); + + let bytes_written = installer + .install(None) + .map_err(|e| LifecycleError::InstallError(format!("Flasher error: {e}")))?; + + ctx.record_bytes_written(bytes_written); + info!(bytes = bytes_written, "Firmware installation complete"); Ok(UpdatePhase::Rebooting) } UpdatePhase::Rebooting => { @@ -151,14 +202,19 @@ impl LifecycleEngine { } } - /// Handle fallback recovery �?idempotent operations to restore the system. + /// Handle fallback recovery — idempotent operations to restore the system. async fn handle_fallback_recovery(&self, ctx: &LifecycleContext) -> LifecycleResult<()> { warn!("Executing fallback recovery procedures"); // Fallback steps (all must be idempotent): // 1. Set boot flag to FallbackRequested // 2. Restore primary slot version markers - // 3. Clean up partial downloads + // 3. Clear the FPK path to prevent stale state + // 4. Clean up partial downloads + + if let Ok(mut fpk) = ctx.fpk_path.lock() { + *fpk = None; + } let reason = ctx .metrics @@ -175,7 +231,7 @@ impl LifecycleEngine { phase: UpdatePhase::FallbackRecovery, }); - info!("Fallback recovery complete �?system restored to last known-good state"); + info!("Fallback recovery complete — system restored to last known-good state"); Ok(()) } } @@ -235,13 +291,101 @@ pub async fn run_lifecycle( mod tests { use super::*; use crate::LifecycleConfig; + use std::io::Write; use std::sync::Mutex; + use tempfile::NamedTempFile; + use vela_flashpack::header::{FpkHeader, PayloadType}; + use sha2::{Digest, Sha256}; + use flate2::write::GzEncoder; + use flate2::Compression; fn make_ctx() -> LifecycleContext { - LifecycleContext { - update_id: "test-update-001".into(), - metrics: Mutex::new(LifecycleMetrics::default()), - } + LifecycleContext::new("test-update-001") + } + + /// Build a minimal valid `.fpk` file for testing. + fn build_test_fpk(payload_data: &[u8]) -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let fpk_path = dir.path().join("test.fpk"); + + // Compress the payload + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(payload_data).unwrap(); + let compressed = encoder.finish().unwrap(); + + let payload_sha256 = { + let mut h = Sha256::new(); + h.update(&compressed); + hex::encode(h.finalize()) + }; + + let header = FpkHeader { + format_version: "1.0.0".into(), + min_reader_version: "1.0.0".into(), + bundle_name: "test-lifecycle-bundle".into(), + bundle_version: "2.0.0".into(), + compatible_slots: vec!["test-slot".into()], + payload_type: PayloadType::FullImage, + payload_size: compressed.len() as u64, + requires_version: "1.0.0".into(), + created_at: "2026-05-22T00:00:00Z".into(), + builder_id: "test-ci".into(), + compat_flags: vec![], + }; + let header_json = serde_json::to_vec_pretty(&header).unwrap(); + let header_sha256 = { + let mut h = Sha256::new(); + h.update(&header_json); + hex::encode(h.finalize()) + }; + + let file = std::fs::File::create(&fpk_path).unwrap(); + let mut archive = tar::Builder::new(file); + + let mut hdr = tar::Header::new_gnu(); + hdr.set_path("fpk-header.json").unwrap(); + hdr.set_size(header_json.len() as u64); + hdr.set_mode(0o644); + hdr.set_cksum(); + archive.append(&hdr, header_json.as_slice()).unwrap(); + + let mut phdr = tar::Header::new_gnu(); + phdr.set_path("payload/data.gz").unwrap(); + phdr.set_size(compressed.len() as u64); + phdr.set_mode(0o644); + phdr.set_cksum(); + archive.append(&phdr, compressed.as_slice()).unwrap(); + + let cs = format!( + "SHA256(fpk-header.json)= {header_sha256}\nSHA256(payload/data.gz)= {payload_sha256}\n" + ); + let mut cshdr = tar::Header::new_gnu(); + cshdr.set_path("checksums.sha256").unwrap(); + cshdr.set_size(cs.len() as u64); + cshdr.set_mode(0o644); + cshdr.set_cksum(); + archive.append(&cshdr, cs.as_bytes()).unwrap(); + + let sig = b"PLACEHOLDER_SIGNATURE"; + let mut shdr = tar::Header::new_gnu(); + shdr.set_path("signature.p7s").unwrap(); + shdr.set_size(sig.len() as u64); + shdr.set_mode(0o644); + shdr.set_cksum(); + archive.append(&shdr, sig.as_slice()).unwrap(); + + archive.finish().unwrap(); + (dir, fpk_path) + } + + /// Set up a context with fpk_path and target_device populated. + fn ctx_with_fpk(payload: &[u8]) -> (tempfile::TempDir, LifecycleContext, NamedTempFile) { + let (dir, fpk_path) = build_test_fpk(payload); + let device = NamedTempFile::new().unwrap(); + let ctx = LifecycleContext::new("test-update-with-fpk"); + *ctx.fpk_path.lock().unwrap() = Some(fpk_path.to_string_lossy().to_string()); + *ctx.target_device.lock().unwrap() = Some(device.path().to_string_lossy().to_string()); + (dir, ctx, device) } #[tokio::test(flavor = "current_thread")] @@ -273,11 +417,11 @@ mod tests { let engine = LifecycleEngine::new(LifecycleConfig::default()); let ctx = make_ctx(); - // Idle �?Polling + // Idle -> Polling let next = engine.execute_phase(&ctx, UpdatePhase::Idle).await.unwrap(); assert_eq!(next, UpdatePhase::Polling); - // Polling �?Idle (no update available) + // Polling -> Idle (no update available) let next = engine.execute_phase(&ctx, next).await.unwrap(); assert_eq!(next, UpdatePhase::Idle); } @@ -333,10 +477,73 @@ mod tests { let engine = LifecycleEngine::new(LifecycleConfig::default()); let ctx = make_ctx(); let outcome = run_lifecycle(&engine, &ctx).await.unwrap(); - // With current stub implementations, Polling �?Idle completes. + // With current stub implementations, Polling -> Idle completes. assert!(matches!( outcome, LifecycleOutcome::Aborted | LifecycleOutcome::FallbackRecovery { .. } )); } + + #[tokio::test(flavor = "current_thread")] + async fn test_validating_with_fpk() { + let payload = b"Lifecycle validating test payload!"; + let (_dir, ctx, _device) = ctx_with_fpk(payload); + + let engine = LifecycleEngine::new(LifecycleConfig::default()); + let result = engine.execute_phase(&ctx, UpdatePhase::Validating).await; + assert_eq!(result.unwrap(), UpdatePhase::Installing); + + // Validation time should be recorded + let metrics = ctx.metrics.lock().unwrap(); + assert!(metrics.validation_time_ms > 0); + } + + #[tokio::test(flavor = "current_thread")] + async fn test_validating_no_fpk_fails() { + let ctx = make_ctx(); + let engine = LifecycleEngine::new(LifecycleConfig::default()); + let result = engine.execute_phase(&ctx, UpdatePhase::Validating).await; + // Should fail because no .fpk path is set, triggering fallback + assert_eq!(result.unwrap(), UpdatePhase::FallbackRecovery); + } + + #[tokio::test(flavor = "current_thread")] + async fn test_installing_with_fpk() { + let payload = b"Installing phase test payload data here!"; + let (_dir, ctx, device) = ctx_with_fpk(payload); + + // Also set a checksum for the decompressed payload + let decompressed_hash = { + let mut h = Sha256::new(); + h.update(payload); + hex::encode(h.finalize()) + }; + *ctx.expected_checksum.lock().unwrap() = Some(decompressed_hash); + + let engine = LifecycleEngine::new(LifecycleConfig::default()); + let result = engine.execute_phase(&ctx, UpdatePhase::Installing).await; + assert_eq!(result.unwrap(), UpdatePhase::Rebooting); + + // Verify the device file contains the decompressed payload + let written = std::fs::read(device.path()).unwrap(); + assert_eq!(&written[..payload.len()], payload); + } + + #[tokio::test(flavor = "current_thread")] + async fn test_fallback_recovery_clears_fpk_path() { + let (dir, fpk_path) = build_test_fpk(b"fallback test"); + let ctx = make_ctx(); + *ctx.fpk_path.lock().unwrap() = Some(fpk_path.to_string_lossy().to_string()); + + let engine = LifecycleEngine::new(LifecycleConfig::default()); + engine + .execute_phase(&ctx, UpdatePhase::FallbackRecovery) + .await + .unwrap(); + + // FPK path should be cleared + assert!(ctx.fpk_path.lock().unwrap().is_none()); + // Prevent dir from being dropped (unused variable warning) + let _ = dir; + } } diff --git a/src/vela/vela-core/crates/vela-lifecycle/src/lib.rs b/src/vela/vela-core/crates/vela-lifecycle/src/lib.rs index 72a71f72..ec215829 100644 --- a/src/vela/vela-core/crates/vela-lifecycle/src/lib.rs +++ b/src/vela/vela-core/crates/vela-lifecycle/src/lib.rs @@ -15,7 +15,7 @@ pub mod engine; pub use engine::{LifecycleEngine, run_lifecycle}; /// Errors during the update lifecycle. -#[derive(Error, Debug, Clone)] +#[derive(Error, Debug)] pub enum LifecycleError { #[error("Phase timeout: {0:?}")] PhaseTimeout(UpdatePhase), @@ -26,6 +26,18 @@ pub enum LifecycleError { #[error("Hook execution failed in phase {phase:?}: {message}")] HookError { phase: UpdatePhase, message: String }, + #[error("FPK not available: no FlashPack path configured")] + FpkNotAvailable, + + #[error("No target device configured")] + NoTargetDevice, + + #[error("Install error: {0}")] + InstallError(String), + + #[error("Slot error: {0}")] + SlotError(String), + #[error("Operation aborted")] Aborted, } @@ -107,9 +119,29 @@ pub struct LifecycleMetrics { pub struct LifecycleContext { pub update_id: String, pub metrics: Mutex, + /// Path to the downloaded `.fpk` file. + pub fpk_path: Mutex>, + /// Target block device for flashing (e.g., `/dev/mmcblk0p3`). + pub target_device: Mutex>, + /// Expected SHA-256 checksum of the decompressed payload. + pub expected_checksum: Mutex>, + /// Target version for this update. + pub target_version: Mutex>, } impl LifecycleContext { + /// Create a new context with the given update ID. + pub fn new(update_id: impl Into) -> Self { + Self { + update_id: update_id.into(), + metrics: Mutex::new(LifecycleMetrics::default()), + fpk_path: Mutex::new(None), + target_device: Mutex::new(None), + expected_checksum: Mutex::new(None), + target_version: Mutex::new(None), + } + } + /// Record an error that occurred during a phase. pub fn record_error(&self, _err: &LifecycleError) { if let Ok(mut m) = self.metrics.lock() { diff --git a/src/vela/vela-core/crates/vela-slotmgr/Cargo.toml b/src/vela/vela-core/crates/vela-slotmgr/Cargo.toml index 09269b5c..9612cc94 100644 --- a/src/vela/vela-core/crates/vela-slotmgr/Cargo.toml +++ b/src/vela/vela-core/crates/vela-slotmgr/Cargo.toml @@ -11,7 +11,8 @@ thiserror.workspace = true async-trait.workspace = true serde.workspace = true libc.workspace = true -tokio = { workspace = true, features = ["fs", "io-util"] } +tokio = { workspace = true, features = ["fs", "io-util", "rt"] } +vela-flasher = { path = "../vela-flasher" } [dev-dependencies] tempfile.workspace = true diff --git a/src/vela/vela-core/crates/vela-slotmgr/src/lib.rs b/src/vela/vela-core/crates/vela-slotmgr/src/lib.rs index a6db6057..5f5af153 100644 --- a/src/vela/vela-core/crates/vela-slotmgr/src/lib.rs +++ b/src/vela/vela-core/crates/vela-slotmgr/src/lib.rs @@ -37,6 +37,9 @@ pub enum SlotError { #[error("Slot swap failed: {0}")] SwapFailed(String), + #[error("Flash error: {0}")] + FlashError(Box), + #[error("IO error: {0}")] IoError(#[from] std::io::Error), } diff --git a/src/vela/vela-core/crates/vela-slotmgr/src/manager.rs b/src/vela/vela-core/crates/vela-slotmgr/src/manager.rs index bac99229..1254bba8 100644 --- a/src/vela/vela-core/crates/vela-slotmgr/src/manager.rs +++ b/src/vela/vela-core/crates/vela-slotmgr/src/manager.rs @@ -1,11 +1,13 @@ //! Concrete SlotManager — high-level slot abstraction used by the orchestrator. //! -//! Wraps the MockSlotProvider for testing and provides synchronous -//! slot selection, writing, and label management. +//! Generic over `SlotProvider`, defaulting to `MockSlotProvider` for testing. +//! Provides synchronous slot selection, real block-device writing via +//! `vela_flasher::BlockDeviceWriter`, and label management. -use tracing::{debug, instrument}; +use tracing::{debug, info, instrument, warn}; -use crate::{MockSlotProvider, SlotResult}; +use crate::{MockSlotProvider, SlotError, SlotId, SlotLayout, SlotProvider, SlotResult}; +use vela_flasher::{BlockDeviceWriter, FlashConfig}; /// Label for a specific slot partition. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -22,6 +24,14 @@ impl SlotLabel { Self::Alternate => "alternate", } } + + /// Convert to SlotId. + pub fn to_slot_id(&self) -> SlotId { + match self { + Self::Primary => SlotId::Primary, + Self::Alternate => SlotId::Alternate, + } + } } impl std::fmt::Display for SlotLabel { @@ -32,22 +42,37 @@ impl std::fmt::Display for SlotLabel { /// High-level slot manager that coordinates A/B slot operations. /// -/// Uses a `MockSlotProvider` internally for both testing and production. -/// The orchestrator drives this through `select_inactive_slot()` and `write_slot()`. -pub struct SlotManager { - mock: MockSlotProvider, +/// Generic over the `SlotProvider` trait so callers can plug in +/// real Linux block-device detection or a mock for testing. +/// Defaults to `MockSlotProvider`. +pub struct SlotManager { + provider: Box

, active: SlotLabel, + /// Cached slot layout, refreshed via `refresh()`. + layout: Option, + /// Override for the target device path (for testing). + device_override: Option, } -impl SlotManager { - /// Create a SlotManager from a mock provider (for testing). +impl SlotManager { + /// Create a SlotManager backed by a `MockSlotProvider` (for testing). pub fn with_mock(mock: MockSlotProvider) -> Self { Self { + provider: Box::new(mock), active: SlotLabel::Primary, - mock, + layout: None, + device_override: None, } } +} + +impl Default for SlotManager { + fn default() -> Self { + Self::with_mock(MockSlotProvider::new()) + } +} +impl SlotManager

{ /// Select the non-active (inactive) slot — the target for installation. #[instrument(skip(self))] pub fn select_inactive_slot(&self) -> SlotLabel { @@ -62,44 +87,141 @@ impl SlotManager { self.active } - /// Write update data to the given slot. + /// Refresh the cached slot layout by calling `detect_slots()` on the provider. + /// + /// Call this before `write_slot` to ensure the layout is up-to-date. + pub fn refresh(&mut self) -> SlotResult<()> { + // This is a synchronous wrapper; in production, callers should use + // an async-aware wrapper. We create a single-threaded tokio runtime + // to bridge the async provider trait to sync callers. + let rt = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .map_err(|e| SlotError::DetectionFailed(format!("failed to create runtime: {e}")))?; + let layout = rt.block_on(self.provider.detect_slots())?; + debug!( + primary = %layout.primary.device_path, + alternate = %layout.alternate.device_path, + "Slot layout refreshed" + ); + self.layout = Some(layout); + Ok(()) + } + + /// Get the device path for a given slot label. + /// + /// Returns the device path from the cached layout, or the override if set. + pub fn device_path(&self, slot: SlotLabel) -> Option { + if let Some(ref ov) = self.device_override { + return Some(ov.clone()); + } + self.layout.as_ref().map(|layout| match slot { + SlotLabel::Primary => layout.primary.device_path.clone(), + SlotLabel::Alternate => layout.alternate.device_path.clone(), + }) + } + + /// Get the current version installed in a given slot. + /// + /// Returns `None` if the layout hasn't been refreshed yet. + pub fn slot_version(&self, slot: SlotLabel) -> Option { + self.layout.as_ref().map(|layout| match slot { + SlotLabel::Primary => layout.primary.current_version.clone(), + SlotLabel::Alternate => layout.alternate.current_version.clone(), + }) + } + + /// Swap the active/inactive roles (simulating boot slot toggle), + /// and call `swap_slots()` on the provider to persist the change. + /// + /// Returns an error if the provider's `swap_slots()` fails. + pub fn swap_and_commit(&mut self) -> SlotResult<()> { + // Persist the swap via the provider + let rt = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .map_err(|e| SlotError::SwapFailed(format!("failed to create runtime: {e}")))?; + rt.block_on(self.provider.swap_slots())?; + + // Update local state + self.active = match self.active { + SlotLabel::Primary => SlotLabel::Alternate, + SlotLabel::Alternate => SlotLabel::Primary, + }; + debug!(new_active = %self.active, "Active slot swapped and committed"); + Ok(()) + } + + /// Override the detected slot layout with a custom one. + /// + /// Useful for testing scenarios where the real layout is unavailable. + pub fn override_layout(&mut self, layout: SlotLayout) { + debug!( + primary = %layout.primary.device_path, + alternate = %layout.alternate.device_path, + "Slot layout manually overridden" + ); + self.layout = Some(layout); + } + + /// Override the device path used for writing (for testing with temp files). + pub fn set_device_override(&mut self, path: Option) { + self.device_override = path; + } + + /// Write firmware data to the given slot using `BlockDeviceWriter`. + /// + /// The device path is derived from the cached slot layout, or from + /// an explicitly set device override. If a `FlashConfig` is provided, + /// it will be used; otherwise a default config is constructed from the + /// detected or overridden device path. #[instrument(skip(self, data))] - pub fn write_slot(&mut self, slot: SlotLabel, data: &[u8]) -> SlotResult<()> { + pub fn write_slot( + &mut self, + slot: SlotLabel, + data: &[u8], + config: Option, + ) -> SlotResult { + let device = self + .device_path(slot) + .ok_or_else(|| SlotError::DetectionFailed("no device path for slot".into()))?; + debug!( slot = %slot, + device = %device, bytes = data.len(), "Writing data to slot" ); - if let SlotLabel::Alternate = slot { - // Use consume_space to validate capacity - if let Err(e) = self.mock.consume_space(data.len() as u64) { - return Err(e); - } - } + let flash_config = config.unwrap_or_else(|| FlashConfig::new(&device)); + let mut writer = BlockDeviceWriter::new(flash_config); - Ok(()) + let bytes_written = writer + .write_image(data, None) + .map_err(|e| SlotError::FlashError(Box::new(e)))?; + + info!(bytes = bytes_written, "Slot write completed"); + Ok(bytes_written) } - /// Swap the active/inactive roles (simulating boot slot toggle). + /// Swap the active slot in memory (without calling the provider). + /// + /// This is a lightweight swap for testing; use `swap_and_commit()` + /// for a real persisted swap. pub fn swap_active(&mut self) { self.active = match self.active { SlotLabel::Primary => SlotLabel::Alternate, SlotLabel::Alternate => SlotLabel::Primary, }; - debug!(new_active = %self.active, "Active slot swapped"); - } -} - -impl Default for SlotManager { - fn default() -> Self { - Self::with_mock(MockSlotProvider::new()) + debug!(new_active = %self.active, "Active slot swapped (in-memory)"); } } #[cfg(test)] mod tests { use super::*; + use crate::SlotLayout; + use tempfile::NamedTempFile; #[test] fn test_slot_label_display() { @@ -128,17 +250,159 @@ mod tests { } #[test] - fn test_write_slot_accepts_data() { + fn test_write_slot_uses_temp_device() { + let tmp = NamedTempFile::new().unwrap(); + let dev_path = tmp.path().to_string_lossy().to_string(); + let mut mgr = SlotManager::default(); - let data = vec![0u8; 1024]; - let result = mgr.write_slot(SlotLabel::Alternate, &data); - assert!(result.is_ok()); + mgr.set_device_override(Some(dev_path)); + + let data = vec![0xDEu8; 1024]; + let bytes = mgr.write_slot(SlotLabel::Alternate, &data, None).unwrap(); + assert_eq!(bytes, 1024); + } + + #[test] + fn test_write_slot_with_custom_config() { + let tmp = NamedTempFile::new().unwrap(); + let dev_path = tmp.path().to_string_lossy().to_string(); + + let mut mgr = SlotManager::default(); + mgr.set_device_override(Some(dev_path)); + + let config = FlashConfig::new(&dev_path) + .chunk_size(128) + .sync_after_chunk(false) + .verify_after_write(true); + + let data = vec![0xADu8; 512]; + let bytes = mgr + .write_slot(SlotLabel::Alternate, &data, Some(config)) + .unwrap(); + assert_eq!(bytes, 512); + + // Verify read-back: the file should contain the written data + let written = std::fs::read(tmp.path()).unwrap(); + assert_eq!(&written[..512], data.as_slice()); + } + + #[test] + fn test_write_slot_no_device_path() { + let mut mgr = SlotManager::default(); + // No layout and no device override -> should fail + let data = vec![0u8; 100]; + let result = mgr.write_slot(SlotLabel::Alternate, &data, None); + assert!(result.is_err()); + } + + #[test] + fn test_refresh_populates_layout() { + let mut mgr = SlotManager::default(); + assert!(mgr.layout.is_none()); + + mgr.refresh().unwrap(); + assert!(mgr.layout.is_some()); + + let layout = mgr.layout.as_ref().unwrap(); + assert!(layout.primary.device_path.contains("mock-p2")); + assert!(layout.alternate.device_path.contains("mock-p3")); + } + + #[test] + fn test_device_path_after_refresh() { + let mut mgr = SlotManager::default(); + mgr.refresh().unwrap(); + + assert_eq!( + mgr.device_path(SlotLabel::Primary).unwrap(), + "/dev/mock-p2" + ); + assert_eq!( + mgr.device_path(SlotLabel::Alternate).unwrap(), + "/dev/mock-p3" + ); + } + + #[test] + fn test_slot_version_after_refresh() { + let mock = MockSlotProvider::with_versions("1.5.0", "2.0.0"); + let mut mgr = SlotManager::with_mock(mock); + mgr.refresh().unwrap(); + + assert_eq!(mgr.slot_version(SlotLabel::Primary).unwrap(), "1.5.0"); + assert_eq!(mgr.slot_version(SlotLabel::Alternate).unwrap(), "2.0.0"); + } + + #[test] + fn test_device_override_takes_precedence() { + let mut mgr = SlotManager::default(); + mgr.refresh().unwrap(); + + // Layout says /dev/mock-p2 and /dev/mock-p3 + assert_eq!( + mgr.device_path(SlotLabel::Primary).unwrap(), + "/dev/mock-p2" + ); + + // Override should take precedence + mgr.set_device_override(Some("/tmp/test_override".into())); + assert_eq!( + mgr.device_path(SlotLabel::Primary).unwrap(), + "/tmp/test_override" + ); + assert_eq!( + mgr.device_path(SlotLabel::Alternate).unwrap(), + "/tmp/test_override" + ); } #[test] - fn test_write_slot_primary_is_noop() { + fn test_override_layout() { let mut mgr = SlotManager::default(); - let result = mgr.write_slot(SlotLabel::Primary, &[0u8; 1024]); + let custom = SlotLayout { + primary: crate::SlotInfo { + id: crate::SlotId::Primary, + device_path: "/dev/custom-p1".into(), + fs_type: crate::FileSystemType::Ext4, + current_version: "3.0.0".into(), + is_bootable: true, + }, + alternate: crate::SlotInfo { + id: crate::SlotId::Alternate, + device_path: "/dev/custom-p2".into(), + fs_type: crate::FileSystemType::Ext4, + current_version: "3.1.0".into(), + is_bootable: true, + }, + persistent_data: None, + }; + + mgr.override_layout(custom); + assert_eq!( + mgr.device_path(SlotLabel::Primary).unwrap(), + "/dev/custom-p1" + ); + assert_eq!( + mgr.slot_version(SlotLabel::Alternate).unwrap(), + "3.1.0" + ); + } + + #[test] + fn test_write_slot_primary_is_noop_capacity_check() { + // Writing to the primary slot is allowed — it's up to the caller + // to decide whether that's safe. + let tmp = NamedTempFile::new().unwrap(); + let mut mgr = SlotManager::default(); + mgr.set_device_override(Some(tmp.path().to_string_lossy().to_string())); + + let result = mgr.write_slot(SlotLabel::Primary, &[0u8; 1024], None); assert!(result.is_ok()); } + + #[test] + fn test_slot_label_to_slot_id() { + assert_eq!(SlotLabel::Primary.to_slot_id(), SlotId::Primary); + assert_eq!(SlotLabel::Alternate.to_slot_id(), SlotId::Alternate); + } } From ecad5542bd9ab6ec8e4765510f0d4980f0966457 Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Fri, 22 May 2026 12:03:52 +0800 Subject: [PATCH 2/2] feat(vela): complete firmware flash-to-block-device capability - Add vela-flasher crate with BlockDeviceWriter and FpkInstaller - Refactor vela-slotmgr SlotManager: generic over SlotProvider, real write_slot - Wire vela-lifecycle Installing phase with actual firmware flashing - Fix vela-ffi #[no_mangle] to #[unsafe(no_mangle)] for Rust 2024 edition Closes #222 --- .../crates/vela-flasher/src/direct_writer.rs | 21 ++++++++++--------- .../crates/vela-flasher/src/fpk_installer.rs | 7 ++++--- .../crates/vela-lifecycle/src/engine.rs | 1 - .../crates/vela-slotmgr/src/manager.rs | 2 +- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/vela/vela-core/crates/vela-flasher/src/direct_writer.rs b/src/vela/vela-core/crates/vela-flasher/src/direct_writer.rs index a7dc498e..381098e2 100644 --- a/src/vela/vela-core/crates/vela-flasher/src/direct_writer.rs +++ b/src/vela/vela-core/crates/vela-flasher/src/direct_writer.rs @@ -98,11 +98,8 @@ impl BlockDeviceWriter { /// Return the SHA-256 hex string of all data written so far. /// - /// Returns `None` if no data has been written yet. + /// Returns the SHA-256 of the empty string if no data has been written yet. pub fn sha256_checksum(&self) -> Option { - if self.bytes_written == 0 { - return None; - } let hash = self.hasher.clone().finalize(); Some(hex::encode(hash)) } @@ -262,13 +259,14 @@ mod tests { let mut writer = writer_for_temp(&tmp); let payload = vec![0xAAu8; 5000]; - let mut callback_calls: Vec<(u64, u64)> = Vec::new(); + let callback_calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::<(u64, u64)>::new())); + let calls_ref = callback_calls.clone(); let cb: ProgressCallback = Box::new(move |written, total| { - callback_calls.push((written, total)); + calls_ref.lock().unwrap().push((written, total)); }); writer.write_image(&payload, Some(&cb)).unwrap(); - assert!(!callback_calls.is_empty(), "Progress callback was never called"); + assert!(!callback_calls.lock().unwrap().is_empty(), "Progress callback was never called"); } #[test] @@ -327,11 +325,14 @@ mod tests { } #[test] - fn test_sha256_none_before_write() { + fn test_sha256_returns_empty_hash_before_write() { let tmp = NamedTempFile::new().unwrap(); let writer = writer_for_temp(&tmp); - assert!(writer.sha256_checksum().is_none()); - assert!(!writer.verify_checksum("anything")); // no data yet + // sha256_checksum() returns the hash of empty input when no data written + let checksum = writer.sha256_checksum().expect("should return a checksum even for empty input"); + // SHA-256 of empty string + assert_eq!(checksum, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + assert!(!writer.verify_checksum("anything")); } #[test] diff --git a/src/vela/vela-core/crates/vela-flasher/src/fpk_installer.rs b/src/vela/vela-core/crates/vela-flasher/src/fpk_installer.rs index ea10e9fa..44fc3efc 100644 --- a/src/vela/vela-core/crates/vela-flasher/src/fpk_installer.rs +++ b/src/vela/vela-core/crates/vela-flasher/src/fpk_installer.rs @@ -146,7 +146,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let fpk_path = dir.path().join("test.fpk"); - let file = File::create(&fpk_path).unwrap(); + let file = std::fs::File::create(&fpk_path).unwrap(); let mut archive = tar::Builder::new(file); // 1. Compress the payload @@ -256,9 +256,10 @@ mod tests { let writer = writer_for_temp(device_file.path()); let mut installer = FpkInstaller::new(fpk_path.to_string_lossy().as_ref(), writer); - let mut calls = Vec::new(); + let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::<(u64, u64)>::new())); + let calls_ref = calls.clone(); let cb: ProgressCallback = Box::new(move |w, t| { - calls.push((w, t)); + calls_ref.lock().unwrap().push((w, t)); }); let bytes = installer.install(Some(&cb)).unwrap(); diff --git a/src/vela/vela-core/crates/vela-lifecycle/src/engine.rs b/src/vela/vela-core/crates/vela-lifecycle/src/engine.rs index 5eb6d9af..44f0cf3c 100644 --- a/src/vela/vela-core/crates/vela-lifecycle/src/engine.rs +++ b/src/vela/vela-core/crates/vela-lifecycle/src/engine.rs @@ -292,7 +292,6 @@ mod tests { use super::*; use crate::LifecycleConfig; use std::io::Write; - use std::sync::Mutex; use tempfile::NamedTempFile; use vela_flashpack::header::{FpkHeader, PayloadType}; use sha2::{Digest, Sha256}; diff --git a/src/vela/vela-core/crates/vela-slotmgr/src/manager.rs b/src/vela/vela-core/crates/vela-slotmgr/src/manager.rs index 1254bba8..fd47798f 100644 --- a/src/vela/vela-core/crates/vela-slotmgr/src/manager.rs +++ b/src/vela/vela-core/crates/vela-slotmgr/src/manager.rs @@ -268,7 +268,7 @@ mod tests { let dev_path = tmp.path().to_string_lossy().to_string(); let mut mgr = SlotManager::default(); - mgr.set_device_override(Some(dev_path)); + mgr.set_device_override(Some(dev_path.clone())); let config = FlashConfig::new(&dev_path) .chunk_size(128)