diff --git a/PROVESFlightControllerReference/Components/FlashWorker/CMakeLists.txt b/PROVESFlightControllerReference/Components/FlashWorker/CMakeLists.txt index 7f9dda2f..0099bfb3 100644 --- a/PROVESFlightControllerReference/Components/FlashWorker/CMakeLists.txt +++ b/PROVESFlightControllerReference/Components/FlashWorker/CMakeLists.txt @@ -20,6 +20,10 @@ register_fprime_library( "${CMAKE_CURRENT_LIST_DIR}/FlashWorker.fpp" SOURCES "${CMAKE_CURRENT_LIST_DIR}/FlashWorker.cpp" + "${CMAKE_CURRENT_LIST_DIR}/UpdateSequencer.cpp" + "${CMAKE_CURRENT_LIST_DIR}/SegmentPlan.cpp" + "${CMAKE_CURRENT_LIST_DIR}/PatchApplier.cpp" + "${CMAKE_CURRENT_LIST_DIR}/LzssDecoder.cpp" # DEPENDS # MyPackage_MyOtherModule ) diff --git a/PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.cpp b/PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.cpp index ef9fb0a6..14851133 100644 --- a/PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.cpp +++ b/PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.cpp @@ -6,20 +6,78 @@ #include "PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.hpp" +#include // same CRC primitive Os::File::calculateCrc uses + +#include + #include "Os/File.hpp" #include "Os/Task.hpp" #include #include +#include + +namespace { +//! Flash area holding the running image, used as the reference a delta patch is applied to +constexpr U8 RUNNING_IMAGE_REGION = FIXED_PARTITION_ID(slot0_partition); + +//! Seed for an incremental CRC32. +//! +//! Must equal Os::File::INITIAL_CRC, which is private, so that a CRC accumulated here over data +//! already in memory is directly comparable to one from Os::File::calculateCrc and to the value +//! tools/bin/calculate-crc.py reports on the ground. +constexpr U32 FILE_CRC_SEED = 0xFFFFFFFF; +} // namespace namespace Components { -// static_assert(FlashWorker::REGION_NUMBER == UPLOAD_FLASH_AREA_LABEL, -// "FlashWorker REGION_NUMBER must match zephyr mcuboot image for upload area"); + +// UpdateSequencer mirrors Update.FlashWorkerUpdateStatus so that the update sequencing logic can be +// unit tested without F Prime. Keep the two in lockstep; reordering either is a compile error here. +static_assert(static_cast(UpdateSequencer::Status::OP_OK) == static_cast(Update::UpdateStatus::OP_OK), + "UpdateSequencer::Status::OP_OK must match Update::UpdateStatus::OP_OK"); +static_assert(static_cast(UpdateSequencer::Status::BUSY) == static_cast(Update::UpdateStatus::BUSY), + "UpdateSequencer::Status::BUSY must match Update::UpdateStatus::BUSY"); +static_assert(static_cast(UpdateSequencer::Status::UNPREPARED) == static_cast(Update::UpdateStatus::UNPREPARED), + "UpdateSequencer::Status::UNPREPARED must match Update::UpdateStatus::UNPREPARED"); +static_assert(static_cast(UpdateSequencer::Status::PREPARATION_ERROR) == + static_cast(Update::UpdateStatus::PREPARATION_ERROR), + "UpdateSequencer::Status::PREPARATION_ERROR must match Update::UpdateStatus::PREPARATION_ERROR"); +static_assert(static_cast(UpdateSequencer::Status::IMAGE_FILE_READ_ERROR) == + static_cast(Update::UpdateStatus::IMAGE_FILE_READ_ERROR), + "UpdateSequencer::Status::IMAGE_FILE_READ_ERROR must match Update::UpdateStatus::IMAGE_FILE_READ_ERROR"); +static_assert(static_cast(UpdateSequencer::Status::IMAGE_CRC_MISMATCH) == + static_cast(Update::UpdateStatus::IMAGE_CRC_MISMATCH), + "UpdateSequencer::Status::IMAGE_CRC_MISMATCH must match Update::UpdateStatus::IMAGE_CRC_MISMATCH"); +static_assert(static_cast(UpdateSequencer::Status::NEXT_BOOT_ERROR) == + static_cast(Update::UpdateStatus::NEXT_BOOT_ERROR), + "UpdateSequencer::Status::NEXT_BOOT_ERROR must match Update::UpdateStatus::NEXT_BOOT_ERROR"); +static_assert(static_cast(UpdateSequencer::Status::FLASH_WRITE_ERROR) == + static_cast(Update::UpdateStatus::FLASH_WRITE_ERROR), + "UpdateSequencer::Status::FLASH_WRITE_ERROR must match Update::UpdateStatus::FLASH_WRITE_ERROR"); + +// The sequencer's stage mirrors Components.FlashUpdateStage in FlashWorker.fpp +static_assert(static_cast(UpdateSequencer::Stage::IDLE) == static_cast(Components::FlashUpdateStage::IDLE), + "UpdateSequencer::Stage::IDLE must match FlashUpdateStage::IDLE"); +static_assert(static_cast(UpdateSequencer::Stage::PREPARING) == + static_cast(Components::FlashUpdateStage::PREPARING), + "UpdateSequencer::Stage::PREPARING must match FlashUpdateStage::PREPARING"); +static_assert(static_cast(UpdateSequencer::Stage::PREPARED) == + static_cast(Components::FlashUpdateStage::PREPARED), + "UpdateSequencer::Stage::PREPARED must match FlashUpdateStage::PREPARED"); +static_assert(static_cast(UpdateSequencer::Stage::WRITING) == + static_cast(Components::FlashUpdateStage::WRITING), + "UpdateSequencer::Stage::WRITING must match FlashUpdateStage::WRITING"); +static_assert(static_cast(UpdateSequencer::Stage::UPDATED) == + static_cast(Components::FlashUpdateStage::UPDATED), + "UpdateSequencer::Stage::UPDATED must match FlashUpdateStage::UPDATED"); +static_assert(static_cast(UpdateSequencer::Stage::FAILED) == static_cast(Components::FlashUpdateStage::FAILED), + "UpdateSequencer::Stage::FAILED must match FlashUpdateStage::FAILED"); // ---------------------------------------------------------------------- // Component construction and destruction // ---------------------------------------------------------------------- -FlashWorker ::FlashWorker(const char* const compName) : FlashWorkerComponentBase(compName), m_last_successful(IDLE) {} +FlashWorker ::FlashWorker(const char* const compName) + : FlashWorkerComponentBase(compName), m_pending_confirm_seconds(0), m_last_reported_percent(0) {} FlashWorker ::~FlashWorker() {} @@ -27,47 +85,115 @@ FlashWorker ::~FlashWorker() {} // Flash helpers // ---------------------------------------------------------------------- -Update::UpdateStatus FlashWorker ::writeImage(const Fw::StringBase& file_name, Os::File& file, U32 expected_crc32) { +Update::UpdateStatus FlashWorker ::toUpdateStatus(UpdateSequencer::Status status) { + return static_cast(static_cast(status)); +} + +Components::FlashUpdateStage FlashWorker ::toUpdateStage(UpdateSequencer::Stage stage) { + return static_cast(static_cast(stage)); +} + +void FlashWorker ::reportStage(Components::FlashUpdateStage stage, Update::UpdateStatus status) { + this->tlmWrite_UpdateStage(stage); + this->tlmWrite_LastUpdateStatus(status); +} + +void FlashWorker ::reportProgress(U32 written, U32 total) { + this->tlmWrite_BytesWritten(written); + const U8 percent = UpdateSequencer::percentComplete(written, total); + + Fw::ParamValid valid = Fw::ParamValid::INVALID; + const U8 step = this->paramGet_PROGRESS_STEP_PERCENT(valid); + if (UpdateSequencer::progressReportDue(percent, this->m_last_reported_percent, step)) { + this->log_ACTIVITY_LO_UpdateProgress(written, total, percent); + this->m_last_reported_percent = percent; + } +} + +UpdateSequencer::WriteOutcome FlashWorker ::writeImage(const Fw::StringBase& file_name, + Os::File& file, + U32 expected_crc32) { const FwSizeType CHUNK = static_cast(sizeof(this->m_data)); FW_ASSERT(file.isOpen()); FwSizeType size = 0; U32 file_crc = 0; - Update::UpdateStatus return_status = Update::UpdateStatus::OP_OK; - // Read file size, and default to 0 if unavailable + + // Read file size, needed to bound the write loop Os::File::Status file_status = file.size(size); - if (file_status == Os::File::Status::OP_OK) { - // Loop through file chunk by chunk - file_status = file.calculateCrc(file_crc); - if (file_status != Os::File::Status::OP_OK || file_crc != expected_crc32) { - this->log_WARNING_LO_ImageFileCrcMismatch( - file_name, Os::FileStatus(static_cast(file_status)), expected_crc32, file_crc); - return_status = Update::UpdateStatus::IMAGE_CRC_MISMATCH; - } else { - file_status = file.seek(0, Os::File::SeekType::ABSOLUTE); - } + if (file_status != Os::File::Status::OP_OK) { + this->log_WARNING_LO_ImageFileReadError(file_name, Os::FileStatus(static_cast(file_status))); + return UpdateSequencer::WriteOutcome::FILE_QUERY_FAILED; + } + + // Validate the file before touching the flash, so that a bad image never displaces the erased + // staging slot and the operator can retry without paying for another erase + file_status = file.calculateCrc(file_crc); + if (file_status != Os::File::Status::OP_OK || file_crc != expected_crc32) { + this->log_WARNING_LO_ImageFileCrcMismatch( + file_name, Os::FileStatus(static_cast(file_status)), expected_crc32, file_crc); + return (file_status != Os::File::Status::OP_OK) ? UpdateSequencer::WriteOutcome::FILE_QUERY_FAILED + : UpdateSequencer::WriteOutcome::CRC_MISMATCH; + } + + // calculateCrc leaves the cursor at the end of the file; rewind before streaming it out + file_status = file.seek(0, Os::File::SeekType::ABSOLUTE); + if (file_status != Os::File::Status::OP_OK) { + this->log_WARNING_LO_ImageFileReadError(file_name, Os::FileStatus(static_cast(file_status))); + return UpdateSequencer::WriteOutcome::FILE_QUERY_FAILED; } + int status = flash_img_init_id(&this->m_flash_context, FlashWorker::REGION_NUMBER); - FwSizeType i = 0; - for (i = 0; i < size && status == 0 && file_status == Os::File::Status::OP_OK; i += CHUNK) { + if (status != 0) { + this->log_WARNING_LO_FlashWriteFailed(static_cast(-1 * status), 0); + return UpdateSequencer::WriteOutcome::FLASH_WRITE_FAILED; + } + + Fw::ParamValid valid = Fw::ParamValid::INVALID; + const U32 chunk_delay_us = this->paramGet_CHUNK_DELAY_US(valid); + + // CRC of the bytes actually streamed out to the flash. Computed with the same primitive and + // seed as Os::File::calculateCrc so that the two are directly comparable, and free because the + // data is already in the buffer. It catches the image changing underneath us or an unstable + // filesystem read, neither of which the pre-write validation above can see. + U32 written_crc = FILE_CRC_SEED; + FwSizeType written = 0; + + // Loop through file chunk by chunk + for (FwSizeType i = 0; i < size; i += CHUNK) { FwSizeType read_size = CHUNK; file_status = file.read(this->m_data, read_size); if (file_status != Os::File::Status::OP_OK) { + this->log_WARNING_LO_ImageFileReadError(file_name, + Os::FileStatus(static_cast(file_status))); + return UpdateSequencer::WriteOutcome::FILE_READ_FAILED; + } + // The file ended earlier than its reported size; stop rather than flushing empty writes + if (read_size == 0) { break; } status = flash_img_buffered_write(&this->m_flash_context, this->m_data, read_size, true); if (status != 0) { - break; + this->log_WARNING_LO_FlashWriteFailed(static_cast(-1 * status), i); + return UpdateSequencer::WriteOutcome::FLASH_WRITE_FAILED; + } + for (FwSizeType byte = 0; byte < read_size; byte++) { + written_crc = static_cast(update_crc_32(written_crc, static_cast(this->m_data[byte]))); + } + written += read_size; + this->reportProgress(static_cast(written), static_cast(size)); + + // Give the flash time to process the data and allow more to be loaded off the filesystem + if (chunk_delay_us > 0) { + Os::Task::delay(Fw::TimeInterval(0, chunk_delay_us)); } - // Give 5ms for flash to process data and allow data to be loaded off the flash - Os::Task::delay(Fw::TimeInterval(0, 5000)); - } - if (file_status != Os::File::Status::OP_OK) { - this->log_WARNING_LO_ImageFileReadError(file_name, Os::FileStatus(static_cast(file_status))); } - if (status != 0) { - this->log_WARNING_LO_FlashWriteFailed(static_cast(-1 * status), i); + + // What landed in the slot must match what was validated before the write started + if (written_crc != expected_crc32) { + this->log_WARNING_HI_ImageWriteCrcMismatch(expected_crc32, written_crc); + return UpdateSequencer::WriteOutcome::FLASH_WRITE_FAILED; } - return return_status; + return UpdateSequencer::WriteOutcome::SUCCESS; } // ---------------------------------------------------------------------- @@ -95,37 +221,392 @@ Update::UpdateStatus FlashWorker ::nextBoot_handler(FwIndexType portNum, const U } void FlashWorker ::prepareImage_handler(FwIndexType portNum) { - Update::UpdateStatus return_status = Update::UpdateStatus::OP_OK; + // The erase is slow enough that an operator needs to see it started, not just finished + this->reportStage(Components::FlashUpdateStage::PREPARING, Update::UpdateStatus::OP_OK); + int status = boot_erase_img_bank(FlashWorker::REGION_NUMBER); if (status != 0) { this->log_WARNING_LO_FlashEraseFailed(static_cast(-1 * status)); - return_status = Update::UpdateStatus::PREPARATION_ERROR; - } else { - this->m_last_successful = PREPARE; } + const Update::UpdateStatus return_status = + FlashWorker::toUpdateStatus(this->m_sequencer.onPrepareComplete(status == 0)); + + this->tlmWrite_BytesWritten(0); + this->tlmWrite_ImageTotalBytes(0); + this->reportStage(FlashWorker::toUpdateStage(this->m_sequencer.settledStage()), return_status); this->prepareImageDone_out(0, return_status); } void FlashWorker ::updateImage_handler(FwIndexType portNum, const Fw::StringBase& file, U32 crc32) { + if (!this->m_sequencer.isPrepared()) { + this->log_WARNING_LO_NoImagePrepared(); + const Update::UpdateStatus return_status = FlashWorker::toUpdateStatus(UpdateSequencer::Status::UNPREPARED); + // The sequence itself is untouched by a rejected request, so only the status is reported + this->tlmWrite_LastUpdateStatus(return_status); + this->updateImageDone_out(0, return_status); + return; + } + Os::File image_file; - Update::UpdateStatus return_status = Update::UpdateStatus::OP_OK; + UpdateSequencer::WriteOutcome outcome = UpdateSequencer::WriteOutcome::FILE_OPEN_FAILED; - if (this->m_last_successful != PREPARE) { - return_status = Update::UpdateStatus::UNPREPARED; - this->m_last_successful = IDLE; - this->log_WARNING_LO_NoImagePrepared(); - } else { - Os::File::Status file_status = image_file.open(file.toChar(), Os::File::Mode::OPEN_READ); - if (file_status == Os::File::Status::OP_OK) { - return_status = this->writeImage(file, image_file, crc32); - this->m_last_successful = UPDATE; - } else { - return_status = Update::UpdateStatus::IMAGE_FILE_READ_ERROR; - this->m_last_successful = IDLE; - this->log_WARNING_LO_ImageFileReadError(file, static_cast(file_status)); + this->m_last_reported_percent = 0; + this->tlmWrite_BytesWritten(0); + this->reportStage(Components::FlashUpdateStage::WRITING, Update::UpdateStatus::OP_OK); + + Os::File::Status file_status = image_file.open(file.toChar(), Os::File::Mode::OPEN_READ); + if (file_status == Os::File::Status::OP_OK) { + FwSizeType total = 0; + if (image_file.size(total) == Os::File::Status::OP_OK) { + this->tlmWrite_ImageTotalBytes(static_cast(total)); } + outcome = this->writeImage(file, image_file, crc32); + } else { + this->log_WARNING_LO_ImageFileReadError(file, static_cast(file_status)); } + + const Update::UpdateStatus return_status = FlashWorker::toUpdateStatus(this->m_sequencer.onUpdateComplete(outcome)); + this->reportStage(FlashWorker::toUpdateStage(this->m_sequencer.settledStage()), return_status); this->updateImageDone_out(0, return_status); } +void FlashWorker ::run_handler(FwIndexType portNum, U32 context) { + // boot_is_img_confirmed reports whether the running image is already the permanent choice. + // While it is false this is a test boot that reverts on the next reboot unless confirmed. + const bool confirmed = (boot_is_img_confirmed() != 0); + this->tlmWrite_RunningImageConfirmed(confirmed); + + if (confirmed) { + this->m_pending_confirm_seconds = 0; + this->tlmWrite_PendingConfirmSeconds(0); + return; + } + + // Saturate rather than wrap, so a very long unconfirmed run cannot roll back under the delay + if (this->m_pending_confirm_seconds < std::numeric_limits::max()) { + this->m_pending_confirm_seconds++; + } + this->tlmWrite_PendingConfirmSeconds(this->m_pending_confirm_seconds); + + Fw::ParamValid enabled_valid = Fw::ParamValid::INVALID; + Fw::ParamValid delay_valid = Fw::ParamValid::INVALID; + const bool enabled = this->paramGet_AUTO_CONFIRM_ENABLED(enabled_valid); + const U32 delay = this->paramGet_AUTO_CONFIRM_DELAY_SECONDS(delay_valid); + + if (!UpdateSequencer::autoConfirmDue(enabled, confirmed, this->m_pending_confirm_seconds, delay)) { + return; + } + + const int status = boot_write_img_confirmed(); + if (status != 0) { + this->log_WARNING_HI_AutoConfirmFailed(static_cast(-1 * status)); + // Leave the counter alone so the next tick retries; a transient flash error should not + // cost the image its chance to be kept + return; + } + this->log_ACTIVITY_HI_AutoConfirmed(this->m_pending_confirm_seconds); + this->m_pending_confirm_seconds = 0; +} + +// ---------------------------------------------------------------------- +// Command handler implementations +// ---------------------------------------------------------------------- + +void FlashWorker ::ASSEMBLE_IMAGE_cmdHandler(FwOpcodeType opCode, + U32 cmdSeq, + const Fw::CmdStringArg& prefix, + U16 segments, + const Fw::CmdStringArg& destination, + U32 crc32) { + if (!SegmentPlan::isValidSegmentCount(segments)) { + this->log_WARNING_HI_InvalidSegmentCount(segments); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::VALIDATION_ERROR); + return; + } + this->log_ACTIVITY_HI_AssembleStarted(segments, destination); + + Os::File assembled; + if (assembled.open(destination.toChar(), Os::File::Mode::OPEN_CREATE, Os::File::OverwriteType::OVERWRITE) != + Os::File::Status::OP_OK) { + this->log_WARNING_HI_AssembleFailed(0, Update::UpdateStatus::IMAGE_FILE_READ_ERROR); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::EXECUTION_ERROR); + return; + } + + U32 total = 0; + for (U16 segment = 0; segment < segments; segment++) { + char name[FileNameStringSize]; + if (!SegmentPlan::formatSegmentName(prefix.toChar(), segment, name, sizeof(name))) { + this->log_WARNING_HI_AssembleFailed(segment, Update::UpdateStatus::IMAGE_FILE_READ_ERROR); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::EXECUTION_ERROR); + return; + } + Os::File piece; + if (piece.open(name, Os::File::Mode::OPEN_READ) != Os::File::Status::OP_OK) { + this->log_WARNING_HI_AssembleFailed(segment, Update::UpdateStatus::IMAGE_FILE_READ_ERROR); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::EXECUTION_ERROR); + return; + } + // Stream the segment through the existing chunk buffer rather than sizing a new one + while (true) { + FwSizeType chunk = static_cast(sizeof(this->m_data)); + if (piece.read(this->m_data, chunk) != Os::File::Status::OP_OK) { + this->log_WARNING_HI_AssembleFailed(segment, Update::UpdateStatus::IMAGE_FILE_READ_ERROR); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::EXECUTION_ERROR); + return; + } + if (chunk == 0) { + break; + } + FwSizeType written = chunk; + if ((assembled.write(this->m_data, written) != Os::File::Status::OP_OK) || (written != chunk)) { + this->log_WARNING_HI_AssembleFailed(segment, Update::UpdateStatus::IMAGE_FILE_READ_ERROR); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::EXECUTION_ERROR); + return; + } + total += static_cast(written); + } + } + assembled.close(); + + // Validate the joined image before anyone tries to flash it, so a missing or reordered + // segment is caught here rather than by the bootloader + Os::File verify; + U32 actual_crc = 0; + if ((verify.open(destination.toChar(), Os::File::Mode::OPEN_READ) != Os::File::Status::OP_OK) || + (verify.calculateCrc(actual_crc) != Os::File::Status::OP_OK)) { + this->log_WARNING_HI_AssembleFailed(segments, Update::UpdateStatus::IMAGE_FILE_READ_ERROR); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::EXECUTION_ERROR); + return; + } + if (actual_crc != crc32) { + this->log_WARNING_LO_ImageFileCrcMismatch(destination, Os::FileStatus(Os::FileStatus::T::OP_OK), crc32, + actual_crc); + this->log_WARNING_HI_AssembleFailed(segments, Update::UpdateStatus::IMAGE_CRC_MISMATCH); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::EXECUTION_ERROR); + return; + } + + this->log_ACTIVITY_HI_AssembleSucceeded(total); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::OK); +} + +//! Sources and sink for a patch apply: the running image in flash, three cursors into the patch +//! file, and the reconstructed image on the filesystem. +class FlashWorker::PatchIo final : public PatchApplier::Io { + public: + PatchIo(const struct flash_area* area, Os::File& control, Os::File& diff, Os::File& extra, Os::File& out) + : m_area(area), m_control(control), m_diff(diff), m_extra(extra), m_out(out) {} + + bool readReference(uint32_t offset, uint8_t* buffer, size_t size) override { + return flash_area_read(this->m_area, static_cast(offset), buffer, size) == 0; + } + bool readControl(uint8_t* buffer, size_t size) override { return readExactly(this->m_control, buffer, size); } + bool readDiff(uint8_t* buffer, size_t size) override { return readExactly(this->m_diff, buffer, size); } + bool readExtra(uint8_t* buffer, size_t size) override { return readExactly(this->m_extra, buffer, size); } + bool writeOutput(const uint8_t* buffer, size_t size) override { + FwSizeType written = static_cast(size); + return (this->m_out.write(buffer, written) == Os::File::Status::OP_OK) && + (written == static_cast(size)); + } + + private: + static bool readExactly(Os::File& file, uint8_t* buffer, size_t size) { + FwSizeType requested = static_cast(size); + return (file.read(buffer, requested) == Os::File::Status::OP_OK) && + (requested == static_cast(size)); + } + + const struct flash_area* m_area; + Os::File& m_control; + Os::File& m_diff; + Os::File& m_extra; + Os::File& m_out; +}; + +//! Compressed patch bytes, read sequentially from the patch file +class FlashWorker::PatchSource final : public LzssDecoder::Source { + public: + explicit PatchSource(Os::File& file) : m_file(file) {} + bool read(uint8_t* buffer, size_t size) override { + FwSizeType requested = static_cast(size); + return (this->m_file.read(buffer, requested) == Os::File::Status::OP_OK) && + (requested == static_cast(size)); + } + + private: + Os::File& m_file; +}; + +//! Decoded patch bytes, appended to the scratch file +class FlashWorker::PatchSink final : public LzssDecoder::Sink { + public: + explicit PatchSink(Os::File& file) : m_file(file) {} + bool write(const uint8_t* buffer, size_t size) override { + FwSizeType requested = static_cast(size); + return (this->m_file.write(buffer, requested) == Os::File::Status::OP_OK) && + (requested == static_cast(size)); + } + + private: + Os::File& m_file; +}; + +bool FlashWorker ::decompressPatch(const Fw::StringBase& patch, const char* scratch_path, U32 expected_size) { + Os::File compressed; + Os::File plain; + if (compressed.open(patch.toChar(), Os::File::Mode::OPEN_READ) != Os::File::Status::OP_OK) { + return false; + } + // Skip the header; the payload is everything after it + if (compressed.seek(static_cast(PatchApplier::HEADER_SIZE), Os::File::SeekType::ABSOLUTE) != + Os::File::Status::OP_OK) { + return false; + } + if (plain.open(scratch_path, Os::File::Mode::OPEN_CREATE, Os::File::OverwriteType::OVERWRITE) != + Os::File::Status::OP_OK) { + return false; + } + PatchSource source(compressed); + PatchSink sink(plain); + const LzssDecoder::Error error = LzssDecoder::decode(source, sink, expected_size, this->m_window); + plain.close(); + return error == LzssDecoder::Error::NONE; +} + +void FlashWorker ::APPLY_PATCH_cmdHandler(FwOpcodeType opCode, + U32 cmdSeq, + const Fw::CmdStringArg& patch, + const Fw::CmdStringArg& destination, + U32 crc32) { + this->log_ACTIVITY_HI_PatchStarted(patch, destination); + + Os::File header_file; + if (header_file.open(patch.toChar(), Os::File::Mode::OPEN_READ) != Os::File::Status::OP_OK) { + this->log_WARNING_HI_PatchFailed(static_cast(PatchApplier::Error::PATCH_READ_FAILED)); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::EXECUTION_ERROR); + return; + } + U8 header_bytes[PatchApplier::HEADER_SIZE]; + FwSizeType header_size = static_cast(sizeof(header_bytes)); + if ((header_file.read(header_bytes, header_size) != Os::File::Status::OP_OK) || + (header_size != static_cast(sizeof(header_bytes)))) { + this->log_WARNING_HI_PatchFailed(static_cast(PatchApplier::Error::TRUNCATED_PATCH)); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::EXECUTION_ERROR); + return; + } + header_file.close(); + + PatchApplier::Header header; + PatchApplier::Error error = PatchApplier::decodeHeader(header_bytes, sizeof(header_bytes), header); + if (error != PatchApplier::Error::NONE) { + this->log_WARNING_HI_PatchFailed(static_cast(error)); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::EXECUTION_ERROR); + return; + } + + const struct flash_area* area = nullptr; + if (flash_area_open(RUNNING_IMAGE_REGION, &area) != 0) { + this->log_WARNING_HI_PatchFailed(static_cast(PatchApplier::Error::REFERENCE_READ_FAILED)); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::EXECUTION_ERROR); + return; + } + + // Prove the running image is the one the ground diffed against. Patching a different image + // produces a plausible but corrupt result that would then be flashed and booted. + U32 reference_crc = FILE_CRC_SEED; + for (U32 offset = 0; offset < header.reference_size;) { + const U32 remaining = header.reference_size - offset; + const size_t chunk = (remaining < sizeof(this->m_data)) ? remaining : sizeof(this->m_data); + if (flash_area_read(area, static_cast(offset), this->m_data, chunk) != 0) { + flash_area_close(area); + this->log_WARNING_HI_PatchFailed(static_cast(PatchApplier::Error::REFERENCE_READ_FAILED)); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::EXECUTION_ERROR); + return; + } + for (size_t i = 0; i < chunk; i++) { + reference_crc = static_cast(update_crc_32(reference_crc, static_cast(this->m_data[i]))); + } + offset += static_cast(chunk); + } + if (reference_crc != header.reference_crc32) { + flash_area_close(area); + this->log_WARNING_HI_PatchReferenceMismatch(header.reference_crc32, header.reference_size, reference_crc); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::EXECUTION_ERROR); + return; + } + + // A compressed patch is decoded once into a scratch file, so the apply always works on plain + // streams and needs no codec of its own + Fw::String stream_source(patch); + Fw::String scratch_path(destination); + scratch_path += ".streams"; + const U32 stream_bytes = header.control_size + header.diff_size + header.extra_size; + FwSizeType stream_base = static_cast(PatchApplier::HEADER_SIZE); + if (header.compression != PatchApplier::Compression::NONE) { + if (!this->decompressPatch(patch, scratch_path.toChar(), stream_bytes)) { + flash_area_close(area); + this->log_WARNING_HI_PatchFailed(static_cast(PatchApplier::Error::PATCH_READ_FAILED)); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::EXECUTION_ERROR); + return; + } + stream_source = scratch_path; + stream_base = 0; + } + + // Three cursors into one file, one per stream, so the apply can interleave them + Os::File control_file; + Os::File diff_file; + Os::File extra_file; + Os::File out_file; + const FwSizeType control_start = stream_base; + const FwSizeType diff_start = control_start + static_cast(header.control_size); + const FwSizeType extra_start = diff_start + static_cast(header.diff_size); + const bool opened = + (control_file.open(stream_source.toChar(), Os::File::Mode::OPEN_READ) == Os::File::Status::OP_OK) && + (diff_file.open(stream_source.toChar(), Os::File::Mode::OPEN_READ) == Os::File::Status::OP_OK) && + (extra_file.open(stream_source.toChar(), Os::File::Mode::OPEN_READ) == Os::File::Status::OP_OK) && + (out_file.open(destination.toChar(), Os::File::Mode::OPEN_CREATE, Os::File::OverwriteType::OVERWRITE) == + Os::File::Status::OP_OK) && + (control_file.seek(control_start, Os::File::SeekType::ABSOLUTE) == Os::File::Status::OP_OK) && + (diff_file.seek(diff_start, Os::File::SeekType::ABSOLUTE) == Os::File::Status::OP_OK) && + (extra_file.seek(extra_start, Os::File::SeekType::ABSOLUTE) == Os::File::Status::OP_OK); + if (!opened) { + flash_area_close(area); + this->log_WARNING_HI_PatchFailed(static_cast(PatchApplier::Error::PATCH_READ_FAILED)); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::EXECUTION_ERROR); + return; + } + + PatchIo io(area, control_file, diff_file, extra_file, out_file); + error = PatchApplier::apply(header, header.reference_size, io, this->m_data, sizeof(this->m_data)); + flash_area_close(area); + out_file.close(); + if (error != PatchApplier::Error::NONE) { + this->log_WARNING_HI_PatchFailed(static_cast(error)); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::EXECUTION_ERROR); + return; + } + + // Independent check that the reconstruction is what the ground intended + Os::File verify; + U32 actual_crc = 0; + if ((verify.open(destination.toChar(), Os::File::Mode::OPEN_READ) != Os::File::Status::OP_OK) || + (verify.calculateCrc(actual_crc) != Os::File::Status::OP_OK)) { + this->log_WARNING_HI_PatchFailed(static_cast(PatchApplier::Error::OUTPUT_WRITE_FAILED)); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::EXECUTION_ERROR); + return; + } + if (actual_crc != crc32) { + this->log_WARNING_LO_ImageFileCrcMismatch(destination, Os::FileStatus(Os::FileStatus::T::OP_OK), crc32, + actual_crc); + this->log_WARNING_HI_PatchFailed(static_cast(PatchApplier::Error::SIZE_MISMATCH)); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::EXECUTION_ERROR); + return; + } + + this->log_ACTIVITY_HI_PatchSucceeded(header.new_size); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::OK); +} + } // namespace Components diff --git a/PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.fpp b/PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.fpp index 27e2c447..1dd7797e 100644 --- a/PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.fpp +++ b/PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.fpp @@ -1,8 +1,90 @@ module Components { + @ Stage of the flight software update sequence, reported as telemetry so that an operator can + @ see where an update stands without replaying the event history from an earlier pass. + enum FlashUpdateStage { + IDLE, @< No update in progress + PREPARING, @< Erasing the staging slot + PREPARED, @< Staging slot erased and ready to receive an image + WRITING, @< Writing an image into the staging slot + UPDATED, @< Image written and verified in the staging slot + FAILED @< The last operation failed; see LastUpdateStatus + } + @ Performs long-running operations for the flash subsystem active component FlashWorker { import Update.UpdateWorker + @ Microseconds to pause after each buffered flash write, giving the flash time to settle. + @ Exposed as a parameter so that it can be tuned against real hardware rather than rebuilt. + param CHUNK_DELAY_US: U32 default 5000 + + @ Percent of the image to advance between progress reports. Larger values mean fewer + @ progress events and less downlink spent reporting on an update in flight. + param PROGRESS_STEP_PERCENT: U8 default 10 + + @ Whether the flight software may confirm a test-booted image without the ground. + @ + @ An image booted in TEST mode reverts unless it is confirmed before the next reboot. If the + @ confirming pass is missed, a working image is thrown away along with the uplink that + @ delivered it. Arming this lets the spacecraft keep an image that has demonstrated it can + @ run. Defaults to disabled, so confirmation stays operator-in-the-loop until armed. + @ + @ Set with AUTO_CONFIRM_ENABLED_PRM_SET followed by PRM_SAVE: the decision is made after the + @ reboot into the test image, so an unsaved value would be lost exactly when it is needed. + param AUTO_CONFIRM_ENABLED: bool default false + + @ Seconds the test-booted image must run continuously before it confirms itself. Counted + @ from the start of the image that is pending confirmation, and reset by any reboot. + param AUTO_CONFIRM_DELAY_SECONDS: U32 default 1800 + + @ Concatenate numbered uplink segments into a single image file. + @ + @ A full image does not fit in one pass and file uplink cannot resume across passes, so an + @ image is uplinked as ".000", ".001", and so on. This joins them back + @ together and validates the result before it is written to flash. + async command ASSEMBLE_IMAGE( + prefix: string size FileNameStringSize @< Segment file name prefix + segments: U16 @< Number of segments to join + destination: string size FileNameStringSize @< Image file to write + crc32: U32 @< Expected CRC32 of the assembled image + ) + + @ Reconstruct an image from a delta patch applied to the running image. + @ + @ Uplinking a patch rather than a whole image is the difference between an update that fits + @ a pass and one that does not. The patch names the reference it was built from and is + @ refused if the running image is not it. + async command APPLY_PATCH( + patch: string size FileNameStringSize @< Patch file to apply + destination: string size FileNameStringSize @< Image file to write + crc32: U32 @< Expected CRC32 of the reconstructed image + ) + + @ Stage of the update sequence + telemetry UpdateStage: FlashUpdateStage + + @ Bytes of the image written into the staging slot so far + telemetry BytesWritten: U32 + + @ Total size in bytes of the image currently being written + telemetry ImageTotalBytes: U32 + + @ Status reported by the most recent preparation or update operation + telemetry LastUpdateStatus: Update.UpdateStatus + + @ Whether the running image has been confirmed. False means this is a test boot that will + @ revert on the next reboot unless it is confirmed. + telemetry RunningImageConfirmed: bool + + @ Seconds the running image has been up while pending confirmation + telemetry PendingConfirmSeconds: U32 + + @ Scheduled input used to age a test-booted image toward self-confirmation + sync input port run: Svc.Sched + + event UpdateProgress(written: U32, total: U32, percent: U8) severity activity low \ + format "Update progress: {}/{} bytes ({}%)" + event NoImagePrepared() severity warning low \ format "No image has been prepared for update" @@ -25,17 +107,75 @@ module Components { severity warning low \ format "Failed CRC validation of {} with status {} expected 0x{x} and actual 0x{x}" + @ Emitted when the bytes written to flash do not match the bytes that were validated. This + @ indicates the image file changed underneath the update or the filesystem read is unstable. + event ImageWriteCrcMismatch(expected: U32, actual: U32) severity warning high \ + format "Image written to flash failed verification: expected 0x{x} and actual 0x{x}" + + @ The running image confirmed itself after demonstrating it can run + event AutoConfirmed(seconds: U32) severity activity high \ + format "Test image confirmed automatically after {} seconds of operation" + + event AutoConfirmFailed(error_number: I32) severity warning high \ + format "Automatic confirmation failed (errno: {})" + + event AssembleStarted(segments: U16, destination: string) severity activity high \ + format "Assembling {} segments into {}" + + event AssembleSucceeded(bytes: U32) severity activity high \ + format "Assembled image of {} bytes" + + event AssembleFailed(segment: U16, status: Update.UpdateStatus) severity warning high \ + format "Assembly failed at segment {} with status {}" + + event InvalidSegmentCount(segments: U16) severity warning high \ + format "Segment count {} is out of range" + + event PatchStarted(patch: string, destination: string) severity activity high \ + format "Applying patch {} to produce {}" + + event PatchSucceeded(bytes: U32) severity activity high \ + format "Reconstructed image of {} bytes from patch" + + @ The error code is Components::PatchApplier::Error + event PatchFailed(error_code: U8) severity warning high \ + format "Patch application failed with error {}" + + @ Emitted when the running image is not the one the patch was built against. Applying the + @ patch anyway would produce a corrupt image, so it is refused. + event PatchReferenceMismatch(expected_crc: U32, expected_size: U32, actual_crc: U32) \ + severity warning high \ + format "Patch reference mismatch: expected CRC 0x{x} over {} bytes but running image has CRC 0x{x}" + ############################################################################### # Standard AC Ports: Required for Channels, Events, Commands, and Parameters # ############################################################################### @ Port for requesting the current time time get port timeCaller + @ Port to return the value of a parameter + param get port prmGetOut + + @ Port to set the value of a parameter + param set port prmSetOut + + @ Port for sending command registrations + command reg port cmdRegOut + + @ Port for receiving commands + command recv port cmdIn + + @ Port for sending command responses + command resp port cmdResponseOut + @ Port for sending textual representation of events text event port logTextOut @ Port for sending events to downlink event port logOut + @ Port for sending telemetry channels to downlink + telemetry port tlmOut + } } diff --git a/PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.hpp b/PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.hpp index 657fdca2..4470de94 100644 --- a/PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.hpp +++ b/PROVESFlightControllerReference/Components/FlashWorker/FlashWorker.hpp @@ -8,13 +8,22 @@ #define Update_FlashWorker_HPP #include "Os/File.hpp" #include "PROVESFlightControllerReference/Components/FlashWorker/FlashWorkerComponentAc.hpp" +#include "PROVESFlightControllerReference/Components/FlashWorker/LzssDecoder.hpp" +#include "PROVESFlightControllerReference/Components/FlashWorker/PatchApplier.hpp" +#include "PROVESFlightControllerReference/Components/FlashWorker/SegmentPlan.hpp" +#include "PROVESFlightControllerReference/Components/FlashWorker/UpdateSequencer.hpp" #include +#include namespace Components { class FlashWorker final : public FlashWorkerComponentBase { public: - constexpr static U8 REGION_NUMBER = 2; // 0: bootloader, 1: slot0, **2: slot1** - enum Step { IDLE, PREPARE, UPDATE }; + //! Flash area holding the MCUBoot staging slot that updates are written into. + //! + //! Derived from the device tree rather than hard coded: fixed partition IDs follow the + //! declaration order of the partitions node, so a literal would silently point at the wrong + //! region if a partition were ever added above slot1_partition. + constexpr static U8 REGION_NUMBER = FIXED_PARTITION_ID(slot1_partition); // ---------------------------------------------------------------------- // Component construction and destruction // ---------------------------------------------------------------------- @@ -27,7 +36,48 @@ class FlashWorker final : public FlashWorkerComponentBase { ~FlashWorker(); private: - Update::UpdateStatus writeImage(const Fw::StringBase& file_name, Os::File& image_file, U32 crc32); + //! Stream an image file into the staging slot, reporting what went wrong if anything did + UpdateSequencer::WriteOutcome writeImage(const Fw::StringBase& file_name, Os::File& image_file, U32 crc32); + + //! Convert a sequencer status into the autocoded status reported over the update ports + static Update::UpdateStatus toUpdateStatus(UpdateSequencer::Status status); + + //! Convert a sequencer stage into the autocoded stage reported as telemetry + static Components::FlashUpdateStage toUpdateStage(UpdateSequencer::Stage stage); + + //! Report the stage of the sequence, along with the status of the operation that produced it + void reportStage(Components::FlashUpdateStage stage, Update::UpdateStatus status); + + //! Report write progress, emitting an event only when the configured step has been passed + void reportProgress(U32 written, U32 total); + + //! Sources and sink used when applying a delta patch + class PatchIo; + + //! Compressed patch input and decoded output, used when a patch carries a codec + class PatchSource; + class PatchSink; + + //! Decompress a patch payload into a scratch file, leaving the apply to operate on plain + //! streams. Returns true on success; the caller reports the failure. + bool decompressPatch(const Fw::StringBase& patch, const char* scratch_path, U32 expected_size); + + //! Handler implementation for command ASSEMBLE_IMAGE + void ASSEMBLE_IMAGE_cmdHandler(FwOpcodeType opCode, //!< The opcode + U32 cmdSeq, //!< The command sequence number + const Fw::CmdStringArg& prefix, //!< Segment file name prefix + U16 segments, //!< Number of segments to join + const Fw::CmdStringArg& destination, //!< Image file to write + U32 crc32 //!< Expected CRC32 of the image + ) override; + + //! Handler implementation for command APPLY_PATCH + void APPLY_PATCH_cmdHandler(FwOpcodeType opCode, //!< The opcode + U32 cmdSeq, //!< The command sequence number + const Fw::CmdStringArg& patch, //!< Patch file to apply + const Fw::CmdStringArg& destination, //!< Image file to write + U32 crc32 //!< Expected CRC32 of the image + ) override; private: // ---------------------------------------------------------------------- @@ -56,9 +106,19 @@ class FlashWorker final : public FlashWorkerComponentBase { U32 crc32 //!< Expected CRC32 of the file used to verify file integrity ) override; + //! Handler implementation for run + //! + //! Ages a test-booted image toward self-confirmation + void run_handler(FwIndexType portNum, //!< The port number + U32 context //!< The call order + ) override; + private: - Step m_last_successful; + UpdateSequencer m_sequencer; + U32 m_pending_confirm_seconds; //!< Seconds the running image has been up while unconfirmed + U8 m_last_reported_percent; //!< Percent at the most recent progress event, for step throttling U8 m_data[CONFIG_IMG_BLOCK_BUF_SIZE]; + U8 m_window[LzssDecoder::WINDOW_SIZE]; //!< History buffer for decoding a compressed patch struct flash_img_context m_flash_context; }; diff --git a/PROVESFlightControllerReference/Components/FlashWorker/LzssDecoder.cpp b/PROVESFlightControllerReference/Components/FlashWorker/LzssDecoder.cpp new file mode 100644 index 00000000..00a38c65 --- /dev/null +++ b/PROVESFlightControllerReference/Components/FlashWorker/LzssDecoder.cpp @@ -0,0 +1,85 @@ +// ====================================================================== +// \title LzssDecoder.cpp +// \brief cpp file for decoding the LZ77 stream a delta patch is carried in +// ====================================================================== + +#include "LzssDecoder.hpp" + +namespace Components { + +// Out of line definitions so these can be odr-used by callers; not every translation unit here is +// compiled as C++17, where static constexpr members would be implicitly inline. +constexpr size_t LzssDecoder::WINDOW_SIZE; +constexpr uint8_t LzssDecoder::MIN_MATCH; + +LzssDecoder::Error LzssDecoder ::decode(Source& source, Sink& sink, uint32_t expected_size, uint8_t* window) { + if (window == nullptr) { + return Error::OUTPUT_WRITE_FAILED; + } + + uint32_t produced = 0; + // Position in the ring buffer where the next produced byte goes + size_t cursor = 0; + + while (produced < expected_size) { + uint8_t tag = 0; + if (!source.read(&tag, 1)) { + return Error::TRUNCATED_INPUT; + } + + for (uint8_t bit = 0; bit < 8; bit++) { + if (produced >= expected_size) { + // The final group is padded; ignore whatever follows the last real token + break; + } + + if ((tag & (1U << bit)) != 0) { + // Literal + uint8_t value = 0; + if (!source.read(&value, 1)) { + return Error::TRUNCATED_INPUT; + } + if (!sink.write(&value, 1)) { + return Error::OUTPUT_WRITE_FAILED; + } + window[cursor] = value; + cursor = (cursor + 1) % LzssDecoder::WINDOW_SIZE; + produced++; + continue; + } + + // Match: distance back, then length + uint8_t encoded[3] = {0, 0, 0}; + if (!source.read(encoded, sizeof(encoded))) { + return Error::TRUNCATED_INPUT; + } + const size_t distance = static_cast(encoded[0]) | (static_cast(encoded[1]) << 8); + const size_t length = static_cast(encoded[2]) + LzssDecoder::MIN_MATCH; + + if ((distance == 0) || (distance > LzssDecoder::WINDOW_SIZE) || + (distance > static_cast(produced))) { + return Error::BAD_DISTANCE; + } + if (length > (expected_size - produced)) { + return Error::OUTPUT_OVERRUN; + } + + // Copied one byte at a time on purpose: a match is allowed to overlap the bytes it is + // producing, which is how a run is encoded, so the source advances with the output + size_t from = (cursor + LzssDecoder::WINDOW_SIZE - distance) % LzssDecoder::WINDOW_SIZE; + for (size_t i = 0; i < length; i++) { + const uint8_t value = window[from]; + if (!sink.write(&value, 1)) { + return Error::OUTPUT_WRITE_FAILED; + } + window[cursor] = value; + cursor = (cursor + 1) % LzssDecoder::WINDOW_SIZE; + from = (from + 1) % LzssDecoder::WINDOW_SIZE; + } + produced += static_cast(length); + } + } + return Error::NONE; +} + +} // namespace Components diff --git a/PROVESFlightControllerReference/Components/FlashWorker/LzssDecoder.hpp b/PROVESFlightControllerReference/Components/FlashWorker/LzssDecoder.hpp new file mode 100644 index 00000000..87da8148 --- /dev/null +++ b/PROVESFlightControllerReference/Components/FlashWorker/LzssDecoder.hpp @@ -0,0 +1,85 @@ +// ====================================================================== +// \title LzssDecoder.hpp +// \brief hpp file for decoding the LZ77 stream a delta patch is carried in +// ====================================================================== + +#pragma once + +#include +#include + +namespace Components { + +//! Decodes the LZ77 stream that tools/bin/make-patch.py wraps a delta patch in. +//! +//! A raw bsdiff patch is about the size of the image it rebuilds, so it is worth nothing over the +//! radio. Its difference stream is roughly 84% zero bytes in short, close-together runs, which this +//! coder collapses about 7x: a measured 728,388 byte patch becomes 101,171 bytes, turning ~24 +//! minutes of uplink into ~3.3. +//! +//! The format is deliberately small and self contained rather than a third party library, so that +//! the exact decoder that flies can be round-tripped against real patches in host unit tests: +//! +//! - Tokens are grouped in eights, preceded by one tag byte. Bit b of the tag is set when token +//! b is a literal. +//! - A literal is one byte, emitted as is. +//! - A match is a little endian uint16 distance backwards, then one byte holding length minus 3, +//! so lengths run 3 to 258. A match may overlap the bytes it is producing, which is how runs +//! are encoded. +//! +//! Decoding streams in both directions and keeps only a WINDOW_SIZE ring buffer, so neither the +//! compressed patch nor the decoded result is ever held in RAM. +class LzssDecoder { + public: + // ---------------------------------------------------------------------- + // Public types + // ---------------------------------------------------------------------- + + //! Bytes of history a match may reach back into. Measured against real patches, growing this + //! to 64 KB saves under 8% of the compressed size, which does not pay for the RAM. + static constexpr size_t WINDOW_SIZE = 4096; + + //! Shortest run worth encoding as a match rather than as literals + static constexpr uint8_t MIN_MATCH = 3; + + //! Why a decode failed + enum class Error : uint8_t { + NONE = 0, //!< No error + TRUNCATED_INPUT = 1, //!< The compressed stream ended before the output was complete + BAD_DISTANCE = 2, //!< A match reached back further than has been produced + OUTPUT_OVERRUN = 3, //!< A token would produce more output than was expected + OUTPUT_WRITE_FAILED = 4 //!< Writing the decoded output failed + }; + + //! Compressed input, supplied a piece at a time + class Source { + public: + virtual ~Source() {} + //! Read exactly size bytes, returning false if that many are not available + virtual bool read(uint8_t* buffer, size_t size) = 0; + }; + + //! Decoded output, consumed a piece at a time + class Sink { + public: + virtual ~Sink() {} + //! Append exactly size bytes + virtual bool write(const uint8_t* buffer, size_t size) = 0; + }; + + public: + // ---------------------------------------------------------------------- + // Public helper methods + // ---------------------------------------------------------------------- + + //! Decode a stream. + //! + //! \param source: compressed input + //! \param sink: destination for the decoded bytes + //! \param expected_size: exact number of bytes the stream decodes to + //! \param window: caller supplied history buffer of at least WINDOW_SIZE bytes + //! \return NONE on success, otherwise the reason the decode failed + static Error decode(Source& source, Sink& sink, uint32_t expected_size, uint8_t* window); +}; + +} // namespace Components diff --git a/PROVESFlightControllerReference/Components/FlashWorker/PatchApplier.cpp b/PROVESFlightControllerReference/Components/FlashWorker/PatchApplier.cpp new file mode 100644 index 00000000..9579f8b6 --- /dev/null +++ b/PROVESFlightControllerReference/Components/FlashWorker/PatchApplier.cpp @@ -0,0 +1,169 @@ +// ====================================================================== +// \title PatchApplier.cpp +// \brief cpp file for applying a delta patch to a reference image +// ====================================================================== + +#include "PatchApplier.hpp" + +namespace Components { + +// Out of line definitions so these can be odr-used (indexed, sized, bound to a reference) by +// callers. Required because these translation units are not all compiled as C++17, where static +// constexpr members would be implicitly inline. +constexpr uint8_t PatchApplier::MAGIC[8]; +constexpr uint8_t PatchApplier::FORMAT_VERSION; +constexpr size_t PatchApplier::HEADER_SIZE; +constexpr size_t PatchApplier::CONTROL_RECORD_SIZE; + +namespace { + +//! Read a little endian unsigned 32 bit value +uint32_t readU32(const uint8_t* buffer) { + return static_cast(buffer[0]) | (static_cast(buffer[1]) << 8) | + (static_cast(buffer[2]) << 16) | (static_cast(buffer[3]) << 24); +} + +//! Read a little endian signed 32 bit value without relying on signed overflow +int32_t readI32(const uint8_t* buffer) { + const uint32_t raw = readU32(buffer); + // Two's complement conversion that stays defined for the full range + if (raw <= 0x7FFFFFFFU) { + return static_cast(raw); + } + return static_cast(raw - 0x80000000U) - 0x7FFFFFFF - 1; +} + +} // namespace + +PatchApplier::Error PatchApplier ::decodeHeader(const uint8_t* buffer, size_t size, Header& header) { + if ((buffer == nullptr) || (size < PatchApplier::HEADER_SIZE)) { + return Error::TRUNCATED_PATCH; + } + for (size_t i = 0; i < sizeof(PatchApplier::MAGIC); i++) { + if (buffer[i] != PatchApplier::MAGIC[i]) { + return Error::BAD_MAGIC; + } + } + const uint8_t version = buffer[8]; + if (version != PatchApplier::FORMAT_VERSION) { + return Error::UNSUPPORTED_VERSION; + } + const uint8_t compression = buffer[9]; + if ((compression != static_cast(Compression::NONE)) && + (compression != static_cast(Compression::LZSS))) { + return Error::UNSUPPORTED_COMPRESSION; + } + header.compression = static_cast(compression); + header.new_size = readU32(&buffer[10]); + header.reference_size = readU32(&buffer[14]); + header.reference_crc32 = readU32(&buffer[18]); + header.control_size = readU32(&buffer[22]); + header.diff_size = readU32(&buffer[26]); + header.extra_size = readU32(&buffer[30]); + return Error::NONE; +} + +bool PatchApplier ::decodeControl(const uint8_t* buffer, size_t size, Control& control) { + if ((buffer == nullptr) || (size < PatchApplier::CONTROL_RECORD_SIZE)) { + return false; + } + control.copy = readI32(&buffer[0]); + control.extra = readI32(&buffer[4]); + control.seek = readI32(&buffer[8]); + return true; +} + +PatchApplier::Error PatchApplier ::apply(const Header& header, + uint32_t reference_size, + Io& io, + uint8_t* scratch, + size_t scratch_size) { + if ((scratch == nullptr) || (scratch_size < 2)) { + return Error::CORRUPT_CONTROL; + } + // The streams reach us through Io already decompressed, so the codec is the caller's concern + // Patching against the wrong reference silently produces a plausible but corrupt image, which + // would then be flashed and booted. Refuse unless the reference is exactly the one the ground + // built the patch from. The caller checks the content; the size is checked here. + if (reference_size != header.reference_size) { + return Error::WRONG_REFERENCE; + } + + // The copy phase needs the reference bytes and the difference bytes at the same time + const size_t half = scratch_size / 2; + uint8_t* const reference_buffer = scratch; + uint8_t* const diff_buffer = scratch + half; + + uint32_t new_position = 0; + // Signed, and wider than the images, so that a hostile seek is caught rather than wrapping + int64_t old_position = 0; + + while (new_position < header.new_size) { + uint8_t record[PatchApplier::CONTROL_RECORD_SIZE]; + if (!io.readControl(record, sizeof(record))) { + return Error::TRUNCATED_PATCH; + } + Control control; + if (!PatchApplier::decodeControl(record, sizeof(record), control)) { + return Error::TRUNCATED_PATCH; + } + if ((control.copy < 0) || (control.extra < 0)) { + return Error::CORRUPT_CONTROL; + } + + // Copy phase: reference bytes plus the per byte difference + uint32_t remaining = static_cast(control.copy); + if (remaining > (header.new_size - new_position)) { + return Error::CORRUPT_CONTROL; + } + if ((old_position < 0) || + ((old_position + static_cast(remaining)) > static_cast(reference_size))) { + return Error::CORRUPT_CONTROL; + } + while (remaining > 0) { + const size_t chunk = (remaining < half) ? static_cast(remaining) : half; + if (!io.readReference(static_cast(old_position), reference_buffer, chunk)) { + return Error::REFERENCE_READ_FAILED; + } + if (!io.readDiff(diff_buffer, chunk)) { + return Error::PATCH_READ_FAILED; + } + for (size_t i = 0; i < chunk; i++) { + // Wrapping addition is the bsdiff definition, not an overflow + reference_buffer[i] = static_cast(reference_buffer[i] + diff_buffer[i]); + } + if (!io.writeOutput(reference_buffer, chunk)) { + return Error::OUTPUT_WRITE_FAILED; + } + old_position += static_cast(chunk); + new_position += static_cast(chunk); + remaining -= static_cast(chunk); + } + + // Extra phase: bytes that exist only in the new image + remaining = static_cast(control.extra); + if (remaining > (header.new_size - new_position)) { + return Error::CORRUPT_CONTROL; + } + while (remaining > 0) { + const size_t chunk = (remaining < scratch_size) ? static_cast(remaining) : scratch_size; + if (!io.readExtra(scratch, chunk)) { + return Error::PATCH_READ_FAILED; + } + if (!io.writeOutput(scratch, chunk)) { + return Error::OUTPUT_WRITE_FAILED; + } + new_position += static_cast(chunk); + remaining -= static_cast(chunk); + } + + old_position += control.seek; + } + + if (new_position != header.new_size) { + return Error::SIZE_MISMATCH; + } + return Error::NONE; +} + +} // namespace Components diff --git a/PROVESFlightControllerReference/Components/FlashWorker/PatchApplier.hpp b/PROVESFlightControllerReference/Components/FlashWorker/PatchApplier.hpp new file mode 100644 index 00000000..f169353f --- /dev/null +++ b/PROVESFlightControllerReference/Components/FlashWorker/PatchApplier.hpp @@ -0,0 +1,130 @@ +// ====================================================================== +// \title PatchApplier.hpp +// \brief hpp file for applying a delta patch to a reference image +// ====================================================================== + +#pragma once + +#include +#include + +namespace Components { + +//! Applies a PROVES delta patch, reconstructing a new image from a reference image. +//! +//! A full image takes roughly 24 minutes to uplink at the ground station's pacing, which does not +//! fit a pass. A delta against an image already on board is a small fraction of that. The patch +//! encodes the classic bsdiff instruction stream: each record copies a run from the reference while +//! adding a per byte difference, appends a run of literal bytes that exist only in the new image, +//! then seeks the reference position. +//! +//! Applied as a streaming state machine so that neither image is ever held in RAM. The reference is +//! read randomly (it is resident in flash) and the output is written sequentially. +//! +//! Kept free of F Prime and Zephyr dependencies so it can be exercised by host unit tests against +//! patches produced by tools/bin/make-patch. +class PatchApplier { + public: + // ---------------------------------------------------------------------- + // Public types + // ---------------------------------------------------------------------- + + //! Magic identifying a PROVES patch container: "PRVSPTCH" + static constexpr uint8_t MAGIC[8] = {'P', 'R', 'V', 'S', 'P', 'T', 'C', 'H'}; + + //! Container format version understood by this implementation + static constexpr uint8_t FORMAT_VERSION = 1; + + //! Payload codec applied to the three instruction streams. + //! + //! The streams are decompressed before apply() sees them, so the codec only tells the caller + //! what to do with the payload. An uncompressed patch is roughly the size of the image itself + //! and has no uplink value; it exists for testing the container end to end. + enum class Compression : uint8_t { + NONE = 0, //!< Streams stored verbatim + LZSS = 1, //!< Streams compressed together, see Components::LzssDecoder + }; + + //! Why an apply failed + enum class Error : uint8_t { + NONE = 0, //!< No error + BAD_MAGIC = 1, //!< Container magic did not match + UNSUPPORTED_VERSION = 2, //!< Container version is newer than this implementation + UNSUPPORTED_COMPRESSION = 3, //!< Container uses a codec this build cannot decode + TRUNCATED_PATCH = 4, //!< The patch ended before the new image was complete + CORRUPT_CONTROL = 5, //!< A control record would read or write outside the images + REFERENCE_READ_FAILED = 6, //!< Reading the reference image failed + PATCH_READ_FAILED = 7, //!< Reading the patch failed + OUTPUT_WRITE_FAILED = 8, //!< Writing the new image failed + SIZE_MISMATCH = 9, //!< The reconstructed image was not the size the patch declared + WRONG_REFERENCE = 10, //!< The on-board reference is not the one the patch was built from + }; + + //! Header of a PROVES patch container, as decoded from the wire + struct Header { + uint32_t new_size; //!< Size in bytes of the reconstructed image + uint32_t reference_size; //!< Size in bytes of the reference the patch was built against + uint32_t reference_crc32; //!< CRC32 of that reference, in the form Os::File reports + uint32_t control_size; //!< Bytes of control records + uint32_t diff_size; //!< Bytes of the difference stream + uint32_t extra_size; //!< Bytes of the literal stream + Compression compression; //!< Codec applied to the three streams + }; + + //! Bytes occupied by the container header on the wire + static constexpr size_t HEADER_SIZE = 8 + 1 + 1 + 4 + 4 + 4 + 4 + 4 + 4; + + //! Bytes occupied by one control record on the wire: three signed 32 bit values + static constexpr size_t CONTROL_RECORD_SIZE = 12; + + //! One decoded control record + struct Control { + int32_t copy; //!< Bytes to copy from the reference, adding the difference stream + int32_t extra; //!< Bytes to append verbatim from the literal stream + int32_t seek; //!< Signed adjustment applied to the reference position afterwards + }; + + //! Sources and sink the apply operates over. + //! + //! Implemented against files and flash in flight, and against memory buffers in unit tests. + class Io { + public: + virtual ~Io() {} + //! Read exactly size bytes of the reference image at the given offset + virtual bool readReference(uint32_t offset, uint8_t* buffer, size_t size) = 0; + //! Read exactly size bytes from a patch stream, advancing that stream's cursor + virtual bool readControl(uint8_t* buffer, size_t size) = 0; + virtual bool readDiff(uint8_t* buffer, size_t size) = 0; + virtual bool readExtra(uint8_t* buffer, size_t size) = 0; + //! Append size bytes to the reconstructed image + virtual bool writeOutput(const uint8_t* buffer, size_t size) = 0; + }; + + public: + // ---------------------------------------------------------------------- + // Public helper methods + // ---------------------------------------------------------------------- + + //! Decode a container header from its wire representation. + //! + //! \param buffer: at least HEADER_SIZE bytes of header + //! \param size: bytes available in buffer + //! \param header: filled in on success + //! \return NONE on success, otherwise the reason the header was rejected + static Error decodeHeader(const uint8_t* buffer, size_t size, Header& header); + + //! Decode one control record from its wire representation + static bool decodeControl(const uint8_t* buffer, size_t size, Control& control); + + //! Reconstruct the new image. + //! + //! \param header: decoded container header + //! \param reference_size: size of the reference image, used to bound reads + //! \param io: sources and sink to operate over + //! \param scratch: working buffer, also bounding how much is processed at a time + //! \param scratch_size: bytes available in scratch, must be at least 1 + //! \return NONE on success, otherwise the reason the apply failed + static Error apply(const Header& header, uint32_t reference_size, Io& io, uint8_t* scratch, size_t scratch_size); +}; + +} // namespace Components diff --git a/PROVESFlightControllerReference/Components/FlashWorker/SegmentPlan.cpp b/PROVESFlightControllerReference/Components/FlashWorker/SegmentPlan.cpp new file mode 100644 index 00000000..7dd5873d --- /dev/null +++ b/PROVESFlightControllerReference/Components/FlashWorker/SegmentPlan.cpp @@ -0,0 +1,61 @@ +// ====================================================================== +// \title SegmentPlan.cpp +// \brief cpp file for uplink segment naming and validation helpers +// ====================================================================== + +#include "SegmentPlan.hpp" + +namespace Components { + +// Out of line definitions so these can be odr-used by callers, for the same reason as in +// PatchApplier.cpp: not every translation unit here is compiled as C++17. +constexpr uint16_t SegmentPlan::MAX_SEGMENTS; +constexpr uint8_t SegmentPlan::SUFFIX_DIGITS; + +bool SegmentPlan ::isValidSegmentCount(uint16_t segments) { + return (segments > 0) && (segments <= SegmentPlan::MAX_SEGMENTS); +} + +size_t SegmentPlan ::requiredNameSize(size_t prefix_length) { + // prefix + '.' + digits + null terminator + return prefix_length + 1U + SegmentPlan::SUFFIX_DIGITS + 1U; +} + +bool SegmentPlan ::formatSegmentName(const char* prefix, uint16_t index, char* buffer, size_t buffer_size) { + if ((prefix == nullptr) || (buffer == nullptr) || (buffer_size == 0)) { + return false; + } + // Guarantee a terminated string even on the failure paths below + buffer[0] = '\0'; + if (index >= SegmentPlan::MAX_SEGMENTS) { + return false; + } + + size_t prefix_length = 0; + while (prefix[prefix_length] != '\0') { + prefix_length++; + } + if (SegmentPlan::requiredNameSize(prefix_length) > buffer_size) { + return false; + } + + for (size_t i = 0; i < prefix_length; i++) { + buffer[i] = prefix[i]; + } + size_t position = prefix_length; + buffer[position] = '.'; + position++; + + // Fixed width, zero padded, so that segment names sort in transfer order + uint16_t remaining = index; + for (uint8_t digit = 0; digit < SegmentPlan::SUFFIX_DIGITS; digit++) { + const size_t offset = position + (SegmentPlan::SUFFIX_DIGITS - 1U - digit); + buffer[offset] = static_cast('0' + (remaining % 10U)); + remaining = static_cast(remaining / 10U); + } + position += SegmentPlan::SUFFIX_DIGITS; + buffer[position] = '\0'; + return true; +} + +} // namespace Components diff --git a/PROVESFlightControllerReference/Components/FlashWorker/SegmentPlan.hpp b/PROVESFlightControllerReference/Components/FlashWorker/SegmentPlan.hpp new file mode 100644 index 00000000..2457b52e --- /dev/null +++ b/PROVESFlightControllerReference/Components/FlashWorker/SegmentPlan.hpp @@ -0,0 +1,52 @@ +// ====================================================================== +// \title SegmentPlan.hpp +// \brief hpp file for uplink segment naming and validation helpers +// ====================================================================== + +#pragma once + +#include +#include + +namespace Components { + +//! Naming and validation for the numbered segments an image is uplinked in. +//! +//! A full flight image does not fit in a single ground pass, and F Prime's file uplink has no +//! cross-pass resume, so an interrupted transfer loses everything sent so far. Uplinking the image +//! as numbered segments bounds that loss to one segment. +//! +//! Kept free of F Prime and Zephyr dependencies so it can be exercised by host unit tests. +class SegmentPlan { + public: + //! Largest number of segments an image may be split into. + //! + //! At the practical minimum segment size this is far more than a 1 MB slot requires, while + //! keeping the assembly loop bounded. + static constexpr uint16_t MAX_SEGMENTS = 999; + + //! Number of digits in the segment suffix, giving names like "/update/img.000" + static constexpr uint8_t SUFFIX_DIGITS = 3; + + //! Whether a segment count can be assembled + static bool isValidSegmentCount(uint16_t segments); + + //! Build the file name of one segment. + //! + //! Writes ".NNN" into buffer, always null terminated. Returns false without writing a + //! usable name when the index is out of range or the buffer is too small, so a caller that + //! ignores the result cannot read an unterminated or truncated name. + //! + //! \param prefix: segment file name prefix, null terminated + //! \param index: zero based segment index + //! \param buffer: destination for the formatted name + //! \param buffer_size: capacity of buffer in bytes, including the null terminator + //! \return true when the full name was written + static bool formatSegmentName(const char* prefix, uint16_t index, char* buffer, size_t buffer_size); + + //! Bytes needed to hold a segment name for a prefix of the given length, including the + //! null terminator + static size_t requiredNameSize(size_t prefix_length); +}; + +} // namespace Components diff --git a/PROVESFlightControllerReference/Components/FlashWorker/UpdateSequencer.cpp b/PROVESFlightControllerReference/Components/FlashWorker/UpdateSequencer.cpp new file mode 100644 index 00000000..cd04361e --- /dev/null +++ b/PROVESFlightControllerReference/Components/FlashWorker/UpdateSequencer.cpp @@ -0,0 +1,144 @@ +// ====================================================================== +// \title UpdateSequencer.cpp +// \brief cpp file for flight software update sequencing helper class +// ====================================================================== + +#include "UpdateSequencer.hpp" + +namespace Components { + +UpdateSequencer ::UpdateSequencer() : m_step(Step::IDLE), m_last_failed(false) {} + +UpdateSequencer ::~UpdateSequencer() {} + +UpdateSequencer::Step UpdateSequencer ::step() const { + return this->m_step; +} + +bool UpdateSequencer ::isPrepared() const { + return this->m_step == Step::PREPARED; +} + +bool UpdateSequencer ::dirtiesStagingSlot(WriteOutcome outcome) { + switch (outcome) { + // These fail before the first byte reaches the flash, so the erased slot is still good + case WriteOutcome::FILE_OPEN_FAILED: + case WriteOutcome::FILE_QUERY_FAILED: + case WriteOutcome::CRC_MISMATCH: + return false; + // These fail part way through, leaving a partial image behind + case WriteOutcome::FILE_READ_FAILED: + case WriteOutcome::FLASH_WRITE_FAILED: + return true; + case WriteOutcome::SUCCESS: + default: + return false; + } +} + +UpdateSequencer::Status UpdateSequencer ::statusForOutcome(WriteOutcome outcome) { + switch (outcome) { + case WriteOutcome::SUCCESS: + return Status::OP_OK; + case WriteOutcome::FILE_OPEN_FAILED: + case WriteOutcome::FILE_QUERY_FAILED: + case WriteOutcome::FILE_READ_FAILED: + return Status::IMAGE_FILE_READ_ERROR; + case WriteOutcome::CRC_MISMATCH: + return Status::IMAGE_CRC_MISMATCH; + case WriteOutcome::FLASH_WRITE_FAILED: + return Status::FLASH_WRITE_ERROR; + default: + return Status::FLASH_WRITE_ERROR; + } +} + +UpdateSequencer::Status UpdateSequencer ::onPrepareComplete(bool erase_succeeded) { + this->m_last_failed = !erase_succeeded; + if (!erase_succeeded) { + // A failed erase leaves the slot in an unknown state; require another preparation + this->m_step = Step::IDLE; + return Status::PREPARATION_ERROR; + } + this->m_step = Step::PREPARED; + return Status::OP_OK; +} + +UpdateSequencer::Status UpdateSequencer ::onUpdateComplete(WriteOutcome outcome) { + // An update may only follow a successful preparation. A rejected attempt does no work, so it + // leaves the sequence untouched rather than forcing an unnecessary erase. + if (!this->isPrepared()) { + return Status::UNPREPARED; + } + this->m_last_failed = (outcome != WriteOutcome::SUCCESS); + if (outcome == WriteOutcome::SUCCESS) { + this->m_step = Step::UPDATED; + return Status::OP_OK; + } + // Only fall back to IDLE when the slot actually holds partial data. Otherwise stay PREPARED so + // that a mistyped file name or a bad CRC can be retried without paying for another erase. + if (UpdateSequencer::dirtiesStagingSlot(outcome)) { + this->m_step = Step::IDLE; + } + return UpdateSequencer::statusForOutcome(outcome); +} + +UpdateSequencer::Stage UpdateSequencer ::settledStage() const { + if (this->m_last_failed) { + return Stage::FAILED; + } + switch (this->m_step) { + case Step::PREPARED: + return Stage::PREPARED; + case Step::UPDATED: + return Stage::UPDATED; + case Step::IDLE: + default: + return Stage::IDLE; + } +} + +uint8_t UpdateSequencer ::percentComplete(uint32_t written, uint32_t total) { + if (total == 0) { + return 0; + } + if (written >= total) { + return 100; + } + // 64-bit intermediate: a 32-bit multiply would overflow well below the 1 MB slot size + const uint64_t percent = (static_cast(written) * 100U) / total; + return static_cast(percent); +} + +bool UpdateSequencer ::autoConfirmDue(bool enabled, + bool already_confirmed, + uint32_t pending_seconds, + uint32_t delay_seconds) { + // Never confirm on the spacecraft's own initiative unless the ground armed it + if (!enabled) { + return false; + } + // Nothing to do for an image that is not on trial + if (already_confirmed) { + return false; + } + // Require at least one elapsed second even when the delay is configured to zero, so that an + // image which crashes immediately cannot confirm itself + const uint32_t required = (delay_seconds == 0) ? 1U : delay_seconds; + return pending_seconds >= required; +} + +bool UpdateSequencer ::progressReportDue(uint8_t percent, uint8_t last_reported, uint8_t step_percent) { + // Completion is always worth reporting + if (percent >= 100) { + return last_reported < 100; + } + // Never let a misconfigured step silence reporting entirely + const uint8_t step = (step_percent == 0) ? 1 : step_percent; + if (percent < last_reported) { + return false; + } + return static_cast(percent - last_reported) >= step; +} + +} // namespace Components diff --git a/PROVESFlightControllerReference/Components/FlashWorker/UpdateSequencer.hpp b/PROVESFlightControllerReference/Components/FlashWorker/UpdateSequencer.hpp new file mode 100644 index 00000000..790c1fcf --- /dev/null +++ b/PROVESFlightControllerReference/Components/FlashWorker/UpdateSequencer.hpp @@ -0,0 +1,140 @@ +// ====================================================================== +// \title UpdateSequencer.hpp +// \brief hpp file for flight software update sequencing helper class +// ====================================================================== + +#pragma once + +#include + +namespace Components { + +//! Tracks the ordering of the flight software update steps and decides the status reported +//! back to the Updater component for a given outcome. +//! +//! This logic is kept free of F Prime and Zephyr dependencies so that it can be exercised by +//! host unit tests. Components::FlashWorker owns an instance and performs the actual platform +//! work; the sequencer only decides "what does this outcome mean" and "what may happen next". +class UpdateSequencer { + public: + // ---------------------------------------------------------------------- + // Public types + // ---------------------------------------------------------------------- + + //! Status codes reported back to the Updater component. + //! + //! Mirrors Update.FlashWorkerUpdateStatus in UpdateStatus/UpdateStatus.fpp. FlashWorker.cpp + //! static_asserts that the two agree, so reordering either one is a compile error. + enum class Status : uint8_t { + OP_OK = 0, //!< Operation successful + BUSY = 1, //!< Another operation is in progress + UNPREPARED = 2, //!< Preparation step was not completed + PREPARATION_ERROR = 3, //!< An error occurred during the preparation step + IMAGE_FILE_READ_ERROR = 4, //!< An error occurred reading the image file + IMAGE_CRC_MISMATCH = 5, //!< The image file failed CRC validation + NEXT_BOOT_ERROR = 6, //!< An error occurred setting the next boot image + FLASH_WRITE_ERROR = 7, //!< An error occurred writing the image to the staging slot + }; + + //! Steps of the update sequence that have completed successfully + enum class Step : uint8_t { + IDLE = 0, //!< No usable staging slot; PREPARE_UPDATE must run before an update + PREPARED = 1, //!< Staging slot erased and ready to receive an image + UPDATED = 2, //!< An image has been written to the staging slot + }; + + //! Stage of the update sequence reported as telemetry. + //! + //! Mirrors Components.FlashUpdateStage in FlashWorker.fpp. FlashWorker.cpp static_asserts that + //! the two agree. PREPARING and WRITING are transient and are reported by FlashWorker while an + //! operation is running; the sequencer only knows the settled stages. + enum class Stage : uint8_t { + IDLE = 0, //!< No update in progress + PREPARING = 1, //!< Erasing the staging slot + PREPARED = 2, //!< Staging slot erased and ready to receive an image + WRITING = 3, //!< Writing an image into the staging slot + UPDATED = 4, //!< Image written and verified in the staging slot + FAILED = 5, //!< The last operation failed + }; + + //! Outcome of an attempt to write an image into the staging slot + enum class WriteOutcome : uint8_t { + SUCCESS = 0, //!< The whole image was written + FILE_OPEN_FAILED = 1, //!< The image file could not be opened + FILE_QUERY_FAILED = 2, //!< Sizing, CRC, or seek of the image file failed before any write + CRC_MISMATCH = 3, //!< The image file failed CRC validation before any write + FILE_READ_FAILED = 4, //!< Reading the image file failed part way through the write + FLASH_WRITE_FAILED = 5, //!< Writing to the staging slot failed + }; + + public: + // ---------------------------------------------------------------------- + // Construction and destruction + // ---------------------------------------------------------------------- + + //! Construct UpdateSequencer object + UpdateSequencer(); + + //! Destroy UpdateSequencer object + ~UpdateSequencer(); + + public: + // ---------------------------------------------------------------------- + // Public helper methods + // ---------------------------------------------------------------------- + + //! Last update step to have completed successfully + Step step() const; + + //! Whether the staging slot is erased and an image may be written to it + bool isPrepared() const; + + //! Whether an outcome left partially written data in the staging slot. + //! + //! An outcome that never reached the flash leaves the slot erased and still usable, so the + //! operator may retry the update directly. An outcome that did reach the flash requires + //! another erase, which costs a full slot erase on orbit. + static bool dirtiesStagingSlot(WriteOutcome outcome); + + //! Status reported to the Updater component for a write outcome + static Status statusForOutcome(WriteOutcome outcome); + + //! Record the result of the preparation (staging slot erase) step + Status onPrepareComplete(bool erase_succeeded); + + //! Record the result of an image write and advance or reset the sequence accordingly + Status onUpdateComplete(WriteOutcome outcome); + + //! Settled stage of the sequence, for telemetry + Stage settledStage() const; + + //! Percentage of an image written so far. + //! + //! Saturates at 100 and reports 0 for an empty image, so that a bad or missing size can never + //! produce a divide by zero or a nonsense percentage in telemetry. + static uint8_t percentComplete(uint32_t written, uint32_t total); + + //! Whether a test-booted image should confirm itself now. + //! + //! Confirmation is refused unless it was explicitly armed from the ground, the running image is + //! actually pending confirmation, and it has run continuously for the configured time. A delay + //! of zero still requires one elapsed second, so an armed spacecraft cannot confirm an image + //! that has not yet demonstrated it can stay up. + static bool autoConfirmDue(bool enabled, bool already_confirmed, uint32_t pending_seconds, uint32_t delay_seconds); + + //! Whether a progress report is due. + //! + //! Reports every step_percent of progress, and always reports completion. A step of 0 is + //! treated as 1 so that a misconfigured parameter cannot disable reporting entirely. + static bool progressReportDue(uint8_t percent, uint8_t last_reported, uint8_t step_percent); + + private: + // ---------------------------------------------------------------------- + // Private member variables + // ---------------------------------------------------------------------- + + Step m_step; //!< Last step to have completed successfully + bool m_last_failed; //!< Whether the most recent operation reported a failure +}; + +} // namespace Components diff --git a/PROVESFlightControllerReference/Components/FlashWorker/UpdateStatus/UpdateStatus.fpp b/PROVESFlightControllerReference/Components/FlashWorker/UpdateStatus/UpdateStatus.fpp index b799edf6..79ce1c27 100644 --- a/PROVESFlightControllerReference/Components/FlashWorker/UpdateStatus/UpdateStatus.fpp +++ b/PROVESFlightControllerReference/Components/FlashWorker/UpdateStatus/UpdateStatus.fpp @@ -16,6 +16,7 @@ module Update { PREPARATION_ERROR, @< An error occurred during the preparation step IMAGE_FILE_READ_ERROR, @< An error occurred reading the image file IMAGE_CRC_MISMATCH, @< The image file failed CRC validation - NEXT_BOOT_ERROR @< An error occurred setting the next boot image + NEXT_BOOT_ERROR, @< An error occurred setting the next boot image + FLASH_WRITE_ERROR @< An error occurred writing the image to the staging slot } } diff --git a/PROVESFlightControllerReference/Components/FlashWorker/docs/sdd.md b/PROVESFlightControllerReference/Components/FlashWorker/docs/sdd.md index cbbee1ae..96168de9 100644 --- a/PROVESFlightControllerReference/Components/FlashWorker/docs/sdd.md +++ b/PROVESFlightControllerReference/Components/FlashWorker/docs/sdd.md @@ -2,69 +2,158 @@ Performs long-running operations for the flash subsystem. The flash worker is responsible for handling the actual operations needed for flight-software update specific to the Zephyr flash API. +It plays two roles: +1. **Update worker.** It implements the `Update.UpdateWorker` interface, so the generic `Update.Updater` component drives it through prepare, write, next-boot, and confirm. +2. **Image builder.** It owns two commands that prepare a candidate image on the filesystem before that image is written to flash, so that a full image never has to cross the radio link in one piece. +## Why images are not simply uplinked whole +A signed flight image is roughly 727 KB. The ground station paces file uplink at 204-byte chunks with a 0.4 s cooldown (`file-uplink-chunk-size` and `file-uplink-cooldown` in `fprime-gds.yml`), which is about 510 B/s, so a whole image needs roughly 24 minutes of continuous contact. A pass is single-digit minutes, and F Prime's file uplink has no cross-pass resume, so an interrupted transfer loses everything sent so far. -## Usage Examples -Add usage examples here +Two ways out, both supported here: -### Diagrams -Add diagrams here +| approach | bytes to uplink | time at 510 B/s | +|---|---|---| +| whole image | 726,784 | ~24 min | +| image in numbered segments | same total, split across passes | survives a pass boundary | +| delta patch against the running image | ~47-97 KB compressed | ~1.5-3.2 min | + +Delta figures are measured with bsdiff against real consecutive CI builds. Naive block-level diffing does **not** work: any code size change shifts every later address, so ~99% of 512-byte blocks differ between builds days apart. + +## Typical Usage + +### Whole image, one pass + +``` +uplink /update/zephyr.signed.bin +Update.updater.PREPARE_UPDATE +Update.updater.UPDATE_IMAGE_FROM("/update/zephyr.signed.bin", ) +Update.updater.CONFIGURE_NEXT_BOOT(TEST) +reboot +Update.updater.CONFIRM_UPDATE +``` + +Get `` from `tools/bin/calculate-crc.py`. + +### Image split across several passes + +``` +uplink /update/img.000, /update/img.001, ... (one or more per pass) +Update.worker.ASSEMBLE_IMAGE("/update/img", , "/update/candidate.bin", ) +Update.updater.PREPARE_UPDATE +Update.updater.UPDATE_IMAGE_FROM("/update/candidate.bin", ) +``` -### Typical Usage -And the typical usage of the component here +Assembly verifies the joined image against `` before anything is written to flash, so a missing or reordered segment is caught rather than left for the bootloader to find. + +### Delta patch + +``` +(ground) tools/bin/make-patch.py -o update.patch +uplink /update/update.patch +Update.worker.APPLY_PATCH("/update/update.patch", "/update/candidate.bin", ) +Update.updater.PREPARE_UPDATE +Update.updater.UPDATE_IMAGE_FROM("/update/candidate.bin", ) +``` + +The reference is the running image, read directly out of the `slot0_partition` flash area. The RP2350 executes XIP from memory-mapped QSPI, so no copy has to be kept on the filesystem. The patch container records the size and CRC32 of the reference it was built from, and `APPLY_PATCH` refuses to run if the running image is not that one. Patching the wrong reference produces a plausible but corrupt image that would then be flashed and booted, so this check is not optional. + +**Compression is not yet available on the flight side.** The patch streams are ~84% zero bytes and compress from ~727 KB to ~60 KB with DEFLATE, ~47 KB with LZMA, or ~97 KB with heatshrink, but this Zephyr workspace ships no decompressor and selecting that dependency is a project decision. Until it is made, `make-patch.py` refuses to emit a patch without `--allow-uncompressed`, because an uncompressed patch is the size of the image and worth nothing over the radio. The container carries a codec field so a compressed format can be added without changing the applier's structure. + +## Flash Layout + +Defined in `boards/bronco_space/proves_flight_control_board_v5/proves_flight_control_board_v5.dtsi`. + +| partition | label | size | role | +|---|---|---|---| +| `boot_partition` | mcuboot | 1 MB | bootloader | +| `slot0_partition` | primary | 1 MB | running image, and the patch reference | +| `slot1_partition` | secondary | 1 MB | staging slot updates are written into | +| `slot2_partition` | reserved | 1 MB | unused in swap-using-offset mode with one image | +| `storage_partition` | n/a | 12 MB | LittleFS, holds uplinked segments and candidates | -## Class Diagram -Add a class diagram here +Partition IDs are taken from the device tree with `FIXED_PARTITION_ID`, never hard coded: fixed partition IDs follow declaration order, so a literal would silently point at the wrong region if a partition were added above it. ## Port Descriptions | Name | Description | |---|---| -|---|---| +| prepareImage | Erase the staging slot, from `Update.Updater` | +| updateImage | Write an image file into the staging slot | +| nextBoot | Set the next boot mode through MCUBoot | +| confirmImage | Confirm the running image so it is not reverted | +| prepareImageDone / updateImageDone | Report completion of the slow operations | ## Component States -Add component states in the chart below + +The sequence is tracked by `Components::UpdateSequencer`. + | Name | Description | |---|---| -|---|---| +| IDLE | No usable staging slot; PREPARE_UPDATE must run before an update | +| PREPARED | Staging slot erased and ready to receive an image | +| UPDATED | An image has been written to the staging slot | -## Sequence Diagrams -Add sequence diagrams here +A failure that never reached the flash (a bad file name, a failed size or CRC read, a CRC mismatch) leaves the sequence in PREPARED, so the operator can retry without paying for another 1 MB erase. A failure that did reach the flash drops to IDLE, because the slot now holds partial data and must be erased again. ## Parameters | Name | Description | |---|---| -|---|---| +| CHUNK_DELAY_US | Microseconds to pause after each buffered flash write, default 5000. Exposed so it can be tuned against real hardware instead of rebuilt; at the default a 727 KB image spends about 7 s asleep. | +| PROGRESS_STEP_PERCENT | Percent of the image between progress events, default 10. Larger values spend less downlink reporting on an update in flight. | ## Commands | Name | Description | |---|---| -|---|---| +| ASSEMBLE_IMAGE | Concatenate numbered uplink segments into one image file and verify its CRC32 | +| APPLY_PATCH | Reconstruct an image from a delta patch applied to the running image | ## Events | Name | Description | |---|---| -|---|---| +| UpdateProgress | Periodic progress during a write | +| NoImagePrepared | An update was requested before a successful preparation | +| NextBootSetFailed / ConfirmImageFailed | MCUBoot next-boot or confirm call failed | +| FlashEraseFailed / FlashWriteFailed | Staging slot erase or write failed | +| ImageFileReadError / ImageFileCrcMismatch | Image file could not be read, or failed validation | +| ImageWriteCrcMismatch | Bytes written to flash did not match the bytes validated | +| AssembleStarted / AssembleSucceeded / AssembleFailed / InvalidSegmentCount | Segment assembly | +| PatchStarted / PatchSucceeded / PatchFailed / PatchReferenceMismatch | Patch application | ## Telemetry | Name | Description | |---|---| -|---|---| +| UpdateStage | IDLE, PREPARING, PREPARED, WRITING, UPDATED, or FAILED | +| BytesWritten | Bytes of the image written into the staging slot so far | +| ImageTotalBytes | Total size of the image being written | +| LastUpdateStatus | Status of the most recent preparation or update | + +These are channels rather than only events so that an operator returning on a later pass can ask where an update stands without replaying event history. They are packetized in `SoftwareUpdate` (packet 23, group 5). ## Unit Tests -Add unit test descriptions in the chart below + +Host tests, no F Prime or Zephyr dependency. Run with `make test-unit`. + | Name | Description | Output | Coverage | |---|---|---|---| -|---|---|---|---| +| test_FlashWorker_UpdateSequencer | Sequence ordering, status mapping, retry cost, progress arithmetic | pass/fail | `UpdateSequencer` | +| test_FlashWorker_SegmentPlan | Segment naming, zero padding, buffer and index bounds | pass/fail | `SegmentPlan` | +| test_FlashWorker_PatchApplier | Container decoding, patch application, malformed and hostile patches | pass/fail | `PatchApplier` | + +Integration tests covering the command surface against hardware are in `test/int/ota_test.py`. They deliberately never set the next boot, so a run cannot leave the board staged to boot an unintended image. ## Requirements -Add requirements in the chart below + | Name | Description | Validation | |---|---|---| -|---|---|---| +| A failed update is never reported as a success | Read and write failures propagate a failure status to the Updater | Unit test | +| A retry costs an erase only when one is needed | Failures that never reached flash leave the slot usable | Unit test, integration test | +| An image is validated before it is flashed | CRC32 is checked before the write, and the written bytes are verified after | Unit test, inspection | +| A patch is applied only to the image it was built from | The container records the reference size and CRC and both are checked | Unit test | +| A malformed patch cannot read or write out of bounds | Control records are range checked against both images | Unit test | ## Change Log | Date | Description | |---|---| -|---| Initial Draft | +| n/a | Initial Draft | +| 2026-08-04 | Correct failure reporting, add progress telemetry and parameters, add segment assembly and delta patching | diff --git a/PROVESFlightControllerReference/ReferenceDeployment/Top/ReferenceDeploymentPackets.fppi b/PROVESFlightControllerReference/ReferenceDeployment/Top/ReferenceDeploymentPackets.fppi index 5ca221ec..ccc9f362 100644 --- a/PROVESFlightControllerReference/ReferenceDeployment/Top/ReferenceDeploymentPackets.fppi +++ b/PROVESFlightControllerReference/ReferenceDeployment/Top/ReferenceDeploymentPackets.fppi @@ -156,6 +156,15 @@ telemetry packets ReferenceDeploymentPackets { fsSpace.TotalSpace } + packet SoftwareUpdate id 23 group 5 { + Update.worker.UpdateStage + Update.worker.BytesWritten + Update.worker.ImageTotalBytes + Update.worker.LastUpdateStatus + Update.worker.RunningImageConfirmed + Update.worker.PendingConfirmSeconds + } + packet Security id 6 group 5 { #ComCcsdsSband.provesRouter.RoutedPackets ComCcsdsLora.provesRouter.RoutedPackets diff --git a/PROVESFlightControllerReference/ReferenceDeployment/Top/topology.fpp b/PROVESFlightControllerReference/ReferenceDeployment/Top/topology.fpp index 9063988a..85526429 100644 --- a/PROVESFlightControllerReference/ReferenceDeployment/Top/topology.fpp +++ b/PROVESFlightControllerReference/ReferenceDeployment/Top/topology.fpp @@ -301,6 +301,7 @@ module ReferenceDeployment { rateGroup1Hz.RateGroupMemberOut[16] -> modeManager.run rateGroup1Hz.RateGroupMemberOut[17] -> adcs.run rateGroup1Hz.RateGroupMemberOut[18] -> thermalManager.run + rateGroup1Hz.RateGroupMemberOut[19] -> Update.worker.run } diff --git a/PROVESFlightControllerReference/test/int/ota_test.py b/PROVESFlightControllerReference/test/int/ota_test.py new file mode 100644 index 00000000..81f6675f --- /dev/null +++ b/PROVESFlightControllerReference/test/int/ota_test.py @@ -0,0 +1,187 @@ +"""Integration tests for the over-the-air update command surface. + +These exercise the update state machine and its telemetry against real hardware without +completing a flash cycle. Nothing here sets the next boot or reboots the board, so a run +cannot leave the spacecraft staged to boot an unintended image. + +The full prepare -> write -> TEST boot -> confirm cycle needs a power cycle and a human, and +lives in test/long/ rather than here. +""" + +import pytest +from fprime_gds.common.testing_fw import predicates + +UPDATER = "Update.updater" +WORKER = "Update.worker" + +# A file name that cannot exist on the flight filesystem +MISSING_IMAGE = "/update/definitely_not_here.bin" + + +def send_and_assert_event(fprime_test_api, command, args, event, timeout=10): + """Send a command and wait for the event it should produce. + + Args: + fprime_test_api: the integration test API + command: mnemonic of the command to send + args: list of string arguments + event: mnemonic of the event expected in response + timeout: seconds to wait for the event + + Returns: + the event data object that was received + """ + fprime_test_api.send_and_assert_event(command, args, [event], timeout=timeout) + + +@pytest.mark.usefixtures("fprime_test_api") +class TestUpdateStateMachine: + """The ordering rules an operator has to work within.""" + + def test_update_without_prepare_is_rejected(self, fprime_test_api): + """An update before a preparation must be refused rather than writing to flash. + + Writing an image into a slot that was never erased corrupts it, so the component + refuses and says so. + """ + fprime_test_api.send_and_assert_event( + f"{UPDATER}.UPDATE_IMAGE_FROM", + [MISSING_IMAGE, "0"], + [f"{WORKER}.NoImagePrepared"], + timeout=15, + ) + + def test_prepare_reports_completion(self, fprime_test_api): + """Preparation erases a 1 MB slot and must report when it finishes. + + This is the slowest step in the sequence, so the operator needs the completion event + to know the board is ready for an image rather than still erasing. + """ + fprime_test_api.send_and_assert_event( + f"{UPDATER}.PREPARE_UPDATE", + [], + [f"{UPDATER}.PrepareUpdateSucceeded"], + timeout=60, + ) + + def test_missing_image_file_is_reported_as_a_failure(self, fprime_test_api): + """A nonexistent image must fail loudly. + + Regression coverage for the defect where read and write failures returned OP_OK and + the Updater announced UpdateSucceeded for an image that was never written. + """ + fprime_test_api.send_and_assert_event( + f"{UPDATER}.PREPARE_UPDATE", + [], + [f"{UPDATER}.PrepareUpdateSucceeded"], + timeout=60, + ) + fprime_test_api.send_and_assert_event( + f"{UPDATER}.UPDATE_IMAGE_FROM", + [MISSING_IMAGE, "0"], + [f"{UPDATER}.UpdateFailed"], + timeout=30, + ) + + def test_a_failed_open_does_not_cost_another_erase(self, fprime_test_api): + """A mistyped file name must be retryable without re-erasing the slot. + + The failure never reached the flash, so the erased slot is still good. Re-running the + update must not report UNPREPARED, which would force another 1 MB erase on orbit. + """ + fprime_test_api.send_and_assert_event( + f"{UPDATER}.PREPARE_UPDATE", + [], + [f"{UPDATER}.PrepareUpdateSucceeded"], + timeout=60, + ) + fprime_test_api.send_and_assert_event( + f"{UPDATER}.UPDATE_IMAGE_FROM", + [MISSING_IMAGE, "0"], + [f"{UPDATER}.UpdateFailed"], + timeout=30, + ) + # The retry must fail on the file again, not on the sequence + fprime_test_api.assert_event_count(0, f"{WORKER}.NoImagePrepared") + fprime_test_api.send_and_assert_event( + f"{UPDATER}.UPDATE_IMAGE_FROM", + [MISSING_IMAGE, "0"], + [f"{UPDATER}.UpdateFailed"], + timeout=30, + ) + fprime_test_api.assert_event_count(0, f"{WORKER}.NoImagePrepared") + + +@pytest.mark.usefixtures("fprime_test_api") +class TestUpdateTelemetry: + """Telemetry an operator relies on when a pass ends mid-update.""" + + def test_stage_is_reported_after_preparation(self, fprime_test_api): + """The stage channel must survive a pass gap. + + An operator returning on the next orbit needs to know where the update stands without + replaying the event history, so the stage is a channel and not only an event. + """ + fprime_test_api.send_and_assert_command( + f"{UPDATER}.PREPARE_UPDATE", [], max_delay=60 + ) + fprime_test_api.assert_telemetry( + f"{WORKER}.UpdateStage", + predicates.equal_to("PREPARED"), + timeout=30, + ) + + def test_failure_status_is_retained(self, fprime_test_api): + """The status of the last operation must remain queryable after it failed.""" + fprime_test_api.send_and_assert_command( + f"{UPDATER}.PREPARE_UPDATE", [], max_delay=60 + ) + fprime_test_api.send_and_assert_command( + f"{UPDATER}.UPDATE_IMAGE_FROM", [MISSING_IMAGE, "0"], max_delay=30 + ) + fprime_test_api.assert_telemetry( + f"{WORKER}.LastUpdateStatus", + predicates.equal_to("IMAGE_FILE_READ_ERROR"), + timeout=30, + ) + + +@pytest.mark.usefixtures("fprime_test_api") +class TestImageAssembly: + """Joining the numbered segments an image is uplinked in.""" + + def test_zero_segments_is_rejected(self, fprime_test_api): + """A zero segment count is meaningless and must be refused before any file work.""" + fprime_test_api.send_and_assert_event( + f"{WORKER}.ASSEMBLE_IMAGE", + ["/update/img", "0", "/update/candidate.bin", "0"], + [f"{WORKER}.InvalidSegmentCount"], + timeout=15, + ) + + def test_missing_segments_are_reported(self, fprime_test_api): + """Assembly must fail on the first missing segment rather than writing a short image. + + A short image would still be flashed and would fail only at boot, so it has to be + caught here. + """ + fprime_test_api.send_and_assert_event( + f"{WORKER}.ASSEMBLE_IMAGE", + ["/update/no_such_prefix", "2", "/update/candidate.bin", "0"], + [f"{WORKER}.AssembleFailed"], + timeout=30, + ) + + +@pytest.mark.usefixtures("fprime_test_api") +class TestPatchApplication: + """Reconstructing an image from a delta patch.""" + + def test_missing_patch_is_reported(self, fprime_test_api): + """A nonexistent patch file must fail rather than producing an empty image.""" + fprime_test_api.send_and_assert_event( + f"{WORKER}.APPLY_PATCH", + ["/update/no_such.patch", "/update/candidate.bin", "0"], + [f"{WORKER}.PatchFailed"], + timeout=30, + ) diff --git a/PROVESFlightControllerReference/test/unit-tests/CMakeLists.txt b/PROVESFlightControllerReference/test/unit-tests/CMakeLists.txt index 389ccaf6..0f829763 100644 --- a/PROVESFlightControllerReference/test/unit-tests/CMakeLists.txt +++ b/PROVESFlightControllerReference/test/unit-tests/CMakeLists.txt @@ -72,6 +72,38 @@ target_include_directories(proves_router_bypasser PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../../.. ) +# FlashWorker UpdateSequencer +add_library(flash_worker_update_sequencer STATIC + ${CMAKE_CURRENT_SOURCE_DIR}/../../../PROVESFlightControllerReference/Components/FlashWorker/UpdateSequencer.cpp +) +target_include_directories(flash_worker_update_sequencer PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/../../.. +) + +# FlashWorker SegmentPlan +add_library(flash_worker_segment_plan STATIC + ${CMAKE_CURRENT_SOURCE_DIR}/../../../PROVESFlightControllerReference/Components/FlashWorker/SegmentPlan.cpp +) +target_include_directories(flash_worker_segment_plan PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/../../.. +) + +# FlashWorker LzssDecoder +add_library(flash_worker_lzss_decoder STATIC + ${CMAKE_CURRENT_SOURCE_DIR}/../../../PROVESFlightControllerReference/Components/FlashWorker/LzssDecoder.cpp +) +target_include_directories(flash_worker_lzss_decoder PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/../../.. +) + +# FlashWorker PatchApplier +add_library(flash_worker_patch_applier STATIC + ${CMAKE_CURRENT_SOURCE_DIR}/../../../PROVESFlightControllerReference/Components/FlashWorker/PatchApplier.cpp +) +target_include_directories(flash_worker_patch_applier PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/../../.. +) + # Find PSA provider (we use libmbedcrypto) and ensure PSA headers exist find_path(PSA_CRYPTO_H psa/crypto.h) find_library(MBEDCRYPTO_LIB mbedcrypto) @@ -105,6 +137,10 @@ foreach(test_src ${TEST_SOURCES}) security_deframer_authenticator rtc_manager_rtc_helper proves_router_bypasser + flash_worker_update_sequencer + flash_worker_segment_plan + flash_worker_patch_applier + flash_worker_lzss_decoder ) add_test(NAME ${test_name} COMMAND ${test_name}) diff --git a/PROVESFlightControllerReference/test/unit-tests/test_FlashWorker_LzssDecoder.cpp b/PROVESFlightControllerReference/test/unit-tests/test_FlashWorker_LzssDecoder.cpp new file mode 100644 index 00000000..4367a2d4 --- /dev/null +++ b/PROVESFlightControllerReference/test/unit-tests/test_FlashWorker_LzssDecoder.cpp @@ -0,0 +1,188 @@ +// ====================================================================== +// \title test_FlashWorker_LzssDecoder.cpp +// \brief Unit tests for the delta patch transport codec +// ====================================================================== + +#include + +#include +#include + +#include "PROVESFlightControllerReference/Components/FlashWorker/LzssDecoder.hpp" + +using Components::LzssDecoder; + +namespace { + +class MemSource final : public LzssDecoder::Source { + public: + explicit MemSource(std::vector data) : m_data(std::move(data)) {} + bool read(uint8_t* buffer, size_t size) override { + if (m_position + size > m_data.size()) { + return false; + } + std::memcpy(buffer, m_data.data() + m_position, size); + m_position += size; + return true; + } + + private: + std::vector m_data; + size_t m_position = 0; +}; + +class MemSink final : public LzssDecoder::Sink { + public: + bool write(const uint8_t* buffer, size_t size) override { + if (m_fail) { + return false; + } + out.insert(out.end(), buffer, buffer + size); + return true; + } + void fail() { m_fail = true; } + std::vector out; + + private: + bool m_fail = false; +}; + +//! Encode one group of up to 8 tokens. Each token is either a literal byte or a (distance, length) +//! match, matching what tools/bin/make-patch.py emits. +struct Token { + bool literal; + uint16_t value; // literal byte, or distance + uint16_t length; +}; + +std::vector encode(const std::vector& tokens) { + std::vector out; + for (size_t start = 0; start < tokens.size(); start += 8) { + uint8_t tag = 0; + for (size_t bit = 0; bit < 8 && start + bit < tokens.size(); bit++) { + if (tokens[start + bit].literal) { + tag |= static_cast(1U << bit); + } + } + out.push_back(tag); + for (size_t bit = 0; bit < 8 && start + bit < tokens.size(); bit++) { + const Token& token = tokens[start + bit]; + if (token.literal) { + out.push_back(static_cast(token.value)); + } else { + out.push_back(static_cast(token.value & 0xFF)); + out.push_back(static_cast(token.value >> 8)); + out.push_back(static_cast(token.length - LzssDecoder::MIN_MATCH)); + } + } + } + return out; +} + +LzssDecoder::Error run(const std::vector& tokens, uint32_t expected, std::vector& out) { + MemSource source(encode(tokens)); + MemSink sink; + std::vector window(LzssDecoder::WINDOW_SIZE); + const LzssDecoder::Error error = LzssDecoder::decode(source, sink, expected, window.data()); + out = sink.out; + return error; +} + +} // namespace + +TEST(LzssDecoderTest, DecodesLiterals) { + std::vector out; + EXPECT_EQ(LzssDecoder::Error::NONE, run({{true, 'a', 0}, {true, 'b', 0}, {true, 'c', 0}}, 3, out)); + EXPECT_EQ(std::vector({'a', 'b', 'c'}), out); +} + +TEST(LzssDecoderTest, DecodesAMatch) { + std::vector out; + // "abc" then copy 3 bytes from 3 back + EXPECT_EQ(LzssDecoder::Error::NONE, run({{true, 'a', 0}, {true, 'b', 0}, {true, 'c', 0}, {false, 3, 3}}, 6, out)); + EXPECT_EQ(std::vector({'a', 'b', 'c', 'a', 'b', 'c'}), out); +} + +TEST(LzssDecoderTest, OverlappingMatchProducesARun) { + // Distance 1 with length 5 repeats the previous byte, which is how the zero runs that dominate + // a patch are encoded. Copying byte-at-a-time rather than block-at-a-time is what makes this + // work, so this is the case that would break a memcpy-based decoder. + std::vector out; + EXPECT_EQ(LzssDecoder::Error::NONE, run({{true, 0, 0}, {false, 1, 5}}, 6, out)); + EXPECT_EQ(std::vector(6, 0), out); +} + +TEST(LzssDecoderTest, StopsAtTheExpectedSizeMidGroup) { + // The final group is padded to 8 tokens; anything past the expected size must be ignored + std::vector out; + EXPECT_EQ(LzssDecoder::Error::NONE, run({{true, 'x', 0}, {true, 'y', 0}, {true, 'z', 0}}, 2, out)); + EXPECT_EQ(std::vector({'x', 'y'}), out); +} + +TEST(LzssDecoderTest, RejectsADistanceBeyondWhatHasBeenProduced) { + // A corrupt or hostile stream must not read outside the window + std::vector out; + EXPECT_EQ(LzssDecoder::Error::BAD_DISTANCE, run({{true, 'a', 0}, {false, 8, 3}}, 4, out)); +} + +TEST(LzssDecoderTest, RejectsAZeroDistance) { + std::vector out; + EXPECT_EQ(LzssDecoder::Error::BAD_DISTANCE, run({{true, 'a', 0}, {false, 0, 3}}, 4, out)); +} + +TEST(LzssDecoderTest, RejectsAMatchRunningPastTheExpectedSize) { + std::vector out; + EXPECT_EQ(LzssDecoder::Error::OUTPUT_OVERRUN, run({{true, 'a', 0}, {false, 1, 10}}, 3, out)); +} + +TEST(LzssDecoderTest, ReportsATruncatedStream) { + std::vector out; + MemSource source(std::vector{}); + MemSink sink; + std::vector window(LzssDecoder::WINDOW_SIZE); + EXPECT_EQ(LzssDecoder::Error::TRUNCATED_INPUT, LzssDecoder::decode(source, sink, 4, window.data())); +} + +TEST(LzssDecoderTest, PropagatesOutputFailures) { + MemSource source(encode({{true, 'a', 0}})); + MemSink sink; + sink.fail(); + std::vector window(LzssDecoder::WINDOW_SIZE); + EXPECT_EQ(LzssDecoder::Error::OUTPUT_WRITE_FAILED, LzssDecoder::decode(source, sink, 1, window.data())); +} + +TEST(LzssDecoderTest, RejectsAMissingWindow) { + MemSource source(encode({{true, 'a', 0}})); + MemSink sink; + EXPECT_EQ(LzssDecoder::Error::OUTPUT_WRITE_FAILED, LzssDecoder::decode(source, sink, 1, nullptr)); +} + +TEST(LzssDecoderTest, AnEmptyStreamNeedsNoInput) { + MemSource source(std::vector{}); + MemSink sink; + std::vector window(LzssDecoder::WINDOW_SIZE); + EXPECT_EQ(LzssDecoder::Error::NONE, LzssDecoder::decode(source, sink, 0, window.data())); + EXPECT_TRUE(sink.out.empty()); +} + +TEST(LzssDecoderTest, MatchesReachAcrossTheWindowBoundary) { + // Produce more than a full window so the ring buffer wraps, then match recent history + std::vector tokens; + for (size_t i = 0; i < 200; i++) { + tokens.push_back({true, static_cast('A' + (i % 26)), 0}); + } + // Repeat the last 104 bytes until well past WINDOW_SIZE. 104 is a multiple of the 26 letter + // cycle, so the pattern continues seamlessly and any ring buffer wrap shows up as a mismatch. + for (size_t i = 0; i < 60; i++) { + tokens.push_back({false, 104, 104}); + } + std::vector out; + const uint32_t expected = 200 + 60 * 104; + ASSERT_GT(expected, LzssDecoder::WINDOW_SIZE); + EXPECT_EQ(LzssDecoder::Error::NONE, run(tokens, expected, out)); + ASSERT_EQ(expected, out.size()); + // Every byte must still follow the original repeating pattern + for (size_t i = 0; i < out.size(); i++) { + EXPECT_EQ('A' + (i % 26), out[i]) << "at " << i; + } +} diff --git a/PROVESFlightControllerReference/test/unit-tests/test_FlashWorker_PatchApplier.cpp b/PROVESFlightControllerReference/test/unit-tests/test_FlashWorker_PatchApplier.cpp new file mode 100644 index 00000000..a002b794 --- /dev/null +++ b/PROVESFlightControllerReference/test/unit-tests/test_FlashWorker_PatchApplier.cpp @@ -0,0 +1,374 @@ +// ====================================================================== +// \title test_FlashWorker_PatchApplier.cpp +// \brief Unit tests for delta patch application +// ====================================================================== + +#include + +#include +#include + +#include "PROVESFlightControllerReference/Components/FlashWorker/PatchApplier.hpp" + +using Components::PatchApplier; + +namespace { + +//! Sources and sink backed by memory, standing in for flash and the filesystem +class MemoryIo final : public PatchApplier::Io { + public: + MemoryIo(std::vector reference, + std::vector control, + std::vector diff, + std::vector extra) + : m_reference(std::move(reference)), + m_control(std::move(control)), + m_diff(std::move(diff)), + m_extra(std::move(extra)) {} + + bool readReference(uint32_t offset, uint8_t* buffer, size_t size) override { + if (m_fail_reference || (static_cast(offset) + size > m_reference.size())) { + return false; + } + std::memcpy(buffer, m_reference.data() + offset, size); + return true; + } + bool readControl(uint8_t* buffer, size_t size) override { return take(m_control, m_control_pos, buffer, size); } + bool readDiff(uint8_t* buffer, size_t size) override { return take(m_diff, m_diff_pos, buffer, size); } + bool readExtra(uint8_t* buffer, size_t size) override { return take(m_extra, m_extra_pos, buffer, size); } + bool writeOutput(const uint8_t* buffer, size_t size) override { + if (m_fail_output) { + return false; + } + m_output.insert(m_output.end(), buffer, buffer + size); + return true; + } + + const std::vector& output() const { return m_output; } + void failReference() { m_fail_reference = true; } + void failOutput() { m_fail_output = true; } + + private: + static bool take(const std::vector& source, size_t& position, uint8_t* buffer, size_t size) { + if (position + size > source.size()) { + return false; + } + std::memcpy(buffer, source.data() + position, size); + position += size; + return true; + } + + std::vector m_reference, m_control, m_diff, m_extra, m_output; + size_t m_control_pos = 0, m_diff_pos = 0, m_extra_pos = 0; + bool m_fail_reference = false, m_fail_output = false; +}; + +//! Append a little endian int32 +void putI32(std::vector& out, int32_t value) { + const uint32_t raw = static_cast(value); + out.push_back(static_cast(raw & 0xFF)); + out.push_back(static_cast((raw >> 8) & 0xFF)); + out.push_back(static_cast((raw >> 16) & 0xFF)); + out.push_back(static_cast((raw >> 24) & 0xFF)); +} + +//! Append one control record +void putControl(std::vector& out, int32_t copy, int32_t extra, int32_t seek) { + putI32(out, copy); + putI32(out, extra); + putI32(out, seek); +} + +//! Build a decoded header. Reference CRC is unused by apply, which checks content via the caller. +PatchApplier::Header hdr(uint32_t new_size, + uint32_t reference_size, + uint32_t control_size, + uint32_t diff_size, + uint32_t extra_size) { + PatchApplier::Header header{}; + header.new_size = new_size; + header.reference_size = reference_size; + header.reference_crc32 = 0; + header.control_size = control_size; + header.diff_size = diff_size; + header.extra_size = extra_size; + header.compression = PatchApplier::Compression::NONE; + return header; +} + +//! Build a valid header on the wire +std::vector buildHeaderWire(uint32_t new_size, + uint32_t reference_size, + uint32_t reference_crc32, + uint32_t control_size, + uint32_t diff_size, + uint32_t extra_size, + uint8_t version = PatchApplier::FORMAT_VERSION, + uint8_t codec = 0) { + std::vector out(PatchApplier::MAGIC, PatchApplier::MAGIC + sizeof(PatchApplier::MAGIC)); + out.push_back(version); + out.push_back(codec); + for (uint32_t value : {new_size, reference_size, reference_crc32, control_size, diff_size, extra_size}) { + putI32(out, static_cast(value)); + } + return out; +} + +} // namespace + +// ---------------------------------------------------------------------- +// Header decoding +// ---------------------------------------------------------------------- + +TEST(PatchApplierTest, DecodesAValidHeader) { + const std::vector wire = buildHeaderWire(1000, 900, 0xDEADBEEF, 24, 40, 60); + ASSERT_EQ(PatchApplier::HEADER_SIZE, wire.size()); + PatchApplier::Header header{}; + EXPECT_EQ(PatchApplier::Error::NONE, PatchApplier::decodeHeader(wire.data(), wire.size(), header)); + EXPECT_EQ(1000u, header.new_size); + EXPECT_EQ(900u, header.reference_size); + EXPECT_EQ(0xDEADBEEFu, header.reference_crc32); + EXPECT_EQ(24u, header.control_size); + EXPECT_EQ(40u, header.diff_size); + EXPECT_EQ(60u, header.extra_size); + EXPECT_EQ(PatchApplier::Compression::NONE, header.compression); +} + +TEST(PatchApplierTest, RejectsForeignOrCorruptHeaders) { + const std::vector wire = buildHeaderWire(10, 10, 0, 12, 0, 10); + PatchApplier::Header header{}; + + std::vector bad_magic = wire; + bad_magic[0] = 'X'; + EXPECT_EQ(PatchApplier::Error::BAD_MAGIC, PatchApplier::decodeHeader(bad_magic.data(), bad_magic.size(), header)); + + // A newer container must be refused rather than misinterpreted + const std::vector future = buildHeaderWire(10, 10, 0, 12, 0, 10, PatchApplier::FORMAT_VERSION + 1); + EXPECT_EQ(PatchApplier::Error::UNSUPPORTED_VERSION, + PatchApplier::decodeHeader(future.data(), future.size(), header)); + + // An unknown codec must be refused, not silently treated as verbatim + const std::vector compressed = buildHeaderWire(10, 10, 0, 12, 0, 10, PatchApplier::FORMAT_VERSION, 9); + EXPECT_EQ(PatchApplier::Error::UNSUPPORTED_COMPRESSION, + PatchApplier::decodeHeader(compressed.data(), compressed.size(), header)); + + EXPECT_EQ(PatchApplier::Error::TRUNCATED_PATCH, + PatchApplier::decodeHeader(wire.data(), PatchApplier::HEADER_SIZE - 1, header)); + EXPECT_EQ(PatchApplier::Error::TRUNCATED_PATCH, PatchApplier::decodeHeader(nullptr, 100, header)); +} + +// ---------------------------------------------------------------------- +// Control decoding +// ---------------------------------------------------------------------- + +TEST(PatchApplierTest, DecodesSignedControlValues) { + std::vector wire; + putControl(wire, 5, 3, -7); + PatchApplier::Control control{}; + ASSERT_TRUE(PatchApplier::decodeControl(wire.data(), wire.size(), control)); + EXPECT_EQ(5, control.copy); + EXPECT_EQ(3, control.extra); + EXPECT_EQ(-7, control.seek); +} + +TEST(PatchApplierTest, DecodesExtremeSignedControlValues) { + std::vector wire; + putControl(wire, 0, 0, INT32_MIN); + PatchApplier::Control control{}; + ASSERT_TRUE(PatchApplier::decodeControl(wire.data(), wire.size(), control)); + EXPECT_EQ(INT32_MIN, control.seek); +} + +// ---------------------------------------------------------------------- +// Reference binding +// ---------------------------------------------------------------------- + +TEST(PatchApplierTest, RefusesToPatchAgainstAReferenceOfTheWrongSize) { + // Patching the wrong reference yields a plausible but corrupt image that would then be + // flashed and booted, so a mismatch must stop the apply before it does any work + const std::vector reference = {1, 2, 3, 4}; + std::vector control; + putControl(control, 4, 0, 0); + MemoryIo io(reference, control, std::vector(4, 0), {}); + uint8_t scratch[8]; + EXPECT_EQ(PatchApplier::Error::WRONG_REFERENCE, + PatchApplier::apply(hdr(4, 8, 12, 4, 0), 4, io, scratch, sizeof(scratch))); + EXPECT_TRUE(io.output().empty()); +} + +// ---------------------------------------------------------------------- +// Applying +// ---------------------------------------------------------------------- + +TEST(PatchApplierTest, ReconstructsAnIdenticalImage) { + // One record copying the whole reference with a zero difference reproduces it exactly + const std::vector reference = {1, 2, 3, 4, 5, 6, 7, 8}; + std::vector control; + putControl(control, 8, 0, 0); + + MemoryIo io(reference, control, std::vector(8, 0), {}); + uint8_t scratch[16]; + EXPECT_EQ(PatchApplier::Error::NONE, PatchApplier::apply(hdr(8, 8, 12, 8, 0), 8, io, scratch, sizeof(scratch))); + EXPECT_EQ(reference, io.output()); +} + +TEST(PatchApplierTest, AppliesPerByteDifferences) { + const std::vector reference = {10, 20, 30, 40}; + std::vector control; + putControl(control, 4, 0, 0); + + MemoryIo io(reference, control, std::vector({1, 2, 3, 4}), {}); + uint8_t scratch[16]; + EXPECT_EQ(PatchApplier::Error::NONE, PatchApplier::apply(hdr(4, 4, 12, 4, 0), 4, io, scratch, sizeof(scratch))); + EXPECT_EQ(std::vector({11, 22, 33, 44}), io.output()); +} + +TEST(PatchApplierTest, DifferenceAdditionWrapsRatherThanSaturating) { + // Wrapping addition is the bsdiff definition; saturating here would corrupt the image + const std::vector reference = {250, 0}; + std::vector control; + putControl(control, 2, 0, 0); + + MemoryIo io(reference, control, std::vector({10, 255}), {}); + uint8_t scratch[8]; + EXPECT_EQ(PatchApplier::Error::NONE, PatchApplier::apply(hdr(2, 2, 12, 2, 0), 2, io, scratch, sizeof(scratch))); + EXPECT_EQ(std::vector({4, 255}), io.output()); +} + +TEST(PatchApplierTest, AppendsLiteralBytesFromTheExtraStream) { + const std::vector reference = {1, 2}; + std::vector control; + putControl(control, 2, 3, 0); + + MemoryIo io(reference, control, std::vector(2, 0), std::vector({77, 88, 99})); + uint8_t scratch[8]; + EXPECT_EQ(PatchApplier::Error::NONE, PatchApplier::apply(hdr(5, 2, 12, 2, 3), 2, io, scratch, sizeof(scratch))); + EXPECT_EQ(std::vector({1, 2, 77, 88, 99}), io.output()); +} + +TEST(PatchApplierTest, SeekMovesTheReferencePositionBothWays) { + // Reconstruct {3,4,1,2} from {1,2,3,4}: skip ahead, copy the tail, rewind, copy the head + const std::vector reference = {1, 2, 3, 4}; + std::vector control; + putControl(control, 0, 0, 2); + putControl(control, 2, 0, -4); + putControl(control, 2, 0, 0); + + MemoryIo io(reference, control, std::vector(4, 0), {}); + uint8_t scratch[8]; + EXPECT_EQ(PatchApplier::Error::NONE, PatchApplier::apply(hdr(4, 4, 36, 4, 0), 4, io, scratch, sizeof(scratch))); + EXPECT_EQ(std::vector({3, 4, 1, 2}), io.output()); +} + +TEST(PatchApplierTest, WorksWithATinyScratchBuffer) { + // The apply must chunk correctly when the buffer is far smaller than the runs + const std::vector reference = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; + std::vector control; + putControl(control, 10, 4, 0); + + MemoryIo io(reference, control, std::vector(10, 1), std::vector({100, 101, 102, 103})); + uint8_t scratch[2]; // one byte per half + EXPECT_EQ(PatchApplier::Error::NONE, PatchApplier::apply(hdr(14, 10, 12, 10, 4), 10, io, scratch, sizeof(scratch))); + EXPECT_EQ(std::vector({2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 100, 101, 102, 103}), io.output()); +} + +// ---------------------------------------------------------------------- +// Malformed and hostile patches +// +// A patch arrives over the radio, so the applier must refuse to read or write outside the +// images rather than trusting the control stream. +// ---------------------------------------------------------------------- + +TEST(PatchApplierTest, RejectsNegativeCopyOrExtra) { + const std::vector reference = {1, 2, 3, 4}; + for (int field = 0; field < 2; field++) { + std::vector control; + putControl(control, (field == 0) ? -1 : 1, (field == 0) ? 1 : -1, 0); + MemoryIo io(reference, control, std::vector(4, 0), {}); + uint8_t scratch[8]; + EXPECT_EQ(PatchApplier::Error::CORRUPT_CONTROL, + PatchApplier::apply(hdr(4, 4, 12, 4, 0), 4, io, scratch, sizeof(scratch))); + } +} + +TEST(PatchApplierTest, RejectsCopyRunningPastTheReference) { + const std::vector reference = {1, 2, 3, 4}; + std::vector control; + putControl(control, 8, 0, 0); // more than the reference holds + MemoryIo io(reference, control, std::vector(8, 0), {}); + uint8_t scratch[8]; + EXPECT_EQ(PatchApplier::Error::CORRUPT_CONTROL, + PatchApplier::apply(hdr(8, 4, 12, 8, 0), 4, io, scratch, sizeof(scratch))); +} + +TEST(PatchApplierTest, RejectsWritingPastTheDeclaredImageSize) { + const std::vector reference = {1, 2, 3, 4}; + std::vector control; + putControl(control, 4, 0, 0); // writes 4 bytes into a 2 byte image + MemoryIo io(reference, control, std::vector(4, 0), {}); + uint8_t scratch[8]; + EXPECT_EQ(PatchApplier::Error::CORRUPT_CONTROL, + PatchApplier::apply(hdr(2, 4, 12, 4, 0), 4, io, scratch, sizeof(scratch))); +} + +TEST(PatchApplierTest, RejectsSeekingBeforeTheStartOfTheReference) { + const std::vector reference = {1, 2, 3, 4}; + std::vector control; + putControl(control, 0, 0, -100); // seek far negative + putControl(control, 1, 0, 0); // then try to read + MemoryIo io(reference, control, std::vector(4, 0), {}); + uint8_t scratch[8]; + EXPECT_EQ(PatchApplier::Error::CORRUPT_CONTROL, + PatchApplier::apply(hdr(1, 4, 24, 4, 0), 4, io, scratch, sizeof(scratch))); +} + +TEST(PatchApplierTest, ReportsATruncatedControlStream) { + const std::vector reference = {1, 2, 3, 4}; + MemoryIo io(reference, {}, {}, {}); // no control records at all + uint8_t scratch[8]; + EXPECT_EQ(PatchApplier::Error::TRUNCATED_PATCH, + PatchApplier::apply(hdr(4, 4, 0, 0, 0), 4, io, scratch, sizeof(scratch))); +} + +TEST(PatchApplierTest, ReportsATruncatedDiffStream) { + const std::vector reference = {1, 2, 3, 4}; + std::vector control; + putControl(control, 4, 0, 0); + MemoryIo io(reference, control, std::vector(2, 0), {}); // diff is short + uint8_t scratch[8]; + EXPECT_EQ(PatchApplier::Error::PATCH_READ_FAILED, + PatchApplier::apply(hdr(4, 4, 12, 4, 0), 4, io, scratch, sizeof(scratch))); +} + +TEST(PatchApplierTest, PropagatesIoFailures) { + const std::vector reference = {1, 2, 3, 4}; + std::vector control; + putControl(control, 4, 0, 0); + uint8_t scratch[8]; + + MemoryIo reference_failure(reference, control, std::vector(4, 0), {}); + reference_failure.failReference(); + EXPECT_EQ(PatchApplier::Error::REFERENCE_READ_FAILED, + PatchApplier::apply(hdr(4, 4, 12, 4, 0), 4, reference_failure, scratch, sizeof(scratch))); + + MemoryIo output_failure(reference, control, std::vector(4, 0), {}); + output_failure.failOutput(); + EXPECT_EQ(PatchApplier::Error::OUTPUT_WRITE_FAILED, + PatchApplier::apply(hdr(4, 4, 12, 4, 0), 4, output_failure, scratch, sizeof(scratch))); +} + +TEST(PatchApplierTest, RejectsAnUnusableScratchBuffer) { + const std::vector reference = {1, 2}; + MemoryIo io(reference, {}, {}, {}); + uint8_t scratch[2]; + EXPECT_EQ(PatchApplier::Error::CORRUPT_CONTROL, PatchApplier::apply(hdr(2, 2, 0, 0, 0), 2, io, nullptr, 8)); + EXPECT_EQ(PatchApplier::Error::CORRUPT_CONTROL, PatchApplier::apply(hdr(2, 2, 0, 0, 0), 2, io, scratch, 1)); +} + +TEST(PatchApplierTest, AnEmptyImageNeedsNoRecords) { + const std::vector reference = {1, 2}; + MemoryIo io(reference, {}, {}, {}); + uint8_t scratch[8]; + EXPECT_EQ(PatchApplier::Error::NONE, PatchApplier::apply(hdr(0, 2, 0, 0, 0), 2, io, scratch, sizeof(scratch))); + EXPECT_TRUE(io.output().empty()); +} diff --git a/PROVESFlightControllerReference/test/unit-tests/test_FlashWorker_SegmentPlan.cpp b/PROVESFlightControllerReference/test/unit-tests/test_FlashWorker_SegmentPlan.cpp new file mode 100644 index 00000000..753852bb --- /dev/null +++ b/PROVESFlightControllerReference/test/unit-tests/test_FlashWorker_SegmentPlan.cpp @@ -0,0 +1,86 @@ +// ====================================================================== +// \title test_ImageBuilder_SegmentPlan.cpp +// \brief Unit tests for uplink segment naming and validation +// ====================================================================== + +#include + +#include + +#include "PROVESFlightControllerReference/Components/FlashWorker/SegmentPlan.hpp" + +using Components::SegmentPlan; + +namespace { + +//! Format a segment name into a generously sized buffer +std::string name(const char* prefix, uint16_t index, size_t buffer_size = 64) { + std::string buffer(buffer_size, '\xFF'); + const bool ok = SegmentPlan::formatSegmentName(prefix, index, &buffer[0], buffer_size); + EXPECT_TRUE(ok) << "formatting segment " << index << " of " << prefix; + return std::string(buffer.c_str()); +} + +} // namespace + +// ---------------------------------------------------------------------- +// Segment counts +// ---------------------------------------------------------------------- + +TEST(SegmentPlanTest, RejectsEmptyAndOversizedCounts) { + EXPECT_FALSE(SegmentPlan::isValidSegmentCount(0)); + EXPECT_TRUE(SegmentPlan::isValidSegmentCount(1)); + EXPECT_TRUE(SegmentPlan::isValidSegmentCount(SegmentPlan::MAX_SEGMENTS)); + EXPECT_FALSE(SegmentPlan::isValidSegmentCount(SegmentPlan::MAX_SEGMENTS + 1)); +} + +// ---------------------------------------------------------------------- +// Naming +// ---------------------------------------------------------------------- + +TEST(SegmentPlanTest, NamesAreZeroPaddedToFixedWidth) { + // Fixed width keeps segment names sorting in transfer order + EXPECT_EQ("/update/img.000", name("/update/img", 0)); + EXPECT_EQ("/update/img.001", name("/update/img", 1)); + EXPECT_EQ("/update/img.042", name("/update/img", 42)); + EXPECT_EQ("/update/img.998", name("/update/img", 998)); +} + +TEST(SegmentPlanTest, NamesSortInTransferOrder) { + EXPECT_LT(name("/u/i", 0), name("/u/i", 1)); + EXPECT_LT(name("/u/i", 9), name("/u/i", 10)); + EXPECT_LT(name("/u/i", 99), name("/u/i", 100)); +} + +TEST(SegmentPlanTest, RejectsIndexAtOrAboveTheLimit) { + char buffer[64]; + EXPECT_FALSE(SegmentPlan::formatSegmentName("/u/i", SegmentPlan::MAX_SEGMENTS, buffer, sizeof(buffer))); + // Even on rejection the buffer must hold a usable, terminated string + EXPECT_STREQ("", buffer); +} + +TEST(SegmentPlanTest, RejectsBuffersThatCannotHoldTheName) { + const char* prefix = "/update/img"; + const size_t needed = SegmentPlan::requiredNameSize(11); + EXPECT_EQ(16u, needed); // 11 prefix + '.' + 3 digits + null + + char exact[16]; + EXPECT_TRUE(SegmentPlan::formatSegmentName(prefix, 7, exact, sizeof(exact))); + EXPECT_STREQ("/update/img.007", exact); + + // One byte short must fail rather than truncate + char small[15]; + EXPECT_FALSE(SegmentPlan::formatSegmentName(prefix, 7, small, sizeof(small))); + EXPECT_STREQ("", small); +} + +TEST(SegmentPlanTest, RejectsNullArguments) { + char buffer[64]; + EXPECT_FALSE(SegmentPlan::formatSegmentName(nullptr, 0, buffer, sizeof(buffer))); + EXPECT_FALSE(SegmentPlan::formatSegmentName("/u/i", 0, nullptr, sizeof(buffer))); + EXPECT_FALSE(SegmentPlan::formatSegmentName("/u/i", 0, buffer, 0)); +} + +TEST(SegmentPlanTest, HandlesAnEmptyPrefix) { + EXPECT_EQ(".005", name("", 5)); +} diff --git a/PROVESFlightControllerReference/test/unit-tests/test_FlashWorker_UpdateSequencer.cpp b/PROVESFlightControllerReference/test/unit-tests/test_FlashWorker_UpdateSequencer.cpp new file mode 100644 index 00000000..0523b244 --- /dev/null +++ b/PROVESFlightControllerReference/test/unit-tests/test_FlashWorker_UpdateSequencer.cpp @@ -0,0 +1,289 @@ +// ====================================================================== +// \title test_FlashWorker_UpdateSequencer.cpp +// \brief Unit tests for the flight software update sequencing helper +// ====================================================================== + +#include + +#include "PROVESFlightControllerReference/Components/FlashWorker/UpdateSequencer.hpp" + +using Components::UpdateSequencer; + +namespace { + +//! Drive a sequencer to the PREPARED state +UpdateSequencer prepared() { + UpdateSequencer sequencer; + EXPECT_EQ(UpdateSequencer::Status::OP_OK, sequencer.onPrepareComplete(true)); + return sequencer; +} + +} // namespace + +// ---------------------------------------------------------------------- +// Initial state +// ---------------------------------------------------------------------- + +TEST(UpdateSequencerTest, StartsIdleAndUnprepared) { + UpdateSequencer sequencer; + EXPECT_EQ(UpdateSequencer::Step::IDLE, sequencer.step()); + EXPECT_FALSE(sequencer.isPrepared()); +} + +// ---------------------------------------------------------------------- +// Preparation +// ---------------------------------------------------------------------- + +TEST(UpdateSequencerTest, SuccessfulPrepareBecomesPrepared) { + UpdateSequencer sequencer; + EXPECT_EQ(UpdateSequencer::Status::OP_OK, sequencer.onPrepareComplete(true)); + EXPECT_EQ(UpdateSequencer::Step::PREPARED, sequencer.step()); + EXPECT_TRUE(sequencer.isPrepared()); +} + +TEST(UpdateSequencerTest, FailedPrepareReportsPreparationErrorAndStaysIdle) { + UpdateSequencer sequencer; + EXPECT_EQ(UpdateSequencer::Status::PREPARATION_ERROR, sequencer.onPrepareComplete(false)); + EXPECT_EQ(UpdateSequencer::Step::IDLE, sequencer.step()); + EXPECT_FALSE(sequencer.isPrepared()); +} + +TEST(UpdateSequencerTest, FailedPrepareAfterSuccessfulPrepareDropsToIdle) { + UpdateSequencer sequencer = prepared(); + EXPECT_EQ(UpdateSequencer::Status::PREPARATION_ERROR, sequencer.onPrepareComplete(false)); + EXPECT_EQ(UpdateSequencer::Step::IDLE, sequencer.step()); +} + +// ---------------------------------------------------------------------- +// Ordering: an update requires a preparation +// ---------------------------------------------------------------------- + +TEST(UpdateSequencerTest, UpdateWithoutPrepareIsRejected) { + UpdateSequencer sequencer; + EXPECT_EQ(UpdateSequencer::Status::UNPREPARED, sequencer.onUpdateComplete(UpdateSequencer::WriteOutcome::SUCCESS)); + EXPECT_EQ(UpdateSequencer::Step::IDLE, sequencer.step()); +} + +TEST(UpdateSequencerTest, SecondUpdateWithoutRepreparingIsRejected) { + UpdateSequencer sequencer = prepared(); + EXPECT_EQ(UpdateSequencer::Status::OP_OK, sequencer.onUpdateComplete(UpdateSequencer::WriteOutcome::SUCCESS)); + EXPECT_EQ(UpdateSequencer::Step::UPDATED, sequencer.step()); + // The slot now holds an image; writing another without erasing first must not be allowed + EXPECT_EQ(UpdateSequencer::Status::UNPREPARED, sequencer.onUpdateComplete(UpdateSequencer::WriteOutcome::SUCCESS)); + EXPECT_EQ(UpdateSequencer::Step::UPDATED, sequencer.step()); +} + +TEST(UpdateSequencerTest, PrepareAfterUpdateAllowsAnotherUpdate) { + UpdateSequencer sequencer = prepared(); + EXPECT_EQ(UpdateSequencer::Status::OP_OK, sequencer.onUpdateComplete(UpdateSequencer::WriteOutcome::SUCCESS)); + EXPECT_EQ(UpdateSequencer::Status::OP_OK, sequencer.onPrepareComplete(true)); + EXPECT_TRUE(sequencer.isPrepared()); + EXPECT_EQ(UpdateSequencer::Status::OP_OK, sequencer.onUpdateComplete(UpdateSequencer::WriteOutcome::SUCCESS)); +} + +// ---------------------------------------------------------------------- +// Failures are reported as failures +// +// Regression coverage for the defect where every non-CRC failure returned OP_OK, causing the +// Updater component to emit UpdateSucceeded for an image that was never fully written. +// ---------------------------------------------------------------------- + +TEST(UpdateSequencerTest, FileOpenFailureReportsReadError) { + UpdateSequencer sequencer = prepared(); + EXPECT_EQ(UpdateSequencer::Status::IMAGE_FILE_READ_ERROR, + sequencer.onUpdateComplete(UpdateSequencer::WriteOutcome::FILE_OPEN_FAILED)); +} + +TEST(UpdateSequencerTest, FileQueryFailureReportsReadError) { + UpdateSequencer sequencer = prepared(); + EXPECT_EQ(UpdateSequencer::Status::IMAGE_FILE_READ_ERROR, + sequencer.onUpdateComplete(UpdateSequencer::WriteOutcome::FILE_QUERY_FAILED)); +} + +TEST(UpdateSequencerTest, FileReadFailureReportsReadError) { + UpdateSequencer sequencer = prepared(); + EXPECT_EQ(UpdateSequencer::Status::IMAGE_FILE_READ_ERROR, + sequencer.onUpdateComplete(UpdateSequencer::WriteOutcome::FILE_READ_FAILED)); +} + +TEST(UpdateSequencerTest, CrcMismatchReportsCrcMismatch) { + UpdateSequencer sequencer = prepared(); + EXPECT_EQ(UpdateSequencer::Status::IMAGE_CRC_MISMATCH, + sequencer.onUpdateComplete(UpdateSequencer::WriteOutcome::CRC_MISMATCH)); +} + +TEST(UpdateSequencerTest, FlashWriteFailureReportsFlashWriteError) { + UpdateSequencer sequencer = prepared(); + EXPECT_EQ(UpdateSequencer::Status::FLASH_WRITE_ERROR, + sequencer.onUpdateComplete(UpdateSequencer::WriteOutcome::FLASH_WRITE_FAILED)); +} + +TEST(UpdateSequencerTest, NoFailureOutcomeReportsSuccess) { + // Every outcome other than SUCCESS must map to a non-OK status + const UpdateSequencer::WriteOutcome failures[] = { + UpdateSequencer::WriteOutcome::FILE_OPEN_FAILED, UpdateSequencer::WriteOutcome::FILE_QUERY_FAILED, + UpdateSequencer::WriteOutcome::CRC_MISMATCH, UpdateSequencer::WriteOutcome::FILE_READ_FAILED, + UpdateSequencer::WriteOutcome::FLASH_WRITE_FAILED, + }; + for (const UpdateSequencer::WriteOutcome outcome : failures) { + EXPECT_NE(UpdateSequencer::Status::OP_OK, UpdateSequencer::statusForOutcome(outcome)); + } + EXPECT_EQ(UpdateSequencer::Status::OP_OK, + UpdateSequencer::statusForOutcome(UpdateSequencer::WriteOutcome::SUCCESS)); +} + +// ---------------------------------------------------------------------- +// Retry cost: only failures that reached the flash force another erase +// ---------------------------------------------------------------------- + +TEST(UpdateSequencerTest, FailuresBeforeAnyFlashWriteStayPrepared) { + // A mistyped file name or a bad CRC never reaches the flash, so the erased slot is still good + // and the operator can retry immediately instead of paying for another 1 MB erase on orbit. + const UpdateSequencer::WriteOutcome clean[] = { + UpdateSequencer::WriteOutcome::FILE_OPEN_FAILED, + UpdateSequencer::WriteOutcome::FILE_QUERY_FAILED, + UpdateSequencer::WriteOutcome::CRC_MISMATCH, + }; + for (const UpdateSequencer::WriteOutcome outcome : clean) { + UpdateSequencer sequencer = prepared(); + EXPECT_FALSE(UpdateSequencer::dirtiesStagingSlot(outcome)); + sequencer.onUpdateComplete(outcome); + EXPECT_TRUE(sequencer.isPrepared()) << "outcome " << static_cast(outcome) << " should be retryable"; + } +} + +TEST(UpdateSequencerTest, FailuresPartWayThroughRequireAnotherPrepare) { + // These leave a partial image behind, so the slot must be erased again before another attempt + const UpdateSequencer::WriteOutcome dirty[] = { + UpdateSequencer::WriteOutcome::FILE_READ_FAILED, + UpdateSequencer::WriteOutcome::FLASH_WRITE_FAILED, + }; + for (const UpdateSequencer::WriteOutcome outcome : dirty) { + UpdateSequencer sequencer = prepared(); + EXPECT_TRUE(UpdateSequencer::dirtiesStagingSlot(outcome)); + sequencer.onUpdateComplete(outcome); + EXPECT_EQ(UpdateSequencer::Step::IDLE, sequencer.step()); + EXPECT_FALSE(sequencer.isPrepared()); + // And the retry is correctly refused until a preparation runs + EXPECT_EQ(UpdateSequencer::Status::UNPREPARED, + sequencer.onUpdateComplete(UpdateSequencer::WriteOutcome::SUCCESS)); + } +} + +// ---------------------------------------------------------------------- +// Stage reporting +// ---------------------------------------------------------------------- + +TEST(UpdateSequencerTest, SettledStageFollowsTheSequence) { + UpdateSequencer sequencer; + EXPECT_EQ(UpdateSequencer::Stage::IDLE, sequencer.settledStage()); + sequencer.onPrepareComplete(true); + EXPECT_EQ(UpdateSequencer::Stage::PREPARED, sequencer.settledStage()); + sequencer.onUpdateComplete(UpdateSequencer::WriteOutcome::SUCCESS); + EXPECT_EQ(UpdateSequencer::Stage::UPDATED, sequencer.settledStage()); +} + +TEST(UpdateSequencerTest, SettledStageReportsFailure) { + UpdateSequencer sequencer; + sequencer.onPrepareComplete(false); + EXPECT_EQ(UpdateSequencer::Stage::FAILED, sequencer.settledStage()); + + UpdateSequencer other = prepared(); + other.onUpdateComplete(UpdateSequencer::WriteOutcome::FLASH_WRITE_FAILED); + EXPECT_EQ(UpdateSequencer::Stage::FAILED, other.settledStage()); +} + +TEST(UpdateSequencerTest, SettledStageClearsFailureAfterRecovery) { + UpdateSequencer sequencer = prepared(); + sequencer.onUpdateComplete(UpdateSequencer::WriteOutcome::FLASH_WRITE_FAILED); + EXPECT_EQ(UpdateSequencer::Stage::FAILED, sequencer.settledStage()); + // A successful re-preparation must not leave stale failure state in telemetry + sequencer.onPrepareComplete(true); + EXPECT_EQ(UpdateSequencer::Stage::PREPARED, sequencer.settledStage()); +} + +// ---------------------------------------------------------------------- +// Progress reporting +// ---------------------------------------------------------------------- + +TEST(UpdateSequencerTest, PercentCompleteIsProportional) { + EXPECT_EQ(0, UpdateSequencer::percentComplete(0, 1000)); + EXPECT_EQ(25, UpdateSequencer::percentComplete(250, 1000)); + EXPECT_EQ(50, UpdateSequencer::percentComplete(500, 1000)); + EXPECT_EQ(100, UpdateSequencer::percentComplete(1000, 1000)); +} + +TEST(UpdateSequencerTest, PercentCompleteHandlesDegenerateSizes) { + // An unreadable size must not divide by zero + EXPECT_EQ(0, UpdateSequencer::percentComplete(0, 0)); + EXPECT_EQ(0, UpdateSequencer::percentComplete(500, 0)); + // Nor may over-reporting produce a percentage above 100 + EXPECT_EQ(100, UpdateSequencer::percentComplete(2000, 1000)); +} + +TEST(UpdateSequencerTest, PercentCompleteFloorsAtRealisticImageSizes) { + // Sizes taken from a real signed image (726784 bytes). Percentage floors rather than rounds, so + // telemetry never claims 100% before the last byte is written. + EXPECT_EQ(50, UpdateSequencer::percentComplete(363392, 726784)); + EXPECT_EQ(98, UpdateSequencer::percentComplete(719516, 726784)); + EXPECT_EQ(99, UpdateSequencer::percentComplete(726000, 726784)); + EXPECT_EQ(100, UpdateSequencer::percentComplete(726784, 726784)); +} + +TEST(UpdateSequencerTest, ProgressReportsEveryStep) { + EXPECT_FALSE(UpdateSequencer::progressReportDue(0, 0, 10)); + EXPECT_FALSE(UpdateSequencer::progressReportDue(9, 0, 10)); + EXPECT_TRUE(UpdateSequencer::progressReportDue(10, 0, 10)); + EXPECT_FALSE(UpdateSequencer::progressReportDue(15, 10, 10)); + EXPECT_TRUE(UpdateSequencer::progressReportDue(20, 10, 10)); +} + +TEST(UpdateSequencerTest, ProgressAlwaysReportsCompletionExactlyOnce) { + // Completion matters even when it does not land on a step boundary + EXPECT_TRUE(UpdateSequencer::progressReportDue(100, 95, 10)); + EXPECT_FALSE(UpdateSequencer::progressReportDue(100, 100, 10)); +} + +TEST(UpdateSequencerTest, ProgressStepOfZeroDoesNotSilenceReporting) { + // A misconfigured parameter must not disable progress entirely + EXPECT_TRUE(UpdateSequencer::progressReportDue(1, 0, 0)); + EXPECT_TRUE(UpdateSequencer::progressReportDue(50, 49, 0)); +} + +TEST(UpdateSequencerTest, ProgressDoesNotReportGoingBackwards) { + EXPECT_FALSE(UpdateSequencer::progressReportDue(10, 50, 10)); +} + +// ---------------------------------------------------------------------- +// Automatic confirmation of a test-booted image +// ---------------------------------------------------------------------- + +TEST(UpdateSequencerTest, AutoConfirmNeverHappensUnlessArmedFromTheGround) { + // Confirmation changes which image the spacecraft keeps, so it stays operator-in-the-loop + // until the ground explicitly arms it + EXPECT_FALSE(UpdateSequencer::autoConfirmDue(false, false, 100000, 10)); +} + +TEST(UpdateSequencerTest, AutoConfirmSkipsAnAlreadyConfirmedImage) { + EXPECT_FALSE(UpdateSequencer::autoConfirmDue(true, true, 100000, 10)); +} + +TEST(UpdateSequencerTest, AutoConfirmWaitsForTheConfiguredTime) { + EXPECT_FALSE(UpdateSequencer::autoConfirmDue(true, false, 9, 10)); + EXPECT_TRUE(UpdateSequencer::autoConfirmDue(true, false, 10, 10)); + EXPECT_TRUE(UpdateSequencer::autoConfirmDue(true, false, 11, 10)); +} + +TEST(UpdateSequencerTest, AutoConfirmStillRequiresAnElapsedSecondAtZeroDelay) { + // A zero delay must not let an image that crashes immediately confirm itself + EXPECT_FALSE(UpdateSequencer::autoConfirmDue(true, false, 0, 0)); + EXPECT_TRUE(UpdateSequencer::autoConfirmDue(true, false, 1, 0)); +} + +TEST(UpdateSequencerTest, RetryAfterCleanFailureCanSucceed) { + UpdateSequencer sequencer = prepared(); + EXPECT_EQ(UpdateSequencer::Status::IMAGE_CRC_MISMATCH, + sequencer.onUpdateComplete(UpdateSequencer::WriteOutcome::CRC_MISMATCH)); + EXPECT_EQ(UpdateSequencer::Status::OP_OK, sequencer.onUpdateComplete(UpdateSequencer::WriteOutcome::SUCCESS)); + EXPECT_EQ(UpdateSequencer::Step::UPDATED, sequencer.step()); +} diff --git a/boards/bronco_space/proves_flight_control_board_v5/proves_flight_control_board_v5.dtsi b/boards/bronco_space/proves_flight_control_board_v5/proves_flight_control_board_v5.dtsi index 5b9a1132..aafc2799 100644 --- a/boards/bronco_space/proves_flight_control_board_v5/proves_flight_control_board_v5.dtsi +++ b/boards/bronco_space/proves_flight_control_board_v5/proves_flight_control_board_v5.dtsi @@ -74,18 +74,21 @@ zephyr_udc0: &usbd { reg = <0x0 0x100000>; }; + /* MCUBoot primary slot: the image that is currently running */ slot0_partition: partition@100000 { - label = "current"; + label = "primary"; reg = <0x100000 0x100000>; }; + /* MCUBoot secondary slot: staging area that OTA updates are written into */ slot1_partition: partition@200000 { - label = "golden"; + label = "secondary"; reg = <0x200000 0x100000>; }; + /* Reserved. Not used by MCUBoot in swap-using-offset mode with a single image. */ slot2_partition: partition@300000 { - label = "test"; + label = "reserved"; reg = <0x300000 0x100000>; }; diff --git a/docs-site/components/FlashWorker.md b/docs-site/components/FlashWorker.md index cbbee1ae..96168de9 100644 --- a/docs-site/components/FlashWorker.md +++ b/docs-site/components/FlashWorker.md @@ -2,69 +2,158 @@ Performs long-running operations for the flash subsystem. The flash worker is responsible for handling the actual operations needed for flight-software update specific to the Zephyr flash API. +It plays two roles: +1. **Update worker.** It implements the `Update.UpdateWorker` interface, so the generic `Update.Updater` component drives it through prepare, write, next-boot, and confirm. +2. **Image builder.** It owns two commands that prepare a candidate image on the filesystem before that image is written to flash, so that a full image never has to cross the radio link in one piece. +## Why images are not simply uplinked whole +A signed flight image is roughly 727 KB. The ground station paces file uplink at 204-byte chunks with a 0.4 s cooldown (`file-uplink-chunk-size` and `file-uplink-cooldown` in `fprime-gds.yml`), which is about 510 B/s, so a whole image needs roughly 24 minutes of continuous contact. A pass is single-digit minutes, and F Prime's file uplink has no cross-pass resume, so an interrupted transfer loses everything sent so far. -## Usage Examples -Add usage examples here +Two ways out, both supported here: -### Diagrams -Add diagrams here +| approach | bytes to uplink | time at 510 B/s | +|---|---|---| +| whole image | 726,784 | ~24 min | +| image in numbered segments | same total, split across passes | survives a pass boundary | +| delta patch against the running image | ~47-97 KB compressed | ~1.5-3.2 min | + +Delta figures are measured with bsdiff against real consecutive CI builds. Naive block-level diffing does **not** work: any code size change shifts every later address, so ~99% of 512-byte blocks differ between builds days apart. + +## Typical Usage + +### Whole image, one pass + +``` +uplink /update/zephyr.signed.bin +Update.updater.PREPARE_UPDATE +Update.updater.UPDATE_IMAGE_FROM("/update/zephyr.signed.bin", ) +Update.updater.CONFIGURE_NEXT_BOOT(TEST) +reboot +Update.updater.CONFIRM_UPDATE +``` + +Get `` from `tools/bin/calculate-crc.py`. + +### Image split across several passes + +``` +uplink /update/img.000, /update/img.001, ... (one or more per pass) +Update.worker.ASSEMBLE_IMAGE("/update/img", , "/update/candidate.bin", ) +Update.updater.PREPARE_UPDATE +Update.updater.UPDATE_IMAGE_FROM("/update/candidate.bin", ) +``` -### Typical Usage -And the typical usage of the component here +Assembly verifies the joined image against `` before anything is written to flash, so a missing or reordered segment is caught rather than left for the bootloader to find. + +### Delta patch + +``` +(ground) tools/bin/make-patch.py -o update.patch +uplink /update/update.patch +Update.worker.APPLY_PATCH("/update/update.patch", "/update/candidate.bin", ) +Update.updater.PREPARE_UPDATE +Update.updater.UPDATE_IMAGE_FROM("/update/candidate.bin", ) +``` + +The reference is the running image, read directly out of the `slot0_partition` flash area. The RP2350 executes XIP from memory-mapped QSPI, so no copy has to be kept on the filesystem. The patch container records the size and CRC32 of the reference it was built from, and `APPLY_PATCH` refuses to run if the running image is not that one. Patching the wrong reference produces a plausible but corrupt image that would then be flashed and booted, so this check is not optional. + +**Compression is not yet available on the flight side.** The patch streams are ~84% zero bytes and compress from ~727 KB to ~60 KB with DEFLATE, ~47 KB with LZMA, or ~97 KB with heatshrink, but this Zephyr workspace ships no decompressor and selecting that dependency is a project decision. Until it is made, `make-patch.py` refuses to emit a patch without `--allow-uncompressed`, because an uncompressed patch is the size of the image and worth nothing over the radio. The container carries a codec field so a compressed format can be added without changing the applier's structure. + +## Flash Layout + +Defined in `boards/bronco_space/proves_flight_control_board_v5/proves_flight_control_board_v5.dtsi`. + +| partition | label | size | role | +|---|---|---|---| +| `boot_partition` | mcuboot | 1 MB | bootloader | +| `slot0_partition` | primary | 1 MB | running image, and the patch reference | +| `slot1_partition` | secondary | 1 MB | staging slot updates are written into | +| `slot2_partition` | reserved | 1 MB | unused in swap-using-offset mode with one image | +| `storage_partition` | n/a | 12 MB | LittleFS, holds uplinked segments and candidates | -## Class Diagram -Add a class diagram here +Partition IDs are taken from the device tree with `FIXED_PARTITION_ID`, never hard coded: fixed partition IDs follow declaration order, so a literal would silently point at the wrong region if a partition were added above it. ## Port Descriptions | Name | Description | |---|---| -|---|---| +| prepareImage | Erase the staging slot, from `Update.Updater` | +| updateImage | Write an image file into the staging slot | +| nextBoot | Set the next boot mode through MCUBoot | +| confirmImage | Confirm the running image so it is not reverted | +| prepareImageDone / updateImageDone | Report completion of the slow operations | ## Component States -Add component states in the chart below + +The sequence is tracked by `Components::UpdateSequencer`. + | Name | Description | |---|---| -|---|---| +| IDLE | No usable staging slot; PREPARE_UPDATE must run before an update | +| PREPARED | Staging slot erased and ready to receive an image | +| UPDATED | An image has been written to the staging slot | -## Sequence Diagrams -Add sequence diagrams here +A failure that never reached the flash (a bad file name, a failed size or CRC read, a CRC mismatch) leaves the sequence in PREPARED, so the operator can retry without paying for another 1 MB erase. A failure that did reach the flash drops to IDLE, because the slot now holds partial data and must be erased again. ## Parameters | Name | Description | |---|---| -|---|---| +| CHUNK_DELAY_US | Microseconds to pause after each buffered flash write, default 5000. Exposed so it can be tuned against real hardware instead of rebuilt; at the default a 727 KB image spends about 7 s asleep. | +| PROGRESS_STEP_PERCENT | Percent of the image between progress events, default 10. Larger values spend less downlink reporting on an update in flight. | ## Commands | Name | Description | |---|---| -|---|---| +| ASSEMBLE_IMAGE | Concatenate numbered uplink segments into one image file and verify its CRC32 | +| APPLY_PATCH | Reconstruct an image from a delta patch applied to the running image | ## Events | Name | Description | |---|---| -|---|---| +| UpdateProgress | Periodic progress during a write | +| NoImagePrepared | An update was requested before a successful preparation | +| NextBootSetFailed / ConfirmImageFailed | MCUBoot next-boot or confirm call failed | +| FlashEraseFailed / FlashWriteFailed | Staging slot erase or write failed | +| ImageFileReadError / ImageFileCrcMismatch | Image file could not be read, or failed validation | +| ImageWriteCrcMismatch | Bytes written to flash did not match the bytes validated | +| AssembleStarted / AssembleSucceeded / AssembleFailed / InvalidSegmentCount | Segment assembly | +| PatchStarted / PatchSucceeded / PatchFailed / PatchReferenceMismatch | Patch application | ## Telemetry | Name | Description | |---|---| -|---|---| +| UpdateStage | IDLE, PREPARING, PREPARED, WRITING, UPDATED, or FAILED | +| BytesWritten | Bytes of the image written into the staging slot so far | +| ImageTotalBytes | Total size of the image being written | +| LastUpdateStatus | Status of the most recent preparation or update | + +These are channels rather than only events so that an operator returning on a later pass can ask where an update stands without replaying event history. They are packetized in `SoftwareUpdate` (packet 23, group 5). ## Unit Tests -Add unit test descriptions in the chart below + +Host tests, no F Prime or Zephyr dependency. Run with `make test-unit`. + | Name | Description | Output | Coverage | |---|---|---|---| -|---|---|---|---| +| test_FlashWorker_UpdateSequencer | Sequence ordering, status mapping, retry cost, progress arithmetic | pass/fail | `UpdateSequencer` | +| test_FlashWorker_SegmentPlan | Segment naming, zero padding, buffer and index bounds | pass/fail | `SegmentPlan` | +| test_FlashWorker_PatchApplier | Container decoding, patch application, malformed and hostile patches | pass/fail | `PatchApplier` | + +Integration tests covering the command surface against hardware are in `test/int/ota_test.py`. They deliberately never set the next boot, so a run cannot leave the board staged to boot an unintended image. ## Requirements -Add requirements in the chart below + | Name | Description | Validation | |---|---|---| -|---|---|---| +| A failed update is never reported as a success | Read and write failures propagate a failure status to the Updater | Unit test | +| A retry costs an erase only when one is needed | Failures that never reached flash leave the slot usable | Unit test, integration test | +| An image is validated before it is flashed | CRC32 is checked before the write, and the written bytes are verified after | Unit test, inspection | +| A patch is applied only to the image it was built from | The container records the reference size and CRC and both are checked | Unit test | +| A malformed patch cannot read or write out of bounds | Control records are range checked against both images | Unit test | ## Change Log | Date | Description | |---|---| -|---| Initial Draft | +| n/a | Initial Draft | +| 2026-08-04 | Correct failure reporting, add progress telemetry and parameters, add segment assembly and delta patching | diff --git a/lib/fprime-extras b/lib/fprime-extras index f4d4924f..982139f9 160000 --- a/lib/fprime-extras +++ b/lib/fprime-extras @@ -1 +1 @@ -Subproject commit f4d4924f0b9bd472b52f310041516dd309f9b26f +Subproject commit 982139f94ed833a5b3b97bab903b97e05f385b26 diff --git a/tools/bin/make-patch.py b/tools/bin/make-patch.py new file mode 100644 index 00000000..3444347b --- /dev/null +++ b/tools/bin/make-patch.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +"""Build a PROVES delta patch that reconstructs a new image from one already on board. + +A full signed image is ~727 KB, which at the ground station's file uplink pacing +(``file-uplink-chunk-size`` / ``file-uplink-cooldown`` in fprime-gds.yml, ~510 B/s) takes +roughly 24 minutes of contact. That does not fit a pass. A delta against an image the +spacecraft already holds is a small fraction of that. + +The container written here is consumed by Components::PatchApplier on the flight side. It +carries the classic bsdiff instruction streams: + + magic 8 bytes "PRVSPTCH" + version 1 byte + codec 1 byte 0 = none, 1 = LZ77 (Components::LzssDecoder) + new_size 4 bytes little endian, size of the reconstructed image + ref_size 4 bytes little endian, size of the reference the patch was built against + ref_crc32 4 bytes little endian, CRC32 of that reference + ctrl_size 4 bytes little endian + diff_size 4 bytes little endian + extra_size 4 bytes little endian + payload the three streams concatenated, then compressed per codec + +The three stream lengths describe the decompressed data. The streams are, in order: control +records of three little endian int32 (copy, extra, seek), the difference stream, and the +literal stream. + +The reference size and CRC are carried so the spacecraft can prove it is patching against the +image the ground actually diffed. Applying a patch to the wrong reference yields a plausible +but corrupt image that would then be flashed and booted, so this is checked before any work. + +The streams are ~84% zero bytes in short, close-together runs, so the LZ77 codec collapses +them about 7x: a measured 728,388 byte patch becomes 101,171 bytes, turning ~24 minutes of +uplink into ~3.3. The codec is deliberately small and self contained rather than a third +party library, so the exact decoder that flies is round-tripped against real patches in host +unit tests. +""" + +import argparse +import bz2 +import struct +import sys +import zlib +from pathlib import Path + +MAGIC = b"PRVSPTCH" +FORMAT_VERSION = 1 +COMPRESSION_NONE = 0 +COMPRESSION_LZSS = 1 + +# Must match Components::LzssDecoder. A larger window saves under 8% on real patches, which does +# not pay for the RAM on the flight side. +LZSS_WINDOW = 4096 +LZSS_MIN_MATCH = 3 +LZSS_MAX_MATCH = 258 +# Bounds how far back the encoder searches for a match; larger is slower for little gain +LZSS_MAX_CANDIDATES = 48 + +# Ground station file uplink rate implied by fprime-gds.yml, used for the size report +UPLINK_BYTES_PER_SECOND = 204 / 0.400 + + +def parse_bsdiff40(patch: bytes) -> tuple[bytes, bytes, bytes, int]: + """Split a classic BSDIFF40 patch into its three decompressed streams. + + bsdiff4 emits a well defined container: the magic, three 64 bit lengths, then the + control, diff, and extra streams each bzip2 compressed. Re-encoding those streams into + the PROVES container lets the proven diff algorithm do the hard part while keeping the + on-orbit decoder trivial. + + Args: + patch: a BSDIFF40 patch as produced by bsdiff4.diff + + Returns: + control, diff, extra streams and the size of the reconstructed image + """ + if patch[:8] != b"BSDIFF40": + raise ValueError("not a BSDIFF40 patch") + control_len = struct.unpack(" bytes: + """Re-encode bsdiff control records into fixed width little endian int32 triples. + + bsdiff stores offsets in a sign-magnitude 64 bit form. Flight images are bounded by the + 1 MB slot, so 32 bit records are ample and are far simpler to decode on the spacecraft. + + Args: + control: the raw bsdiff control stream + + Returns: + the re-encoded control stream + + Raises: + ValueError: if a value does not fit in an int32, which would mean an image far + larger than a slot + """ + if len(control) % 24 != 0: + raise ValueError("control stream is not a whole number of records") + + out = bytearray() + for offset in range(0, len(control), 24): + values = [] + for field in range(3): + raw = struct.unpack( + " bytes: + """Compress with the LZ77 variant Components::LzssDecoder understands. + + Tokens are grouped in eights behind a tag byte whose bit b is set when token b is a + literal. A literal is one byte; a match is a little endian uint16 distance backwards + followed by one byte holding length minus 3. Matches may overlap the bytes they produce, + which is how runs are encoded. + + Args: + data: bytes to compress + + Returns: + the compressed stream + """ + tokens = [] + table: dict[bytes, list[int]] = {} + position = 0 + length = len(data) + + while position < length: + best_length = 0 + best_distance = 0 + if position + LZSS_MIN_MATCH <= length: + key = data[position : position + LZSS_MIN_MATCH] + for candidate in reversed(table.get(key, [])): + distance = position - candidate + if distance > LZSS_WINDOW: + break + match = LZSS_MIN_MATCH + while ( + match < LZSS_MAX_MATCH + and position + match < length + and data[candidate + match] == data[position + match] + ): + match += 1 + if match > best_length: + best_length = match + best_distance = distance + if match >= LZSS_MAX_MATCH: + break + + if best_length >= LZSS_MIN_MATCH: + tokens.append((False, best_distance, best_length)) + step = best_length + else: + tokens.append((True, data[position], 0)) + step = 1 + + for index in range(position, min(position + step, length)): + if index + LZSS_MIN_MATCH <= length: + bucket = table.setdefault(data[index : index + LZSS_MIN_MATCH], []) + bucket.append(index) + if len(bucket) > LZSS_MAX_CANDIDATES: + bucket.pop(0) + position += step + + out = bytearray() + for start in range(0, len(tokens), 8): + group = tokens[start : start + 8] + tag = 0 + for bit, (is_literal, _, _) in enumerate(group): + if is_literal: + tag |= 1 << bit + out.append(tag) + for is_literal, value, match_length in group: + if is_literal: + out.append(value) + else: + out += struct.pack(" bytes: + """Assemble the PROVES patch container. + + The three streams are concatenated and compressed as one blob, so the flight side decodes + once into a scratch file and then applies the patch from it. The stream lengths in the header + describe the decompressed data. + + Args: + control: re-encoded control stream + diff: bsdiff difference stream + extra: bsdiff literal stream + new_size: size of the reconstructed image + reference: the reference image, whose size and CRC bind the patch to it + compress: whether to LZ77 the streams + + Returns: + the complete container + """ + payload = control + diff + extra + header = MAGIC + struct.pack( + " int: + """CRC32 in the form F Prime's Os::File::calculateCrc reports. + + F Prime accumulates with lib_crc's update_crc_32 from an initial 0xFFFFFFFF and does not + invert the result, which is the complement of what zlib returns. tools/bin/calculate-crc.py + does the same conversion; the two must agree or an uplinked image is rejected on board. + + Args: + data: bytes to checksum + + Returns: + the CRC32 as an unsigned 32 bit value + """ + return ~zlib.crc32(data) & 0xFFFFFFFF + + +def parse_args() -> argparse.Namespace: + """Parse command line arguments. + + Returns: + the parsed arguments + """ + parser = argparse.ArgumentParser( + description="Build a PROVES delta patch from a reference image to a target image" + ) + parser.add_argument("reference", type=Path, help="Image already on the spacecraft") + parser.add_argument( + "target", type=Path, help="Image to reconstruct on the spacecraft" + ) + parser.add_argument( + "-o", "--output", type=Path, required=True, help="Patch file to write" + ) + parser.add_argument( + "--allow-uncompressed", + action="store_true", + help="Store the streams verbatim instead of compressing them. Produces a patch about the " + "size of the image itself; useful only for exercising the container end to end.", + ) + return parser.parse_args() + + +def main() -> int: + """Entry point. + + Returns: + process exit status + """ + args = parse_args() + try: + import bsdiff4 + except ImportError: + print( + "bsdiff4 is required: pip install bsdiff4", + file=sys.stderr, + ) + return 1 + + for path in (args.reference, args.target): + if not path.is_file(): + print(f"no such file: {path}", file=sys.stderr) + return 1 + + reference = args.reference.read_bytes() + target = args.target.read_bytes() + + control, diff, extra, new_size = parse_bsdiff40(bsdiff4.diff(reference, target)) + if new_size != len(target): + raise ValueError("bsdiff reported a size that does not match the target image") + container = build_container( + transcode_control(control), + diff, + extra, + new_size, + reference, + compress=not args.allow_uncompressed, + ) + + minutes = len(container) / UPLINK_BYTES_PER_SECOND / 60 + print( + f"reference {args.reference} {len(reference)} bytes CRC32 0x{crc32_fprime(reference):08x}" + ) + print(f"target {args.target} {len(target)} bytes") + print(f"patch {len(container)} bytes (~{minutes:.1f} min to uplink)") + print(f"target CRC32 0x{crc32_fprime(target):08x}") + + args.output.write_bytes(container) + print(f"wrote {args.output}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())