From 01e67e7de3c6931b15099dd54c67d21736360f92 Mon Sep 17 00:00:00 2001 From: aeddi Date: Tue, 3 Nov 2026 20:07:33 +0100 Subject: [PATCH 01/11] fix(state): drain the hook's output while it runs --- src/chain/state/hook.rs | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src/chain/state/hook.rs b/src/chain/state/hook.rs index d7010362..9de0c25e 100644 --- a/src/chain/state/hook.rs +++ b/src/chain/state/hook.rs @@ -8,7 +8,9 @@ use crate::{ use cometbft::block; use serde::Deserialize; use std::{ + io::Read, process::{Command, Stdio}, + thread, time::Duration, }; use wait_timeout::ChildExt; @@ -32,16 +34,28 @@ pub fn run(config: &HookConfig) -> Result { .args(args) .stdout(Stdio::piped()) .spawn()?; + + // Drained while the hook runs rather than once it exits: a hook that writes + // more than the pipe holds blocks on that write until something reads it + let mut stdout = match child.stdout.take() { + Some(stdout) => stdout, + None => fail!(HookError, "couldn't consume stdout from child"), + }; + let reader = thread::spawn(move || { + let mut output = Vec::new(); + stdout.read_to_end(&mut output).map(|_| output) + }); + let timeout = Duration::from_secs(config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS)); match child.wait_timeout(timeout)? { Some(status) => { if status.success() { - if let Some(stdout) = child.stdout { - Ok(serde_json::from_reader(stdout)?) - } else { - fail!(HookError, "couldn't consume stdout from child"); - } + let output = match reader.join() { + Ok(read) => read?, + Err(_) => fail!(HookError, "couldn't read the hook's output"), + }; + Ok(serde_json::from_slice(&output)?) } else { fail!(HookError, "subcommand returned status {:?}", status.code()) } @@ -83,6 +97,21 @@ mod tests { assert_eq!(output.latest_block_height.value(), 12345); } + /// Nothing may wait on the hook to exit before draining its stdout: a hook + /// that writes more than a pipe holds blocks on the write, never exits, and + /// the run would be reported as a timeout instead of read + #[test] + fn a_hook_writing_more_than_a_pipe_holds_is_still_read() { + // A mebibyte of leading whitespace, past any platform's pipe buffer, + // then the JSON a hook is expected to print + let output = super::run(&shell_hook( + "head -c 1048576 /dev/zero | tr '\\0' ' '; echo '{\"latest_block_height\":\"5\"}'", + )) + .expect("a large but well-formed output should be read"); + + assert_eq!(output.latest_block_height.value(), 5); + } + #[test] fn nonzero_exit_status_is_an_error() { let err = super::run(&shell_hook("exit 3")).expect_err("expected hook failure"); From 1906e03090ca5a08b60a2c3394e5a18d5415e7dd Mon Sep 17 00:00:00 2001 From: aeddi Date: Tue, 3 Nov 2026 20:07:33 +0100 Subject: [PATCH 02/11] docs: update the verification counts --- FORK.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FORK.md b/FORK.md index 31383c5c..dbfa7f2b 100644 --- a/FORK.md +++ b/FORK.md @@ -192,7 +192,7 @@ Signing is unaffected either way. - `cargo fmt --all -- --check` — clean. - `cargo clippy --all-features --all-targets` — clean. -- `cargo test --all-features -- --test-threads 1` — 64 unit + 23 integration tests +- `cargo test --all-features -- --test-threads 1` — 65 unit + 23 integration tests pass (10 ignored, all hardware-dependent). - The published image is pulled and checked on every release: it reports its own version, keeps `tmkms` as the entrypoint, and links the USB libraries the From 849acdae5f3dee842f0b2dc4928471e439b8c4b2 Mon Sep 17 00:00:00 2001 From: aeddi Date: Tue, 3 Nov 2026 21:34:01 +0100 Subject: [PATCH 03/11] fix(state): return an error when the output reader cannot start --- src/chain/state/hook.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/chain/state/hook.rs b/src/chain/state/hook.rs index 9de0c25e..264d8441 100644 --- a/src/chain/state/hook.rs +++ b/src/chain/state/hook.rs @@ -41,10 +41,12 @@ pub fn run(config: &HookConfig) -> Result { Some(stdout) => stdout, None => fail!(HookError, "couldn't consume stdout from child"), }; - let reader = thread::spawn(move || { - let mut output = Vec::new(); - stdout.read_to_end(&mut output).map(|_| output) - }); + let reader = thread::Builder::new() + .name("state-hook-output".to_owned()) + .spawn(move || { + let mut output = Vec::new(); + stdout.read_to_end(&mut output).map(|_| output) + })?; let timeout = Duration::from_secs(config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS)); From 05810cbfdf7e766b2a1c6c96b25ea1386870cc49 Mon Sep 17 00:00:00 2001 From: aeddi Date: Tue, 3 Nov 2026 21:36:40 +0100 Subject: [PATCH 04/11] fix(state): bound reading the hook's output by its timeout --- src/chain/state/hook.rs | 42 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/src/chain/state/hook.rs b/src/chain/state/hook.rs index 264d8441..64d11544 100644 --- a/src/chain/state/hook.rs +++ b/src/chain/state/hook.rs @@ -10,8 +10,9 @@ use serde::Deserialize; use std::{ io::Read, process::{Command, Stdio}, + sync::mpsc::{self, RecvTimeoutError}, thread, - time::Duration, + time::{Duration, Instant}, }; use wait_timeout::ChildExt; @@ -41,21 +42,33 @@ pub fn run(config: &HookConfig) -> Result { Some(stdout) => stdout, None => fail!(HookError, "couldn't consume stdout from child"), }; - let reader = thread::Builder::new() + let (sender, receiver) = mpsc::channel(); + thread::Builder::new() .name("state-hook-output".to_owned()) .spawn(move || { let mut output = Vec::new(); - stdout.read_to_end(&mut output).map(|_| output) + let read = stdout.read_to_end(&mut output).map(|_| output); + // Fails only once `run` has stopped waiting, so nobody is left to tell + let _ = sender.send(read); })?; let timeout = Duration::from_secs(config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS)); + let deadline = Instant::now() + timeout; match child.wait_timeout(timeout)? { Some(status) => { if status.success() { - let output = match reader.join() { + // Bounded by the hook's own deadline: a process the hook leaves + // behind can hold stdout open long after the hook has exited + let remaining = deadline.saturating_duration_since(Instant::now()); + let output = match receiver.recv_timeout(remaining) { Ok(read) => read?, - Err(_) => fail!(HookError, "couldn't read the hook's output"), + Err(RecvTimeoutError::Timeout) => { + fail!(HookError, "hook output still open after {:?}", timeout) + } + Err(RecvTimeoutError::Disconnected) => { + fail!(HookError, "couldn't read the hook's output") + } }; Ok(serde_json::from_slice(&output)?) } else { @@ -81,6 +94,7 @@ pub struct Output { #[cfg(test)] mod tests { use crate::{config::chain::HookConfig, error::ErrorKind}; + use std::time::{Duration, Instant}; /// Build a config for a hook that runs the given shell snippet fn shell_hook(script: &str) -> HookConfig { @@ -114,6 +128,24 @@ mod tests { assert_eq!(output.latest_block_height.value(), 5); } + /// The timeout bounds the whole run, reading the output included: a process + /// the hook leaves behind holding stdout open must not hold up startup + #[test] + fn output_held_open_past_the_timeout_is_an_error() { + let mut config = shell_hook("echo '{\"latest_block_height\":\"5\"}'; sleep 5 &"); + config.timeout_secs = Some(1); + + let started = Instant::now(); + let err = super::run(&config).expect_err("output held open past the timeout should fail"); + + assert_eq!(*err.kind(), ErrorKind::HookError); + assert!( + started.elapsed() < Duration::from_secs(4), + "took {:?}", + started.elapsed() + ); + } + #[test] fn nonzero_exit_status_is_an_error() { let err = super::run(&shell_hook("exit 3")).expect_err("expected hook failure"); From 7bf26103bfeaa478d41d1559702fd8ee85220440 Mon Sep 17 00:00:00 2001 From: aeddi Date: Tue, 3 Nov 2026 21:56:02 +0100 Subject: [PATCH 05/11] fix(state): cap how much hook output is kept --- src/chain/state/hook.rs | 86 +++++++++++++++++++++++++++++------------ 1 file changed, 61 insertions(+), 25 deletions(-) diff --git a/src/chain/state/hook.rs b/src/chain/state/hook.rs index 64d11544..39071c1a 100644 --- a/src/chain/state/hook.rs +++ b/src/chain/state/hook.rs @@ -19,6 +19,10 @@ use wait_timeout::ChildExt; /// Default timeout to use when a user one is unspecified const DEFAULT_TIMEOUT_SECS: u64 = 1; +/// Most output a hook may produce. Valid output is one short JSON object, so the +/// limit exists only to stop a runaway hook from growing the signer's memory. +const MAX_OUTPUT_BYTES: u64 = 1024 * 1024; + /// Sanity limit on how far the block height from the hook can diverge from the /// last known state pub const BLOCK_HEIGHT_SANITY_LIMIT: u64 = 9000; @@ -38,7 +42,7 @@ pub fn run(config: &HookConfig) -> Result { // Drained while the hook runs rather than once it exits: a hook that writes // more than the pipe holds blocks on that write until something reads it - let mut stdout = match child.stdout.take() { + let stdout = match child.stdout.take() { Some(stdout) => stdout, None => fail!(HookError, "couldn't consume stdout from child"), }; @@ -47,7 +51,11 @@ pub fn run(config: &HookConfig) -> Result { .name("state-hook-output".to_owned()) .spawn(move || { let mut output = Vec::new(); - let read = stdout.read_to_end(&mut output).map(|_| output); + // One byte past the limit, so that going over it can be told apart + let read = stdout + .take(MAX_OUTPUT_BYTES + 1) + .read_to_end(&mut output) + .map(|_| output); // Fails only once `run` has stopped waiting, so nobody is left to tell let _ = sender.send(read); })?; @@ -55,33 +63,46 @@ pub fn run(config: &HookConfig) -> Result { let timeout = Duration::from_secs(config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS)); let deadline = Instant::now() + timeout; - match child.wait_timeout(timeout)? { - Some(status) => { - if status.success() { - // Bounded by the hook's own deadline: a process the hook leaves - // behind can hold stdout open long after the hook has exited - let remaining = deadline.saturating_duration_since(Instant::now()); - let output = match receiver.recv_timeout(remaining) { - Ok(read) => read?, - Err(RecvTimeoutError::Timeout) => { - fail!(HookError, "hook output still open after {:?}", timeout) - } - Err(RecvTimeoutError::Disconnected) => { - fail!(HookError, "couldn't read the hook's output") - } - }; - Ok(serde_json::from_slice(&output)?) - } else { - fail!(HookError, "subcommand returned status {:?}", status.code()) - } - } + let status = match child.wait_timeout(timeout)? { + Some(status) => status, None => { // timeout child.kill()?; child.wait()?; fail!(HookError, "subcommand timed out after {:?}", timeout) } + }; + + // Bounded by the hook's own deadline: a process the hook leaves behind can + // hold stdout open long after the hook has exited + let remaining = deadline.saturating_duration_since(Instant::now()); + let output = receiver.recv_timeout(remaining); + + // Checked before the exit status: a hook cut off at the limit dies of + // SIGPIPE, and its status alone would not say why + if matches!(&output, Ok(Ok(output)) if output.len() as u64 > MAX_OUTPUT_BYTES) { + fail!( + HookError, + "hook wrote more than {} bytes of output", + MAX_OUTPUT_BYTES + ) } + + if !status.success() { + fail!(HookError, "subcommand returned status {:?}", status.code()) + } + + let output = match output { + Ok(read) => read?, + Err(RecvTimeoutError::Timeout) => { + fail!(HookError, "hook output still open after {:?}", timeout) + } + Err(RecvTimeoutError::Disconnected) => { + fail!(HookError, "couldn't read the hook's output") + } + }; + + Ok(serde_json::from_slice(&output)?) } /// JSON output from the hook command (parsed with serde) @@ -118,16 +139,31 @@ mod tests { /// the run would be reported as a timeout instead of read #[test] fn a_hook_writing_more_than_a_pipe_holds_is_still_read() { - // A mebibyte of leading whitespace, past any platform's pipe buffer, - // then the JSON a hook is expected to print + // 256 KiB of leading whitespace, past any platform's pipe buffer and + // short of the output limit, then the JSON a hook is expected to print let output = super::run(&shell_hook( - "head -c 1048576 /dev/zero | tr '\\0' ' '; echo '{\"latest_block_height\":\"5\"}'", + "head -c 262144 /dev/zero | tr '\\0' ' '; echo '{\"latest_block_height\":\"5\"}'", )) .expect("a large but well-formed output should be read"); assert_eq!(output.latest_block_height.value(), 5); } + /// Output past the limit is refused rather than held, and reported as such: + /// a hook cut off at the limit dies of SIGPIPE, so the cause must not be + /// reported as a bare exit status + #[test] + fn output_past_the_limit_is_an_error() { + // Two mebibytes of leading whitespace before otherwise valid JSON + let err = super::run(&shell_hook( + "head -c 2097152 /dev/zero | tr '\\0' ' '; echo '{\"latest_block_height\":\"5\"}'", + )) + .expect_err("output past the limit should be refused"); + + assert_eq!(*err.kind(), ErrorKind::HookError); + assert!(err.to_string().contains("more than"), "{err}"); + } + /// The timeout bounds the whole run, reading the output included: a process /// the hook leaves behind holding stdout open must not hold up startup #[test] From b476070b52ce659651d2f8d372386b3a5fb6d39a Mon Sep 17 00:00:00 2001 From: aeddi Date: Tue, 3 Nov 2026 21:58:21 +0100 Subject: [PATCH 06/11] docs: update the verification counts --- FORK.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FORK.md b/FORK.md index dbfa7f2b..dc5b321c 100644 --- a/FORK.md +++ b/FORK.md @@ -192,7 +192,7 @@ Signing is unaffected either way. - `cargo fmt --all -- --check` — clean. - `cargo clippy --all-features --all-targets` — clean. -- `cargo test --all-features -- --test-threads 1` — 65 unit + 23 integration tests +- `cargo test --all-features -- --test-threads 1` — 67 unit + 23 integration tests pass (10 ignored, all hardware-dependent). - The published image is pulled and checked on every release: it reports its own version, keeps `tmkms` as the entrypoint, and links the USB libraries the From 010663b13c1cfc64a93abb3f7712385beb7d0e17 Mon Sep 17 00:00:00 2001 From: aeddi Date: Tue, 3 Nov 2026 22:04:17 +0100 Subject: [PATCH 07/11] docs(config): give the hook timeout's real default and what it bounds --- src/config/chain/hook.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/chain/hook.rs b/src/config/chain/hook.rs index e130e913..2aa343f0 100644 --- a/src/config/chain/hook.rs +++ b/src/config/chain/hook.rs @@ -7,7 +7,7 @@ pub struct HookConfig { /// Command (with arguments) to invoke pub cmd: Vec, - /// Timeout (in seconds) to wait when executing the command (default 5) + /// Time limit (in seconds) for the command to exit and close its stdout (default 1) pub timeout_secs: Option, /// Whether or not to fail open or closed if this command fails to execute. From 7681aa5d4bf96dac049eeda66c9fcb64af3d4cab Mon Sep 17 00:00:00 2001 From: aeddi Date: Wed, 4 Nov 2026 11:13:07 +0100 Subject: [PATCH 08/11] test(state): pin the cap on how much hook output is read --- src/chain/state/hook.rs | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/src/chain/state/hook.rs b/src/chain/state/hook.rs index 39071c1a..00d53eb1 100644 --- a/src/chain/state/hook.rs +++ b/src/chain/state/hook.rs @@ -8,7 +8,7 @@ use crate::{ use cometbft::block; use serde::Deserialize; use std::{ - io::Read, + io::{self, Read}, process::{Command, Stdio}, sync::mpsc::{self, RecvTimeoutError}, thread, @@ -50,14 +50,8 @@ pub fn run(config: &HookConfig) -> Result { thread::Builder::new() .name("state-hook-output".to_owned()) .spawn(move || { - let mut output = Vec::new(); - // One byte past the limit, so that going over it can be told apart - let read = stdout - .take(MAX_OUTPUT_BYTES + 1) - .read_to_end(&mut output) - .map(|_| output); // Fails only once `run` has stopped waiting, so nobody is left to tell - let _ = sender.send(read); + let _ = sender.send(read_capped(stdout)); })?; let timeout = Duration::from_secs(config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS)); @@ -105,6 +99,14 @@ pub fn run(config: &HookConfig) -> Result { Ok(serde_json::from_slice(&output)?) } +/// Read a hook's output, stopping one byte past the limit: going over it can then +/// be told apart without holding everything a runaway hook writes +fn read_capped(output: impl Read) -> io::Result> { + let mut read = Vec::new(); + output.take(MAX_OUTPUT_BYTES + 1).read_to_end(&mut read)?; + Ok(read) +} + /// JSON output from the hook command (parsed with serde) #[derive(Debug, Deserialize)] pub struct Output { @@ -115,7 +117,10 @@ pub struct Output { #[cfg(test)] mod tests { use crate::{config::chain::HookConfig, error::ErrorKind}; - use std::time::{Duration, Instant}; + use std::{ + io::{self, Read}, + time::{Duration, Instant}, + }; /// Build a config for a hook that runs the given shell snippet fn shell_hook(script: &str) -> HookConfig { @@ -164,6 +169,17 @@ mod tests { assert!(err.to_string().contains("more than"), "{err}"); } + /// The cap is what bounds the signer's memory. Without it the length check + /// still rejects an oversized hook, but only after holding all of its output + #[test] + fn reading_stops_one_byte_past_the_limit() { + let source = io::repeat(b' ').take(4 * super::MAX_OUTPUT_BYTES); + + let read = super::read_capped(source).unwrap(); + + assert_eq!(read.len() as u64, super::MAX_OUTPUT_BYTES + 1); + } + /// The timeout bounds the whole run, reading the output included: a process /// the hook leaves behind holding stdout open must not hold up startup #[test] From 412176330cdcc7b5795c591fce96b7fce7d6020a Mon Sep 17 00:00:00 2001 From: aeddi Date: Wed, 4 Nov 2026 11:13:40 +0100 Subject: [PATCH 09/11] test(state): keep hook tests from spawning processes at the same time --- src/chain/state/hook.rs | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/chain/state/hook.rs b/src/chain/state/hook.rs index 00d53eb1..6f59eca3 100644 --- a/src/chain/state/hook.rs +++ b/src/chain/state/hook.rs @@ -116,12 +116,33 @@ pub struct Output { #[cfg(test)] mod tests { - use crate::{config::chain::HookConfig, error::ErrorKind}; + use crate::{ + config::chain::HookConfig, + error::{Error, ErrorKind}, + }; use std::{ io::{self, Read}, + sync::{Mutex, MutexGuard, PoisonError}, time::{Duration, Instant}, }; + /// Held while a test runs a hook. On macOS a pipe is created before it is + /// marked close-on-exec, so a child spawned on another test thread in that + /// window inherits it. A child that outlives its own test, like the `sleep &` + /// below, would then hold another test's output open past its deadline. + static SPAWNING: Mutex<()> = Mutex::new(()); + + /// Wait until no other test is running a hook + fn spawning() -> MutexGuard<'static, ()> { + SPAWNING.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Run a hook with no other test spawning one at the same time + fn run(config: &HookConfig) -> Result { + let _spawning = spawning(); + super::run(config) + } + /// Build a config for a hook that runs the given shell snippet fn shell_hook(script: &str) -> HookConfig { HookConfig { @@ -133,7 +154,7 @@ mod tests { #[test] fn reads_latest_block_height_from_hook_stdout() { - let output = super::run(&shell_hook("echo '{\"latest_block_height\":\"12345\"}'")) + let output = run(&shell_hook("echo '{\"latest_block_height\":\"12345\"}'")) .expect("hook should produce output"); assert_eq!(output.latest_block_height.value(), 12345); @@ -146,7 +167,7 @@ mod tests { fn a_hook_writing_more_than_a_pipe_holds_is_still_read() { // 256 KiB of leading whitespace, past any platform's pipe buffer and // short of the output limit, then the JSON a hook is expected to print - let output = super::run(&shell_hook( + let output = run(&shell_hook( "head -c 262144 /dev/zero | tr '\\0' ' '; echo '{\"latest_block_height\":\"5\"}'", )) .expect("a large but well-formed output should be read"); @@ -160,7 +181,7 @@ mod tests { #[test] fn output_past_the_limit_is_an_error() { // Two mebibytes of leading whitespace before otherwise valid JSON - let err = super::run(&shell_hook( + let err = run(&shell_hook( "head -c 2097152 /dev/zero | tr '\\0' ' '; echo '{\"latest_block_height\":\"5\"}'", )) .expect_err("output past the limit should be refused"); @@ -187,6 +208,8 @@ mod tests { let mut config = shell_hook("echo '{\"latest_block_height\":\"5\"}'; sleep 5 &"); config.timeout_secs = Some(1); + // Timed from once the lock is held, so waiting on other tests does not count + let _spawning = spawning(); let started = Instant::now(); let err = super::run(&config).expect_err("output held open past the timeout should fail"); @@ -200,7 +223,7 @@ mod tests { #[test] fn nonzero_exit_status_is_an_error() { - let err = super::run(&shell_hook("exit 3")).expect_err("expected hook failure"); + let err = run(&shell_hook("exit 3")).expect_err("expected hook failure"); assert_eq!(*err.kind(), ErrorKind::HookError); } @@ -213,7 +236,7 @@ mod tests { fail_closed: true, }; - let err = super::run(&config).expect_err("expected an error for an empty command"); + let err = run(&config).expect_err("expected an error for an empty command"); assert_eq!(*err.kind(), ErrorKind::HookError); } @@ -223,7 +246,7 @@ mod tests { let mut config = shell_hook("sleep 5"); config.timeout_secs = Some(1); - let err = super::run(&config).expect_err("expected a timeout error"); + let err = run(&config).expect_err("expected a timeout error"); assert_eq!(*err.kind(), ErrorKind::HookError); } From b56d69a08ae983674b61e47a3388b2f578c9bb05 Mon Sep 17 00:00:00 2001 From: aeddi Date: Wed, 4 Nov 2026 11:13:41 +0100 Subject: [PATCH 10/11] test(state): name the pipe buffer the drain test relies on --- src/chain/state/hook.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/chain/state/hook.rs b/src/chain/state/hook.rs index 6f59eca3..59a5c15d 100644 --- a/src/chain/state/hook.rs +++ b/src/chain/state/hook.rs @@ -165,8 +165,9 @@ mod tests { /// the run would be reported as a timeout instead of read #[test] fn a_hook_writing_more_than_a_pipe_holds_is_still_read() { - // 256 KiB of leading whitespace, past any platform's pipe buffer and - // short of the output limit, then the JSON a hook is expected to print + // 256 KiB of leading whitespace, four times the 64 KiB pipe buffer of macOS + // and of Linux on 4 KiB pages, yet short of the output limit, then the JSON + // a hook is expected to print let output = run(&shell_hook( "head -c 262144 /dev/zero | tr '\\0' ' '; echo '{\"latest_block_height\":\"5\"}'", )) From ef380d716719a140bf3f292f65aac2ca70d62cb3 Mon Sep 17 00:00:00 2001 From: aeddi Date: Wed, 4 Nov 2026 11:13:49 +0100 Subject: [PATCH 11/11] docs: update the verification counts --- FORK.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FORK.md b/FORK.md index dc5b321c..82d5c890 100644 --- a/FORK.md +++ b/FORK.md @@ -192,7 +192,7 @@ Signing is unaffected either way. - `cargo fmt --all -- --check` — clean. - `cargo clippy --all-features --all-targets` — clean. -- `cargo test --all-features -- --test-threads 1` — 67 unit + 23 integration tests +- `cargo test --all-features -- --test-threads 1` — 68 unit + 23 integration tests pass (10 ignored, all hardware-dependent). - The published image is pulled and checked on every release: it reports its own version, keeps `tmkms` as the entrypoint, and links the USB libraries the