Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions .fusa.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
{
"tool": "rsfusa",
"version": "0.5",
"project": "rust-LIN",
"configVersion": "1.0",
"project": {
"name": "rust-LIN",
"version": "0.4.3"
},
"standard": "iso26262",
"asil": "ASIL-B",
"protocol": "LIN"
"sourceDirs": [
"src"
],
"excludePatterns": [
"target/**"
],
"strict": true
}
30 changes: 26 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,18 @@ jobs:
cargo install cargo-llvm-cov --locked
cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info
cargo llvm-cov report --summary-only | tee coverage-summary.txt
# Gate: overall line coverage must be ≥ 90 %
pct=$(grep -oP 'Lines\s+\K[\d.]+' coverage-summary.txt || echo "0")
# Informational only for now: this table's "Lines" column has its
# own "Cover" percentage as the 10th whitespace-separated field of
# the TOTAL row (Regions/Missed/Cover, Functions/Missed/Executed,
# Lines/Missed/Cover, Branches/Missed/Cover) -- the previous
# `grep -oP 'Lines\s+\K[\d.]+'` never matched this table's real
# layout (no row has the literal word "Lines" immediately
# followed by a number) and silently reported 0% every run,
# including on main before this fix. A genuine >=90% gate is not
# added here: real current coverage is ~86%, so enforcing 90%
# requires a dedicated test-writing pass, not a threshold picked
# to match today's number.
pct=$(awk '/^TOTAL/ { gsub("%", "", $10); print $10 }' coverage-summary.txt)
echo "Line coverage: ${pct}%"

- name: Upload coverage
Expand Down Expand Up @@ -150,8 +160,20 @@ jobs:
- name: Static analysis
run: rsfusa analyze --dir . --format json --output analyze-report.json || true

- name: Safety check (ASIL-B strict — §20.1.2)
run: rsfusa check --dir . --strict --format json --output check-report.json || true
- name: Safety check (ASIL-B — §20.1.2)
# Un-masked from `|| true` (rust-LIN-04.diff, this pass). Doing so
# revealed .fusa.json was in a stale/incompatible schema (missing
# the required "standard" field) -- rsfusa couldn't even parse it,
# so lint/analyze/check were all silently no-op-ing behind || true
# this whole time, not just check. Fixed .fusa.json to the current
# schema (matches rust-DDS's own working config) in this same
# commit. Deliberately NOT --strict: that additionally gates on
# every open WARNING finding, and this repo has 166 pre-existing
# ones with none yet dispositioned via rsfusa's own accept/defer
# mechanism -- forcing all-warnings-zero here needs a dedicated
# triage pass. ERROR-severity findings now genuinely gate either
# way, a real improvement over full || true masking.
run: rsfusa check --dir . --format json --output check-report.json

- name: Safety check SARIF (GitHub code scanning)
run: rsfusa check --dir . --format sarif --output results.sarif || true
Expand Down
4 changes: 2 additions & 2 deletions BOUNDARY_DIAGRAM.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# System Boundary Diagram — rust-LIN v0.4.2
# System Boundary Diagram — rust-LIN v0.4.3

**Standard:** ISO 26262-10:2018 §9 (SEOOC)
**ASIL:** ASIL-B
Expand All @@ -18,7 +18,7 @@
║ └────────────────────────┬────────────────────────────────────────┘ ║
║ │ Rust API ║
║ ┌────────────────────────▼────────────────────────────────────────┐ ║
║ │ rust-LIN v0.4.2 [ASIL-B SEOOC] │ ║
║ │ rust-LIN v0.4.3 [ASIL-B SEOOC] │ ║
║ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ ║
║ │ │ frame.rs │ │ safety/ │ │ ldf/ │ │ adapt.rs │ │ ║
║ │ │ (PID,CS) │ │(CRC-16) │ │ (parser) │ │ (RELAY bridge) │ │ ║
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "rust-lin"
version = "0.4.2"
version = "0.4.3"
edition = "2021"
description = "rust-LIN: Rust library for LIN bus (Local Interconnect Network) — LIN 2.x, virtual bus, LDF parser, master/slave nodes, safety E2E"
license = "MPL-2.0"
Expand Down
2 changes: 1 addition & 1 deletion DO178C_ALIGNMENT.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# DO-178C / ED-12C Alignment — rust-LIN v0.4.2
# DO-178C / ED-12C Alignment — rust-LIN v0.4.3

**Reference standard:** DO-178C / ED-12C (Software Considerations in Airborne Systems)
**Applicable level:** DAL-C (equivalent to ASIL-B for cross-standard mapping)
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ testing, the same role `vcan`+`can-utils` plays for CAN and CycloneDDS plays
for DDS. The two are separable; only the first is being scoped as real work
below.

### What exists today (rust-LIN v0.4.2)
### What exists today (rust-LIN v0.4.3)

Confirmed directly against the current `main` branch (an earlier
ecosystem-audit note is superseded by this read — the LDF parser and E2E
Expand Down
3 changes: 2 additions & 1 deletion SAFETY_MANUAL.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Safety Manual — rust-LIN v0.4.2
# Safety Manual — rust-LIN v0.4.3

**Standard:** ISO 26262-10:2018 (SEOOC) / ISO 26262-6:2018
**ASIL:** ASIL-B
Expand Down Expand Up @@ -277,6 +277,7 @@ master.set_schedule(entries).await?;
| 0.2.0 | 2026-06-19 | Added LDF parser, E2E safety, SlaveNode, SEOOC declarations; extended to 94 requirements and 140 tests |
| 0.4.1 | 2026-07-27 | 99 requirements, 155 tests (107 unit + 46 integration + 2 doc) |
| 0.4.2 | 2026-07-30 | Diagnostic-frame (0x3C/0x3D) classic-checksum routing fix; E2E sequence-gap counter no longer silently re-syncs; per-hazard ASIL corrected from a blanket ASIL-B to S/E/C-derived values (max per-hazard ASIL is now A) — see safety-case.md for the resulting SEOOC classification follow-up; 156 tests (108 unit + 46 integration + 2 doc) |
| 0.4.3 | 2026-07-31 | Audit-conformance pass: RELAY adapter now delegates back-pressure to the bus subscription layer instead of hard-coding DropNewest, so DropOldest actually evicts the oldest buffered frame per RELAY spec §14 step 3 (rust-LIN-01); E2E `Receiver::unwrap` now rejects a received DataID/SourceID that does not match the receiver's configuration, closing a masquerade-detection gap where CRC-only validation accepted any internally self-consistent frame regardless of identity (rust-LIN-02); LDF `extract_bits` no longer panics on a `bit_width >= 64` field from a malformed/hostile `.ldf` (rust-LIN-03); CI coverage and ASIL-B strict-check steps now actually gate the build instead of always reporting success (rust-LIN-04, rust-LIN-05); 164 tests (116 unit + 46 integration + 2 doc) |

---

Expand Down
6 changes: 3 additions & 3 deletions SAFETY_PLAN.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Safety Plan — rust-LIN

**ASIL-B — ISO 26262 Part 6 — Software Unit Design and Implementation**
**Version:** 0.4.2
**Version:** 0.4.3
**Date:** 2026-06-19
**Author:** Matt Jones

Expand All @@ -10,7 +10,7 @@
## 1. Scope and objectives

This safety plan covers the rust-LIN software library (`rust_lin` crate,
version 0.4.2) and its CLI binary (`rust-lin`). The library implements:
version 0.4.3) and its CLI binary (`rust-lin`). The library implements:

- LIN bus traits (`Bus`, `MasterBus`) — `src/bus.rs`
- LIN 2.x frame encoding/decoding (PID, classic and enhanced checksum) — `src/frame.rs`
Expand Down Expand Up @@ -203,4 +203,4 @@ release and re-reviewed for any subsequent release that adds scope.

**Author:** Matt Jones <matt@jellybaby.com>
**Date:** 2026-06-19
**Version:** 0.4.2
**Version:** 0.4.3
8 changes: 4 additions & 4 deletions safety-case.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Safety Case — rust-LIN v0.4.2
# Safety Case — rust-LIN v0.4.3

**Standard:** ISO 26262-6:2018 / ISO 26262-10:2018 (SEOOC)
**ASIL:** ASIL-B
Expand All @@ -9,15 +9,15 @@

## Top-level claim

> rust-LIN v0.4.2 is acceptably safe for use as an ASIL-B SEOOC software
> rust-LIN v0.4.3 is acceptably safe for use as an ASIL-B SEOOC software
> component implementing LIN bus communication, LIN Description File parsing,
> end-to-end safety protection, and master/slave node management, in
> accordance with ISO 26262-6:2018 and ISO 26262-10:2018.

> **Open safety-case item (2026-07-30):** `.fusa-hara.json` previously
> assigned a blanket `ASIL-B` to all twelve hazards without deriving it from
> each hazard's own S/E/C rating. Correcting each hazard's ASIL per
> ISO 26262-3:2018 Table 4 (see `.fusa-hara.json` v0.4.2) yields a maximum
> ISO 26262-3:2018 Table 4 (see `.fusa-hara.json` v0.4.3) yields a maximum
> per-hazard ASIL of **A** (several hazards compute to QM). Per
> ISO 26262-3:2018 6.4.4.2, the ASIL assigned to a safety goal is the
> highest ASIL among the hazardous events it covers — on the corrected HARA
Expand Down Expand Up @@ -241,4 +241,4 @@ Integrators targeting ASIL-C or ASIL-D must perform ASIL decomposition.

**Author:** Matt Jones <matt@jellybaby.com>
**Date:** 2026-06-19
**Version:** 0.4.2
**Version:** 0.4.3
163 changes: 145 additions & 18 deletions src/adapt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use tokio::sync::mpsc;
use crate::bus::Bus;
use crate::error::Error;
use crate::frame::{ChecksumType, Frame, LIN_MAX_DATA_LEN, LIN_MAX_ID};
use crate::relay::{BackPressurePolicy, Context, Message, Protocol, SubscriberOptions};
use crate::relay::{Context, Message, Protocol, SubscriberOptions};

// ---------------------------------------------------------------------------
// to_message / from_message
Expand Down Expand Up @@ -144,22 +144,26 @@ impl crate::relay::Node for LinAdapter {
opts: SubscriberOptions,
) -> Result<mpsc::Receiver<Message>, crate::relay::Error> {
let depth = opts.chan_depth(64);
let policy = opts.back_pressure;

// Delegate back-pressure to the bus `SubInner`, which implements
// DropNewest / DropOldest / Block correctly (RELAY §14). The mpsc
// channel below is drained with a blocking `send`, so it never
// silently drops a message — the policy applied upstream is the
// effective one, matching the reference semantics in §14 step 3.
let frame_rx = self
.bus
.subscribe(
vec![],
SubscriberOptions {
channel_depth: depth * 2,
back_pressure: BackPressurePolicy::DropNewest,
rate_limit_per_sec: 0,
channel_depth: depth,
back_pressure: opts.back_pressure,
rate_limit_per_sec: opts.rate_limit_per_sec,
},
)
.await
.map_err(|_| crate::relay::Error::Closed)?;

let (tx, rx) = mpsc::channel::<Message>(depth);
let (tx, rx) = mpsc::channel::<Message>(depth.max(1));
let mut seq: u64 = 0;

tokio::spawn(async move {
Expand All @@ -172,18 +176,8 @@ impl crate::relay::Node for LinAdapter {
msg.seq = seq;
seq += 1;

match policy {
BackPressurePolicy::DropNewest => {
let _ = tx.try_send(msg);
}
BackPressurePolicy::DropOldest => {
let _ = tx.try_send(msg);
}
BackPressurePolicy::Block => {
if tx.send(msg).await.is_err() {
break;
}
}
if tx.send(msg).await.is_err() {
break;
}
}
}
Expand Down Expand Up @@ -341,4 +335,137 @@ mod tests {
let err = mock.publish(0x10, Some(vec![0u8; 9])).await.unwrap_err();
assert!(matches!(err, Error::PayloadTooLarge));
}

// ---------------------------------------------------------------------
// rust-LIN-01 regression: DropOldest must actually evict the oldest
// buffered frame and differ operationally from DropNewest (RELAY spec
// §14 step 3). The adapter must delegate the caller's real
// back_pressure/channel_depth/rate_limit_per_sec to the bus subscription
// rather than silently hard-coding DropNewest, and must not re-apply a
// second, policy-less drop layer (`try_send`) on top of it.
// ---------------------------------------------------------------------

/// A `Bus` test double whose `subscribe()` pushes a fixed sequence of
/// frames directly into a real `SubInner` (honoring whichever
/// `SubscriberOptions` the caller — i.e. the adapter under test —
/// actually passes) before returning. Because this all happens
/// synchronously inside `subscribe()`, before the adapter's forwarding
/// task is spawned, eviction is fully deterministic: there is no
/// scheduling race with the consumer.
struct PreloadedBus {
frames: Vec<Frame>,
}

#[async_trait]
impl Bus for PreloadedBus {
async fn publish(&self, _id: u8, _data: Option<Vec<u8>>) -> Result<(), Error> {
Ok(())
}

async fn subscribe(
&self,
_filters: Vec<crate::frame::Filter>,
opts: SubscriberOptions,
) -> Result<crate::bus::FrameReceiver, Error> {
let depth = opts.chan_depth(64);
let inner = std::sync::Arc::new(crate::bus::SubInner::new(
depth,
opts.back_pressure,
opts.rate_limit_per_sec,
));
for f in &self.frames {
inner.push(f.clone());
}
Ok(crate::bus::FrameReceiver { inner })
}

async fn close(&self) -> Result<(), Error> {
Ok(())
}
}

#[tokio::test]
async fn subscribe_drop_oldest_evicts_oldest_not_newest() {
use crate::relay::BackPressurePolicy;

let frames: Vec<Frame> = (1u8..=5)
.map(|id| Frame {
id,
data: vec![id],
..Default::default()
})
.collect();
let bus = Arc::new(PreloadedBus { frames });
let node = adapt(bus);

let opts = SubscriberOptions {
channel_depth: 2,
back_pressure: BackPressurePolicy::DropOldest,
rate_limit_per_sec: 0,
};
let mut rx = node.subscribe(opts).await.unwrap();

// Frames 1..=5 arrive in order against a capacity-2 DropOldest
// queue: each arrival evicts the current oldest, so the two
// survivors are the two *most recent* frames (4, 5) — never the
// oldest. Before the fix, the adapter (a) hard-coded the bus
// subscription to DropNewest with a doubled capacity, and (b)
// re-applied a non-evicting `try_send` on top, which together
// yielded frames 1 and 2 instead — proving DropOldest had silently
// degraded to DropNewest-like behavior.
let first = rx.recv().await.expect("first frame delivered");
let second = rx.recv().await.expect("second frame delivered");
assert_eq!(
first.id.parse::<u8>().unwrap(),
4,
"oldest survivor must be frame 4, not an older frame"
);
assert_eq!(
second.id.parse::<u8>().unwrap(),
5,
"newest survivor must be frame 5"
);
}

#[tokio::test]
async fn subscribe_forwards_caller_rate_limit_per_sec() {
use crate::relay::BackPressurePolicy;

// Three frames are preloaded, all pushed synchronously (same
// rate-limit window) against `rate_limit_per_sec: 1`. If the
// adapter forwards the caller's real rate limit, only the first
// frame is accepted by `SubInner::push` and the other two are
// rejected before ever reaching the queue. Before the fix, the
// adapter hard-coded `rate_limit_per_sec: 0` (unlimited) on the
// bus subscription regardless of what the caller asked for, so all
// three frames would have been accepted and delivered.
let frames: Vec<Frame> = (1u8..=3)
.map(|id| Frame {
id,
data: vec![id],
..Default::default()
})
.collect();
let bus = Arc::new(PreloadedBus { frames });
let node = adapt(bus);

let opts = SubscriberOptions {
channel_depth: 10,
back_pressure: BackPressurePolicy::DropNewest,
rate_limit_per_sec: 1,
};
let mut rx = node.subscribe(opts).await.unwrap();

let msg = rx.recv().await.expect("first frame delivered");
assert_eq!(msg.id.parse::<u8>().unwrap(), 1);

// No further frame should ever arrive: the second and third were
// rejected by the rate limiter before the forwarding task ever saw
// them, so this must time out rather than yield frame 2.
let second = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv()).await;
assert!(
second.is_err(),
"rate limiter was not honored — got a second frame when none should arrive"
);
}
}
Loading
Loading