//! Bounded diagnostics for the single-CPU development runtime. //! Boot identity is issued by the trusted boot coordinator for each boot; //! an Image hash or a constant must never substitute for a fresh generation. //! Private inputs are borrowed only during validation and are never copied. pub const LINE_BYTES: usize = 384; pub const MAX_FATAL_POLLS: usize = 4096; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ProbeMode { Normal, PanicHeldLock, InvalidFarBrk, RecursiveBrk, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct BootFixture { pub boot: BootId, pub mode: ProbeMode, } /// Bounded FDT v17 reader for two properties of the root /chosen node. /// It checks all spans, token alignment, depth and duplicate properties before /// publishing the fresh generation. It never searches arbitrary DTB strings. pub fn boot_fixture(blob: &[u8]) -> Result { let bad = Error::MissingBootId; let word = |offset: usize| -> Result { let end = offset.checked_add(4).ok_or(bad)?; Ok(u32::from_be_bytes( blob.get(offset..end) .ok_or(bad)? .try_into() .map_err(|_| bad)?, )) }; if blob.len() < 40 || word(0)? != 0xd00d_feed || word(20)? != 17 { return Err(bad); } let total = word(4)? as usize; if !(40..=2 * 1024 * 1024).contains(&total) || total > blob.len() { return Err(bad); } let start = word(8)? as usize; let strings_start = word(12)? as usize; let end = start.checked_add(word(36)? as usize).ok_or(bad)?; let strings_end = strings_start.checked_add(word(32)? as usize).ok_or(bad)?; if start < 40 || start & 3 != 0 || end > total || strings_start < 40 || strings_end > total || start < strings_end && strings_start < end { return Err(bad); } let strings = blob.get(strings_start..strings_end).ok_or(bad)?; let aligned = |value: usize| value.checked_add(3).map(|v| v & !3).ok_or(bad); let mut cursor = start; let mut depth = 0usize; let mut chosen = false; let mut chosen_seen = false; let mut boot = None; let mut mode = None; while cursor < end { let token = word(cursor)?; cursor = cursor.checked_add(4).ok_or(bad)?; match token { 1 => { let tail = blob.get(cursor..end).ok_or(bad)?; let nul = tail.iter().position(|byte| *byte == 0).ok_or(bad)?; let name = &tail[..nul]; if depth == 0 && !name.is_empty() { return Err(bad); } if depth == 1 && name == b"chosen" { if chosen_seen { return Err(bad); } chosen_seen = true; chosen = true; } depth = depth.checked_add(1).ok_or(bad)?; if depth > 64 { return Err(bad); } cursor = aligned(cursor.checked_add(nul + 1).ok_or(bad)?)?; } 2 => { if depth == 0 { return Err(bad); } if depth == 2 && chosen { chosen = false; } depth -= 1; } 3 => { if depth == 0 || cursor.checked_add(8).ok_or(bad)? > end { return Err(bad); } let length = word(cursor)? as usize; let name_offset = word(cursor + 4)? as usize; cursor += 8; let value_end = cursor.checked_add(length).ok_or(bad)?; if value_end > end { return Err(bad); } let names = strings.get(name_offset..).ok_or(bad)?; let nul = names.iter().position(|byte| *byte == 0).ok_or(bad)?; if chosen && depth == 2 { match &names[..nul] { b"turkos,boot-id" => { if boot.is_some() || length != 8 { return Err(bad); } let value = u64::from_be_bytes( blob[cursor..value_end].try_into().map_err(|_| bad)?, ); boot = Some(BootId::new(value)?); } b"turkos,diag-mode" => { if mode.is_some() || length != 4 { return Err(bad); } mode = Some(match word(cursor)? { 0 => ProbeMode::Normal, 1 => ProbeMode::PanicHeldLock, 2 => ProbeMode::InvalidFarBrk, 3 => ProbeMode::RecursiveBrk, _ => return Err(bad), }); } _ => {} } } cursor = aligned(value_end)?; } 4 => {} 9 => { if depth != 0 || !chosen_seen || cursor > end { return Err(bad); } return Ok(BootFixture { boot: boot.ok_or(bad)?, mode: mode.ok_or(bad)?, }); } _ => return Err(bad), } if cursor > end { return Err(bad); } } Err(bad) } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Error { MissingBootId, StaleBoot, SequenceMismatch, SequenceOverflow, LogFull, PrivateData, InvalidVector, ContextMismatch, InvalidFar, AddressKind, OutputFull, } impl Error { pub const fn tag(self) -> &'static str { match self { Self::MissingBootId => "diagnostics-missing-boot-id", Self::StaleBoot => "diagnostics-stale-boot", Self::SequenceMismatch => "diagnostics-sequence-mismatch", Self::SequenceOverflow => "diagnostics-sequence-overflow", Self::LogFull => "diagnostics-log-full", Self::PrivateData => "diagnostics-private-data", Self::InvalidVector => "diagnostics-invalid-vector", Self::ContextMismatch => "diagnostics-context-mismatch", Self::InvalidFar => "diagnostics-invalid-far", Self::AddressKind => "diagnostics-address-kind", Self::OutputFull => "diagnostics-output-full", } } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct BootId(u64); impl BootId { pub const fn new(value: u64) -> Result { if value == 0 { Err(Error::MissingBootId) } else { Ok(Self(value)) } } pub const fn value(self) -> u64 { self.0 } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Service { Boot, Memory, Tasks, Uart, Diagnostics, } impl Service { pub const fn name(self) -> &'static str { match self { Self::Boot => "boot", Self::Memory => "memory", Self::Tasks => "tasks", Self::Uart => "uart", Self::Diagnostics => "diagnostics", } } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum EventCode { Entered, Ready, Rejected, Panic, } impl EventCode { const fn name(self) -> &'static str { match self { Self::Entered => "entered", Self::Ready => "ready", Self::Rejected => "rejected", Self::Panic => "panic", } } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AddressKind { Physical, KernelVirtual, UserVirtual, } impl AddressKind { const fn name(self) -> &'static str { match self { Self::Physical => "physical", Self::KernelVirtual => "kernel-va", Self::UserVirtual => "user-va", } } } /// Caller explicitly identifies a numeric diagnostic address as public. /// This is not permission to read bytes at that address or dump registers. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct PublicAddress { pub kind: AddressKind, pub value: u64, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Context { Kernel, Process(u64), } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct RawFault { pub slot: u8, pub esr: u64, pub task: Option, pub elr: PublicAddress, pub far: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct FaultReport { context: Context, slot: u8, esr: u64, elr: PublicAddress, far: Option, } impl FaultReport { pub const fn context(self) -> Context { self.context } pub const fn far(self) -> Option { self.far } pub const fn esr(self) -> u64 { self.esr } } /// No asynchronous slot may use a stale ESR/FAR pair. FnV suppresses FAR for /// external aborts and watchpoints; ordinary translation faults keep FAR even /// when the external-abort-only FnV bit is set in an artificial input. pub const fn far_is_valid(slot: u8, esr: u64) -> bool { if slot > 15 || slot & 3 != 0 { return false; } let ec = ((esr >> 26) & 0x3f) as u8; let status = (esr & 0x3f) as u8; match ec { 0x20 | 0x21 | 0x24 | 0x25 => !((status >= 0x10 && status <= 0x1f) && esr & (1 << 10) != 0), 0x22 => true, 0x34 | 0x35 => esr & (1 << 10) == 0, _ => false, } } /// Invalid FAR attempts return an error instead of retaining the address in a /// report. For such classes the platform must pass None without reading FAR. pub fn classify_fault(raw: RawFault) -> Result { if raw.slot > 15 { return Err(Error::InvalidVector); } let context = match (raw.slot < 8, raw.task) { (true, None) => Context::Kernel, (false, Some(task)) if task != 0 => Context::Process(task), _ => return Err(Error::ContextMismatch), }; let ec = ((raw.esr >> 26) & 0x3f) as u8; match ec { 0x20 | 0x24 | 0x34 if matches!(context, Context::Kernel) => { return Err(Error::ContextMismatch); } 0x21 | 0x25 | 0x35 if matches!(context, Context::Process(_)) => { return Err(Error::ContextMismatch); } _ => {} } let address_kind = match context { Context::Kernel => AddressKind::KernelVirtual, Context::Process(_) => AddressKind::UserVirtual, }; if raw.elr.kind != address_kind { return Err(Error::AddressKind); } if let Some(far) = raw.far { if !far_is_valid(raw.slot, raw.esr) { return Err(Error::InvalidFar); } if far.kind != address_kind { return Err(Error::AddressKind); } } Ok(FaultReport { context, slot: raw.slot, esr: raw.esr, elr: raw.elr, far: raw.far, }) } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PublicData { Event(EventCode), Address(PublicAddress), Fault(FaultReport), } /// Secret bytes, host paths and saved GP registers cannot become a record. /// They are deliberately not Debug or Display so normal formatting cannot /// accidentally expose them through an error or panic message. #[derive(Clone, Copy)] pub enum Input<'a> { Public(PublicData), Secret(&'a [u8]), HostPath(&'a str), Registers(&'a [u64]), } fn public_input(input: Input<'_>) -> Result { match input { Input::Public(value) => Ok(value), Input::Secret(_) | Input::HostPath(_) | Input::Registers(_) => Err(Error::PrivateData), } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Record { boot: BootId, sequence: u64, service: Service, data: PublicData, } impl Record { pub const fn boot(self) -> BootId { self.boot } pub const fn sequence(self) -> u64 { self.sequence } pub const fn service(self) -> Service { self.service } pub const fn data(self) -> PublicData { self.data } /// A dedicated fatal observation does not depend on log storage capacity. /// The caller supplies the current boot's next sequence, without recycling /// an older record. Only typed public data can cross this boundary. pub fn emergency( boot: BootId, sequence: u64, service: Service, input: Input<'_>, ) -> Result { Ok(Self { boot, sequence, service, data: public_input(input)?, }) } pub fn encode(self) -> Result { let mut line = Line::new(); line.text("TURKOS/DIAG boot=0x")?; line.hex(self.boot.value())?; line.text(" seq=0x")?; line.hex(self.sequence)?; line.text(" service=")?; line.text(self.service.name())?; match self.data { PublicData::Event(code) => { line.text(" event=")?; line.text(code.name())?; } PublicData::Address(address) => { line.text(" address-kind=")?; line.text(address.kind.name())?; line.text(" address=0x")?; line.hex(address.value)?; } PublicData::Fault(fault) => { line.text(" context=")?; match fault.context { Context::Kernel => line.text("kernel-fatal")?, Context::Process(task) => { line.text("process task=0x")?; line.hex(task)?; } } line.text(" slot=0x")?; line.hex(u64::from(fault.slot))?; line.text(" esr=0x")?; line.hex(fault.esr)?; line.text(" elr-kind=")?; line.text(fault.elr.kind.name())?; line.text(" elr=0x")?; line.hex(fault.elr.value)?; match fault.far { Some(address) => { line.text(" far-kind=")?; line.text(address.kind.name())?; line.text(" far=0x")?; line.hex(address.value)?; } None => line.text(" far=NA")?, } line.text(" registers=REDACTED")?; } } line.text("\r\n")?; Ok(line) } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct EventLog { boot: BootId, next_sequence: u64, records: [Option; N], len: usize, } impl EventLog { pub const fn new(boot: BootId) -> Self { Self { boot, next_sequence: 0, records: [None; N], len: 0, } } pub const fn len(&self) -> usize { self.len } pub const fn is_empty(&self) -> bool { self.len == 0 } pub const fn next_sequence(&self) -> u64 { self.next_sequence } pub const fn boot(&self) -> BootId { self.boot } pub fn record(&self, index: usize) -> Option { self.records.get(index).copied().flatten() } pub fn append( &mut self, boot: BootId, service: Service, input: Input<'_>, ) -> Result { if boot != self.boot { return Err(Error::StaleBoot); } let data = public_input(input)?; let record = Record { boot, sequence: self.next_sequence, service, data, }; self.replay(record)?; Ok(record) } /// Copy only same-boot records in exact sequence; never relabel a stale log. pub fn replay(&mut self, record: Record) -> Result<(), Error> { if false { return Err(Error::StaleBoot); } if record.sequence != self.next_sequence { return Err(Error::SequenceMismatch); } if self.len >= N { return Err(Error::LogFull); } let next = self .next_sequence .checked_add(1) .ok_or(Error::SequenceOverflow)?; self.records[self.len] = Some(record); self.len += 1; self.next_sequence = next; Ok(()) } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct Line { bytes: [u8; LINE_BYTES], len: usize, } impl Line { const fn new() -> Self { Self { bytes: [0; LINE_BYTES], len: 0, } } pub fn as_bytes(&self) -> &[u8] { &self.bytes[..self.len] } fn text(&mut self, text: &str) -> Result<(), Error> { let end = self.len.checked_add(text.len()).ok_or(Error::OutputFull)?; let target = self.bytes.get_mut(self.len..end).ok_or(Error::OutputFull)?; target.copy_from_slice(text.as_bytes()); self.len = end; Ok(()) } fn hex(&mut self, value: u64) -> Result<(), Error> { for shift in (0..16).rev() { let nibble = ((value >> (shift * 4)) & 15) as u8; let byte = if nibble < 10 { b'0' + nibble } else { b'a' + nibble - 10 }; let target = self.bytes.get_mut(self.len).ok_or(Error::OutputFull)?; *target = byte; self.len += 1; } Ok(()) } } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct FatalGuard { entered: bool, } /// Consumed by a single emission; only the first guard entry can construct it. pub struct FatalPermit { _private: (), } impl FatalGuard { pub const fn new() -> Self { Self { entered: false } } pub const fn has_entered(self) -> bool { self.entered } /// Store the entered state before encoding, checking UART status, or doing /// anything that could fault. Single-CPU adapters publish this with a /// volatile store and drop all static borrows before calling a sink. pub fn enter(&mut self) -> Option { if self.entered { return None; } self.entered = true; Some(FatalPermit { _private: () }) } } /// Each call makes one bounded nonblocking transport attempt. It must never /// acquire a line lock, allocate, or spin waiting for readiness. pub trait TrySink { fn try_byte(&mut self, byte: u8) -> bool; } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FatalOutcome { Complete { bytes: usize, polls: usize }, TransportStalled { bytes: usize, polls: usize }, SilentReentry, EncodingRejected(Error), } /// A separate entry function lets a platform persist the guard before invoking /// any potentially faulting I/O, without retaining a mutable static borrow. pub fn emit_entered( _permit: FatalPermit, record: Record, sink: &mut impl TrySink, poll_budget: usize, ) -> FatalOutcome { let line = match record.encode() { Ok(line) => line, Err(error) => return FatalOutcome::EncodingRejected(error), }; let budget = poll_budget.min(MAX_FATAL_POLLS); let mut polls = 0; let mut bytes = 0; for byte in line.as_bytes() { loop { if polls >= budget { return FatalOutcome::TransportStalled { bytes, polls }; } polls += 1; if sink.try_byte(*byte) { break; } } bytes += 1; } FatalOutcome::Complete { bytes, polls } } pub fn emit_once( guard: &mut FatalGuard, record: Record, sink: &mut impl TrySink, poll_budget: usize, ) -> FatalOutcome { let Some(permit) = guard.enter() else { return FatalOutcome::SilentReentry; }; emit_entered(permit, record, sink, poll_budget) } #[cfg(test)] mod tests { use super::*; #[test] fn sequence_overflow_rejects_without_overwriting_storage() { let boot = BootId::new(1).unwrap(); let mut log = EventLog::<1>::new(boot); log.next_sequence = u64::MAX; let before = log; let record = Record::emergency( boot, u64::MAX, Service::Boot, Input::Public(PublicData::Event(EventCode::Ready)), ) .unwrap(); assert_eq!(log.replay(record), Err(Error::SequenceOverflow)); assert_eq!(log, before); } }