diff --git a/.github/packaging/winget/main.rs b/.github/packaging/winget/main.rs index fc60dda..b99309f 100644 --- a/.github/packaging/winget/main.rs +++ b/.github/packaging/winget/main.rs @@ -17,7 +17,7 @@ use std::process::ExitCode; /// Manifest generation failure reported by the standalone release helper. type Error = Box; -// Inputs supplied by the release workflow; clap doc comments remain user-facing. +// Inputs supplied by the release workflow; clap doc comments remain user-facing #[derive(Parser)] #[command(about = "Generate winget manifests from a released Windows executable")] struct Args { @@ -55,16 +55,24 @@ fn parse_version(value: &str) -> Result { Ok(version) } -/// Checks the executable name, hashes its bytes and writes the three winget manifests. +/// Checks the executable name, hashes its bytes and writes the three winget +/// manifests. +/// /// Generation performs no network requests or repository publication. fn generate(args: &Args) -> Result<(), Error> { + // The executable must carry the release's own name let binary = format!("ark-{}-windows-amd64.exe", args.version); if args.binary.file_name().and_then(|name| name.to_str()) != Some(binary.as_str()) { return Err(format!("expected {binary}").into()); } + + // The installer manifest pins the executable's uppercase SHA-256 let mut hash = Sha256::new(); io::copy(&mut File::open(&args.binary)?, &mut hash)?; let digest = hex::encode_upper(hash.finalize()); + + // Every manifest shares the package identity and the quoted version, then + // adds its own fields let version = serde_json::to_string(&args.version.to_string())?; let base = format!("PackageIdentifier: DarkBio.Ark\nPackageVersion: {version}\n"); let root = format!( @@ -104,6 +112,8 @@ Installers: ), ), ]; + + // Write the version, locale and installer manifests side by side fs::create_dir_all(&args.output)?; for (suffix, kind, content) in manifests { fs::write( @@ -114,10 +124,13 @@ Installers: Ok(()) } +/// Tests of the release version parsing. #[cfg(test)] mod tests { use super::*; + /// Checks that plain and prerelease versions parse, while prefixed, partial, + /// malformed, build-tagged and path-like ones fail. #[test] fn release_versions() { for version in ["0.1.0", "0.1.0-rc.1"] { diff --git a/connect/src/ark.rs b/connect/src/ark.rs index 95f7d7e..2547987 100644 --- a/connect/src/ark.rs +++ b/connect/src/ark.rs @@ -20,15 +20,16 @@ use std::marker::PhantomData; use std::sync::{Arc, mpsc}; use std::time::{Duration, Instant}; -/// Owner of a connection to an Ark. Closing or dropping it ends the session, -/// including requests issued through its [`Client`] handles. +/// Owner of a connection to an Ark, whose closing or dropping ends the session. /// -/// Connect dispatches relay traffic internally when a cloud is selected. Other requests -/// wait in a bounded queue for [`Self::recv`] while clients issue outgoing calls. +/// Ending the session also ends the requests issued through its [`Client`] +/// handles. Relay traffic is dispatched internally when a cloud is selected. +/// Other requests wait in a bounded queue for [`Self::recv`] while clients +/// issue outgoing calls. pub struct Ark { /// Request handle of the wire session owned by the dispatcher. requester: Requester, - /// Stops wire, cloud setup and application receives together. + /// Handle that stops wire, cloud setup and application receives together. closer: Closer, /// Requests not claimed by cloud services. incoming: Arc, @@ -37,16 +38,20 @@ pub struct Ark { } impl Ark { - /// Installs cloud credentials before using the session's clients. The provider - /// is shared across cloud requests and reconnects, and is never called by status. + /// Installs the cloud credentials provider for this session's clients. + /// + /// Install it before using the clients. The provider serves every cloud + /// request and reconnect, and status requests never call it. A connection + /// without a cloud route ignores it. pub fn set_cloud_auth(&mut self, auth: impl crate::CloudAuth + 'static) { self.services.set_cloud_auth(Arc::new(auth)); } /// Authenticates the peer under wire's handshake timeout, then selects cloud - /// routing for the session. Returns the verifier's identity information. - /// Every operation of the connection reads time from the stream's clock. - /// Failure closes the stream. + /// routing for the session. + /// + /// It returns the verifier's identity information. Every operation of the + /// connection reads time from the stream's clock. Failure closes the stream. pub(crate) fn attach( stream: transport::Stream, verifier: &V, @@ -57,7 +62,10 @@ impl Ark { W: transport::Write + Send + 'static, V: Verifier, { + // Take the stream's clock before the handshake consumes the stream let clock = stream.clock(); + + // Authenticate the peer, stream I/O running out of time being a timeout let (session, info) = protocol::connect(stream, verifier).map_err(|err| { if let protocol::Error::Transport(cause) = &err && let transport::Error::RecvFailed(io) | transport::Error::SendFailed(io) = @@ -71,6 +79,8 @@ impl Ark { } Error::Handshake(err) })?; + + // Route cloud traffic by the authenticated identity, then start dispatch let services = Arc::new(Services::new(&info, cloud(&info), &clock)); Ok((Self::start(session, services)?, info)) } @@ -95,8 +105,9 @@ impl Ark { Ok(ark) } - /// Returns a clonable request handle bound to this session. The handle - /// does not keep the connection open. + /// Returns a clonable request handle bound to this session. + /// + /// The handle does not keep the connection open. pub fn client(&self) -> Client { Client { requester: self.requester.clone(), @@ -105,9 +116,11 @@ impl Ark { } /// Blocks for a request not handled by cloud services, or returns the - /// session's ending reason. Closing discards any queued requests. - /// Wire answers unknown request types automatically. - /// The responder retains wire's reply completion and automatic reply semantics. + /// session's ending reason. + /// + /// Closing discards any queued requests. Wire answers unknown request types + /// automatically. The responder keeps wire's reply completion and automatic + /// reply semantics. pub fn recv(&mut self) -> Result<(schema::ark_to_host::Content, Responder), Error> { Ok(self.incoming.recv()?) } @@ -119,7 +132,9 @@ impl Ark { } /// Closes the session, wakes blocked receives and fails pending requests. - /// Does not join application handlers or wait for the peer to observe closure. + /// + /// It does not join application handlers or wait for the peer to observe + /// closure. pub fn close(&self) { self.closer().close(); } @@ -132,18 +147,25 @@ impl Drop for Ark { } } -/// Clonable handle for closing the connection and waking callers waiting for setup. +/// Clonable handle for closing the connection and waking callers waiting for +/// setup. +/// /// Holding the handle does not keep the Ark session open. #[derive(Clone, Debug)] pub struct Closer { - wire: protocol::Closer, // Closes the original wire session - services: Arc, // Ends prerequisite waits on that session - incoming: Arc, // Wakes application receives when the owner closes + /// Closer of the original wire session. + wire: protocol::Closer, + /// Cloud setup state, whose prerequisite waits end with the session. + services: Arc, + /// Application queue, whose receives wake when the session closes. + incoming: Arc, } impl Closer { - /// Closes the original connection and relay. Setup I/O already in progress - /// retains its deadline; setup waiters are released immediately. + /// Closes the original connection and its relay. + /// + /// Setup I/O already in progress keeps its deadline; setup waiters are + /// released immediately. pub fn close(&self) { self.wire.close(); self.services.close(); @@ -152,26 +174,33 @@ impl Closer { } /// Clonable handle for issuing typed requests through its original session. +/// /// Each request carries its own deadline. Handles do not keep the session open. #[derive(Clone, Debug)] pub struct Client { - requester: Requester, // Wire handle bound to the original session - services: Arc, // Prerequisite state shared with the owner and other clients + /// Wire request handle bound to the original session. + requester: Requester, + /// Prerequisite state shared with the owner and the other clients. + services: Arc, } impl Client { - /// Returns the clock of this client's connection. Every deadline passed to - /// the client is measured on it, so callers build their deadlines from it. + /// Returns the clock of this client's connection. + /// + /// Every deadline passed to the client is measured on it, so callers build + /// their deadlines from it. pub fn clock(&self) -> Clock { self.services.clock().clone() } /// Sends a request and waits for its typed response under the chosen timing. + /// /// An absolute deadline covers setup, queueing, sending and accepting the - /// response; decoding is outside it. Reuse it to bound several calls. - /// An inactivity allowance is renewed for each prerequisite and the request. + /// response; decoding is outside it. Reuse it to bound several calls. An + /// inactivity allowance is renewed for each prerequisite and the request. /// Expiration does not cancel an operation the Ark has already received. - /// A reserved UNAVAILABLE refusal is retried once after sync only if the + /// + /// A reserved `UNAVAILABLE` refusal is retried once after sync, only if the /// device reports lost cloud setup. Other refusals are returned unchanged. pub fn call( &self, @@ -180,9 +209,13 @@ impl Client { ) -> Result { let timing = timing.into(); let request = request.into(); + + // A request needing no cloud setup has no lost setup to retry if matches!(R::SETUP, Setup::None) { return self.send_message::(request, timing)?.wait(); } + + // Keep a copy of the request for one retry after a reported setup loss let result = self.send_message::(request.clone(), timing)?.wait(); if matches!(&result, Err(Error::Remote(error)) if error.code == schema::ReservedErrors::Unavailable as u64) && !self.services.synced(&self.requester, timing)? @@ -195,8 +228,10 @@ impl Client { result } - /// Sends a request and waits with a budget starting now. Use [`Self::call`] - /// with one deadline when several requests must share a budget. + /// Sends a request and waits with a budget starting now. + /// + /// Use [`Self::call`] with one deadline when several requests must share a + /// budget. pub fn call_timeout( &self, request: R, @@ -211,13 +246,15 @@ impl Client { self.call(request, deadline) } - /// Establishes the request's prerequisites, then queues it without waiting for - /// output or a response. The first cloud-dependent send may wait for sync - /// and relay attachment, according to the request's prerequisites. - /// Unlike [`Self::call`], a refusal is returned without retrying lost setup. - /// Waiting on the promise does not refresh the deadline; dropping - /// it does not cancel the request. Wire's output queue has no capacity limit, - /// so the caller bounds the number of outstanding requests. + /// Establishes the request's prerequisites, then queues it without waiting + /// for output or a response. + /// + /// The first cloud-dependent send may wait for sync and relay attachment, + /// according to the request's prerequisites. Unlike [`Self::call`], a + /// refusal is returned without retrying lost setup. Waiting on the promise + /// does not refresh the deadline; dropping it does not cancel the request. + /// Wire's output queue has no capacity limit, so the caller bounds the + /// number of outstanding requests. pub fn send( &self, request: R, @@ -227,21 +264,29 @@ impl Client { } /// Establishes typed prerequisites before starting the wire response window. - /// Device-info results retain their request time for clock observations. + /// + /// A device info request keeps its request time, so its answer can inform + /// the lazy cloud setup. fn send_message( &self, request: Message, timing: Timing, ) -> Result, Error> { tracing::debug!("sending request {}", std::any::type_name::()); + + // Establish the prerequisites the request type needs match R::SETUP { Setup::None => {} Setup::Cloud => self.services.sync(&self.requester, timing)?, Setup::Relay => self.services.relay(&self.requester, timing)?, } + + // Bound the response by its protocol window or the inactivity allowance let clock = self.services.clock(); let deadline = R::WINDOW.map_or_else(|| timing.io(clock), |window| timing.window(clock, window)); + + // Queue the request, noting when a device info query was sent let setup = matches!(request, Message::DeviceInfoRequest(_)) .then(|| (self.services.clone(), clock.now())); let promise = self @@ -256,7 +301,8 @@ impl Client { } /// Establishes prerequisites and queues a request with a budget starting now. - /// Waiting on the returned promise retains that deadline, even if done later. + /// + /// Waiting on the returned promise keeps that deadline, even if done later. pub fn send_timeout( &self, request: R, @@ -271,22 +317,28 @@ impl Client { self.send(request, deadline) } - /// Checks the cloud registry for this Ark, synchronizing first if - /// necessary. Setup, proof generation and HTTP share the supplied deadline. - /// A refused proof triggers one refresh and a retry with a new proof. - /// The returned registration may be inactive; its flags explain why. + /// Checks the cloud registry for this Ark, synchronizing first if necessary. + /// + /// Setup, proof generation and HTTP share the supplied deadline. A refused + /// proof triggers one refresh and a retry with a new proof. The returned + /// registration may be inactive; its flags explain why. pub fn genuine(&self, timing: impl Into) -> Result { self.services.genuine(&self.requester, timing.into()) } - /// Authorizes, streams, verifies and installs firmware under the chosen timing. - /// Reads and callbacks run on this thread and must return promptly; a blocking - /// reader cannot be interrupted by the deadline. Success acknowledges installation; - /// the Ark then reboots, and this call does not verify the subsequent boot. - /// Failed updates are never replayed automatically. Raw firmware requests - /// must not run concurrently with this operation. - /// A cloud proof rejection refreshes keys, then returns [`Error::ProofRejected`] - /// so the caller can start a new attempt with a new approval. + /// Authorizes, streams, verifies and installs firmware under the chosen + /// timing. + /// + /// Reads and callbacks run on this thread and must return promptly; the + /// deadline cannot interrupt a blocking reader. Success acknowledges + /// installation; the Ark then reboots, and this call does not verify the + /// subsequent boot. Failed updates are never replayed automatically. A + /// concurrent update through another clone fails at once, and raw firmware + /// requests must not run concurrently with this operation. + /// + /// A cloud proof rejection refreshes keys, then returns + /// [`Error::ProofRejected`] so the caller can start a new attempt with a new + /// approval. pub fn update_firmware( &self, firmware: &Firmware, @@ -298,10 +350,11 @@ impl Client { .update_firmware(&self.requester, firmware, reader, timing.into(), progress) } - /// Pairs an unpaired Ark through the cloud rendezvous. The caller presents - /// the rendezvous to the owner; the Ark authenticates every opaque exchange. - /// Authentication may refresh cloud keys and retry once. The pairing - /// exchange itself is never retried automatically. + /// Pairs an unpaired Ark through the cloud rendezvous. + /// + /// The caller presents the rendezvous to the owner; the Ark authenticates + /// every opaque exchange. Authentication may refresh cloud keys and retry + /// once. The pairing exchange itself is never retried automatically. /// Progress callbacks run on this thread and must return promptly so the /// rendezvous and approval windows can be serviced. pub fn pair( @@ -312,21 +365,26 @@ impl Client { self.services.pair(&self.requester, timing.into(), progress) } - /// Refreshes the cloud keys and signed clock explicitly. Other requests - /// synchronize lazily, reusing the Ark's reported setup while fresh. + /// Refreshes the cloud keys and signed clock explicitly. + /// + /// Other requests synchronize lazily, reusing the Ark's reported setup while + /// fresh. pub fn sync(&self, timing: impl Into) -> Result<(), Error> { self.services.resync(&self.requester, timing.into()) } - /// Attaches the companion relay after sync, reusing an existing attachment. + /// Attaches the companion relay after sync, reusing a connected attachment. pub fn attach_relay(&self, timing: impl Into) -> Result<(), Error> { self.services.relay(&self.requester, timing.into()) } - /// Identifies a dataset from its first MiB without opening an upload session. - /// Consumes that prefix from the reader; rewind it before uploading. - /// A shorter source is an error. The reader must enforce its own read timeout; - /// the operation deadline is checked before and after reading. + /// Identifies a dataset from its first 1 MiB without opening an upload + /// session. + /// + /// It consumes that prefix from the reader; rewind it before uploading. A + /// source shorter than the prefix is an error. The reader must enforce its + /// own read timeout; the operation deadline is checked before and after + /// reading. pub fn identify_dataset( &self, name: &str, @@ -337,6 +395,8 @@ impl Client { let timing = timing.into(); let clock = self.services.clock(); timing.check(clock)?; + + // Read the prefix, a timed out source read being a timeout let mut chunk = vec![0; size.min(1024 * 1024) as usize]; reader.read_exact(&mut chunk).map_err(|error| { if error.kind() == io::ErrorKind::TimedOut { @@ -346,6 +406,8 @@ impl Client { } })?; timing.check(clock)?; + + // Ask the Ark to identify the prefix as any dataset kind self.call( schema::SlotIdentifyRequest { name: name.into(), @@ -357,11 +419,13 @@ impl Client { ) } - /// Streams exactly the declared bytes and waits for processing. The Ark - /// identifies the target when no slot is supplied. An expected SHA-256 is - /// checked before processing; no URLs, caching or retries are involved. - /// Reads and progress callbacks run on this thread and must return promptly. - /// Failures attempt cancellation without replacing the original error. + /// Streams exactly the declared bytes and waits for processing. + /// + /// The Ark identifies the target when no slot is supplied. An expected + /// SHA-256 is checked before processing; no URLs, caching or retries are + /// involved. Reads and progress callbacks run on this thread and must + /// return promptly. Failures attempt cancellation without replacing the + /// original error. pub fn upload_dataset( &self, dataset: &Dataset, @@ -375,20 +439,23 @@ impl Client { } /// Uploads an app, obtains companion approval and waits for its result. + /// /// The source must contain exactly `size` bytes. The Ark validates the app /// and its dataset requirements. An unsuccessful app still returns its - /// result; inspect its `success` flag. Output is retained as returned by the - /// Ark. Failed apps only retain their streams when developer output is enabled. + /// result; inspect its `success` flag. Output is kept as the Ark returns it, + /// and a failed app's streams come back only when its manifest sets + /// `develop`. /// /// Setup, upload, approval and execution share the deadline. Reads and - /// progress callbacks run on this thread and must return promptly. A blocking - /// reader cannot be interrupted by the deadline. Failed operations attempt - /// cancellation within the remaining time and are never retried. + /// progress callbacks run on this thread and must return promptly. The + /// deadline cannot interrupt a blocking reader. Failed operations attempt + /// cancellation within the remaining time, at most 1 s, and are never + /// retried. /// /// [`ExecutionProgress::Started`] supplies the task ID for cancellation with /// [`schema::ExecutionCancelRequest`] through another client clone. Closing - /// the connection alone does not cancel the task. Retrieving a completed - /// result consumes it, so only one caller should poll a task's status. + /// the connection sends no cancellation. Retrieving a completed result + /// consumes it, so only one caller should poll a task's status. pub fn execute( &self, size: u64, @@ -406,12 +473,14 @@ impl Client { } /// Result of a typed request, decoded when [`Self::wait`] takes the response. -/// Dropping it discards the result without cancelling the request. +/// +/// Dropping it discards the result without canceling the request. #[derive(Debug)] pub struct Pending { /// Encoded response and the notification registered for its completion. promise: Promise, - /// Device-info observations also inform this connection's lazy setup. + /// Setup state and request time of a device info query, whose answer + /// informs this connection's lazy setup. setup: Option<(Arc, Instant)>, /// Response type selected by the request, without owning a value of it. response: PhantomData T>, @@ -419,6 +488,7 @@ pub struct Pending { impl Pending { /// Sends an event when the request completes, successfully or with an error. + /// /// One channel can observe many requests. Registration leaves the response /// encoded until [`Self::wait`] and does not change its deadline. /// @@ -433,8 +503,10 @@ impl Pending { } impl> Pending { - /// Waits for completion and decodes the expected response. An accepted - /// response remains available after its deadline or the session's closure. + /// Waits for completion and decodes the expected response. + /// + /// An accepted response remains available after its deadline or the + /// session's closure. pub fn wait(self) -> Result { let message = self.promise.wait::()?; if let Some((services, requested)) = self.setup @@ -465,11 +537,19 @@ mod tests { fn test_cloud_selection_after_handshake() { use crate::trust::{Environment, Realm}; - struct Attested(Environment); + /// Verifier reporting every peer it accepts as attested under a fixed + /// environment. + struct Attested( + /// Environment the test fabricates for every accepted peer. + Environment, + ); impl Verifier for Attested { + /// Identity the routing under test reads its cloud environment from. type Info = Identity; + /// Verifies the self-signed peer, then reports it as an attested + /// device in the fabricated environment. fn verify( &self, attestation: &transport::Attestation, @@ -493,6 +573,8 @@ mod tests { } } + // Each environment's attested identity reaches the selector once, and + // the session it routes still serves requests let clock = test_clock().clock(); for &env in crate::identity::ENVIRONMENTS { let mut peer = Peer::spawn(&clock, Box::new(answering)); @@ -531,7 +613,8 @@ mod tests { assert!(matches!(result, Err(Error::Handshake(_)))); } - /// Exercises manual reverse requests without automatic cloud attachment. + /// Unlock request needing no setup, for exercising reverse requests without + /// automatic cloud attachment. struct ManualUnlock; impl From for Message { @@ -542,7 +625,9 @@ mod tests { } impl Request for ManualUnlock { + /// Unlock response the scripted peer returns. type Response = UnlockResponse; + /// No automatic prerequisites, leaving the relay to the test. const SETUP: Setup = Setup::None; } @@ -562,6 +647,7 @@ mod tests { } } + // The peer relays each unlock to the host, then refuses it as unavailable let clock = test_clock().clock(); let mut peer = Peer::spawn( &clock, @@ -594,6 +680,8 @@ mod tests { true }), ); + + // The host refuses the relayed request with its own application error let (mut ark, _) = peer.attach().unwrap(); let client = ark.client(); let pending = client.send(ManualUnlock, clock.now() + TIMEOUT).unwrap(); @@ -608,6 +696,8 @@ mod tests { pending.wait(), Err(Error::Remote(error)) if error.code == schema::ReservedErrors::Unavailable as u64 )); + + // The refused unlock leaves the session open for later requests assert_eq!( client .call(DeviceInfoRequest {}, clock.now() + TIMEOUT) @@ -638,6 +728,7 @@ mod tests { content: Vec, } + // A raw peer sends unknown and known requests, then collects both replies let clock = test_clock().clock(); let signer = xdsa::SecretKey::generate(); let identity = signer.public_key(); @@ -678,6 +769,8 @@ mod tests { } replies }); + + // The host refuses the known request itself, as unsupported let (mut ark, _) = Ark::attach(host, &crate::TrustMode::Recover(Box::new(identity)), |_| { None @@ -699,6 +792,7 @@ mod tests { .unwrap() .wait() .unwrap(); + // Both replies have been written. Close before joining so a missing reply // causes a peer EOF rather than leaving this test waiting indefinitely. ark.close(); @@ -713,10 +807,11 @@ mod tests { ); } - /// Concurrent typed requests retain their responses and completion tokens. - /// A reserved peer refusal is returned through the same request interface. + /// Concurrent typed requests keep their responses and completion tokens, and + /// a reserved refusal returns through the same interface. #[test] fn test_requests() { + // A notified request signals its token before its response is taken let clock = test_clock().clock(); let mut peer = Peer::spawn(&clock, Box::new(answering)); let (ark, _) = peer.attach().unwrap(); @@ -729,6 +824,7 @@ mod tests { assert_eq!(events.recv().unwrap(), 7); assert_eq!(pending.wait().unwrap().firmware_version, "1.0.0"); + // Concurrent callers sharing one deadline each get their own response let deadline = clock.now() + TIMEOUT; let callers: Vec<_> = (0..8) .map(|_| { @@ -744,6 +840,8 @@ mod tests { for caller in callers { assert_eq!(caller.join().unwrap(), "1.0.0"); } + + // A reserved refusal comes back as a remote error assert!( matches!(client.call(OnboardingRequest::default(), clock.now() + TIMEOUT), Err(Error::Remote(error)) if error.code == schema::ReservedErrors::Unsupported as u64) ); @@ -806,6 +904,7 @@ mod tests { /// A short request timeout leaves a concurrent request's budget intact. #[test] fn test_timeouts() { + // Leave a request outstanding under the long budget let mut tester = test_clock(); let clock = tester.clock(); let mut peer = Peer::spawn(&clock, silent()); @@ -813,7 +912,8 @@ mod tests { let client = ark.client(); let pending = client.send_timeout(DeviceInfoRequest {}, TIMEOUT).unwrap(); - // The short call's deadline is the earliest one, and reaching it expires the call + // The short call's deadline is the earliest one, and reaching it + // expires the call let deadline = clock.now() + Duration::from_millis(20); let short = thread::spawn({ let client = client.clone(); @@ -831,6 +931,7 @@ mod tests { /// Reusing a deadline across calls and cloned handles does not renew its budget. #[test] fn test_deadlines() { + // A call within the shared deadline succeeds let mut tester = test_clock(); let clock = tester.clock(); let mut peer = Peer::spawn(&clock, Box::new(answering)); @@ -844,12 +945,15 @@ mod tests { .firmware_version, "1.0.0" ); - // Spend the remaining operation budget before issuing the next request. + + // Spend the rest of the budget, which a cloned handle cannot renew tester.advance_to(deadline); assert!(matches!( client.clone().call(DeviceInfoRequest {}, deadline), Err(Error::Timeout) )); + + // A budget starting now still serves a call assert_eq!( client .call_timeout(DeviceInfoRequest {}, TIMEOUT) @@ -862,13 +966,14 @@ mod tests { /// An unlock can wait for the host to return an opaque companion response. #[test] fn test_reverse_requests() { + // The peer answers an unlock after the host answers its relay request let clock = test_clock().clock(); let mut peer = Peer::spawn( &clock, Box::new(|session, _, responder| { let deadline = session.clock().now() + TIMEOUT; // Unlock cannot complete until the application returns the opaque - // companion response through this reverse request. + // companion response through this reverse request let approval = session .requester() .request( @@ -891,6 +996,8 @@ mod tests { true }), ); + + // The application answers the relay request while the unlock waits let (mut ark, _) = peer.attach().unwrap(); let client = ark.client(); let deadline = clock.now() + TIMEOUT; @@ -914,12 +1021,12 @@ mod tests { operation.join().unwrap().unwrap(); } - /// Request handles can move between threads before selecting where to decode - /// a response, including a response type that cannot itself move between threads. + /// Request handles can move between threads before choosing where to + /// decode, even for a response type that is not `Send`. #[test] fn test_thread_capabilities() { - // Request completion handles remain Send even when a user's response wrapper - // isn't Send. The conversion runs in the caller that waits. + // Request completion handles remain Send even when a user's response + // wrapper isn't Send. The conversion runs in the caller that waits. /// Fails compilation if a handle loses its cross-thread guarantee. fn assert_send() {} assert_send::>>(); diff --git a/connect/src/cloud/auth.rs b/connect/src/cloud/auth.rs index d112a33..171aee7 100644 --- a/connect/src/cloud/auth.rs +++ b/connect/src/cloud/auth.rs @@ -13,30 +13,44 @@ use std::sync::{Arc, RwLock}; use std::time::Instant; use ureq::http::{HeaderMap, StatusCode}; -/// Caller-owned authentication for a cloud host. Connect supplies the HTTPS -/// origin; credential storage, response recognition and login stay with the caller. -/// The same headers are used for HTTP requests and WebSocket upgrades. Deadlines -/// are measured on the clock of the connection, which its clients return. +/// Caller-owned authentication for a protected cloud host, installed with +/// [`Ark::set_cloud_auth`](crate::Ark::set_cloud_auth). +/// +/// Connect supplies the HTTPS origin, while credential storage, response +/// recognition and login stay with the caller. The same headers go on HTTP +/// requests and WebSocket upgrades. Deadlines are measured on the clock of the +/// connection, which [`Client::clock`](crate::Client::clock) returns. pub trait CloudAuth: Send + Sync { /// Returns cached authentication headers without prompting, or an empty map. - /// The deadline bounds lookup. Headers must not replace protocol headers. + /// + /// The deadline bounds the lookup. The headers must not replace protocol + /// headers. fn headers(&self, origin: &str, deadline: Instant) -> HeaderMap; - /// Whether a response refused caller authentication before reaching the cloud. - /// A device proof rejection must return false so cloud key refresh stays separate. + /// Checks whether a response refused the caller's authentication before + /// reaching the cloud. + /// + /// A refused device proof must return false, so refreshing cloud keys stays + /// separate from login. fn rejected(&self, origin: &str, status: StatusCode, headers: &HeaderMap) -> bool; - /// Obtains fresh authentication headers. The optional absolute deadline must - /// be honored; the caller chooses its login window and interaction policy. - /// Failure diagnostics must never contain credentials. + /// Obtains fresh authentication headers. + /// + /// The optional absolute deadline must be honored, while the caller chooses + /// its own login window and interaction policy. A failure diagnostic must + /// never contain credentials. fn login(&self, origin: &str, deadline: Option) -> Result; } /// Shared provider and its cached headers, installed before cloud operations. #[derive(Default)] pub(super) struct Authorization { - provider: RwLock>>, // Caller policy, copied before callbacks - cached: RwLock>, // Headers reused until the host refuses them + /// Caller's provider, copied out before each callback so none runs under + /// the lock. + provider: RwLock>>, + /// Headers reused for every request until a login or a new provider + /// replaces them. + cached: RwLock>, } impl std::fmt::Debug for Authorization { @@ -47,12 +61,13 @@ impl std::fmt::Debug for Authorization { } impl Authorization { - /// Whether the caller supplied authentication for this cloud host. + /// Checks whether the caller installed an authentication provider. pub(super) fn configured(&self) -> bool { self.provider().is_some() } - /// Replaces the provider without invoking it or starting network I/O. + /// Replaces the provider and drops the cached headers, without invoking it + /// or starting network I/O. pub(super) fn set(&self, provider: Arc) { *self .provider @@ -61,7 +76,8 @@ impl Authorization { *self.cached.write().expect("cloud credentials not poisoned") = None; } - /// Copies the provider so callbacks never hold the configuration lock. + /// Copies the provider out, so its callbacks never run under the + /// configuration lock. fn provider(&self) -> Option> { self.provider .read() @@ -69,7 +85,10 @@ impl Authorization { .clone() } - /// Reads credentials once, outside the lock. A concurrent login takes priority. + /// Returns the cached headers, asking the provider once when none are cached. + /// + /// The provider runs outside the lock. Headers from a concurrent login take + /// priority over the ones it returns. pub(super) fn headers(&self, origin: &str, deadline: Instant) -> HeaderMap { if let Some(cached) = &*self.cached.read().expect("cloud credentials not poisoned") { return cached.clone(); @@ -81,14 +100,20 @@ impl Authorization { cached.get_or_insert(headers).clone() } - /// Defers gateway response recognition to the caller's authentication policy. + /// Asks the provider whether a response refused the caller's credentials. + /// + /// Without a provider, no response counts as a refusal. pub(super) fn rejected(&self, origin: &str, status: StatusCode, headers: &HeaderMap) -> bool { self.provider() .is_some_and(|provider| provider.rejected(origin, status, headers)) } - /// Refreshes credentials without extending an absolute operation deadline, - /// which is measured on the clock. + /// Replaces the cached headers with fresh ones from the provider's login, + /// within the operation's absolute deadline. + /// + /// The deadline is measured on the clock, and an inactivity allowance does + /// not bound the login. Without a provider, the login fails with + /// [`Failure::CloudAuth`]. pub(super) fn login(&self, origin: &str, clock: &Clock, timing: Timing) -> Result<(), Failure> { let check = || { timing @@ -100,6 +125,8 @@ impl Authorization { origin: origin.into(), message: "cloud access requires login".into(), })?; + + // The provider bounds its own wait, so check the deadline again after it let headers = provider.login(origin, timing.deadline()); check()?; let headers = headers.map_err(|message| Failure::CloudAuth { @@ -111,7 +138,8 @@ impl Authorization { } } -/// Redacts every caller-supplied authentication value from HTTP debug output. +/// Marks every caller-supplied header value sensitive, which redacts it from +/// debug output. fn sensitive(mut headers: HeaderMap) -> HeaderMap { for value in headers.values_mut() { value.set_sensitive(true); @@ -119,6 +147,8 @@ fn sensitive(mut headers: HeaderMap) -> HeaderMap { headers } +/// Credential caching and login deadlines, with the provider stand-in that the +/// other cloud tests share. #[cfg(test)] pub(super) mod tests { use super::*; @@ -128,11 +158,15 @@ pub(super) mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; - /// Local authentication policy used by HTTP, socket and firmware tests. + /// Authentication provider stand-in for the cloud tests, counting its + /// lookups and logins. #[derive(Clone, Default)] pub(in crate::cloud) struct Login { + /// Number of cached header lookups. pub lookups: Arc, + /// Number of logins. pub logins: Arc, + /// Whether every login fails. pub fail: bool, /// Test clock and the time each login spends on it, standing in for the /// owner signing in through the browser. @@ -140,19 +174,24 @@ pub(super) mod tests { } impl CloudAuth for Login { + /// Counts the lookup and returns an `authorization: cached` header. fn headers(&self, _: &str, _: Instant) -> HeaderMap { self.lookups.fetch_add(1, Ordering::SeqCst); HeaderMap::from_iter([("authorization".parse().unwrap(), "cached".parse().unwrap())]) } + /// Treats any response carrying an `x-test-auth` header as a refusal. fn rejected(&self, _: &str, _: StatusCode, headers: &HeaderMap) -> bool { headers.contains_key("x-test-auth") } + /// Counts the login, then fails or returns an `authorization: refreshed` + /// header. fn login(&self, _: &str, deadline: Option) -> Result { self.logins.fetch_add(1, Ordering::SeqCst); - // Spend the browser's time on the test clock, returning by the deadline as a provider must + // Spend the browser's time on the test clock, returning by the + // deadline as a provider must if let Some((tester, delay)) = &self.browser { let mut tester = tester.lock().unwrap(); let now = tester.clock().now(); @@ -170,19 +209,26 @@ pub(super) mod tests { } } + /// Formats an empty response carrying the `X-Test-Auth` header, which + /// [`Login`] treats as refusing the caller's credentials. pub(in crate::cloud) fn refused(status: u16) -> String { format!( "HTTP/1.1 {status} Test\r\nX-Test-Auth: required\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" ) } + /// Headers are looked up once, redacted from debug output and replaced by a + /// login. #[test] fn credentials_are_cached_redacted_and_refreshed() { + // Installing a provider looks nothing up let clock = test_clock().clock(); let auth = Authorization::default(); let login = Login::default(); auth.set(Arc::new(login.clone())); assert_eq!(login.lookups.load(Ordering::SeqCst), 0); + + // Repeated requests reuse the first lookup, redacted let deadline = clock.now() + Duration::from_secs(1); for _ in 0..2 { let headers = auth.headers("https://test.invalid", deadline); @@ -190,6 +236,8 @@ pub(super) mod tests { assert!(headers["authorization"].is_sensitive()); assert!(!format!("{headers:?}").contains("cached")); } + + // A login replaces the cached headers, still redacted auth.login("https://test.invalid", &clock, deadline.into()) .unwrap(); let headers = auth.headers("https://test.invalid", deadline); @@ -200,6 +248,8 @@ pub(super) mod tests { assert_eq!(login.logins.load(Ordering::SeqCst), 1); } + /// An absolute deadline ends a login with a timeout, while an inactivity + /// allowance does not bound it. #[test] fn login_retains_absolute_deadlines() { // Let every browser login take 100 ms of the test clock @@ -219,7 +269,8 @@ pub(super) mod tests { )); assert_eq!(login.logins.load(Ordering::SeqCst), 0); - // An inactivity allowance does not bound the login, an absolute deadline does + // An inactivity allowance does not bound the login, but an absolute + // deadline does let timing = Timing::inactivity(Duration::from_millis(1)); auth.login("https://test.invalid", &clock, timing).unwrap(); assert!(matches!( diff --git a/connect/src/cloud/dns.rs b/connect/src/cloud/dns.rs index da2f7cd..d81ff58 100644 --- a/connect/src/cloud/dns.rs +++ b/connect/src/cloud/dns.rs @@ -15,13 +15,17 @@ use std::sync::{Arc, Mutex}; use std::thread; use std::time::Instant; -/// Coalesces unfinished lookups by host and port, without caching completed DNS. -/// System lookups cannot be cancelled on timeout, so a retry joins the one -/// still running instead of starting another. +/// Resolver that coalesces unfinished lookups by host and port, without caching +/// completed results. +/// +/// The standard library's lookup cannot be canceled on timeout, so a retry +/// joins the one still running instead of starting another. #[derive(Debug)] pub(super) struct Resolver { - clock: Clock, // clock that the waiters' deadlines are measured on - pending: Mutex>>, // Only unfinished system calls + /// Clock that the waiters' deadlines are measured on. + clock: Clock, + /// Lookups still running by host and port, each removed once it completes. + pending: Mutex>>, } /// One system lookup retained until every attached waiter releases it. @@ -29,7 +33,7 @@ pub(super) struct Resolver { struct Lookup { /// Addresses or failure, published once by the resolver worker. result: sync::Mutex, Failure>>>, - /// Wakes all callers when the shared system lookup returns. + /// Signal that wakes every waiter once the shared system lookup returns. ready: sync::Condvar, } @@ -42,7 +46,11 @@ impl Resolver { }) } - /// A caller's deadline ends its wait, leaving the lookup available to retries. + /// Resolves a host and port to socket addresses, joining a lookup already + /// running for them. + /// + /// An IP address needs no lookup. A caller's deadline ends only its own + /// wait, leaving the lookup available to retries. pub(super) fn resolve( self: &Arc, host: &str, @@ -62,8 +70,11 @@ impl Resolver { }) } - /// Completed lookups are removed so a later attachment refreshes DNS. An - /// expired waiter neither cancels nor replaces a system call still running. + /// Waits for the lookup under `key`, starting it on a worker thread when + /// none is running. + /// + /// A completed lookup is forgotten, so a later attempt resolves afresh. An + /// expired waiter neither cancels nor replaces a lookup still running. fn lookup( self: &Arc, key: (String, u16), @@ -71,6 +82,8 @@ impl Resolver { lookup: impl FnOnce() -> Result, Failure> + Send + 'static, ) -> Result, Failure> { self.clock.remaining(deadline).map_err(socket::io_error)?; + + // Join the lookup running for this key, or start one on a worker thread let mut pending = self.pending.lock().expect("DNS lookups not poisoned"); let attempt = match pending.get(&key) { Some(attempt) => attempt.clone(), @@ -87,6 +100,9 @@ impl Resolver { let key = key.clone(); move || { let result = lookup(); + + // Publish and forget under the map's lock, so a new + // caller either joins this lookup or starts afresh let mut pending = resolver.pending.lock().expect("DNS lookups not poisoned"); *attempt.result.lock().expect("DNS result not poisoned") = Some(result); @@ -100,6 +116,8 @@ impl Resolver { } }; drop(pending); + + // Wait for the shared result until this caller's own deadline let mut result = attempt.result.lock().expect("DNS result not poisoned"); loop { if let Some(result) = &*result { @@ -115,6 +133,7 @@ impl Resolver { } } +/// Lookup sharing, expiry and refresh. #[cfg(test)] mod tests { use super::*; @@ -123,7 +142,8 @@ mod tests { use std::sync::mpsc; use std::time::Duration; - /// Retries join a stalled lookup, and the next completed attempt refreshes it. + /// Retries join a stalled lookup, and an attempt after it completes starts + /// a fresh one. #[test] fn test_timeout_and_retry() { // Stall the first system lookup until released diff --git a/connect/src/cloud/firmware.rs b/connect/src/cloud/firmware.rs index f26ffa7..c60427b 100644 --- a/connect/src/cloud/firmware.rs +++ b/connect/src/cloud/firmware.rs @@ -17,11 +17,16 @@ use serde::Deserialize; use sha2::{Digest, Sha256}; use std::io::Read; -/// Archive bytes per acknowledged transfer, amortizing USB and device write latency. +/// Archive bytes per acknowledged upload request, 1 MiB to amortize each round +/// trip. const CHUNK_SIZE: usize = 1024 * 1024; -/// A published encrypted archive. The Ark verifies its signature, contents and -/// version before installation; the host checks the download's length and hash. +/// Published encrypted firmware archive, as +/// [`Client::update_firmware`](crate::Client::update_firmware) streams it to the +/// Ark. +/// +/// The host checks the archive's length and SHA-256 while streaming it, and +/// the Ark decrypts and verifies it before installation. #[derive(Clone, Debug)] pub struct Firmware { /// Published version authorized by the cloud and checked by the Ark. @@ -32,33 +37,39 @@ pub struct Firmware { pub sha256: [u8; 32], } -/// Update stages reported on the caller's thread. Uploaded bytes have been -/// acknowledged by the Ark; installation completes only when the call returns. +/// Stages of [`Client::update_firmware`](crate::Client::update_firmware), +/// reported on the caller's thread. +/// +/// Uploaded bytes count only what the Ark acknowledged, and installation +/// completes only when the call returns. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum UpdateProgress { - /// Synchronization has completed; the Ark may now request approval. + /// Preparation after cloud sync, during which the Ark may ask for approval. Preparing, - /// Downloaded archive bytes acknowledged by the Ark so far. + /// Upload of the archive, with the bytes the Ark acknowledged so far. Uploading { /// Archive bytes acknowledged so far. uploaded: u64, /// Declared encrypted archive length in bytes. total: u64, }, - /// The complete archive passed the host checks and is being verified by the Ark. + /// Verification by the Ark, once the complete archive passed the host's + /// checks. Verifying, - /// The verified firmware is being installed; success will reboot the Ark. + /// Installation of the verified firmware, which reboots the Ark on success. Installing, } /// Firmware authorization returned by the cloud for this prepared update. #[derive(Deserialize)] struct Access { - access: String, // Cloud response sealed to the Ark's ephemeral update key + /// Archive key sealed to the Ark's ephemeral update key, in standard base64. + access: String, } impl Services { - /// Requires a cloud route and preserves the owning session's ending reason. + /// Returns the cloud route, failing first with the session's ending reason + /// once it ended. fn firmware_cloud(&self) -> Result<&http::Api, Error> { if let Some(error) = &self.state.lock().expect("cloud setup not poisoned").error { return Err(error.clone().into()); @@ -66,8 +77,10 @@ impl Services { self.cloud.as_ref().ok_or(Error::MissingEnvironment) } - /// Holds one update across its cloud requests and wire calls. A failure ends - /// the sequence; upload chunks and installation are never retried implicitly. + /// Runs one firmware update, from cloud authorization through installation. + /// + /// One update runs at a time across client clones. A failure ends the + /// sequence, and upload chunks and installation are never retried. pub(crate) fn update_firmware( &self, requester: &Requester, @@ -76,6 +89,7 @@ impl Services { timing: Timing, mut progress: impl FnMut(UpdateProgress), ) -> Result<(), Error> { + // Admit one update of a non-empty archive at a time, on a synced Ark let cloud = self.firmware_cloud()?; if firmware.size == 0 { return Err(Error::Firmware("firmware archive is empty".into())); @@ -86,11 +100,14 @@ impl Services { .map_err(|_| Error::Firmware("another firmware update is already running".into()))?; self.sync(requester, timing)?; let clock = &self.clock; + // Finish any browser login before asking the Ark to prepare an update. // A protected host can need login even when device sync is still fresh. if cloud.auth.configured() { cloud.with_auth(timing, || cloud.identity(timing.io(clock)))?; } + + // Ask the Ark to prepare, which may wait for the owner's approval progress(UpdateProgress::Preparing); let prepared = requester .request( @@ -102,6 +119,8 @@ impl Services { timing.approval(clock), )? .wait::()?; + + // Fetch the archive key for the prepared update and hand it to the Ark let access: Access = match http::get_authenticated( cloud, cloud @@ -122,8 +141,8 @@ impl Services { }); } Err(Failure::ProofRejected) => { - // Preparation may already have required approval. Refresh keys - // for the next attempt, leaving the caller to start it explicitly. + // Preparation may already have required approval. Refresh keys for + // the next attempt, leaving the caller to start it explicitly. self.resync(requester, timing)?; return Err(Error::ProofRejected); } @@ -139,12 +158,14 @@ impl Services { )? .wait::()?; + // Stream the archive, checking its length and hash along the way progress(UpdateProgress::Uploading { uploaded: 0, total: firmware.size, }); upload(requester, reader, firmware, timing, &mut progress)?; + // Let the Ark verify the archive, then install it progress(UpdateProgress::Verifying); requester .request(schema::FirmwareUpdateVerifyRequest {}, timing.io(clock))? @@ -166,6 +187,7 @@ fn upload( timing: Timing, progress: &mut impl FnMut(UpdateProgress), ) -> Result<(), Error> { + // Send the declared bytes one acknowledged chunk at a time, hashing them let clock = &requester.clock(); let mut uploaded = 0; let mut hash = Sha256::new(); @@ -187,6 +209,8 @@ fn upload( total: firmware.size, }); } + + // The source must end at the declared size and match the published hash timing.check(clock)?; if reader.read(&mut [0]).map_err(Error::FirmwareRead)? != 0 { return Err(Error::Integrity( @@ -201,6 +225,8 @@ fn upload( Ok(()) } +/// Update sequencing, refusals, integrity checks and cloud access over real +/// wire peers. #[cfg(test)] mod tests { use super::*; @@ -216,6 +242,7 @@ mod tests { use std::thread; use std::time::Duration; + /// Describes an archive holding `bytes` as published firmware. fn firmware(bytes: &[u8]) -> Firmware { Firmware { version: "2.0.0-1234567".into(), @@ -224,20 +251,31 @@ mod tests { } } - /// Stops accepting when the test finishes, including when a refused device - /// request means later HTTP stages must never be contacted. + /// Loopback cloud serving sync and firmware access, recording the paths it + /// serves. + /// + /// It stops accepting when the test finishes, including when a refused + /// device request means later HTTP stages are never contacted. struct Cloud { + /// Base URL of the loopback API. url: String, - address: SocketAddr, // listener that the stopping connection wakes - stopped: Arc, // marks the next connection as the signal to stop + /// Listener address, which the stopping connection wakes. + address: SocketAddr, + /// Marks the next connection as the signal to stop. + stopped: Arc, + /// Serving thread, returning the paths it served. worker: Option>>, } impl Cloud { + /// Starts serving sync and the access key for `firmware`, answering the + /// access request with `access_status`. fn start(firmware: &Firmware, _bytes: Vec, access_status: u16) -> Self { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let url = format!("http://{address}/v1"); + + // The access route carries the version and hash in its query let key = format!( "/v1/firmware?version={}&sha256={}", firmware.version, @@ -249,10 +287,13 @@ mod tests { move || { let mut paths = Vec::new(); loop { + // A connection after the stop flag is the signal to end let (mut stream, _) = listener.accept().unwrap(); if stopped.load(Ordering::SeqCst) { break paths; } + + // Answer the routes of one update and record each path stream.set_read_timeout(Some(TIMEOUT)).unwrap(); stream.set_write_timeout(Some(TIMEOUT)).unwrap(); let headers = headers(&mut stream); @@ -275,7 +316,7 @@ mod tests { "HTTP/1.1 {status} Test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len() ); - // A failed wire upload may close the download mid-response. + // The client may hang up before the response is written let _ = stream .write_all(header.as_bytes()) .and_then(|()| stream.write_all(body)); @@ -290,6 +331,7 @@ mod tests { } } + /// Stops the server and returns the paths it served, in order. fn finish(mut self) -> Vec { self.stop(); self.worker.take().unwrap().join().unwrap() @@ -304,6 +346,7 @@ mod tests { } impl Drop for Cloud { + /// Stops a server the test did not finish, joining its thread. fn drop(&mut self) { if let Some(worker) = self.worker.take() { self.stop(); @@ -312,6 +355,7 @@ mod tests { } } + /// Reads a request's headers one byte at a time, up to the blank line. fn headers(stream: &mut TcpStream) -> String { let mut bytes = Vec::new(); while !bytes.ends_with(b"\r\n\r\n") { @@ -322,8 +366,11 @@ mod tests { String::from_utf8(bytes).unwrap() } - /// Records the update sequence and optionally refuses or disconnects during - /// one stage. Only a fully uploaded archive is eligible for verification. + /// Spawns an Ark peer that records the update stages it serves, refusing + /// the stage `fail` names. + /// + /// A `fail` of `"disconnect"` hangs up at installation instead. Verification + /// checks that the whole archive arrived. fn peer( clock: &Clock, firmware: &Firmware, @@ -382,6 +429,8 @@ mod tests { } other => return answering(session, other, responder), }; + + // Record the stage, then hang up, refuse or answer as asked observed.lock().unwrap().push(stage); if fail == Some("disconnect") && stage == "install" { return false; @@ -400,8 +449,11 @@ mod tests { (peer, stages) } + /// An update runs every stage in order, reports progress per chunk and + /// refuses a concurrent update. #[test] fn test_update() { + // Serve an archive of two chunks through a cloud and Ark accepting all let clock = test_clock().clock(); let bytes = vec![42; CHUNK_SIZE + 17]; let expected = firmware(&bytes); @@ -410,6 +462,8 @@ mod tests { let ark = attach(&mut peer, cloud.url.clone()); let client = ark.client(); let deadline = clock.now() + TIMEOUT; + + // Update, starting a second update while the first one prepares let mut progress = Vec::new(); client .clone() @@ -423,6 +477,9 @@ mod tests { progress.push(stage); }) .unwrap(); + + // The Ark saw every stage in order, progress tracked each chunk, and the + // cloud served sync and one access request assert_eq!( *stages.lock().unwrap(), [ @@ -489,6 +546,8 @@ mod tests { if failure != "disconnect" { assert!(matches!(error, Error::Remote(error) if error.code == 0x777)); } + + // The failing stage came last, and installation ran at most once let stages = stages.lock().unwrap().clone(); assert_eq!( stages.last().copied(), @@ -506,8 +565,8 @@ mod tests { } } - /// Truncated, oversized or corrupt downloads never reach verification or - /// installation, even if earlier upload chunks were accepted by the device. + /// Truncated, oversized or corrupt archives never reach verification or + /// installation, even after the Ark accepted earlier upload chunks. #[test] fn test_download_integrity() { let clock = test_clock().clock(); @@ -531,8 +590,8 @@ mod tests { } } - /// A rejected proof refreshes keys without repeating preparation or starting - /// an upload. Expiring the deadline after transfer also prevents verification. + /// A refused proof refreshes keys without preparing again or uploading, and + /// a deadline passing after the transfer prevents verification. #[test] fn test_access_and_deadline() { let mut tester = test_clock(); @@ -544,6 +603,8 @@ mod tests { let (mut peer, stages) = peer(&clock, &firmware, None); let ark = attach(&mut peer, cloud.url.clone()); let deadline = clock.now() + Duration::from_secs(1); + + // The cloud refuses the proof, or the deadline passes at verification let error = ark .client() .update_firmware(&firmware, &mut bytes.as_slice(), deadline, |stage| { @@ -560,6 +621,8 @@ mod tests { let stages = stages.lock().unwrap().clone(); assert!(!stages.contains(if expire { &"verify" } else { &"init" })); let paths = cloud.finish(); + + // A refused proof resyncs once and never prepares again if !expire { assert_eq!( stages, @@ -580,6 +643,8 @@ mod tests { } } + /// A needed login runs before preparation, and one needed after it ends the + /// update for a rerun instead of preparing again. #[test] fn caller_login_precedes_preparation_and_never_repeats_approval() { use crate::cloud::{ @@ -588,6 +653,8 @@ mod tests { }; let clock = test_clock().clock(); for expires_after_preparation in [false, true] { + // The cloud refuses the caller's credentials either at the check + // before preparation or at the access request after it let bytes = vec![42; 17]; let firmware = firmware(&bytes); let mut responses = sync_responses(); @@ -601,6 +668,8 @@ mod tests { response(200, r#"{"access":"/w=="}"#) }); let (url, requests) = serve(responses); + + // Update through the login stand-in, noting its logins at preparation let (mut peer, stages) = peer(&clock, &firmware, None); let mut ark = attach(&mut peer, url); let login = Login::default(); @@ -618,6 +687,8 @@ mod tests { } }, ); + + // The Ark prepared and synced once, and the stand-in logged in once let stages = stages.lock().unwrap(); assert_eq!( stages.iter().filter(|stage| **stage == "prepare").count(), @@ -631,6 +702,9 @@ mod tests { 1 ); assert_eq!(login.logins.load(Ordering::SeqCst), 1); + + // A login after preparation ends the update for a rerun, while one + // before it lets the update finish if expires_after_preparation { assert!( matches!(result, Err(Error::CloudAuth { message, .. }) if message.contains("rerun")) @@ -645,10 +719,11 @@ mod tests { } } - /// An emulator receives the update request and supplies its own refusal. - /// The refusal stops the sequence before access keys or archives are fetched. + /// An emulator's own refusal of the update stops the sequence before the + /// access key is requested. #[test] fn test_emulator_refusal() { + // Route an unattested peer that refuses preparation to the emulator realm let clock = test_clock().clock(); let bytes = vec![42]; let expected = firmware(&bytes); @@ -666,6 +741,8 @@ mod tests { let ark = crate::Ark::start(session, Arc::new(services)).unwrap(); let client = ark.client(); let deadline = clock.now() + TIMEOUT; + + // The refusal comes back unchanged, and the cloud served only sync let error = client .update_firmware(&expected, &mut bytes.as_slice(), deadline, |_| {}) .unwrap_err(); @@ -683,9 +760,11 @@ mod tests { ); } - /// Firmware discovery needs a selected environment and a live owner. + /// An update fails without a cloud route, and with the session's ending + /// reason once it closed. #[test] fn test_identity_and_closure() { + // Without a cloud route, every update fails the same way let clock = test_clock().clock(); let mut peer = Peer::spawn( &clock, @@ -717,6 +796,8 @@ mod tests { ), Err(Error::MissingEnvironment) )); + + // Once closed, the ending reason comes first services.close(); assert!(matches!( services.update_firmware( diff --git a/connect/src/cloud/http.rs b/connect/src/cloud/http.rs index 8485abd..5a32d40 100644 --- a/connect/src/cloud/http.rs +++ b/connect/src/cloud/http.rs @@ -20,35 +20,48 @@ use serde::{Deserialize, de::DeserializeOwned}; use std::sync::Arc; use std::time::Instant; -/// Maximum JSON response, enough for cloud certificates, signed time or registry state. +/// Largest JSON response body read, 64 KiB, enough for cloud certificates, +/// signed time or registry state. const MAX_RESPONSE: u64 = 64 * 1024; -/// Cloud operations selected by the attestation or an explicit environment. +/// Cloud API client for the environment the attestation or the caller selects. #[derive(Debug)] pub(super) struct Api { - pub(super) clock: Clock, // clock that the deadlines are measured on - - pub(super) auth: auth::Authorization, // Caller credentials, independent of the Ark proof - pub(super) origin: String, // HTTPS origin shared by API and socket credentials - pub(super) agent: ureq::Agent, // HTTP connections reused across the cloud exchange - pub(super) url: String, // API of the selected environment - pub(super) realm: Realm, // Realm selecting the device registry - pub(super) resolver: Arc, // lookups shared by this connection's cloud sockets - serial: Option, // Attested serial, when available, checked against the registry + /// Clock that the deadlines are measured on. + pub(super) clock: Clock, + + /// Caller credentials, independent of the Ark's proof. + pub(super) auth: auth::Authorization, + /// HTTPS origin of the API, which the caller's credentials are kept for. + pub(super) origin: String, + /// HTTP client, reusing its connections across the cloud exchange. + pub(super) agent: ureq::Agent, + /// Base URL of the selected environment's API. + pub(super) url: String, + /// Realm selecting the registry and the socket routes. + pub(super) realm: Realm, + /// DNS lookups shared by this connection's cloud sockets. + pub(super) resolver: Arc, + /// Serial from the attestation, when there is one, which the registry's + /// answer must match. + serial: Option, } impl Api { - /// Uses the selected environment and realm for relay attachment. + /// Returns the WebSocket URL for relay attachment in the selected + /// environment and realm. pub(super) fn relay_url(&self) -> String { self.socket_url("relaying") } - /// Uses the same realm for the companion pairing rendezvous. + /// Returns the WebSocket URL of the pairing rendezvous in the selected + /// environment and realm. pub(super) fn pairing_url(&self) -> String { self.socket_url("pairing") } - /// Converts the API origin to WebSocket and selects the realm-specific route. + /// Converts the API URL to its WebSocket form and appends the route, under + /// `sandbox/` for the emulator realm. fn socket_url(&self, route: &str) -> String { let url = self .url @@ -60,8 +73,11 @@ impl Api { } } - /// Prepares cloud access without I/O. An explicit environment overrides the - /// attested one. Its discovery realm is used only without an attested realm. + /// Prepares cloud access without I/O, or returns `None` when neither the + /// attestation nor the caller selects an environment. + /// + /// An explicit environment overrides the attested one. The caller's realm + /// applies only when no attestation fixes it. pub(super) fn new( identity: &Identity, cloud: Option<(Environment, Realm)>, @@ -104,8 +120,8 @@ impl Api { fetch_time(self, challenge, deadline) } - /// Checks the registry with the Ark's opaque proof and matches its serial - /// against the identity authenticated during the handshake, when attested. + /// Checks the registry with the Ark's opaque proof, requiring the registered + /// serial to match an attested one. pub(super) fn genuine(&self, proof: &[u8], deadline: Instant) -> Result { let registration = fetch_registration(self, proof, deadline)?; if self @@ -120,8 +136,13 @@ impl Api { Ok(registration) } - /// Retries only authentication or read-only requests after login. Callers - /// recreate short-lived Ark proofs inside the attempt, after browser login. + /// Runs a cloud step, logging in and running it once more when the host + /// refuses the caller's credentials. + /// + /// Only authentication or read-only steps go through it, since a step can + /// run twice. A step creates its Ark proof inside itself, so the run after + /// a browser login carries a fresh one. A refusal after the login ends in + /// [`Failure::CloudAuth`]. pub(super) fn with_auth( &self, timing: Timing, @@ -143,7 +164,8 @@ impl Api { } impl Failure { - /// Adds HTTP operation context without erasing a timeout or device error. + /// Prefixes a cloud failure with the operation that failed, leaving other + /// kinds unchanged. fn context(self, context: &str) -> Self { match self { Self::Cloud(error) => Self::Cloud(format!("{context}: {error}")), @@ -153,7 +175,8 @@ impl Failure { } impl From for Failure { - /// Keeps HTTP timeouts actionable without treating other failures as wire loss. + /// Maps an HTTP client timeout to a wire timeout and any other error to a + /// cloud failure. fn from(error: ureq::Error) -> Self { match error { ureq::Error::Timeout(_) => Self::Wire(protocol::Error::Timeout), @@ -162,7 +185,8 @@ impl From for Failure { } } -/// Cloud sync serves hardware and emulators through the same environment routes. +/// Returns the API base URL of an environment, the same for hardware and +/// emulators. fn api_url(env: Environment) -> &'static str { match env { Environment::Release => "https://api.dark.bio/v1", @@ -171,8 +195,10 @@ fn api_url(env: Environment) -> &'static str { } } -/// HTTP client for cloud operations. Each request receives the remaining -/// operation budget; redirects are refused rather than changing the endpoint. +/// Builds the HTTP client for cloud operations, which never follows a redirect. +/// +/// Each request gets the remaining operation budget as its timeout, and error +/// statuses come back as responses. fn agent() -> ureq::Agent { ureq::Agent::config_builder() .max_redirects(0) @@ -184,19 +210,26 @@ fn agent() -> ureq::Agent { /// Cloud attestations encoded as standard base64 by the identity route. #[derive(Deserialize)] struct Certificates { - signer: String, // CWT attesting the cloud signing key - crypto: String, // CWT attesting the cloud encryption key + /// CWT attesting the cloud's signing key. + signer: String, + /// CWT attesting the cloud's encryption key. + crypto: String, } /// Cloud time and signature binding it to the Ark's challenge. #[derive(Deserialize)] struct SignedTime { - unixmilli: u64, // Unix timestamp in milliseconds, decoded without floating point - signature: String, // Detached COSE signature encoded as standard base64 + /// Cloud time in Unix milliseconds, decoded as an integer. + unixmilli: u64, + /// Detached COSE signature, encoded as standard base64. + signature: String, } -/// Registry state returned after the cloud verifies the Ark's proof. A registered -/// device may still be disabled, expired or superseded. +/// Registry state of an Ark, as [`Client::genuine`](crate::Client::genuine) +/// returns it from the cloud. +/// +/// A registered device may still be disabled, expired or superseded, which +/// [`Self::active`] sums up. #[derive(Debug, Deserialize)] pub struct Registration { /// Serial registered for the identity that produced the proof. @@ -212,7 +245,7 @@ pub struct Registration { } impl Registration { - /// Whether the registered device is currently permitted to use the cloud. + /// Checks whether the device is neither disabled, expired nor superseded. pub fn active(&self) -> bool { !self.disabled && !self.expired && !self.superseded } @@ -257,7 +290,9 @@ fn fetch_time( }) } -/// Sends the opaque proof to the registry of the authenticated realm. +/// Sends the opaque proof to the registry of the connection's realm. +/// +/// The proof travels in the `Dark-Auth` header as unpadded base64url. fn fetch_registration(api: &Api, proof: &[u8], deadline: Instant) -> Result { let route = match api.realm { Realm::Hardware => "genuine", @@ -273,7 +308,8 @@ fn fetch_registration(api: &Api, proof: &[u8], deadline: Instant) -> Result( api: &Api, request: ureq::RequestBuilder, @@ -286,7 +322,8 @@ pub(super) fn get_authenticated( json(response) } -/// Reads one successful JSON response under the remaining deadline and size limit. +/// Sends a request and decodes its successful JSON response, within the +/// deadline and the response size limit. pub(super) fn get( api: &Api, request: ureq::RequestBuilder, @@ -295,15 +332,22 @@ pub(super) fn get( json(send(api, request, deadline)?) } -/// Sends a GET under the remaining operation deadline without following redirects. +/// Sends a GET with the caller's credentials under the remaining deadline, +/// without following redirects. +/// +/// An expired deadline fails without sending. A response the caller's provider +/// recognizes as refusing its credentials becomes [`Failure::AuthRequired`]. fn send( api: &Api, mut request: ureq::RequestBuilder, deadline: Instant, ) -> Result, Failure> { + // Carry the caller's cached credentials for (name, value) in &api.auth.headers(&api.origin, deadline) { request = request.header(name, value); } + + // Bound the whole exchange by the time left on the deadline let remaining = deadline .checked_duration_since(api.clock.now()) .filter(|remaining| !remaining.is_zero()) @@ -313,6 +357,8 @@ fn send( .timeout_global(Some(remaining)) .build() .call()?; + + // Hand a refusal of the caller's credentials back for a login if api .auth .rejected(&api.origin, response.status(), response.headers()) @@ -347,8 +393,8 @@ pub(super) mod tests { use std::sync::mpsc; use std::time::Duration; - /// Redirects an attested connection to the loopback cloud, measuring its - /// deadlines on the clock. + /// Builds an API client for a loopback cloud, attested as `test-serial` and + /// measuring its deadlines on the clock. pub(in crate::cloud) fn api(url: String, realm: Realm, clock: &Clock) -> Api { Api { clock: clock.clone(), @@ -362,13 +408,14 @@ pub(super) mod tests { } } - /// Overrides select the cloud without replacing a verified realm or serial. - /// Without attestation, the supplied discovery realm selects the registry. + /// An explicit environment selects the cloud without replacing an attested + /// realm or serial, and only unattested identities take the caller's realm. #[test] fn test_cloud_routing() { let clock = test_clock().clock(); let key = darkbio_crypto::xdsa::SecretKey::generate().public_key(); for &env in crate::identity::ENVIRONMENTS { + // The attestation alone selects its environment and realm let identity = Identity::Attested { env, device: crate::trust::device::Device { @@ -385,6 +432,8 @@ pub(super) mod tests { let cloud = Api::new(&identity, None, &clock).unwrap(); assert_eq!(cloud.url, api_url(env)); assert_eq!(cloud.realm, Realm::Emulator); + + // An explicit environment keeps the attested realm and serial for &selected in crate::identity::ENVIRONMENTS { let cloud = Api::new(&identity, Some((selected, Realm::Hardware)), &clock).unwrap(); assert_eq!(cloud.url, api_url(selected)); @@ -392,6 +441,8 @@ pub(super) mod tests { assert!(cloud.relay_url().ends_with("/sandbox/relaying")); assert_eq!(cloud.serial.as_deref(), Some("attested-serial")); } + + // Unattested identities need an explicit route, whose realm applies for identity in [ Identity::SelfSigned(key.clone()), Identity::Recovered(key.clone()), @@ -412,10 +463,11 @@ pub(super) mod tests { } } - /// Certificates and signatures reach the Ark byte-for-byte. Timestamps retain - /// integer precision, and the challenge is encoded in the time route's query. + /// Sync payloads reach the Ark unchanged, with timestamps at integer + /// precision and the challenge in the time route's query. #[test] fn test_sync_messages() { + // Serve binary certificates and a timestamp beyond a double's precision let signer = [0, 0xff, 0xfb, 3]; let crypto = [0xff, 0, 4, 5, 6]; let signature = [0, 1, 0xfe, 0xff]; @@ -438,6 +490,8 @@ pub(super) mod tests { .to_string(), ), ]); + + // Both payloads decode unchanged let clock = test_clock().clock(); let cloud = api(url, Realm::Hardware, &clock); let deadline = clock.now() + TIMEOUT; @@ -447,6 +501,8 @@ pub(super) mod tests { let finish = fetch_time(&cloud, &[0, 0xfb, 0xff], deadline).unwrap(); assert_eq!(finish.unixmilli, unixmilli); assert_eq!(finish.signature, signature); + + // Each request took its route, with the challenge hex encoded in the query assert!( requests .recv() @@ -461,8 +517,8 @@ pub(super) mod tests { ); } - /// The attested realm selects the registry, and the proof travels in an - /// unpadded URL-safe authentication header. Inactive flags are retained. + /// Each realm reaches its own registry with the proof in an unpadded + /// base64url header, and inactive flags come back intact. #[test] fn test_registry_routes() { let clock = test_clock().clock(); @@ -470,6 +526,7 @@ pub(super) mod tests { (Realm::Hardware, "/v1/genuine"), (Realm::Emulator, "/v1/sandbox/genuine"), ] { + // Serve a registration with every inactive flag set let (url, requests) = serve(vec![response( 200, &json!({ @@ -491,6 +548,8 @@ pub(super) mod tests { assert_eq!(registration.enrolled, 123); assert!(registration.disabled && registration.expired && registration.superseded); assert!(!registration.active()); + + // The request took the realm's route, with the proof in `Dark-Auth` let request = requests.recv().unwrap(); assert!(request.starts_with(&format!("GET {path} HTTP/1.1\r\n"))); assert!( @@ -519,6 +578,7 @@ pub(super) mod tests { /// responses fail before a cloud payload can be forwarded to the Ark. #[test] fn test_bad_responses() { + // Every broken identity response fails let clock = test_clock().clock(); for (status, body) in [ (503, "unavailable".into()), @@ -540,13 +600,15 @@ pub(super) mod tests { let cloud = api(url, Realm::Hardware, &clock); assert!(fetch_identity(&cloud, clock.now() + TIMEOUT).is_err()); } + + // So does a signed time whose signature is not base64 let (url, _requests) = serve(vec![response(200, r#"{"unixmilli":123,"signature":"!"}"#)]); let cloud = api(url, Realm::Hardware, &clock); assert!(fetch_time(&cloud, &[1], clock.now() + TIMEOUT).is_err()); } - /// A later HTTP request retains the original deadline. A stalled response - /// also expires instead of leaving setup waiting indefinitely. + /// A later request keeps the original deadline, and a stalled response + /// expires instead of leaving setup waiting. #[test] fn test_deadlines() { // A request after the clock reached the shared deadline fails without HTTP diff --git a/connect/src/cloud/mod.rs b/connect/src/cloud/mod.rs index 8637265..978be65 100644 --- a/connect/src/cloud/mod.rs +++ b/connect/src/cloud/mod.rs @@ -4,7 +4,8 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -//! Cloud prerequisites and registry checks for an Ark connection. +//! Cloud services of an Ark connection, from setup and registry checks to +//! relaying, pairing and firmware updates. //! //! Clients share one setup state per wire session. The caller that starts an //! attempt performs its I/O; concurrent callers wait on that attempt with their @@ -34,10 +35,14 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, UNIX_EPOCH}; -/// Whether the reported cloud identity and clock can be reused for a request. -/// Sync is needed before the first use or at 15 seconds of drift from the -/// clock's wall time, leaving headroom for proof verification. Key rotation is -/// detected by a refused proof; the marker only records setup since boot. +/// Checks whether the cloud setup that a +/// [`DeviceInfoResponse`](crate::schema::DeviceInfoResponse) reports can be +/// reused for a request. +/// +/// Sync is needed when the Ark reports no setup since boot, or when its clock +/// is 15 s or more away from the wall time of the [`Clock`]. That bound leaves +/// headroom for proof verification. The marker records only setup since boot, +/// so it cannot reveal changed cloud keys. pub fn cloud_synced(info: &crate::schema::DeviceInfoResponse, clock: &Clock) -> bool { let now = clock .system_time() @@ -47,56 +52,82 @@ pub fn cloud_synced(info: &crate::schema::DeviceInfoResponse, clock: &Clock) -> synced_at(info, now) } -/// Compares the boot marker and device clock with host time in Unix seconds. +/// Checks the boot marker and compares the device clock with `now`, the host +/// time in Unix seconds. fn synced_at(info: &crate::schema::DeviceInfoResponse, now: u64) -> bool { info.cloud_synced && info.cloud_clock.abs_diff(now) < 15 } -/// Setup state shared by every client of one connection. Network and device I/O -/// run outside its lock so independent requests and closure remain available. +/// Cloud setup shared by every client of one connection. +/// +/// Network and device I/O run outside its lock, so independent requests and +/// closure stay available. #[derive(Debug)] pub(crate) struct Services { - clock: Clock, // clock of the wire session, which every operation reads - cloud: Option, // Absent without an attested or caller-supplied environment - state: Mutex, // Current initialization attempt and connection lifecycle - updating: Mutex<()>, // One firmware transfer at a time across client clones + /// Clock of the wire session, which every operation reads. + clock: Clock, + /// Cloud API client, absent when neither the attestation nor the caller + /// selects an environment. + cloud: Option, + /// Setup attempts in flight, their reusable results and the session's + /// ending reason. + state: Mutex, + /// Lock admitting one firmware update at a time across client clones. + updating: Mutex<()>, } -/// Initialization progresses once at a time and becomes reusable only on success. +/// Setup attempts, results and ending reason of one connection. +/// +/// Each step runs one attempt at a time and becomes reusable only on success. #[derive(Debug, Default)] struct State { - synced: Option<(Instant, bool)>, // Last sync decision and when it was observed - error: Option, // Why the owning wire session ended - syncing: Option>, // Cloud sync joined by concurrent callers - relay: Option, // Relay attached lazily to this connection - joining: Option>, // Relay attachment joined by concurrent callers + /// Last sync decision and when it was observed. + synced: Option<(Instant, bool)>, + /// Reason the owning wire session ended, once it has. + error: Option, + /// Cloud sync attempt in flight, which concurrent callers join. + syncing: Option>, + /// Relay attached to this connection, once a request needs it. + relay: Option, + /// Relay attachment in flight, which concurrent callers join. + joining: Option>, } /// One attempt's outcome, retained by its waiters even after a retry starts. #[derive(Debug)] struct Attempt { - result: sync::Mutex>>, // Shared success or the original failure - refreshed: AtomicBool, // This attempt exchanged keys and signed time - ready: sync::Condvar, // Wakes waiters on completion or closure + /// Outcome shared by every waiter, set once. + result: sync::Mutex>>, + /// Whether this attempt exchanged keys and signed time with the cloud. + refreshed: AtomicBool, + /// Signal that wakes the waiters on completion or closure. + ready: sync::Condvar, } /// Failures shareable between callers joining the same initialization attempt. #[derive(Clone, Debug)] enum Failure { - /// The identity and caller supplied no route for cloud operations. + /// Neither the attestation nor the caller selected a cloud environment. MissingEnvironment, - /// The cloud refused the Ark's proof, possibly after a key rotation. + /// The cloud answered a request carrying the Ark's proof with HTTP 403. ProofRejected, - /// Caller authentication was rejected before reaching the cloud application. + /// The caller's provider recognized a response as refusing its credentials. AuthRequired, - /// Login failed or the caller cannot prompt for it. + /// A login failed, no provider could log in, or the host refused even fresh + /// credentials. CloudAuth { - origin: String, // Selected host whose credentials need attention - message: String, // Safe diagnostic shared with setup waiters + /// Origin of the host whose credentials need attention. + origin: String, + /// Diagnostic without credentials, shared with setup waiters. + message: String, }, - Cloud(String), // HTTP or response decoding failure - Relay(String), // Relay connection or envelope failure - Wire(protocol::Error), // Device failure, retaining remote codes and disconnect reasons + /// A cloud request or socket failed, or its response could not be used. + Cloud(String), + /// The relay ended, or an exchange on it broke the protocol or its limits. + Relay(String), + /// A wire request failed or an operation timed out, keeping the Ark's + /// refusal or the disconnect reason. + Wire(protocol::Error), } impl From for Failure { @@ -129,14 +160,19 @@ impl From for Error { } impl Services { - /// Installs caller-owned credentials without contacting the selected cloud. + /// Installs the caller's authentication provider without contacting the + /// cloud, doing nothing without a cloud route. pub(crate) fn set_cloud_auth(&self, auth: Arc) { if let Some(cloud) = &self.cloud { cloud.auth.set(auth); } } - /// A stopped dispatcher has dropped its wire session. Preserve its ending - /// reason when a surviving weak requester can only report that it is gone. + + /// Converts a request error, replacing a bare closure with the session's + /// recorded ending reason. + /// + /// A stopped dispatcher drops its wire session, after which a surviving + /// requester can only report it closed. pub(crate) fn wire_error(&self, error: protocol::Error) -> Error { if matches!(error, protocol::Error::Closed) && let Some(ended) = &self.state.lock().expect("cloud setup not poisoned").error @@ -146,8 +182,10 @@ impl Services { error.into() } - /// Records cloud routing without starting network I/O. Every operation of the - /// connection reads its time from the clock of the wire session. + /// Records cloud routing for a connection without starting network I/O. + /// + /// Every operation of the connection reads its time from `clock`, the wire + /// session's clock. pub(crate) fn new( identity: &Identity, cloud: Option<(crate::trust::Environment, crate::trust::Realm)>, @@ -166,8 +204,10 @@ impl Services { &self.clock } - /// Reuses fresh device state, caching that decision for one minute. Each - /// caller bounds its own wait; only one exchange runs at a time. + /// Ensures cloud sync, reusing the Ark's fresh setup and caching that + /// decision for 60 s. + /// + /// Each caller bounds its own wait, and only one exchange runs at a time. pub(crate) fn sync( &self, requester: &Requester, @@ -177,15 +217,20 @@ impl Services { .map_err(Into::into) } - /// An explicit diagnostic refresh invalidates the reused setup, joining an - /// exchange already in flight if another caller is synchronizing. + /// Refreshes cloud keys and signed time explicitly, dropping the reused + /// setup. + /// + /// A sync already in flight is joined, and a fresh exchange follows when + /// that sync reused device state. pub(crate) fn resync(&self, requester: &Requester, timing: Timing) -> Result<(), Error> { self.ensure(requester, Step::Refresh, timing) .map_err(Into::into) } - /// Attaches the relay after cloud sync, reusing a healthy connection. A - /// failed relay is replaced on the next call without replaying any operation. + /// Attaches the relay after cloud sync, reusing a healthy attachment. + /// + /// A failed relay is replaced on the next call, without replaying any + /// operation. pub(crate) fn relay( &self, requester: &Requester, @@ -197,10 +242,14 @@ impl Services { .map_err(Into::into) } - /// Serializes one prerequisite while allowing unrelated device traffic. - /// Waiters retain the attempt they joined, even if a later caller retries it. + /// Establishes one prerequisite, one attempt at a time, while unrelated + /// device traffic continues. + /// + /// Waiters keep the attempt they joined, even if a later caller retries it. fn ensure(&self, requester: &Requester, step: Step, timing: Timing) -> Result<(), Failure> { let deadline = timing.io(&self.clock); + + // Under the lock, reuse a fresh result, or join or lead the step's attempt let (attempt, leader) = { let mut state = self.state.lock().expect("cloud setup not poisoned"); if let Some(error) = &state.error { @@ -220,6 +269,8 @@ impl Services { } _ => {} } + + // A caller out of time neither starts nor joins an attempt if self.clock.now() >= deadline { return Err(protocol::Error::Timeout.into()); } @@ -239,6 +290,8 @@ impl Services { } } }; + + // A follower waits for the leader's outcome under its own deadline if !leader { attempt.wait(deadline)?; // A joined freshness check may have reused device state. An explicit @@ -249,6 +302,7 @@ impl Services { Ok(()) }; } + // Run setup without the state lock, then publish only if the session // remains open. Closure wins over a late successful network response. let result = match step { @@ -279,6 +333,8 @@ impl Services { Ok(()) }) }; + + // Clear the attempt for the next caller and release its waiters match step { Step::Sync | Step::Refresh => state.syncing = None, Step::Relay => state.joining = None, @@ -304,9 +360,13 @@ impl Services { }) } - /// A cloud key can rotate while the Ark still reports sync. Refresh once - /// after a refused proof, then obtain a new proof for the same authentication. - /// Callers must stop here before any pairing, approval or transfer begins. + /// Runs an authenticated cloud step, refreshing cloud keys and running it + /// once more after a refused proof. + /// + /// The Ark's sync marker cannot reveal changed cloud keys, so a refused + /// proof is taken as a sign of stale ones. The step obtains a new proof on + /// each run. Since it may run twice, callers use it only before any + /// pairing, approval or transfer begins. fn authenticate( &self, requester: &Requester, @@ -322,9 +382,12 @@ impl Services { result } - /// Attaches on demand when the Ark conditionally needs authorization. Wire - /// replies progress independently of this dispatcher waiting for attachment. - /// Connections without a cloud route retain their explicit receive interface. + /// Forwards an Ark request to the companion, attaching the relay on demand. + /// + /// Replies to wire requests keep arriving while dispatch waits for the + /// attachment, and a failed attachment refuses the request with + /// `UNAVAILABLE`. Without a cloud route, the request comes back for the + /// application's own receive queue. pub(crate) fn forward( &self, requester: &Requester, @@ -334,11 +397,15 @@ impl Services { if self.cloud.is_none() { return Some((request, responder)); } + + // Attach within the exchange's own time, refusing the request on failure let deadline = self.clock.now() + relay::EXCHANGE_TIMEOUT; if let Err(error) = self.relay(requester, deadline) { relay::fail(responder, &error.to_string()); return None; } + + // Hand the request to the relay, unless it ended meanwhile let state = self.state.lock().expect("cloud setup not poisoned"); match &state.relay { Some(relay) => { @@ -349,7 +416,8 @@ impl Services { None } - /// Verifies the Ark's registration after establishing its cloud prerequisites. + /// Fetches the Ark's registration with a fresh proof, once cloud sync is + /// established. pub(crate) fn genuine( &self, requester: &Requester, @@ -366,14 +434,18 @@ impl Services { .map_err(Into::into) } - /// Exchanges cloud keys and signed time with raw wire requests, bypassing - /// the prerequisite gate that this exchange is completing. + /// Exchanges cloud keys and signed time with the Ark, unless `force` is off + /// and its setup is fresh. + /// + /// The exchange uses raw wire requests, bypassing the prerequisite gate that + /// it completes. Returns whether an exchange ran. fn synchronize( &self, requester: &Requester, timing: Timing, force: bool, ) -> Result { + // Reuse a recent observation of the Ark's setup, or ask the Ark if !force { let reported = self .state @@ -391,6 +463,9 @@ impl Services { return Ok(false); } } + + // Pass the cloud's certificates to the Ark, then signed time for its + // challenge let cloud = self.cloud.as_ref().expect("cloud route available"); let identity = cloud.with_auth(timing, || cloud.identity(timing.io(&self.clock)))?; let started = requester @@ -418,8 +493,11 @@ impl Services { Ok(cloud_synced(&info, &self.clock)) } - /// Reuses device info already requested by the caller. A delayed response - /// must not overwrite a newer observation or an active sync exchange. + /// Records the cloud setup that a caller's own device info request + /// observed. + /// + /// A delayed response never overwrites a newer observation or a sync + /// exchange in flight. pub(crate) fn reported(&self, info: &crate::schema::DeviceInfoResponse, requested: Instant) { let mut state = self.state.lock().expect("cloud setup not poisoned"); if state.error.is_none() @@ -435,7 +513,10 @@ impl Services { self.end(protocol::Error::Closed); } - /// Retains the wire's original ending reason for setup waiters as well. + /// Ends setup with the wire's ending reason, releasing waiters and closing + /// the relay. + /// + /// Only the first reason is kept, and later calls do nothing. pub(crate) fn end(&self, error: protocol::Error) { let mut state = self.state.lock().expect("cloud setup not poisoned"); if state.error.is_some() { @@ -454,19 +535,22 @@ impl Services { } } -/// Setup action to join or start. Relay callers establish cloud sync first. +/// Setup step that callers join or start. +/// +/// Relay callers establish cloud sync first. #[derive(Clone, Copy)] enum Step { - /// Reuse fresh device state or exchange cloud keys and signed time. + /// Cloud sync, reusing fresh device state when it can. Sync, - /// Exchange keys and time even if the device reports usable setup. + /// Cloud sync that exchanges keys and time even when the device reports + /// usable setup. Refresh, - /// Authenticate and start a relay worker, reusing a healthy attachment. + /// Relay attachment with its own worker, reusing a healthy one. Relay, } impl Attempt { - /// Starts an attempt whose waiters measure their deadlines on the clock. + /// Creates an attempt whose waiters measure their deadlines on the clock. fn new(clock: &Clock) -> Self { Self { result: sync::Mutex::new(None), @@ -484,7 +568,8 @@ impl Attempt { } } - /// Waits for this attempt without extending or shortening another caller's budget. + /// Waits for this attempt without extending or shortening another caller's + /// budget. fn wait(&self, deadline: Instant) -> Result<(), Failure> { let mut outcome = self.result.lock().expect("cloud attempt not poisoned"); loop { @@ -524,14 +609,18 @@ pub(crate) mod tests { /// Budget for loopback I/O that is not exercising expiration. pub(super) const TIMEOUT: Duration = Duration::from_secs(5); - /// Serves scripted responses and captures requests, closing each connection - /// after its response. A server left waiting by a failed test blocks only - /// its own thread. + /// Serves scripted responses in order and captures each request's headers. + /// + /// Each connection closes after its response. A server left waiting by a + /// failed test blocks only its own thread. pub(crate) fn serve(responses: Vec) -> (String, mpsc::Receiver) { serve_inner(responses, None) } - /// Holds the first response until released, making setup races deterministic. + /// Serves like [`serve`], holding the first response until `pause` releases + /// it. + /// + /// Holding a response makes setup races deterministic. pub(super) fn serve_inner( responses: Vec, mut pause: Option>, @@ -546,6 +635,8 @@ pub(crate) mod tests { }; stream.set_read_timeout(Some(TIMEOUT)).unwrap(); stream.set_write_timeout(Some(TIMEOUT)).unwrap(); + + // Capture the request up to the end of its headers let mut request = Vec::new(); let mut bytes = [0; 1024]; while !request.windows(4).any(|bytes| bytes == b"\r\n\r\n") { @@ -555,6 +646,8 @@ pub(crate) mod tests { } } sender.send(String::from_utf8(request).unwrap()).unwrap(); + + // Hold the first response until released, then answer if let Some(pause) = pause.take() && pause.recv().is_err() { @@ -574,7 +667,8 @@ pub(crate) mod tests { ) } - /// Loopback tests ignore ambient proxy settings. + /// Builds the HTTP client of the loopback tests, configured like the cloud's + /// own but ignoring ambient proxy settings. pub(super) fn http() -> ureq::Agent { ureq::Agent::config_builder() .proxy(None) @@ -584,7 +678,8 @@ pub(crate) mod tests { .into() } - /// Attaches a real wire session with cloud routes redirected to the test server. + /// Attaches a real wire session whose cloud routes lead to the test server. + /// /// The connection runs on the clock of the peer's stream. pub(crate) fn attach(peer: &mut Peer, url: String) -> Ark { let verifier = TrustMode::Recover(Box::new(peer.identity.clone())); @@ -599,8 +694,10 @@ pub(crate) mod tests { Ark::start(session, services).unwrap() } - /// Peer that only issues a proof after sync, retaining counts for duplicate - /// initialization checks. Optionally refuses its first start request. + /// Spawns an Ark peer that issues proofs and lists slots only after sync, + /// counting its sync starts and proofs. + /// + /// With `refuse_first` set, it refuses its first sync start. fn peer(clock: &Clock, refuse_first: bool) -> (Peer, Arc, Arc) { let starts = Arc::new(AtomicUsize::new(0)); let proofs = Arc::new(AtomicUsize::new(0)); @@ -674,7 +771,7 @@ pub(crate) mod tests { (peer, starts, proofs) } - /// JSON replies used by the real wire peer's cloud synchronization exchange. + /// Returns the cloud's identity and time replies for one sync exchange. pub(super) fn sync_responses() -> Vec { vec![ response(200, r#"{"signer":"AQ==","crypto":"Ag=="}"#), @@ -713,7 +810,8 @@ pub(crate) mod tests { ); assert_eq!(starts.load(Ordering::SeqCst), 0); - // A short waiter on the pending setup expires alone, at the earliest deadline on the clock + // A short waiter on the pending setup expires alone, at the earliest + // deadline on the clock let short = clock.now() + Duration::from_millis(20); let waiter = thread::spawn({ let client = client.clone(); @@ -746,6 +844,7 @@ pub(crate) mod tests { /// A refused attempt preserves its device error and does not poison a retry. #[test] fn test_setup_retry() { + // The Ark refuses the first sync start, whose error comes back unchanged let clock = test_clock().clock(); let mut responses = sync_responses(); responses.insert(0, responses[0].clone()); @@ -756,6 +855,8 @@ pub(crate) mod tests { let deadline = clock.now() + TIMEOUT; assert!(matches!(client.call(GenuinityProofRequest {}, deadline), Err(Error::Remote(error)) if error.code == 0x111)); + + // A retry syncs afresh and gets its proof client.call(GenuinityProofRequest {}, deadline).unwrap(); assert_eq!(starts.load(Ordering::SeqCst), 2); assert_eq!(proofs.load(Ordering::SeqCst), 1); @@ -765,6 +866,7 @@ pub(crate) mod tests { /// and prevents that response from starting any request on the closed Ark. #[test] fn test_close_during_setup() { + // Hold the leader's sync at its first HTTP request let clock = test_clock().clock(); let (release, pause) = mpsc::channel(); let (url, requests) = serve_inner(vec![sync_responses().remove(0)], Some(pause)); @@ -777,23 +879,28 @@ pub(crate) mod tests { move || client.call(GenuinityProofRequest {}, deadline) }); requests.recv().unwrap(); + + // Closing releases a waiter on the held sync at once let waiter = thread::spawn({ let client = client.clone(); move || client.call(GenuinityProofRequest {}, deadline) }); ark.closer().close(); assert!(matches!(waiter.join().unwrap(), Err(Error::Closed))); + + // The leader fails once its response arrives, starting no Ark request release.send(()).unwrap(); assert!(matches!(leader.join().unwrap(), Err(Error::Closed))); assert_eq!(starts.load(Ordering::SeqCst), 0); } - /// Self-signed and recovery sessions retain local operations. Cloud-dependent - /// requests fail clearly when the caller did not supply an environment. + /// Self-signed and recovery sessions without an environment fail cloud + /// requests clearly and keep local ones. #[test] fn test_unattested_setup() { let clock = test_clock().clock(); for recover in [false, true] { + // Attach without attestation or an environment let mut peer = Peer::spawn(&clock, Box::new(answering)); let policy = if recover { TrustMode::Recover(Box::new(peer.identity.clone())) @@ -801,6 +908,8 @@ pub(crate) mod tests { TrustMode::RootOrSelf }; let (ark, _) = Ark::attach(peer.stream(), &policy, |_| None).unwrap(); + + // Cloud requests fail for want of an environment, while status works let client = ark.client(); assert!(matches!( client.call(GenuinityProofRequest {}, clock.now() + TIMEOUT), @@ -820,19 +929,23 @@ pub(crate) mod tests { } } - /// Explicit routing lets self-signed and recovery peers sync and query slots. - /// Registry authentication uses their opaque proofs without an attested serial. + /// An explicit route lets self-signed and recovery peers sync, query slots + /// and pass registry checks with no attested serial. #[test] fn test_unattested_cloud() { let clock = test_clock().clock(); for recover in [false, true] { for realm in [Realm::Hardware, Realm::Emulator] { + // Serve one sync and registration, then two refusals around a + // second sync let mut responses = sync_responses(); responses.push(response(200, r#"{"serial":"registry-serial","enrolled":123,"disabled":false,"expired":false,"superseded":false}"#)); responses.push(response(403, "registry refused proof")); responses.extend(sync_responses()); responses.push(response(403, "registry refused proof")); let (url, requests) = serve(responses); + + // Connect without attestation, which leaves the realm unset let (mut peer, starts, proofs) = peer(&clock, false); let policy = if recover { TrustMode::Recover(Box::new(peer.identity.clone())) @@ -843,6 +956,7 @@ pub(crate) mod tests { assert_eq!(identity.realm(), None); assert_eq!(matches!(identity, Identity::Recovered(_)), recover); + // Route the cloud explicitly, then point it at the loopback server let env = crate::identity::ENVIRONMENTS[0]; let mut services = Services::new(&identity, Some((env, realm)), &session.clock()); let cloud = services.cloud.as_mut().unwrap(); @@ -852,6 +966,7 @@ pub(crate) mod tests { let client = ark.client(); let deadline = clock.now() + TIMEOUT; + // Status needs no sync, and two slot queries share one client.call(DeviceInfoRequest {}, deadline).unwrap(); assert_eq!(starts.load(Ordering::SeqCst), 0); for _ in 0..2 { @@ -859,6 +974,9 @@ pub(crate) mod tests { .call(crate::schema::SlotListRequest {}, deadline) .unwrap(); } + + // The registry answers with no attested serial to match, and a + // later refusal persists through one resync let registration = client.genuine(deadline).unwrap(); assert_eq!(registration.serial, "registry-serial"); assert!(registration.active()); @@ -869,6 +987,7 @@ pub(crate) mod tests { assert_eq!(starts.load(Ordering::SeqCst), 2); assert_eq!(proofs.load(Ordering::SeqCst), 3); + // Every request took the realm's routes, in order let registry = match realm { Realm::Hardware => "/v1/genuine", Realm::Emulator => "/v1/sandbox/genuine", @@ -889,6 +1008,8 @@ pub(crate) mod tests { } } + /// Device setup counts as fresh only with the boot marker set and a clock + /// less than 15 s away from the host's. #[test] fn test_sync_freshness() { use crate::schema::DeviceInfoResponse; @@ -917,8 +1038,8 @@ pub(crate) mod tests { } } - /// A refused authentication refreshes the cloud identity and obtains a new - /// proof once. Other HTTP failures and a second refusal keep their errors. + /// A refused proof triggers one resync and a new proof, while other HTTP + /// failures and a second refusal keep their errors. #[test] fn test_authentication_refresh() { use crate::schema; @@ -928,6 +1049,9 @@ pub(crate) mod tests { if retried == 200 && operation != "genuine" { continue; } + + // Answer the first attempt with the status, and the retry after + // a 403 with the retry status let mut responses = vec![response(status, "refused")]; if status == 403 { responses.extend(sync_responses()); @@ -936,6 +1060,8 @@ pub(crate) mod tests { } else { "still refused" })); } let (url, requests) = serve(responses); + + // An Ark reporting fresh setup, whose proofs change once it resyncs let proofs = Arc::new(AtomicUsize::new(0)); let mut peer = Peer::spawn( &clock, @@ -987,6 +1113,9 @@ pub(crate) mod tests { } }), ); + + // Run the operation, which never starts pairing before it + // authenticates let ark = attach(&mut peer, url); let client = ark.client(); let deadline = clock.now() + TIMEOUT; @@ -998,6 +1127,9 @@ pub(crate) mod tests { } _ => unreachable!(), }; + + // A successful retry passes, a second 403 stays a refused proof, + // and other statuses stay cloud failures if retried == 200 { result.unwrap(); } else if status == 403 { @@ -1007,6 +1139,8 @@ pub(crate) mod tests { let error = result.unwrap_err(); assert!(matches!(error, Error::Cloud(_)), "{error:?}"); } + + // A 403 resyncs between two attempts, each with its own proof let requests: Vec<_> = requests.try_iter().collect(); assert!(requests[0].starts_with(&format!("GET /v1/{operation} "))); assert_eq!( @@ -1038,12 +1172,16 @@ pub(crate) mod tests { } } + /// Refused credentials trigger one login and a retry with a fresh proof and + /// no resync, and a failed login or a second refusal ends the operation. #[test] fn caller_authentication_retries_fresh_proofs_without_cloud_sync() { use auth::tests::{Login, refused}; let clock = test_clock().clock(); for operation in ["genuine", "relaying", "pairing"] { for fail_login in [false, true] { + // Refuse the caller's credentials, and after a login accept only + // the registry check let mut responses = vec![refused(403)]; if !fail_login { responses.push(if operation == "genuine" { @@ -1051,6 +1189,8 @@ pub(crate) mod tests { } else { refused(403) }); } let (url, requests) = serve(responses); + + // An Ark reporting fresh setup, numbering each proof it issues let proofs = Arc::new(AtomicUsize::new(0)); let mut peer = Peer::spawn( &clock, @@ -1093,6 +1233,8 @@ pub(crate) mod tests { } }), ); + + // Status never consults the login stand-in let mut ark = attach(&mut peer, url); let login = Login { fail: fail_login, @@ -1103,6 +1245,8 @@ pub(crate) mod tests { let timing = Timing::inactivity(TIMEOUT); client.call(DeviceInfoRequest {}, timing).unwrap(); assert_eq!(login.lookups.load(Ordering::SeqCst), 0); + + // Only a registry check after a successful login passes let result = match operation { "genuine" => client.genuine(timing).map(drop), "relaying" => client.attach_relay(timing), @@ -1116,6 +1260,9 @@ pub(crate) mod tests { } else { assert!(matches!(result, Err(Error::CloudAuth { .. })), "{result:?}"); } + + // Every case logs in once, and a retry carries refreshed + // credentials and a fresh proof, with no resync in between let requests: Vec<_> = requests.try_iter().collect(); assert_eq!(requests.len(), if fail_login { 1 } else { 2 }); assert_eq!(proofs.load(Ordering::SeqCst), requests.len()); @@ -1142,12 +1289,18 @@ pub(crate) mod tests { } } + /// Cloud sync logs in when the identity request is refused, before any + /// certificate reaches the Ark. #[test] fn cloud_sync_logs_in_before_sending_certificates_to_the_ark() { use auth::tests::{Login, refused}; + + // Refuse the caller's credentials on the first identity request let mut responses = vec![refused(302)]; responses.extend(sync_responses()); let (url, requests) = serve(responses); + + // An explicit sync logs in once and hands the Ark a single identity let (mut peer, starts, _) = peer(&test_clock().clock(), false); let mut ark = attach(&mut peer, url); let login = Login::default(); @@ -1155,6 +1308,9 @@ pub(crate) mod tests { ark.client().sync(Timing::inactivity(TIMEOUT)).unwrap(); assert_eq!(starts.load(Ordering::SeqCst), 1); assert_eq!(login.logins.load(Ordering::SeqCst), 1); + + // The refused request carried the cached credentials, and the two after + // the login carried refreshed ones let requests: Vec<_> = requests.try_iter().collect(); assert_eq!(requests.len(), 3); assert!(requests[0].contains("authorization: cached\r\n")); @@ -1162,11 +1318,13 @@ pub(crate) mod tests { assert!(requests[2].contains("authorization: refreshed\r\n")); } - /// Reusing device state needs no HTTP, while explicit diagnostics always - /// refresh it. Dataset paths keep every field across the connection. + /// Reported device setup spares the sync exchange, an explicit sync always + /// runs it, and dataset paths keep every field across the connection. #[test] fn test_reported_sync_and_explicit_refresh() { use crate::schema; + + // A dataset path with every field set let expected = schema::DatasetPathsResponse { paths: vec![schema::DatasetPath { path: "v1/sample/".into(), @@ -1180,11 +1338,14 @@ pub(crate) mod tests { }; let clock = test_clock().clock(); for initially_synced in [false, true] { + // Serve the explicit sync, plus a first one for an unsynced Ark let mut responses = sync_responses(); if !initially_synced { responses.extend(sync_responses()); } let (url, requests) = serve(responses); + + // An Ark counting status requests, serving paths only once synced let infos = Arc::new(AtomicUsize::new(0)); let mut peer = Peer::spawn( &clock, @@ -1228,6 +1389,9 @@ pub(crate) mod tests { } }), ); + + // Requests after a status reuse what it reported, syncing only when + // the Ark was not synced let ark = attach(&mut peer, url); let client = ark.client(); let deadline = clock.now() + TIMEOUT; @@ -1244,6 +1408,8 @@ pub(crate) mod tests { requests.try_iter().count(), if initially_synced { 0 } else { 2 } ); + + // An explicit sync always runs the exchange, without asking the Ark client.sync(deadline).unwrap(); assert!(requests.recv().unwrap().contains("/cloudsync/identity")); assert!(requests.recv().unwrap().contains("/cloudsync/time")); @@ -1251,9 +1417,10 @@ pub(crate) mod tests { } } - /// Waiting on an older status response must not undo a completed refresh. + /// An older status response read after a refresh does not undo it. #[test] fn test_delayed_device_info_retains_newer_sync() { + // Read a status sent before an explicit sync only after the sync let clock = test_clock().clock(); let (url, requests) = serve(sync_responses()); let (mut peer, starts, _) = peer(&clock, false); @@ -1263,6 +1430,8 @@ pub(crate) mod tests { let pending = client.send(DeviceInfoRequest {}, deadline).unwrap(); client.sync(deadline).unwrap(); assert!(!pending.wait().unwrap().cloud_synced); + + // A later request reuses the sync instead of running another client .call(crate::schema::SlotListRequest {}, deadline) .unwrap(); @@ -1270,12 +1439,15 @@ pub(crate) mod tests { assert_eq!(requests.try_iter().count(), 2); } - /// A core restart can invalidate setup without losing the wire session. - /// Retry only its reserved refusal and only with evidence of lost sync. + /// Only an `UNAVAILABLE` refusal with lost sync reported triggers a resync + /// and one retry, and every other refusal comes back unchanged. #[test] fn test_unavailable_retry_requires_lost_sync() { use crate::schema::{self, ReservedErrors}; let clock = test_clock().clock(); + + // Each case names the refusal, whether it drops the Ark's sync, whether + // it repeats and the retries expected for (code, reset, repeat, retries) in [ (ReservedErrors::Unavailable as u64, true, false, 1), (ReservedErrors::Unavailable as u64, true, true, 1), @@ -1283,11 +1455,15 @@ pub(crate) mod tests { (ReservedErrors::Unauthorized as u64, true, false, 0), (0x1234, true, false, 0), ] { + // Serve one sync exchange when the case retries let (url, requests) = serve(if retries == 1 { sync_responses() } else { vec![] }); + + // An Ark that refuses slot listings as the case asks, reporting its + // sync lost on reset let count = Arc::new(AtomicUsize::new(0)); let mut peer = Peer::spawn( &clock, @@ -1341,6 +1517,9 @@ pub(crate) mod tests { } }), ); + + // The listing retries only as expected, ending in success or the + // original refusal let ark = attach(&mut peer, url); let result = ark .client() diff --git a/connect/src/cloud/pairing.rs b/connect/src/cloud/pairing.rs index 9b6627f..93b8e46 100644 --- a/connect/src/cloud/pairing.rs +++ b/connect/src/cloud/pairing.rs @@ -17,8 +17,11 @@ use darkbio_wire::protocol::Requester; use std::time::{Duration, Instant, UNIX_EPOCH}; use tungstenite::Message; -/// Pairing stages. The caller renders the rendezvous or turns it into a QR code; -/// identity and storage messages remain opaque and are verified by the Ark. +/// Stages of [`Client::pair`](crate::Client::pair), reported on the caller's +/// thread. +/// +/// The caller renders the rendezvous, for example as a QR code. Identity and +/// storage messages stay opaque to the host, and the Ark verifies them. #[derive(Clone, Debug)] pub enum PairingProgress { /// Rendezvous ready to present to the owner for scanning. @@ -30,20 +33,23 @@ pub enum PairingProgress { /// End of the scan window on the connection's clock, when pairing /// stops waiting for the companion. deadline: Instant, - /// Pairing encryption key fingerprint conveyed to the companion out of band. + /// Pairing encryption key fingerprint, conveyed to the companion out of + /// band. fingerprint: Vec, }, /// Companion identity received and about to be submitted to the Ark. Identity, - /// Companion storage key received; storage key exchange is starting. + /// Companion storage key received, starting the storage key exchange. Storage, - /// Storage exchange acknowledged; waiting for physical pairing approval. + /// Storage exchange acknowledged, waiting for the owner to approve the + /// pairing. Approval, - /// Pairing accepted; waiting for the Ark to finish storage setup. + /// Pairing accepted, waiting for the Ark to finish its storage setup. Formatting, } -/// Cloud rendezvous claims read for presentation, without host-side verification. +/// Cloud rendezvous claims read for presentation, without host-side +/// verification. #[derive(Cbor)] #[cbor(array)] struct Rendezvous { @@ -56,9 +62,12 @@ struct Rendezvous { } impl Services { - /// Authenticates the rendezvous, then forwards each opaque pairing exchange. - /// Only initial authentication can retry. Once pairing starts, a failure - /// returns to the caller without replaying approvals or storage changes. + /// Opens the pairing rendezvous and forwards each opaque exchange between + /// the companion and the Ark. + /// + /// Only the rendezvous authentication can retry. Once pairing starts, a + /// failure returns to the caller without replaying approvals or storage + /// changes. pub(crate) fn pair( &self, requester: &Requester, @@ -68,6 +77,8 @@ impl Services { self.sync(requester, timing)?; let clock = &self.clock; let cloud = self.cloud.as_ref().ok_or(Error::MissingEnvironment)?; + + // Open the rendezvous with a fresh pairing authorization on every attempt let (mut socket, fingerprint) = self.authenticate(requester, timing, || { let auth = requester .request(schema::PairingAuthRequest {}, timing.io(clock))? @@ -81,6 +92,8 @@ impl Services { )?; Ok((socket, auth.fprint)) })?; + + // Forward the exchange, which never retries once it starts exchange(requester, timing, &mut socket, fingerprint, progress)?; let _ = socket.close(None); Ok(()) @@ -88,8 +101,10 @@ impl Services { } /// Presents the rendezvous, then forwards each opaque exchange between the -/// companion and the Ark. The owner's scan waits under the cloud's deadline, -/// which only a caller's earlier absolute deadline cuts short. +/// companion and the Ark. +/// +/// The owner's scan waits until the cloud's deadline, which only an earlier +/// absolute deadline of the caller cuts short. fn exchange( requester: &Requester, timing: Timing, @@ -111,6 +126,9 @@ fn exchange( deadline: wait, fingerprint, }); + + // Wait for the owner's scan, which expires the pairing when the cloud's + // deadline runs out first let identity = channel.receive(wait).map_err(|err| { if matches!(err, Error::Timeout) && wait == expires { Error::PairingExpired @@ -125,6 +143,8 @@ fn exchange( timing.io(clock), )? .wait::()?; + + // Exchange storage keys between the companion and the Ark let app_key = channel.receive(timing.approval(clock))?; progress(PairingProgress::Storage); let storage = requester @@ -141,6 +161,8 @@ fn exchange( timing.io(clock), )? .wait::()?; + + // Wait for the owner's approval, then pass the Ark's confirmation on progress(PairingProgress::Approval); let accepted = requester .request( @@ -149,6 +171,8 @@ fn exchange( )? .wait::()?; channel.send(accepted.confirm, timing.io(clock))?; + + // Wait for the Ark to set up its storage, then pass that confirmation on progress(PairingProgress::Formatting); let completed = requester .request( @@ -169,8 +193,10 @@ trait Channel { fn send(&mut self, bytes: Vec, deadline: Instant) -> Result<(), Error>; } -/// Converts the cloud's Unix deadline once, against the clock's wall time, then -/// waits on the clock's monotonic time. +/// Converts the cloud's Unix deadline to an instant on the clock, reading the +/// clock's wall time once. +/// +/// A deadline already reached expires the pairing. fn scan_deadline(clock: &Clock, deadline: u64) -> Result { let start = clock.now(); let now = clock @@ -186,7 +212,7 @@ fn scan_deadline(clock: &Clock, deadline: u64) -> Result { .ok_or_else(|| Error::Pairing("invalid pairing deadline".into())) } -/// Changes the bound before the next opaque exchange. +/// Sets the deadline that the pairing socket's next reads and writes run under. fn bound(socket: &mut Connection, deadline: Instant) { let Socket::Blocking { deadline: bound, .. @@ -197,8 +223,11 @@ fn bound(socket: &mut Connection, deadline: Instant) { *bound = deadline; } -/// Waits for one binary payload, answering control frames without renewing time. -/// The cloud's scan timeout remains distinct from a caller's earlier deadline. +/// Waits for one binary payload, answering control frames without extending +/// the deadline. +/// +/// A close whose reason is `"pairing timed out"` expires the pairing, which +/// keeps the cloud's scan timeout apart from the caller's own deadline. fn receive(socket: &mut Connection, deadline: Instant) -> Result, Error> { bound(socket, deadline); loop { @@ -219,7 +248,7 @@ fn receive(socket: &mut Connection, deadline: Instant) -> Result, Error> } } -/// Sends one opaque Ark reply before advancing to the next pairing stage. +/// Sends one opaque Ark payload to the companion within the deadline. fn send(socket: &mut Connection, bytes: Vec, deadline: Instant) -> Result<(), Error> { bound(socket, deadline); socket @@ -240,6 +269,7 @@ impl Channel for Connection { } } +/// Rendezvous deadlines, close reasons and the forwarded pairing exchange. #[cfg(test)] mod tests { use super::*; @@ -257,14 +287,21 @@ mod tests { sync::{Arc, Mutex, mpsc}, thread, }; + + /// Budget for loopback I/O that is not exercising expiration. const TIMEOUT: Duration = Duration::from_secs(5); + /// The scan deadline maps from the cloud's Unix time, and only a caller's + /// absolute deadline cuts it short. #[test] fn scan_uses_cloud_deadline_and_retains_caller_bound() { // Pin wall time to a whole second, so the cloud's deadline maps exactly let mut tester = test_clock(); tester.set_system_time(UNIX_EPOCH + Duration::from_secs(1_789_000_000)); let clock = tester.clock(); + + // A deadline already reached expires the pairing, and a later one maps + // to the same span on the clock assert!(matches!( scan_deadline(&clock, 0), Err(Error::PairingExpired) @@ -285,11 +322,14 @@ mod tests { assert_eq!(Timing::until(expiry + TIMEOUT).limit(expiry), expiry); } + /// A cloud close for the scan timeout expires the pairing, while any other + /// close reports its reason. #[test] - #[allow(clippy::result_large_err)] // Tungstenite's HTTP callback owns its rejection. + #[allow(clippy::result_large_err)] // the upgrade callback's error is a whole HTTP response fn close_retains_timeout_and_other_reasons() { let clock = test_clock().clock(); for reason in ["pairing timed out", "companion disconnected"] { + // Accept the upgrade, then close it at once with the reason let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let url = format!("ws://{}/pairing", listener.local_addr().unwrap()); let cloud = thread::spawn(move || { @@ -313,6 +353,8 @@ mod tests { })) .unwrap(); }); + + // The next receive maps the close to its error let deadline = clock.now() + TIMEOUT; let mut socket = socket::connect( &http::tests::api(url.clone(), Realm::Hardware, &clock), @@ -350,8 +392,10 @@ mod tests { .unwrap() } - /// Ark answering each pairing request with fixed opaque payloads, checking - /// the ones it receives. It refuses the owner's acceptance when asked to. + /// Spawns an Ark peer that answers each pairing request with fixed opaque + /// payloads and checks the ones it receives. + /// + /// With `refuse` set, it refuses the owner's acceptance. fn pairing_peer(clock: &Clock, refuse: bool) -> Peer { Peer::spawn( clock, @@ -405,25 +449,33 @@ mod tests { ) } - /// The owner's scan outlives the machine allowance under the cloud's - /// deadline, while a caller's earlier absolute deadline still ends it. The - /// presented rendezvous counts down to the deadline the scan waits on. + /// The owner's scan outlives the inactivity allowance until the cloud's + /// deadline, unless a caller's earlier absolute deadline ends it. #[test] fn test_scan_waits_under_the_cloud_deadline() { /// Companion side of the rendezvous, which the test hands each payload - /// to. Every receive reports its deadline before it waits. + /// to. + /// + /// Every receive reports its deadline before it waits. struct Companion { - clock: Clock, // clock the receives wait on - payloads: crossbeam_channel::Receiver>, // payloads the test hands over - waits: mpsc::Sender, // deadline of every receive + /// Clock the receives wait on. + clock: Clock, + /// Payloads the test hands over. + payloads: crossbeam_channel::Receiver>, + /// Deadline of every receive, in order. + waits: mpsc::Sender, } impl Channel for Companion { + /// Reports the deadline, then waits on the clock for the test's + /// next payload. fn receive(&mut self, deadline: Instant) -> Result, Error> { self.waits.send(deadline).unwrap(); self.clock .recv_deadline(&self.payloads, deadline) .map_err(|_| Error::Timeout) } + + /// Discards the payload, which the test does not check. fn send(&mut self, _: Vec, _: Instant) -> Result<(), Error> { Ok(()) } @@ -489,10 +541,10 @@ mod tests { } } - /// The host only relays sealed payloads. Both completion messages and a - /// refusal retain their protocol ordering. + /// The host relays sealed pairing payloads unchanged and in protocol order, + /// through completion or the owner's refusal. #[test] - #[allow(clippy::result_large_err)] // Tungstenite requires a full HTTP rejection response. + #[allow(clippy::result_large_err)] // the upgrade callback's error is a whole HTTP response fn pairing_exchange_and_refusal() { // Pin wall time to a whole second, so the cloud's deadline maps exactly let mut tester = test_clock(); @@ -501,6 +553,8 @@ mod tests { let start = clock.now(); for refuse in [false, true] { + // Serve the cloud's side of the rendezvous, checking every payload + // that comes back from the Ark let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let url = format!("http://{}/v1", listener.local_addr().unwrap()); let signed = clock @@ -556,6 +610,8 @@ mod tests { assert!(matches!(socket.read().unwrap(), Message::Close(_))); } }); + + // Pair an Ark whose cloud setup is already fresh let mut peer = pairing_peer(&clock, refuse); let verifier = TrustMode::Recover(Box::new(peer.identity.clone())); let (session, _) = protocol::connect(peer.stream(), &verifier).unwrap(); @@ -575,6 +631,9 @@ mod tests { .pair(Timing::inactivity(Duration::from_secs(1)), |stage| { stages.push(stage) }); + + // A refusal ends before formatting, and the first stage presents the + // cloud's rendezvous either way if refuse { assert!( matches!(result, Err(Error::Remote(error)) if error.code == 0x1234 && error.msg == "owner refused") diff --git a/connect/src/cloud/relay.rs b/connect/src/cloud/relay.rs index ed7ce15..30057b9 100644 --- a/connect/src/cloud/relay.rs +++ b/connect/src/cloud/relay.rs @@ -32,42 +32,55 @@ use std::time::{Duration, Instant}; use tungstenite::stream::MaybeTlsStream; use tungstenite::{Message, WebSocket}; -/// Relay bodies fit inside a wire message. Queues and requests also have limits. +/// Largest encoded relay envelope, the most one wire message carries. const MAX_MESSAGE: usize = darkbio_wire::transport::MAX_MESSAGE_SIZE; -/// Byte allowance for each queued direction, excluding in-flight wire messages. +/// Byte allowance, 16 MiB, for each of the admission and output queues, not +/// counting messages in flight on the wire. const MAX_BYTES: usize = 16 * 1024 * 1024; -/// Request count limit applied to admission, active exchanges and output queues. +/// Request count limit, 128, for the admission queue, the open exchanges in +/// each direction and the output queue. const MAX_INFLIGHT: usize = 128; -/// The firmware bounds its relay requests by sixty seconds. This also bounds -/// retained responders when a companion never answers, independently of callers. +/// Time an Ark request or a companion request stays open on the relay, 60 s. +/// +/// An Ark request that needs the relay attached spends part of it attaching. +/// It also releases a responder whose companion never answers, whatever the +/// caller's own deadline. pub(super) const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(60); -/// Maximum time to flush an outgoing frame through a backlogged cloud socket. +/// Maximum time, 5 s, that written output may wait to flush through the cloud +/// socket. const WRITE_TIMEOUT: Duration = Duration::from_secs(5); -/// Idle interval between a valid pong and the next liveness probe. +/// Interval, 15 s, from attachment or a matching pong to the next liveness +/// probe. const PING_INTERVAL: Duration = Duration::from_secs(15); -/// Maximum wait for the pong matching the current probe. +/// Maximum wait, 10 s, for the pong matching the current probe. const PONG_TIMEOUT: Duration = Duration::from_secs(10); /// Readiness token for incoming traffic and pending socket writes. const SOCKET: Token = Token(0); -/// Readiness token for queued Ark requests or local closure. +/// Readiness token for queued Ark requests, wire completions and local closure. const WAKE: Token = Token(1); -/// An attached relay. Dropping it ends its worker and outstanding forwarding. +/// Relay attached to one connection, ending its worker and forwarding when +/// dropped. #[derive(Debug)] pub(super) struct Relay { - shared: Arc, // Queue and ending reason observed by the dispatcher - worker: Option, // Started when dispatch can see this attachment + /// Admission queue and ending reason, shared by the dispatcher and the + /// worker. + shared: Arc, + /// Worker resources, until [`Self::start`] moves them into the worker + /// thread. + worker: Option, } -/// Connected resources moved into the worker only after services publishes the relay. +/// Connected resources that [`Relay::start`] moves into the worker thread. #[derive(Debug)] struct Worker { /// Sole owner of WebSocket framing and TLS state. socket: WebSocket>, - /// Waits for socket readiness, admission wakeups and exchange deadlines. + /// Poller for socket readiness, admission wakeups and exchange deadlines. poll: Poll, - /// Issues companion requests through the original Ark session. + /// Requester passing companion requests to the Ark over the original + /// session. requester: Requester, /// Cloud liveness probes, independent of companion availability. heartbeat: Heartbeat, @@ -76,25 +89,33 @@ struct Worker { /// Admission and shutdown handles shared by the dispatcher and relay worker. #[derive(Debug)] struct Shared { - state: Mutex, // Admission and closure are atomic with respect to each other - wake: Arc, // signals queued Ark traffic, wire completions or local closure - socket: TcpStream, // Interrupts reads even inside WebSocket message assembly - /// Notifies a test once the last handle to this relay is gone. + /// Queue and ending reason under one lock, so admission and closure never + /// interleave. + state: Mutex, + /// Waker for queued Ark requests, wire completions and local closure. + wake: Arc, + /// Clone of the TCP stream, shut down to interrupt the worker even inside + /// WebSocket message assembly. + socket: TcpStream, + /// Channel notifying a test once the last handle to this relay is gone. #[cfg(test)] dropped: Mutex>>, } -/// Reverse requests awaiting admission and the first attachment failure. +/// Ark requests waiting for the worker, and the first reason the relay ended. #[derive(Debug, Default)] struct State { /// Ark requests, unanswered responders and their fixed exchange deadlines. queue: VecDeque<(schema::RelayArkToAppRequest, Responder, Instant)>, - bytes: usize, // Opaque bytes waiting for the socket worker - error: Option, // First reason this relay ended + /// Opaque request bytes waiting in the queue. + bytes: usize, + /// First reason this relay ended. + error: Option, } impl Relay { - /// Opens an authenticated socket under the original operation's deadline. + /// Opens an authenticated relay socket under the operation's deadline, + /// ready for [`Self::start`] to hand to the worker. pub(super) fn connect( api: &super::http::Api, url: &str, @@ -104,6 +125,9 @@ impl Relay { ) -> Result { let mut socket = socket::connect(api, url, auth, "Relaying", deadline)?; api.clock.remaining(deadline).map_err(io_error)?; + + // Switch the upgraded stream to readiness polling, keeping a clone to + // shut it down with let Socket::Blocking { stream, .. } = socket_mut(&mut socket) else { unreachable!() }; @@ -121,6 +145,9 @@ impl Relay { ) .map_err(io_error)?; *socket_mut(&mut socket) = Socket::Connected(connected); + + // Share the queue and shutdown handles, holding the worker's resources + // until it starts let shared = Arc::new(Shared { state: Mutex::new(State::default()), wake: Arc::new(Waker::new(poll.registry(), WAKE).map_err(io_error)?), @@ -139,8 +166,14 @@ impl Relay { }) } - /// Starts under the services lock so a companion request arriving immediately - /// after upgrade cannot provoke Ark traffic before dispatch sees this relay. + /// Starts the worker thread that services this relay. + /// + /// The setup calls it under its lock, so dispatch finds this relay for any + /// Ark request that an early companion message provokes. + /// + /// # Panics + /// + /// Panics if the worker already started. pub(super) fn start(&mut self) -> Result<(), Failure> { let Worker { socket, @@ -158,8 +191,10 @@ impl Relay { Ok(()) } - /// Whether this attachment has no recorded failure. This is a local snapshot; - /// a dead peer may remain undetected until I/O or the next heartbeat expires. + /// Checks whether this attachment has not recorded an ending yet. + /// + /// This is a local snapshot, so a dead peer may go unnoticed until I/O + /// fails or the next heartbeat expires. pub(super) fn connected(&self) -> bool { self.shared .state @@ -169,7 +204,10 @@ impl Relay { .is_none() } - /// Queues a reverse request without blocking the Ark's receive loop. + /// Queues an Ark request for the companion without blocking dispatch. + /// + /// A relay that ended, a full queue or a body without 64 bytes of room for + /// its envelope refuses the request at once with `UNAVAILABLE`. pub(super) fn forward( &self, request: schema::RelayArkToAppRequest, @@ -191,7 +229,8 @@ impl Relay { } } - /// Refuses queued work and wakes the worker without waiting for it to join. + /// Ends the relay, refusing queued requests and waking the worker without + /// waiting for it to exit. pub(super) fn close(&self) { self.shared.end("relay closed".into()); } @@ -205,7 +244,10 @@ impl Drop for Relay { } impl Shared { - /// Refuses queued work and interrupts the socket, retaining the first failure. + /// Ends the relay with `error`, refusing queued requests and interrupting + /// the socket. + /// + /// Only the first reason is kept, and later calls do nothing. fn end(&self, error: String) { let mut state = self.state.lock().expect("relay queue not poisoned"); if state.error.is_none() { @@ -231,38 +273,46 @@ impl Drop for Shared { } } -/// Recognizes an incomplete nonblocking operation that the poll loop can resume. +/// Checks whether a WebSocket error is a nonblocking operation left for the +/// poll loop to resume. fn would_block(error: &tungstenite::Error) -> bool { matches!(error, tungstenite::Error::Io(error) if error.kind() == io::ErrorKind::WouldBlock) } -/// An unavailable relay fails the Ark's reverse request, allowing its original -/// operation to finish with an error. Enqueueing the reply does not wait on I/O. +/// Refuses an Ark request with `UNAVAILABLE` and the reason, queueing the reply +/// without waiting on I/O. +/// +/// The reply gets [`protocol::DEFAULT_AUTOREPLY_TIMEOUT`] to go out. pub(super) fn fail(responder: Responder, reason: &str) { let error = schema::Error::reserved(schema::ReservedErrors::Unavailable, reason); let deadline = responder.clock().now() + protocol::DEFAULT_AUTOREPLY_TIMEOUT; let _ = responder.fail(error, deadline); } -/// Current cloud envelope. Only the envelope is interpreted; all bodies stay sealed. +/// Cloud relay envelope, whose framing is all the host reads. +/// +/// Every body stays opaque to the host. #[derive(Cbor, Default)] #[cbor(array)] struct Envelope { - /// Envelope version; the current cloud protocol requires one. + /// Envelope version, which must be `1`. darkrpc: u64, /// Correlation ID present only on requests and responses. id: Option, - /// Sealed notification body, currently ignored after envelope validation. + /// Sealed notification body, accepted and ignored since the wire has no + /// input for it. notify: Option>, /// Sealed request body forwarded without interpretation. request: Option>, /// Sealed response body matched to an outstanding request. response: Option>, - /// Presence body, currently ignored because wire has no corresponding input. + /// Presence body, accepted and ignored since the wire has no input for it. presence: Option>, } -/// Validated envelope shape. The host only originates requests and responses. +/// Envelope validated into one of its three shapes. +/// +/// The host originates only requests and responses. enum Frame { /// Correlation ID and opaque request bytes. Request(u64, Vec), @@ -273,7 +323,8 @@ enum Frame { } impl Frame { - /// Requires the current version and exactly one payload with the proper ID shape. + /// Decodes an envelope, requiring version `1` and exactly one body, with an + /// ID only on requests and responses. fn decode(bytes: &[u8]) -> Result { let envelope: Envelope = cbor::decode(bytes) .map_err(|error| Failure::Relay(format!("invalid relay envelope: {error}")))?; @@ -296,8 +347,12 @@ impl Frame { } } - /// Encodes a forwarded exchange without changing its ID or sealed payload. - /// Notifications cannot be originated by the host. + /// Encodes a request or response envelope, keeping its ID and sealed body + /// unchanged. + /// + /// # Panics + /// + /// Panics on [`Self::Notice`], since the host originates no notifications. fn encode(self) -> Vec { let mut envelope = Envelope { darkrpc: 1, @@ -318,8 +373,11 @@ impl Frame { } } -/// Checks the cloud transport independently of companion availability. Only a -/// pong echoing our current ping proves liveness; other traffic cannot defer it. +/// Liveness probe schedule for the cloud socket, independent of companion +/// traffic. +/// +/// Only a pong echoing the current ping proves liveness, and other traffic +/// cannot defer a probe. #[derive(Debug)] struct Heartbeat { /// Delay before probing again after a matching pong. @@ -335,8 +393,10 @@ struct Heartbeat { } impl Heartbeat { - /// Schedules the first probe of a relay attaching now. The worker's socket - /// timers run on real time, so the schedule starts on it too. + /// Schedules the first probe of a relay attaching now. + /// + /// The worker's socket timers run on real time, so the schedule starts on + /// it too. #[expect( clippy::disallowed_methods, reason = "the heartbeat is one of the worker's socket timers, which run on real time from attachment" @@ -357,7 +417,10 @@ impl Heartbeat { } } - /// Produces at most one ping until its matching pong arrives or expires. + /// Returns a probe payload when one is due, keeping at most one probe + /// outstanding. + /// + /// Fails once the outstanding probe's pong is overdue. fn ping(&mut self, now: Instant) -> Result, Failure> { if let Some((_, deadline)) = self.pending { if now >= deadline { @@ -382,15 +445,17 @@ impl Heartbeat { } } - /// Next instant the worker must wake to send a probe or detect its expiry. + /// Returns the next instant the worker must wake, to send a probe or detect + /// its expiry. fn deadline(&self) -> Instant { self.pending.map_or(self.next, |(_, deadline)| deadline) } } -/// Bounds output the cloud socket has not taken yet. The first write or -/// deferred flush starts the bound, later ones keep its deadline, and only a -/// completed flush ends it. +/// Time bound on output the cloud socket has not taken yet. +/// +/// The first write or deferred flush starts the bound, later ones keep its +/// deadline, and only a completed flush ends it. #[derive(Debug, Default)] struct Backlog { /// Time the pending output must be flushed by, while there is some. @@ -409,26 +474,31 @@ impl Backlog { self.deadline = None; } - /// Whether output is left to flush, which holds back the next frame. + /// Checks whether output is left to flush, which holds back the next frame. fn active(&self) -> bool { self.deadline.is_some() } - /// Whether the output left to flush missed its deadline by `now`. + /// Checks whether the output left to flush missed its deadline by `now`. fn expired(&self, now: Instant) -> bool { self.deadline.is_some_and(|deadline| now >= deadline) } - /// Time left after `now` before the deadline, bounding the next poll. + /// Returns the time left after `now` before the deadline, which bounds the + /// next poll. fn remaining(&self, now: Instant) -> Option { self.deadline .map(|deadline| deadline.saturating_duration_since(now)) } } -/// Writes one frame at a time while receiving concurrently. Wire completions -/// and queued Ark requests wake the poll; idle wakeups check liveness. The -/// socket's own timers read real time and exchange deadlines the session's clock. +/// Runs the relay worker, writing one frame at a time while receiving +/// concurrently. +/// +/// Wire completions and queued Ark requests wake the poll, and idle wakeups +/// check liveness. The socket's own timers read real time, while exchange +/// deadlines read the session's clock. When the relay ends, every Ark request +/// still queued or open is refused with the ending reason. #[expect( clippy::disallowed_methods, reason = "the worker waits on the cloud socket through mio, so its heartbeat and write timers run on real time" @@ -442,23 +512,29 @@ fn pump( ) { let clock = requester.clock(); let mut events = Events::with_capacity(8); + // The two directions have independent ID spaces. Only the worker changes // these maps, so completions never need the shared admission lock. let mut pending: HashMap = HashMap::new(); let mut inbound: HashMap> = HashMap::new(); let (answered, answers) = mpsc::channel(); + + // Output waits in a bounded queue and goes out one frame at a time let mut output = VecDeque::new(); let mut bytes = 0usize; let mut writing = Backlog::default(); + + // Service the relay until the first failure ends it let result = (|| -> Result<(), Failure> { loop { + // Stop once the relay ended, or admit one queued Ark request { let mut state = shared.state.lock().expect("relay queue not poisoned"); if let Some(error) = &state.error { return Err(Failure::Relay(error.clone())); } - // Admission stops while the socket is backlogged; reading and - // completion handling continue independently of that backlog. + // Admission stops while the socket is backlogged, but reading + // and completion handling continue independently of that backlog if output.is_empty() && !writing.active() && pending.len() < MAX_INFLIGHT @@ -481,8 +557,9 @@ fn pump( } } } - // Expired exchanges release their responder even if no further - // traffic arrives. Responses without a pending responder are discarded. + + // Refuse Ark requests whose exchange expired, even if no further + // traffic arrives let now = clock.now(); let expired: Vec<_> = pending .iter() @@ -492,12 +569,14 @@ fn pump( for id in expired { fail(pending.remove(&id).unwrap().0, "relay request timed out"); } + + // Pass the Ark's answers to companion requests on to the cloud while let Ok(id) = answers.try_recv() { if let Some(promise) = inbound.remove(&id) { let response = match promise.wait::() { Ok(response) => response, - // Only the Ark can seal an error for the companion. Let - // this exchange expire there without ending unrelated ones. + // Only the Ark can seal an answer for the companion, so + // this one goes unanswered and unrelated ones continue Err( protocol::Error::Remote(_) | protocol::Error::Timeout @@ -513,6 +592,8 @@ fn pump( enqueue(&mut output, &mut bytes, Frame::Response(id, response.res))?; } } + + // Start writing the next frame once the previous one is flushed if !writing.active() && let Some(frame) = output.pop_front() { @@ -524,8 +605,9 @@ fn pump( Err(error) => return Err(socket_error(error)), } } + // Bound each read batch so a busy companion cannot starve writes, - // expired Ark requests or the heartbeat. + // expired Ark requests or the heartbeat let mut batch_full = false; for index in 0..32 { match socket.read() { @@ -536,6 +618,8 @@ fn pump( "too many or duplicate companion requests".into(), )); } + + // Ask the Ark, waking the worker on its answer let mut promise = requester.request( schema::RelayAppToArkRequest { id, req }, clock.now() + EXCHANGE_TIMEOUT, @@ -549,12 +633,13 @@ fn pump( inbound.insert(id, promise); } Frame::Response(id, res) => { + // Responses to closed exchanges are dropped if let Some((responder, deadline)) = pending.remove(&id) { let _ = responder .reply(schema::RelayAppToArkResponse { id, res }, deadline)?; } } - Frame::Notice => {} // The wire has no notification or presence input. + Frame::Notice => {} // the wire has no notification or presence input }, Ok(Message::Pong(bytes)) => heartbeat.pong(&bytes, Instant::now()), Ok(Message::Ping(_)) => {} @@ -567,6 +652,8 @@ fn pump( } batch_full = index == 31; } + + // Probe the cloud when due, failing once a probe went unanswered if let Some(ping) = heartbeat.ping(Instant::now())? { writing.start(Instant::now()); match socket.write(Message::Ping(ping.to_vec().into())) { @@ -575,6 +662,8 @@ fn pump( Err(error) => return Err(socket_error(error)), } } + + // Flush, ending the relay once output stays backlogged too long match socket.flush() { Ok(()) => writing.flushed(), Err(error) if would_block(&error) => writing.start(Instant::now()), @@ -583,6 +672,8 @@ fn pump( if writing.expired(Instant::now()) { return Err(Failure::Wire(protocol::Error::Timeout)); } + + // Loop again at once while work is ready, without polling let queued = !shared .state .lock() @@ -595,6 +686,7 @@ fn pump( { continue; } + // Wait for readiness, a wakeup or the earliest deadline. Exchanges end // on the session's clock, the socket's own timers on real time. let (now, session) = (Instant::now(), clock.now()); @@ -611,6 +703,8 @@ fn pump( } } })(); + + // Refuse every Ark request still queued or open with the ending reason let error = match result { Err(error) => crate::Error::from(error).to_string(), Ok(()) => "relay ended".into(), @@ -621,7 +715,10 @@ fn pump( } } -/// Retains a bounded amount of output even when the peer stops reading. +/// Queues an encoded frame for the socket, keeping the output bounded when the +/// cloud stops reading. +/// +/// A frame that does not fit fails, which ends the relay. fn enqueue(output: &mut VecDeque>, bytes: &mut usize, frame: Frame) -> Result<(), Failure> { let frame = frame.encode(); if frame.len() > MAX_MESSAGE @@ -651,7 +748,7 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use tungstenite::handshake::server::{Request, Response}; - /// IDs retain all sixty-four bits, including numbers beyond JSON precision. + /// Relay ID with its top bit set, beyond what a JSON number carries exactly. const ID: u64 = (1 << 63) + 7; /// Accepts the next cloud connection, bounding its I/O in case a test fails. @@ -702,8 +799,9 @@ mod tests { (url, worker) } - /// Checks realm routing and the opaque authentication subprotocol. - #[allow(clippy::result_large_err)] // Tungstenite requires a full HTTP rejection response. + /// Accepts a relay upgrade after checking its route and the proof in its + /// subprotocol header. + #[allow(clippy::result_large_err)] // the upgrade callback's error is a whole HTTP response fn upgrade(stream: TcpStream) -> WebSocket { tungstenite::accept_hdr(stream, |request: &Request, mut response: Response| { assert_eq!(request.uri().path(), "/v1/relaying"); @@ -719,6 +817,8 @@ mod tests { .unwrap() } + /// Reads the next binary message as a relay envelope, skipping pings and + /// pongs. fn frame(socket: &mut WebSocket) -> Frame { loop { match socket.read().unwrap() { @@ -737,7 +837,11 @@ mod tests { *heartbeat = Heartbeat::new(Duration::ZERO, timeout, attached); } - /// Keeps serving app requests while an unlock waits for its reverse response. + /// Spawns an Ark peer that asks the companion to authorize guarded requests, + /// serving other requests while an authorization waits. + /// + /// It counts relay joins and cloud syncs, approves on a `[4, 5, 6]` answer + /// and denies on `[0]`. fn peer(clock: &Clock) -> (Peer, Arc, Arc) { let joins = Arc::new(AtomicUsize::new(0)); let syncs = Arc::new(AtomicUsize::new(0)); @@ -839,6 +943,8 @@ mod tests { | Content::SlotDelete(_) | Content::FirmwareUpdatePrep(_) | Content::SlotUploadStart(_)) => { + // Pick the reply, whether the relay must already be + // attached, and whether the companion must authorize let (reply, preflight, authorize): (protocol::Message, _, _) = match request { Content::Unlock(_) => { @@ -869,6 +975,9 @@ mod tests { responder.reply(reply, deadline).unwrap(); return true; } + + // Ask the companion, answering from another thread + // so this peer keeps serving meanwhile let id = next_id; next_id += 1; assert!( @@ -907,6 +1016,7 @@ mod tests { }); } Content::RelayReq(request) => { + // Refuse a `[0]` request and answer the expected one if request.req == [0] { responder .fail( @@ -947,6 +1057,8 @@ mod tests { let (release, pause) = mpsc::channel(); let (staged, stages) = mpsc::channel(); let (url, server) = cloud(1, move |_, stream| { + // Send a presence notice, a ping and a companion request right after + // the upgrade let mut socket = upgrade(stream); socket .send(Message::Binary( @@ -965,6 +1077,8 @@ mod tests { Frame::Request(ID, vec![9, 8, 7]).encode().into(), )) .unwrap(); + + // Approve the unlock while the Ark's answer to the companion arrives let mut requests = 0; let mut responses = 0; for _ in 0..2 { @@ -986,6 +1100,8 @@ mod tests { } } assert_eq!((requests, responses), (1, 1)); + + // Deny the next unlock once the test is ready for it staged.send(()).unwrap(); let Frame::Request(id, _) = frame(&mut socket) else { panic!("expected second unlock") @@ -997,6 +1113,8 @@ mod tests { .unwrap(); pause.recv().unwrap(); }); + + // Status needs neither sync nor relay let clock = test_clock().clock(); let (mut peer, joins, syncs) = peer(&clock); let ark = attach(&mut peer, url); @@ -1005,6 +1123,9 @@ mod tests { client.call(schema::DeviceInfoRequest {}, deadline).unwrap(); assert_eq!(joins.load(Ordering::SeqCst), 0); assert_eq!(syncs.load(Ordering::SeqCst), 0); + + // The first unlock attaches the relay and is approved, and the second + // reuses the attachment and is denied client.call(schema::UnlockRequest {}, deadline).unwrap(); stages.recv().unwrap(); assert!( @@ -1026,6 +1147,9 @@ mod tests { let Frame::Request(id, _) = frame(&mut socket) else { panic!("expected authorization") }; + + // While the authorization waits, send a companion request the Ark + // refuses and one it serves, then approve the authorization for request in [ Frame::Request(ID + 1, vec![0]), Frame::Request(ID, vec![9, 8, 7]), @@ -1056,7 +1180,10 @@ mod tests { /// the Ark, without relying on an earlier unlock on this connection. #[test] fn test_authorization_prerequisites() { + /// Guarded operation under test, run on a fresh connection. type Call = fn(&crate::Client) -> Result<(), Error>; + + // Scheduling, repair, deletion and a whole app run each need approval let calls: [Call; 4] = [ |client| { client @@ -1084,6 +1211,8 @@ mod tests { Ok(()) }, ]; + + // Each runs on a fresh connection whose companion approves once let clock = test_clock().clock(); for call in calls { let (release, pause) = mpsc::channel(); @@ -1108,12 +1237,13 @@ mod tests { } } - /// Unpaired updates and catalog uploads need no companion. Their conditional - /// counterparts attach when the Ark first requests authorization. + /// Conditional requests attach the relay only once the Ark asks for + /// authorization, and not when it answers them directly. #[test] fn test_conditional_authorization() { let clock = test_clock().clock(); for firmware in [false, true] { + // Serve one attachment whose companion approves once let (release, pause) = mpsc::channel(); let (url, server) = cloud(1, move |_, stream| { let mut socket = upgrade(stream); @@ -1127,6 +1257,9 @@ mod tests { .unwrap(); pause.recv().unwrap(); }); + + // A request the Ark answers directly attaches nothing, and one it + // authorizes attaches the relay let (mut peer, joins, _) = peer(&clock); let ark = attach(&mut peer, url); let client = ark.client(); @@ -1163,8 +1296,8 @@ mod tests { } } - /// Client clones share one attachment. A shorter caller expires independently - /// while local requests and two concurrent authorizations remain available. + /// Client clones share one attachment, and a shorter caller expires alone + /// while local requests and concurrent authorizations continue. #[test] fn test_shared_attachment() { // Hold the leader's relay attachment at its upgrade @@ -1199,6 +1332,8 @@ mod tests { move || client.call(schema::UnlockRequest {}, deadline) }); attempts.recv().unwrap(); + + // Status runs while the attachment is held client.call(schema::DeviceInfoRequest {}, deadline).unwrap(); // A short caller joining the attachment expires alone, at the earliest @@ -1268,6 +1403,7 @@ mod tests { fn test_reconnect() { let clock = test_clock().clock(); for refused in [false, true] { + // Refuse or break the first attachment, then approve over the second let (release, pause) = mpsc::channel(); let (url, server) = cloud(2, move |attempt, mut stream| { if attempt == 0 && refused { @@ -1292,6 +1428,8 @@ mod tests { pause.recv().unwrap(); } }); + + // The unlock fails with the first attachment and is not replayed let (mut peer, joins, syncs) = peer(&clock); let ark = attach(&mut peer, url); let client = ark.client(); @@ -1304,6 +1442,8 @@ mod tests { matches!(error, Error::Remote(error) if error.code == schema::ReservedErrors::Unavailable as u64) ); } + + // Local requests go on, and the next guarded request attaches again client.call(schema::DeviceInfoRequest {}, deadline).unwrap(); client .call(schema::SlotDeleteRequest::default(), deadline) @@ -1315,9 +1455,11 @@ mod tests { } } - /// Dropping the owner ends authorization and the socket despite a retained client. + /// Dropping the owner ends authorization and the socket despite a retained + /// client. #[test] fn test_owner_close() { + // Hold an unlock's authorization at the companion until the socket ends let (seen, requests) = mpsc::channel(); let (url, server) = cloud(1, move |_, stream| { let mut socket = upgrade(stream); @@ -1333,6 +1475,8 @@ mod tests { .send(schema::UnlockRequest {}, clock.now() + TIMEOUT) .unwrap(); requests.recv().unwrap(); + + // Drop the owner while the authorization waits drop(ark); assert!(matches!(pending.wait(), Err(Error::Closed))); assert!(matches!( @@ -1342,8 +1486,8 @@ mod tests { server.join().unwrap(); } - /// Unsolicited or late pongs cannot acknowledge a different probe or restart - /// its deadline. A valid pong schedules a fresh probe with a different ID. + /// Only a timely pong echoing the current probe acknowledges it, and the + /// next probe carries a new ID. #[test] fn test_heartbeat() { // Probe first once an interval has passed since the start @@ -1363,7 +1507,8 @@ mod tests { heartbeat.pong(&first, now); assert_eq!(heartbeat.deadline(), now + PING_INTERVAL); - // An old probe's pong or one arriving at the deadline leaves the probe to expire + // An old probe's pong, or one arriving at the deadline, leaves the probe + // to expire tester.advance(PING_INTERVAL); let second = heartbeat.ping(clock.now()).unwrap().unwrap(); assert_ne!(first, second); @@ -1375,9 +1520,8 @@ mod tests { assert!(heartbeat.ping(clock.now()).is_err()); } - /// Output left to flush starts the backlog bound once. Later blockage keeps - /// the original deadline, where the bound expires, and a completed flush - /// clears it for the next output. + /// The backlog bound starts once, expires on its original deadline and + /// clears with a completed flush. #[test] fn test_backlog() { // The first blockage starts the bound @@ -1414,6 +1558,7 @@ mod tests { /// starts that late probes at once. #[test] fn test_heartbeat_starts_at_attachment() { + // Attach a relay to a cloud that waits for the first probe let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let url = format!("ws://{}/v1/relaying", listener.local_addr().unwrap()); let (probed, probes) = mpsc::channel(); @@ -1440,20 +1585,18 @@ mod tests { server.join().unwrap(); } - /// The socket pump accepts matching pongs, then ends an unresponsive relay - /// without disrupting local wire calls. A replacement can attach afterward. - /// Both relays probe at once. The first gives every pong an hour, and the - /// test waits for the server to answer three probes. The second gives its - /// pong no time, and the test waits for the server to see its socket end. + /// The worker keeps a relay whose pongs match, ends one whose probe expires, + /// and leaves the wire session usable for a replacement. #[test] fn test_heartbeat_disconnect() { + // Serve three attachments, one after another let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let url = format!("ws://{}/v1/relaying", listener.local_addr().unwrap()); let (answered, pongs) = mpsc::channel(); let (gone, ended) = mpsc::channel(); let (release, pause) = mpsc::channel(); let server = thread::spawn(move || { - // Answer three probes, each one sent only once the previous pong matched + // Answer three probes, each sent only once the previous pong matched let mut socket = upgrade(accept(&listener)); for _ in 0..3 { assert!(matches!(socket.read().unwrap(), Message::Ping(_))); @@ -1471,6 +1614,8 @@ mod tests { let _replacement = upgrade(accept(&listener)); pause.recv().unwrap(); }); + + // Open the wire session that every relay attaches beside let clock = test_clock().clock(); let mut peer = Peer::spawn(&clock, Box::new(answering)); let verifier = crate::TrustMode::Recover(Box::new(peer.identity.clone())); @@ -1486,7 +1631,8 @@ mod tests { pongs.recv().unwrap(); relay.close(); - // A relay whose probe expires at once ends, leaving the wire session usable + // A relay whose probe expires at once ends, and the wire session stays + // usable let mut relay = Relay::connect(&api, &url, &[0xfb, 0xff], session.requester(), deadline).unwrap(); probe_at_once(&mut relay, Duration::ZERO); @@ -1519,17 +1665,20 @@ mod tests { fn test_close_fragmented_message() { let clock = test_clock().clock(); for started in [false, true] { + // Serve a relay that starts a fragmented message and never ends it let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let url = format!("ws://{}/v1/relaying", listener.local_addr().unwrap()); let (sent, fragments) = mpsc::channel(); let server = thread::spawn(move || { let mut socket = upgrade(accept(&listener)); let mut bytes = vec![0; 128 * 1024]; - bytes[0] = 2; // Non-final binary frame followed by empty continuations + bytes[0] = 2; // non-final binary frame followed by empty continuations socket.get_mut().write_all(&bytes).unwrap(); sent.send(()).unwrap(); assert!(matches!(socket.get_mut().read(&mut [0]), Ok(0) | Err(_))); }); + + // Close amid the unfinished message, before or after the worker starts let mut peer = Peer::spawn(&clock, Box::new(answering)); let verifier = crate::TrustMode::Recover(Box::new(peer.identity.clone())); let (session, _) = protocol::connect(peer.stream(), &verifier).unwrap(); @@ -1557,11 +1706,13 @@ mod tests { } } - /// Every read of a stalled upgrade retains the caller's original deadline. - /// The socket's own timeout is real, so the upgrade stalls for the whole - /// 100 ms budget that the clock leaves it. + /// Every read of a stalled upgrade keeps the caller's original deadline. + /// + /// The socket's own timeout runs on real time, so the upgrade stalls for + /// the whole 100 ms that the clock leaves it. #[test] fn test_handshake_deadline() { + // Stall the upgrade once its request headers arrive let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let url = format!("ws://{}/v1/relaying", listener.local_addr().unwrap()); let (release, pause) = mpsc::channel(); @@ -1570,6 +1721,8 @@ mod tests { headers(&mut stream); pause.recv().unwrap(); }); + + // The attachment times out once the 100 ms pass let clock = test_clock().clock(); let mut peer = Peer::spawn(&clock, Box::new(answering)); let verifier = crate::TrustMode::Recover(Box::new(peer.identity.clone())); @@ -1592,11 +1745,14 @@ mod tests { /// Ambiguous, malformed and foreign-version envelopes never reach the Ark. #[test] fn test_envelopes() { + // A request envelope encodes as a CBOR array and decodes back let encoded = Frame::Request(ID, vec![0xfb, 0xff]).encode(); assert_eq!(hex::encode(&encoded), "86011b8000000000000007f642fbfff6f6"); assert!( matches!(Frame::decode(&encoded).unwrap(), Frame::Request(ID, bytes) if bytes == [0xfb, 0xff]) ); + + // A foreign version, a missing or stray ID and two bodies are refused for envelope in [ Envelope { darkrpc: 2, @@ -1625,6 +1781,8 @@ mod tests { ] { assert!(Frame::decode(&cbor::encode(envelope).unwrap()).is_err()); } + + // So are trailing bytes and garbage let mut trailing = encoded; trailing.push(0); assert!(Frame::decode(&trailing).is_err()); diff --git a/connect/src/cloud/socket.rs b/connect/src/cloud/socket.rs index 78cad83..fc4df3d 100644 --- a/connect/src/cloud/socket.rs +++ b/connect/src/cloud/socket.rs @@ -19,15 +19,18 @@ use tungstenite::{ stream::MaybeTlsStream, }; -/// Bounds cloud frames and assembled messages to wire's transport capacity. +/// Largest cloud frame or assembled message, the most one wire message carries. const MAX_MESSAGE: usize = darkbio_wire::transport::MAX_MESSAGE_SIZE; /// Cloud WebSocket retaining its TLS state when switched to readiness polling. pub(super) type Connection = WebSocket>; -/// Opens a cloud socket and requires the requested application subprotocol. -/// DNS, TCP, TLS and upgrade share one deadline. Caller authentication stays -/// separate from cloud proof refusals, before any application exchange begins. +/// Opens a cloud socket that must agree on the requested subprotocol, carrying +/// the Ark's `auth` proof in the upgrade. +/// +/// DNS, TCP, TLS and the upgrade share one deadline. A refusal of the caller's +/// credentials returns [`Failure::AuthRequired`], kept apart from a refused +/// proof, before any application exchange begins. pub(super) fn connect( api: &Api, url: &str, @@ -35,6 +38,7 @@ pub(super) fn connect( subprotocol: &str, deadline: Instant, ) -> Result { + // Carry the caller's credentials and the Ark's proof on the upgrade request let mut request = url.into_client_request().map_err(socket_error)?; request .headers_mut() @@ -48,6 +52,8 @@ pub(super) fn connect( .parse() .expect("base64url is a valid header"), ); + + // Take the host without IPv6 brackets, and the scheme's default port let host = request .uri() .host() @@ -64,6 +70,7 @@ pub(super) fn connect( 80 }); + // Try the resolved addresses in turn until one connects within the deadline let addresses = api.resolver.resolve(&host, port, deadline)?; let mut failure = io::Error::new( io::ErrorKind::AddrNotAvailable, @@ -82,6 +89,8 @@ pub(super) fn connect( } let stream = connected.ok_or_else(|| io_error(failure))?; stream.set_nodelay(true).map_err(io_error)?; + + // Upgrade with frames and messages capped at what one wire message carries let config = WebSocketConfig::default() .write_buffer_size(0) .max_write_buffer_size(2 * MAX_MESSAGE) @@ -108,6 +117,8 @@ pub(super) fn connect( } HandshakeError::Failure(error) => socket_error(error), })?; + + // The cloud must agree on the requested subprotocol if response .headers() .get("Sec-WebSocket-Protocol") @@ -121,7 +132,11 @@ pub(super) fn connect( Ok(socket) } -/// Blocking reads and writes share a deadline; attached relays use readiness. +/// Stream under a cloud WebSocket, blocking under a deadline or polled for +/// readiness. +/// +/// Blocking reads and writes share one deadline, and an attached relay +/// switches to readiness polling. #[derive(Debug)] pub(super) enum Socket { /// Handshake or pairing stream with a shared read and write bound. @@ -130,7 +145,7 @@ pub(super) enum Socket { clock: Clock, /// Connected TCP socket, optionally wrapped by TLS above this adapter. stream: TcpStream, - /// Absolute bound checked again before each blocking I/O. + /// Absolute bound, turned into a fresh timeout before each blocking I/O. deadline: Instant, }, /// Attached relay socket serviced by the worker's readiness loop. @@ -138,7 +153,8 @@ pub(super) enum Socket { } impl Read for Socket { - /// Applies the remaining blocking deadline or returns readiness-based I/O. + /// Reads within the remaining deadline, or without blocking on a relay + /// socket. fn read(&mut self, bytes: &mut [u8]) -> io::Result { match self { Self::Blocking { @@ -155,7 +171,8 @@ impl Read for Socket { } impl Write for Socket { - /// Applies the remaining blocking deadline or writes through the relay socket. + /// Writes within the remaining deadline, or without blocking on a relay + /// socket. fn write(&mut self, bytes: &[u8]) -> io::Result { match self { Self::Blocking { @@ -169,6 +186,7 @@ impl Write for Socket { Self::Connected(stream) => stream.write(bytes), } } + /// Flushes the underlying stream under the same bound as a write. fn flush(&mut self) -> io::Result<()> { match self { @@ -185,7 +203,8 @@ impl Write for Socket { } } -/// Accesses the readiness adapter under either the cleartext test socket or TLS. +/// Returns the adapter under a WebSocket, reaching through its TLS layer when +/// there is one. pub(super) fn socket_mut(socket: &mut WebSocket>) -> &mut Socket { match socket.get_mut() { MaybeTlsStream::Plain(stream) => stream, @@ -194,7 +213,10 @@ pub(super) fn socket_mut(socket: &mut WebSocket>) -> &mut } } -/// Preserves timeout classification across blocking socket error conventions. +/// Converts an I/O error, treating both timeout kinds as a wire timeout. +/// +/// A blocking socket reports an expired timeout as `TimedOut` or `WouldBlock`, +/// depending on the platform. pub(super) fn io_error(error: io::Error) -> Failure { match error.kind() { io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock => { @@ -204,7 +226,10 @@ pub(super) fn io_error(error: io::Error) -> Failure { } } -/// Separates expired I/O and rejected proofs from other cloud socket failures. +/// Converts a WebSocket error, keeping expired I/O and a refused proof apart +/// from other failures. +/// +/// An HTTP 403 answer to the upgrade counts as a refused proof. pub(super) fn socket_error(error: tungstenite::Error) -> Failure { match error { tungstenite::Error::Io(error) => io_error(error), @@ -213,6 +238,7 @@ pub(super) fn socket_error(error: tungstenite::Error) -> Failure { } } +/// Socket upgrades carrying caller credentials. #[cfg(test)] mod tests { use super::*; @@ -227,11 +253,15 @@ mod tests { use std::sync::{Arc, atomic::Ordering}; use std::thread; + /// A refused upgrade triggers one login, and both the retry and a later + /// reconnect carry the refreshed credentials. #[test] - #[allow(clippy::result_large_err)] // Tungstenite's server callback owns its HTTP response. + #[allow(clippy::result_large_err)] // the upgrade callback's error is a whole HTTP response fn socket_upgrades_and_reconnects_use_refreshed_credentials() { let clock = test_clock().clock(); for subprotocol in ["Pairing", "Relaying"] { + // Refuse the first upgrade for its cached credentials, then accept + // two that carry refreshed ones let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let url = format!("ws://{}/v1/{subprotocol}", listener.local_addr().unwrap()); let server = thread::spawn(move || { @@ -266,6 +296,8 @@ mod tests { drop(socket); } }); + + // Connect twice, logging in once when the first upgrade is refused let cloud = api(url.clone(), Realm::Hardware, &clock); let login = Login::default(); cloud.auth.set(Arc::new(login.clone())); diff --git a/connect/src/dataset.rs b/connect/src/dataset.rs index 5498f69..8612a7c 100644 --- a/connect/src/dataset.rs +++ b/connect/src/dataset.rs @@ -15,21 +15,30 @@ use std::time::Duration; /// Prefix supplied to the Ark for file identification and upload preparation. const IDENTIFY_SIZE: usize = 1024 * 1024; -const CHUNK_SIZE: usize = 2 * 1024 * 1024 - 32 * 1024; // Leave room for sealing and framing -/// Flushes partial chunks from slow sources before the device upload window expires. +/// Largest upload chunk, 32 KiB short of a 2 MiB frame to leave room for +/// sealing and framing. +const CHUNK_SIZE: usize = 2 * 1024 * 1024 - 32 * 1024; +/// Interval after which a chunk goes out partial, keeping the Ark's upload +/// session alive while the source is slow. +/// +/// It is checked between source reads, so it never interrupts a read that +/// blocks. const CHUNK_INTERVAL: Duration = Duration::from_secs(1); /// Delay between processing reports, independent of each response's deadline. const POLL_INTERVAL: Duration = Duration::from_millis(500); -/// Upload stages reported on the caller's thread. Acknowledged bytes may still -/// need writing or validation; only successful processing completes the upload. +/// Upload stages reported on the caller's thread. +/// +/// Acknowledged bytes may still need writing or validation; only successful +/// processing completes the upload. #[derive(Clone, Debug, PartialEq)] pub enum UploadProgress { /// Asking the Ark to identify the file from its first chunk. Identifying, /// The Ark's identification, including its summary and confidence. Identified(schema::SlotIdentifyResponse), - /// Opening an upload session. The Ark may request companion approval. + /// Opening an upload session, for which the Ark may request companion + /// approval. Preparing, /// The upload session is available for explicit cancellation. Started { @@ -47,6 +56,8 @@ pub enum UploadProgress { Processing(schema::SlotUploadProcessResponse), } +/// Source to upload, either identified by the Ark or naming its target slot. +/// /// A local file needs identification; a reference already names its target /// slot and carries the hash advertised alongside the download. #[derive(Clone, Debug)] @@ -55,16 +66,19 @@ pub struct Dataset { pub name: String, /// Exact source length in bytes; truncation and trailing bytes are errors. pub size: u64, - /// Target slot, or None to let the Ark identify the file. + /// Target slot, or `None` to let the Ark identify the file. pub slot: Option, /// Optional SHA-256 checked before processing. pub sha256: Option<[u8; 32]>, } -/// Identifies once, resends that same head in the authorized start request and -/// streams the rest. A failed session is cancelled within the remaining deadline; -/// cleanup never replaces the original error or retries an upload. Deadlines -/// are measured on the clock of the requester's session. +/// Uploads a dataset, identifying it once, resending that head in the start +/// request and streaming the rest. +/// +/// After a failure it requests the session's cancellation within the remaining +/// deadline, at most 1 s. Cancellation errors are ignored, so cleanup never +/// replaces the original error, and it never retries an upload. Deadlines are +/// measured on the clock of the requester's session. pub(crate) fn upload( requester: &Requester, dataset: &Dataset, @@ -77,6 +91,8 @@ pub(crate) fn upload( if dataset.size == 0 { return Err(Error::Dataset("dataset is empty".into())); } + + // Read and hash the identification head, checking a source ending there let head = read_chunk( reader, dataset.size.min(IDENTIFY_SIZE as u64) as usize, @@ -91,6 +107,8 @@ pub(crate) fn upload( if head.len() as u64 == dataset.size { finish_read(reader, hash.take(), dataset, clock, timing)?; } + + // Identify the dataset unless the caller named its slot let kind = match dataset.slot { Some(kind) => kind, None => { @@ -114,6 +132,8 @@ pub(crate) fn upload( kind } }; + + // Open the session with the same head, which may wait for approval progress(UploadProgress::Preparing); let mut uploaded = head.len() as u64; let session = requester @@ -128,12 +148,16 @@ pub(crate) fn upload( )? .wait::()? .session; + + // Run the session's steps as one outcome, so any failure can cancel it let result = (|| { progress(UploadProgress::Started { session }); progress(UploadProgress::Uploading { uploaded, total: dataset.size, }); + + // Stream the rest, checking the source ends at its declared size let mut sent = uploaded; let mut pending: Option<(Promise, u64)> = None; while sent < dataset.size { @@ -148,7 +172,7 @@ pub(crate) fn upload( finish_read(reader, hash.take(), dataset, clock, timing)?; } // Keep at most two chunks outstanding so device writes can overlap - // the next transfer. The last acknowledgement is awaited too. + // the next transfer. The last acknowledgment is awaited too. let next = requester.request( schema::SlotUploadChunkRequest { session, chunk }, timing.io(clock), @@ -171,6 +195,8 @@ pub(crate) fn upload( total: dataset.size, }); } + + // Poll processing until its last phase completes loop { let status = requester .request( @@ -181,7 +207,7 @@ pub(crate) fn upload( if !status.failure.is_empty() { return Err(Error::Dataset(status.failure)); } - // An empty or malformed report must not turn into false success. + // An empty or malformed report must not turn into false success let phases = status.phases.len() as u64; if phases == 0 || status.phase_in == 0 @@ -198,6 +224,9 @@ pub(crate) fn upload( timing.pause(clock, POLL_INTERVAL)?; } })(); + + // Request a failed session's cancellation within the remaining deadline, at + // most 1 s, ignoring the outcome if result.is_err() { let cleanup = timing.io(clock).min(clock.now() + Duration::from_secs(1)); let _ = requester @@ -207,9 +236,14 @@ pub(crate) fn upload( result } -/// Fill the identification head before opening a session. Later chunks flush -/// available bytes periodically so a slow source keeps the Ark's session alive. -/// The caller's reader still owns the timeout of each individual read. +/// Reads up to `size` bytes, returning early once a read completes after +/// `interval` has passed since the call started. +/// +/// The identification head is read without an interval, so it fills before a +/// session opens. Later chunks go out partial so a slow source keeps the Ark's +/// session alive. The interval is checked between reads and never interrupts +/// a blocking one. A source ending early is an error, and the caller's reader +/// still owns the timeout of each individual read. fn read_chunk( reader: &mut impl Read, size: usize, @@ -237,8 +271,10 @@ fn read_chunk( Ok(chunk) } -/// Checks EOF and the advertised hash before sending the final chunk. Earlier -/// chunks may already be accepted, but a bad source never reaches processing. +/// Checks EOF and the advertised hash before sending the final chunk. +/// +/// Earlier chunks may already be accepted, but a bad source never reaches +/// processing. fn finish_read( reader: &mut impl Read, hash: Option, @@ -279,6 +315,7 @@ fn read_error(error: io::Error) -> Error { } } +/// Dataset upload regressions against a scripted Ark. #[cfg(test)] mod tests { use super::*; @@ -291,18 +328,26 @@ mod tests { use std::sync::{Arc, Mutex, mpsc}; use std::thread; + /// Budget for test I/O that is not exercising expiration. const TIMEOUT: Duration = Duration::from_secs(10); + /// Wire exchange recorded by the scripted peer. #[derive(Default)] struct Observed { + /// Stages the peer served, in arrival order. stages: Vec<&'static str>, + /// Head the Ark was asked to identify. head: Vec, + /// Dataset bytes the upload delivered, head included. bytes: Vec, + /// Slot kind the upload session was opened for. kind: Option, - /// Notifies a test each time an upload chunk reaches the Ark. + /// Channel notifying a test each time an upload chunk reaches the Ark. chunks: Option>, } + /// Builds a processing report of two phases, at `phase` with `progress` out + /// of 10,000. fn status(phase: u64, progress: u64) -> schema::SlotUploadProcessResponse { schema::SlotUploadProcessResponse { phases: ["Validate", "Index"] @@ -317,6 +362,8 @@ mod tests { } } + /// Creates a dataset of `size` bytes, a reference into slot 3 when given + /// its hash. fn source(size: usize, reference: Option<[u8; 32]>) -> Dataset { Dataset { name: "sample.vcf.gz".into(), @@ -326,13 +373,18 @@ mod tests { } } + /// Connects a raw wire session to the peer, pinning its identity key. fn attach(peer: &mut Peer) -> Session { let trust = TrustMode::Recover(Box::new(peer.identity.clone())); protocol::connect(peer.stream(), &trust).unwrap().0 } - /// Records the actual wire exchange, optionally refusing a stage. A cancel - /// refusal must never replace the failure that caused cleanup. + /// Spawns a peer recording the wire exchange, optionally failing a stage. + /// + /// Failing `identify` rejects the file in the identification answer, and + /// any other stage name refuses that stage. A failing peer refuses the + /// cancel too, which must never replace the failure that caused cleanup. + /// Processing reports come from `reports`, then a finished one. fn peer( clock: &Clock, fail: Option<&'static str>, @@ -434,14 +486,15 @@ mod tests { (peer, observed) } - /// Start includes the same head used for identification. Remaining chunks - /// arrive exactly once, and an early phase reaching 100% is not completion. + /// An upload resends its identified head, delivers every byte once, and + /// polls on past an early phase at 100%. #[test] fn test_upload() { let mut tester = test_clock(); let clock = tester.clock(); for size in [17, IDENTIFY_SIZE, IDENTIFY_SIZE + 2 * CHUNK_SIZE + 29] { - // Upload and process the source, the first report asking for another poll + // Upload and process the source, the first report asking for + // another poll let bytes: Vec<_> = (0..size).map(|i| (i % 251) as u8).collect(); let (mut peer, observed) = peer(&clock, None, vec![status(1, 10_000), status(2, 10_000)]); @@ -468,6 +521,8 @@ mod tests { wait_deadline(&tester, poll); tester.advance_to(poll); let progress = uploading.join().unwrap().unwrap(); + + // Every byte arrived once, and processing took both reports let observed = observed.lock().unwrap(); assert_eq!(observed.bytes, bytes); assert_eq!(observed.kind, Some(2)); @@ -490,19 +545,27 @@ mod tests { } } - /// A slow source must send a partial chunk before reading the rest. The - /// reader models a source that only continues once the Ark receives it. + /// A slow source sends a partial chunk before reading the rest. + /// + /// The reader continues only once the Ark receives that chunk. #[test] fn test_slow_source_flushes_partial_chunks() { /// Source whose second read takes a whole flush interval of the test /// clock, and whose third waits until the Ark received a chunk. struct Slow<'a> { + /// Test clock the second read advances. tester: &'a mut TestClock, + /// Bytes left to serve. bytes: &'a [u8], + /// Count of reads served so far. reads: usize, + /// Chunk arrivals at the Ark, awaited by the third read. chunks: mpsc::Receiver<()>, } impl Read for Slow<'_> { + /// Serves the head, then 64 KiB reads, advancing the clock by one + /// chunk interval on the second read and awaiting a chunk arrival + /// on the third. fn read(&mut self, buffer: &mut [u8]) -> io::Result { self.reads += 1; if self.reads == 2 { @@ -519,6 +582,8 @@ mod tests { self.bytes.read(&mut buffer[..size]) } } + + // Upload from the slow source through a peer reporting each chunk let mut tester = test_clock(); let clock = tester.clock(); let bytes = vec![42; IDENTIFY_SIZE + 128 * 1024]; @@ -540,6 +605,8 @@ mod tests { |_| {}, ) .unwrap(); + + // The rest arrives in two chunks, the first flushed early let observed = observed.lock().unwrap(); assert_eq!(observed.bytes, bytes); assert_eq!( @@ -552,10 +619,12 @@ mod tests { ); } - /// A successful transfer may still fail validation. Neither a refusal nor a - /// malformed progress report can be mistaken for completed processing. + /// Neither a failed stage nor a failed or malformed processing report + /// passes as a completed upload. #[test] fn test_failures() { + // Each failed stage fails the upload and requests cancellation of any + // session it opened let clock = test_clock().clock(); for fail in ["identify", "peek", "start", "chunk", "process"] { let bytes = vec![42; IDENTIFY_SIZE + 2 * CHUNK_SIZE + 1]; @@ -594,6 +663,8 @@ mod tests { assert!(!observed.stages.contains(&"process")); } } + + // A failed or malformed processing report fails and cancels the upload for report in [ schema::SlotUploadProcessResponse { failure: "invalid genome".into(), @@ -618,10 +689,11 @@ mod tests { } } - /// References bypass identification and retain their advertised kind. A - /// bad length or hash prevents processing, for both small and large files. + /// References skip identification and keep their kind, while a bad length + /// or hash stops processing in small and large files alike. #[test] fn test_integrity() { + // Upload each size's original and damaged copies as references let clock = test_clock().clock(); for size in [17, IDENTIFY_SIZE + CHUNK_SIZE + 17] { let original = vec![42; size]; @@ -645,6 +717,10 @@ mod tests { clock.now() + TIMEOUT, |_| {}, ); + + // References skip identification, and only the original is + // processed, a large damaged copy requesting its open session's + // cancellation let observed = observed.lock().unwrap(); assert!(!observed.stages.contains(&"peek")); assert_eq!(result.is_ok(), change == "none"); @@ -659,10 +735,12 @@ mod tests { } } - /// Two chunks may be queued, but a third cannot precede their acknowledgement. - /// Responses arriving in reverse order must not advance the source early. + /// The source is read no further than two outstanding chunks, even while + /// their acknowledgments arrive reversed. #[test] fn test_transfer_window() { + // The peer answers the second chunk first, holding the first chunk's + // acknowledgment until the test releases it let clock = test_clock().clock(); let (notice, notices) = mpsc::channel(); let (release, released) = mpsc::channel(); @@ -704,15 +782,22 @@ mod tests { true }), ); + + // Upload through a source counting what the uploader read let session = attach(&mut peer); let bytes = vec![42; IDENTIFY_SIZE + 3 * CHUNK_SIZE]; let source = source(bytes.len(), Some(Sha256::digest(&bytes).into())); let read = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + /// Source counting the bytes read from it. struct Counting<'a> { + /// Bytes left to serve. bytes: &'a [u8], + /// Bytes read so far, shared with the test. count: Arc, } impl Read for Counting<'_> { + /// Reads from the source and adds the byte count to the shared + /// total. fn read(&mut self, buf: &mut [u8]) -> io::Result { let count = self.bytes.read(buf)?; self.count @@ -735,6 +820,8 @@ mod tests { |_| {}, ) }); + + // With two chunks outstanding, the source is read no further notices.recv().unwrap(); assert_eq!( read.load(std::sync::atomic::Ordering::SeqCst), @@ -744,14 +831,19 @@ mod tests { worker.join().unwrap().unwrap(); } - /// Caller-owned readers may yield short reads or interrupted syscalls. + /// Short reads and interrupted calls of a caller's reader still upload the + /// whole source, and a timed out read is a timeout. #[test] fn test_reader_errors() { + /// Source serving one byte per read, interrupting every other call. struct Fragmented { + /// Read calls made so far. calls: usize, + /// Bytes left to serve. bytes: &'static [u8], } impl Read for Fragmented { + /// Interrupts every odd call and serves a single byte on the others. fn read(&mut self, buf: &mut [u8]) -> io::Result { self.calls += 1; if self.calls % 2 == 1 { @@ -761,6 +853,8 @@ mod tests { self.bytes.read(&mut buf[..size]) } } + + // A fragmenting and interrupting reader still delivers the whole source let clock = test_clock().clock(); let (mut peer, _) = peer(&clock, None, vec![]); let session = attach(&mut peer); @@ -776,17 +870,21 @@ mod tests { |_| {}, ) .unwrap(); + + // A timed out read maps to a timeout assert!(matches!( read_error(io::ErrorKind::TimedOut.into()), Error::Timeout )); } - /// The public helper establishes cloud setup once across repeated uploads. - /// It does not eagerly attach a relay when the Ark needs no approval. + /// Repeated uploads through a client sync with the cloud once, attaching no + /// relay the Ark does not ask for. #[test] fn test_client_setup() { use crate::cloud::tests::{response, serve}; + + // Upload twice through a client of a cloud-routed connection let clock = test_clock().clock(); let (url, requests) = serve(vec![ response(200, r#"{"signer":"AQ==","crypto":"Ag=="}"#), @@ -804,6 +902,8 @@ mod tests { ) .unwrap(); } + + // Only the first upload synced, before identifying the dataset let observed = observed.lock().unwrap(); assert_eq!( observed @@ -825,6 +925,7 @@ mod tests { .contains("/cloudsync/time?challenge=03") ); } + /// The processing operation may outlive one wait allowance, even when /// successive replies report the same progress percentage. #[test] diff --git a/connect/src/device.rs b/connect/src/device.rs index 437a309..8e31db3 100644 --- a/connect/src/device.rs +++ b/connect/src/device.rs @@ -5,6 +5,7 @@ // license that can be found in the LICENSE file. //! Discovered Arks, their reported details and authenticated connections. +//! //! Discovery metadata is unverified. Connecting establishes the peer's identity. use crate::emulator::Instance; @@ -13,8 +14,10 @@ use crate::{Ark, Error, Identity, TrustMode, emulator, hardware}; use darkbio_clock::Clock; use std::fmt; -/// Kind of Ark reported by discovery. Authentication establishes its identity -/// separately; this classification does not verify the peer's realm. +/// Kind of Ark reported by discovery. +/// +/// Authentication establishes its identity separately; this classification +/// does not verify the peer's realm. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum DeviceKind { /// Physical Ark attached to the host. @@ -54,10 +57,12 @@ impl fmt::Display for Locator { } /// Discovered Ark with the details needed to connect to it. +/// /// Reported metadata remains unverified; connecting returns a separate identity. #[derive(Clone)] pub struct Device { - source: Source, // Discovery record retained for connection and display + /// Discovery record retained for connection and display. + source: Source, } /// Origin of the discovery record, independent of the Ark's authenticated realm. @@ -105,8 +110,10 @@ impl Device { } } - /// Returns the readiness last reported by an emulator launcher. Hardware - /// and launchers omitting readiness return `None`. A report may be stale. + /// Returns the readiness last reported by an emulator launcher. + /// + /// Hardware and launchers omitting readiness return `None`. A report may be + /// stale. pub fn ready(&self) -> Option { match &self.source { Source::Usb(_) => None, @@ -115,6 +122,7 @@ impl Device { } /// Returns the unverified serial from USB enumeration or the launcher. + /// /// Empty serials are treated as absent. pub fn serial(&self) -> Option<&str> { match &self.source { @@ -133,8 +141,10 @@ impl Device { .filter(|value| !value.is_empty()) } - /// Returns the emulator image basename, if reported. Several emulators may - /// use the same basename, so it does not identify an endpoint uniquely. + /// Returns the emulator image basename, if reported. + /// + /// Several emulators may use the same basename, so it does not identify an + /// endpoint uniquely. pub fn image(&self) -> Option<&str> { match &self.source { Source::Usb(_) => None, @@ -153,18 +163,23 @@ impl Device { } /// Connects using the retained endpoint details and authenticates the peer - /// with the supplied verifier. Does not repeat discovery or label selection. - /// The verifier's identity selects cloud routing for later operations; - /// connecting itself does not contact the cloud. + /// with the supplied verifier. + /// + /// It does not repeat discovery or label selection. The verifier's + /// identity selects cloud routing for later operations; connecting itself + /// does not contact the cloud. pub fn connect(&self, verifier: &TrustMode) -> Result<(Ark, Identity), Error> { self.open(verifier, |_| None) } - /// Selects the cloud environment from the authenticated identity without - /// reopening the connection. The callback runs once after a successful - /// handshake, before cloud services start. Cloud access stays lazy. - /// Self-signed and recovery peers use the discovered kind to select a registry; - /// attested peers retain their verified realm. Routing never changes trust. + /// Connects and selects the cloud environment from the authenticated + /// identity, without reopening the connection. + /// + /// The callback runs once after a successful handshake, before cloud + /// services start, and its environment takes precedence over an attested + /// one. Cloud access stays lazy. Self-signed and recovery peers use the + /// discovered kind to select a registry; attested peers keep their verified + /// realm. Routing never changes trust. pub fn connect_with_env( &self, verifier: &TrustMode, @@ -173,8 +188,9 @@ impl Device { self.open(verifier, |identity| Some(env(identity))) } - /// Opens the retained transport with any caller-supplied cloud route. The - /// connection runs on the real clock, which its clients hand to callers. + /// Opens the retained transport with any caller-supplied cloud route. + /// + /// The connection runs on the real clock, which its clients hand to callers. fn open( &self, verifier: &TrustMode, diff --git a/connect/src/discovery.rs b/connect/src/discovery.rs index ccc42bb..8d1457b 100644 --- a/connect/src/discovery.rs +++ b/connect/src/discovery.rs @@ -26,14 +26,16 @@ impl Discovery { } } - /// Selects an endpoint by locator or by a unique serial, name or image basename. - /// Without a selector, requires exactly one device. The `hardware:` and + /// Selects an endpoint by locator or by a unique serial, name or image + /// basename. + /// + /// Without a selector, it requires exactly one device. The `hardware:` and /// `emulator:` prefixes are reserved for locators; a missing locator never - /// falls back to a device name. Display formatting does not determine selection. - /// The bare names `hardware` and `emulator` require one device of that kind. - /// Label matches are exact and case-sensitive. + /// falls back to a device name. Display formatting does not determine + /// selection. The bare names `hardware` and `emulator` require one device of + /// that kind. Label matches are exact and case-sensitive. pub fn select(&self, selector: Option<&str>) -> Result<&Device, Error> { - // A reported name must not shadow a locator, including an absent one. + // A reported name must not shadow a locator, including an absent one if let Some(selector) = selector && (selector.starts_with("hardware:") || selector.starts_with("emulator:")) { @@ -43,7 +45,8 @@ impl Discovery { .find(|device| device.locator().to_string() == selector) .ok_or_else(|| Error::NoMatch(selector.into())); } - // Descriptive labels may be shared. Retain every match for ambiguity errors. + + // Descriptive labels may be shared. Keep every match for ambiguity errors. let matches: Vec<_> = self .devices .iter() @@ -71,8 +74,9 @@ impl Discovery { } } -/// Lists hardware and emulators. A discovery failure for one kind does not hide -/// devices returned by the other. +/// Lists hardware and emulators. +/// +/// A discovery failure for one kind does not hide devices returned by the other. pub fn list() -> Discovery { let mut found = Discovery::default(); found.extend(hardware::list()); @@ -98,10 +102,11 @@ mod tests { }) } - /// Duplicate labels require explicit locators. Reported names cannot shadow - /// locators, including an endpoint that has disappeared. + /// Duplicate labels require explicit locators, and reported names never + /// shadow a locator, even of an endpoint that has disappeared. #[test] fn test_selection() { + // Two emulators sharing an image basename need their locators let mut found = Discovery::default(); found.extend(Ok(vec![device(18181, None), device(18182, None)])); assert_eq!(found.devices[0].to_string(), found.devices[1].to_string()); @@ -113,11 +118,16 @@ mod tests { found.select(Some("emulator:18182")).unwrap().locator(), Locator::Emulator { port: 18182 } ); + + // A name spelling another endpoint's locator does not shadow it found.devices[0] = device(18181, Some("emulator:18182")); assert_eq!( found.select(Some("emulator:18182")).unwrap().locator(), Locator::Emulator { port: 18182 } ); + + // A locator with no endpoint behind it matches no reported name, for + // emulators and hardware alike found.devices.pop(); assert!(matches!( found.select(Some("emulator:18182")), diff --git a/connect/src/emulator/mod.rs b/connect/src/emulator/mod.rs index 8c3cf01..bdc4dd7 100644 --- a/connect/src/emulator/mod.rs +++ b/connect/src/emulator/mod.rs @@ -5,6 +5,7 @@ // license that can be found in the LICENSE file. //! Discovery of emulated Arks running on the host. +//! //! Connect through [`Device::connect`] to authenticate a discovered Ark. mod registry; @@ -16,6 +17,7 @@ pub(crate) use ws::connect; use crate::{Device, Error}; /// Lists emulated Arks published by local launchers, without connecting to them. +/// /// An absent launcher registry returns an empty list. pub fn list() -> Result, Error> { Ok(registry::list()? diff --git a/connect/src/emulator/registry.rs b/connect/src/emulator/registry.rs index 9fb4251..1519a7d 100644 --- a/connect/src/emulator/registry.rs +++ b/connect/src/emulator/registry.rs @@ -21,6 +21,7 @@ use std::time::Duration; const ADDRESS: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 18180); /// Overall timeout for fetching a listing from the local registry. +/// /// Windows retries refused loopback connections before reporting the error. const TIMEOUT: Duration = Duration::from_secs(if cfg!(windows) { 5 } else { 1 }); @@ -28,20 +29,27 @@ const TIMEOUT: Duration = Duration::from_secs(if cfg!(windows) { 5 } else { 1 }) const MAX_LISTING: u64 = 1024 * 1024; /// Emulator endpoint and metadata published by its launcher. +/// /// Optional reports remain absent until the launcher supplies them. #[derive(Clone, Debug, Deserialize)] pub(crate) struct Instance { - pub port: u16, // Host port forwarded to the guest's WebSocket endpoint + /// Host port forwarded to the guest's WebSocket endpoint. + pub port: u16, + /// File name of the disk image, which several emulators may share. #[serde(default)] - pub disk: String, // Image basename, shared by copies of the same image + pub disk: String, + /// Readiness to accept clients, as the firmware reports it. #[serde(default)] - pub ready: Option, // Whether the firmware reports accepting clients + pub ready: Option, + /// Cloud environment the device reports. #[serde(default)] - pub env: Option, // Environment reported by the device + pub env: Option, + /// Device name, if reported. #[serde(default)] - pub name: Option, // Device name, if reported + pub name: Option, + /// Device serial, if reported. #[serde(default)] - pub serial: Option, // Device serial, if reported + pub serial: Option, } impl Instance { @@ -51,32 +59,43 @@ impl Instance { } } -/// The listing version this build understands. A breaking change to the -/// registry bumps it, so a newer registry is refused rather than misread. +/// Listing version this build understands. +/// +/// A breaking change to the registry bumps it, so a newer registry is refused +/// rather than misread. const VERSION: u64 = 1; /// Registry response with entries retained for individual decoding. #[derive(Deserialize)] struct Listing { + /// Schema version of the listing, absent when the body carries none. #[serde(default)] - version: Option, // Absent on a registry older than the contract + version: Option, + /// Raw entries, decoded one by one so an unreadable entry drops alone. #[serde(default)] - instances: Vec, // Entries decoded independently for compatibility + instances: Vec, } -/// Lists emulators from the local registry. An absent registry returns an empty list. +/// Lists emulators from the local registry. +/// +/// An absent registry returns an empty list. pub(super) fn list() -> Result, Error> { list_at(ADDRESS.into()) } -/// Fetches a registry at the supplied address. Only connection refusal is treated -/// as an empty listing; transport, HTTP and decoding failures remain errors. +/// Fetches a registry at the supplied address. +/// +/// Only connection refusal is treated as an empty listing; transport, HTTP and +/// decoding failures remain errors. fn list_at(addr: SocketAddr) -> Result, Error> { + // Nobody listening means no registry is running let body = match fetch(addr) { Ok(body) => body, Err(err) if err.kind() == io::ErrorKind::ConnectionRefused => return Ok(Vec::new()), Err(err) => return Err(Error::Registry(err)), }; + + // Decode the listing, refusing a version this build does not know let listing: Listing = serde_json::from_slice(&body) .map_err(|err| Error::Registry(io::Error::new(io::ErrorKind::InvalidData, err)))?; if listing.version != Some(VERSION) { @@ -90,7 +109,7 @@ fn list_at(addr: SocketAddr) -> Result, Error> { } // Skip entries this build cannot decode without losing compatible entries - // from the same registry response. + // from the same registry response Ok(listing .instances .into_iter() @@ -100,6 +119,7 @@ fn list_at(addr: SocketAddr) -> Result, Error> { /// Fetches a successful HTTP response under the registry's deadline and size limit. fn fetch(addr: SocketAddr) -> io::Result> { + // Ask the loopback registry directly, never through a proxy or redirect let agent: ureq::Agent = ureq::Agent::config_builder() .proxy(None) .max_redirects(0) @@ -114,13 +134,16 @@ fn fetch(addr: SocketAddr) -> io::Result> { ureq::Error::Io(err) => err, err => io::Error::other(err), })?; + + // Only a successful status carries a listing if !response.status().is_success() { return Err(io::Error::other(format!( "listing refused with status {}", response.status() ))); } - // One extra byte distinguishes a complete body from a truncated oversized one. + + // One extra byte distinguishes a complete body from a truncated oversized one let mut body = Vec::new(); response .body_mut() @@ -136,6 +159,8 @@ fn fetch(addr: SocketAddr) -> io::Result> { Ok(body) } +/// Registry listing, framing, size limit and refusal regressions against local +/// listeners. #[cfg(test)] mod tests { use super::*; @@ -151,8 +176,10 @@ mod tests { (listener, addr) } - /// Serves one HTTP request with the supplied response. Reads the complete - /// request first so the client does not write into a closed socket. + /// Serves one HTTP request with the supplied response, on its own thread. + /// + /// It reads the complete request first so the client does not write into a + /// closed socket. fn serve(listener: TcpListener, response: impl Into) { let response = response.into(); thread::spawn(move || { @@ -169,9 +196,8 @@ mod tests { }); } - // Tests that a listing is read leniently, the claims present taken, the - // absent ones left out, and the entries this build cannot make sense of - // dropped rather than failing the listing. + /// A listing is read leniently, taking the claims present, leaving absent + /// ones out and dropping entries this build cannot read. #[test] fn test_listing() { let (listener, addr) = bind(); @@ -198,8 +224,7 @@ mod tests { assert_eq!(instances[1].name, None); } - // Tests that nobody serving the listing is an empty one rather than a - // failure. + /// Nobody serving the listing yields an empty one rather than a failure. #[test] fn test_nobody_serving() { let (listener, addr) = bind(); @@ -238,8 +263,8 @@ mod tests { ); } - // Tests that a listing of a version this build does not know, or of no - // version at all, is refused rather than read. + /// A listing of an unknown version, or of none at all, is refused rather + /// than read. #[test] fn test_unknown_version_is_refused() { for body in [ @@ -255,18 +280,21 @@ mod tests { } } - // Tests that a service answering with anything but a listing fails the - // lookup, a refusal, a body of another shape or no answer at all. + /// A refusing status, a body of another shape or no answer at all fails + /// the lookup. #[test] fn test_refusals() { + // A refusing status fails the lookup let (listener, addr) = bind(); serve(listener, "HTTP/1.1 404 Not Found\r\n\r\nno such route"); assert!(matches!(list_at(addr), Err(Error::Registry(_)))); + // A body of another shape fails decoding let (listener, addr) = bind(); serve(listener, "HTTP/1.1 200 OK\r\n\r\nnot a listing"); assert!(matches!(list_at(addr), Err(Error::Registry(_)))); + // A service that never answers runs out the registry's timeout let (listener, addr) = bind(); let (release, held) = mpsc::channel::<()>(); let stalled = thread::spawn(move || { diff --git a/connect/src/emulator/ws.rs b/connect/src/emulator/ws.rs index 1800655..7cd26a4 100644 --- a/connect/src/emulator/ws.rs +++ b/connect/src/emulator/ws.rs @@ -42,16 +42,17 @@ const MAX_MESSAGE: usize = transport::MAX_FRAME_SIZE + 2; const INBOUND_LIMIT: usize = 2 * MAX_MESSAGE; /// Opens a plain WebSocket endpoint and authenticates its wire session. +/// /// After address resolution, TCP establishment and HTTP upgrade share the -/// handshake timeout. The encrypted wire handshake starts its own timeout. -/// The connection measures its deadlines on the clock. +/// handshake timeout. The encrypted wire handshake starts its own timeout. The +/// connection measures its deadlines on the clock. pub(crate) fn connect>( url: &str, verifier: &V, cloud: impl FnOnce(&crate::Identity) -> Option<(crate::trust::Environment, crate::trust::Realm)>, clock: &Clock, ) -> Result<(Ark, V::Info), Error> { - // Resolve the endpoint before starting the TCP and HTTP handshake budget. + // Resolve the endpoint before starting the TCP and HTTP handshake budget let request = url.into_client_request().map_err(Error::Upgrade)?; let uri = request.uri(); if uri.scheme_str() != Some("ws") { @@ -67,7 +68,8 @@ pub(crate) fn connect>( let addresses = (host, uri.port_u16().unwrap_or(80)) .to_socket_addrs() .map_err(Error::Unreachable)?; - // Failed address attempts consume the same budget as the eventual upgrade. + + // Failed address attempts consume the same budget as the eventual upgrade let deadline = clock.now() + transport::DEFAULT_HANDSHAKE_TIMEOUT; let mut last_error = io::Error::new( io::ErrorKind::AddrNotAvailable, @@ -86,7 +88,10 @@ pub(crate) fn connect>( } let tcp = connected.ok_or(Error::Unreachable(last_error))?; tcp.set_nodelay(true).map_err(Error::Unreachable)?; - // Bound both protocol buffers before accepting peer WebSocket traffic. + + // Bound both protocol buffers before accepting peer WebSocket traffic. An + // expired socket timeout typically reads as WouldBlock on Unix and as + // TimedOut on Windows, so both end the upgrade as a timeout. let config = WebSocketConfig::default() .write_buffer_size(0) .max_write_buffer_size(2 * MAX_MESSAGE) @@ -110,7 +115,8 @@ pub(crate) fn connect>( } HandshakeError::Failure(err) => Error::Upgrade(err), })?; - // Transfer the upgraded socket to its worker and give wire blocking adapters. + + // Transfer the upgraded socket to its worker and give wire blocking adapters let (reader, writer, shutdown) = adapters(socket, clock).map_err(Error::Unreachable)?; Ark::attach( transport::Stream::new(reader, writer, shutdown), @@ -120,7 +126,8 @@ pub(crate) fn connect>( } /// Socket used during the blocking HTTP upgrade and subsequent nonblocking I/O. -/// Replacing its adapter retains bytes already buffered by the WebSocket. +/// +/// Replacing its adapter keeps the bytes already buffered by the WebSocket. enum Socket { /// TCP stream whose individual I/O calls share the upgrade deadline. Handshake { @@ -190,7 +197,8 @@ fn socket_error(err: tungstenite::Error) -> io::Error { } } -/// Whether the WebSocket needs a readiness event before it can make progress. +/// Checks whether the WebSocket needs a readiness event before it can make +/// progress. fn would_block(err: &tungstenite::Error) -> bool { matches!(err, tungstenite::Error::Io(err) if err.kind() == io::ErrorKind::WouldBlock) } @@ -198,20 +206,30 @@ fn would_block(err: &tungstenite::Error) -> bool { /// Binary input waiting for the reader, followed by the worker's ending result. #[derive(Default)] struct Incoming { - chunks: VecDeque, // Messages retained until the reader consumes them - bytes: usize, // Payload bytes still charged against the input limit - ended: bool, // Whether the worker has stopped producing input - error: Option, // Ending failure, returned after buffered input + /// Binary messages kept until the reader consumes them. + chunks: VecDeque, + /// Payload bytes still charged against the input limit. + bytes: usize, + /// Flag set once the worker stops producing input. + ended: bool, + /// Failure that ended the worker, returned after the buffered input. + error: Option, } /// Input and closure state shared by the worker and its blocking adapters. struct Shared { - clock: Clock, // clock that wire's deadlines are measured on - closed: AtomicBool, // Local shutdown signal checked by every participant - incoming: sync::Mutex, // Buffered input and the worker's ending result - available: sync::Condvar, // Wakes the reader for input, failure or closure - wake: Waker, // Wakes the worker when an adapter changes its work - /// Notifies a test each time the flush of a writer's frame waits for the socket. + /// Clock that wire's deadlines are measured on. + clock: Clock, + /// Local shutdown signal that every participant checks. + closed: AtomicBool, + /// Buffered input and the worker's ending result. + incoming: sync::Mutex, + /// Condition that wakes the reader for input, failure or closure. + available: sync::Condvar, + /// Waker of the worker, for when an adapter changes its work. + wake: Waker, + /// Channel notifying a test each time the flush of a writer's frame waits + /// for the socket. #[cfg(test)] blocked: std::sync::Mutex>>, } @@ -219,7 +237,7 @@ struct Shared { impl Shared { /// Marks local closure and wakes both the reader and the socket worker. fn close(&self) { - // Pair the condition change with the reader's lock to prevent a lost wake. + // Pair the condition change with the reader's lock to prevent a lost wake let _incoming = self.incoming.lock().expect("WebSocket input not poisoned"); self.closed.store(true, Ordering::Release); self.available.notify_all(); @@ -234,14 +252,18 @@ impl Shared { self.available.notify_all(); } - /// Reserves room for a complete message before the worker reads another one. + /// Checks that a complete message still fits before the worker reads + /// another one. + /// /// The chunk limit also bounds overhead from many small binary messages. fn can_read(&self) -> bool { let incoming = self.incoming.lock().expect("WebSocket input not poisoned"); incoming.bytes <= INBOUND_LIMIT - MAX_MESSAGE && incoming.chunks.len() < 1024 } - /// Queues a binary message and wakes the reader. Empty messages carry no bytes. + /// Queues a binary message and wakes the reader. + /// + /// An empty message is dropped, since it carries no bytes. fn push(&self, bytes: Bytes) { if bytes.is_empty() { return; @@ -263,19 +285,23 @@ impl Shared { /// One flush submitted by the writer, acknowledged when the worker finishes it. struct Outgoing { - bytes: Vec, // Complete frame accumulated since the previous flush - deadline: Instant, // Original write deadline, including the queue wait - /// Receives the local write result without blocking the socket worker. + /// Complete frame accumulated since the previous flush. + bytes: Vec, + /// Original write deadline, which includes the wait in the queue. + deadline: Instant, + /// Sender of the local write result, which never blocks the socket worker. done: crossbeam_channel::Sender>, } /// Starts the socket worker and returns adapters with their shutdown operation. +/// /// The returned shutdown wakes blocking I/O and closes the underlying socket. /// The adapters measure wire's deadlines on the clock. fn adapters( mut socket: WebSocket, clock: &Clock, ) -> io::Result<(Reader, Writer, impl FnOnce() + Send + use<>)> { + // Switch the upgraded stream to nonblocking I/O registered with a poll let Socket::Handshake { stream, .. } = socket.get_ref() else { unreachable!("socket upgraded once") }; @@ -290,8 +316,10 @@ fn adapters( SOCKET, Interest::READABLE | Interest::WRITABLE, )?; - // Reads and writes must use mio's socket, which rearms readiness on Windows. + // Reads and writes must use mio's socket, which rearms readiness on Windows *socket.get_mut() = Socket::Connected(connected); + + // Run the socket worker, handing its adapters to wire let shared = Arc::new(Shared { clock: clock.clone(), closed: AtomicBool::new(false), @@ -324,9 +352,11 @@ fn adapters( )) } -/// Bounds output the socket has not taken yet, the worker's own control -/// replies included. A deferred flush starts the bound, later ones keep its -/// deadline, and only a completed flush ends it. +/// Bound on output the socket has not taken yet, the worker's own control +/// replies included. +/// +/// A deferred flush starts the bound, later ones keep its deadline, and only a +/// completed flush ends it. #[derive(Debug, Default)] struct Backlog { /// Time the pending output must be flushed by, while there is some. @@ -346,12 +376,13 @@ impl Backlog { self.deadline = None; } - /// Whether the output left to flush missed its deadline by `now`. + /// Checks whether the output left to flush missed its deadline by `now`. fn expired(&self, now: Instant) -> bool { self.deadline.is_some_and(|deadline| now >= deadline) } - /// Time left after `now` before the deadline, bounding the next poll. + /// Returns the time left after `now` before the deadline, bounding the + /// next poll. fn remaining(&self, now: Instant) -> Option { self.deadline .map(|deadline| deadline.saturating_duration_since(now)) @@ -359,9 +390,10 @@ impl Backlog { } /// Drives the WebSocket while preserving input progress during blocked output. -/// One adapter writer submits flushes serially and waits for each acknowledgement. -/// Frame deadlines are wire's and measured on the connection's clock; control -/// replies are bounded on real time. +/// +/// One adapter writer submits flushes serially and waits for each +/// acknowledgment. Frame deadlines are wire's and measured on the connection's +/// clock; control replies are bounded on real time. #[expect( clippy::disallowed_methods, reason = "the worker waits on the socket through mio, so the bound on its own control replies runs on real time" @@ -379,6 +411,8 @@ fn pump( let mut peer_closed = false; let result = (|| -> io::Result<()> { loop { + // Stop on local closure, and fail once a frame or the backlog runs + // out of time if shared.closed.load(Ordering::Acquire) { return Ok(()); } @@ -388,6 +422,7 @@ fn pump( if backlog.expired(Instant::now()) { return Err(io::Error::from(io::ErrorKind::TimedOut)); } + // Admit at most one flush. Its bytes stay in the WebSocket until // output completes, while the original deadline continues to run. if active.is_none() && !peer_closed { @@ -412,7 +447,9 @@ fn pump( Err(mpsc::TryRecvError::Empty) => {} } } - // Limit each batch so continuous ingress cannot starve sends or deadlines. + + // Limit each batch so continuous ingress cannot starve sends or + // deadlines let mut batch_full = false; for index in 0..32 { if peer_closed || !shared.can_read() { @@ -431,8 +468,9 @@ fn pump( } batch_full = index == 31; } + // Flush data and automatic Pong/Close replies through the same owner. - // An acknowledgement covers everything queued before this flush. + // An acknowledgment covers everything queued before this flush. match socket.flush() { Ok(()) => { backlog.flushed(); @@ -456,6 +494,7 @@ fn pump( if batch_full { continue; } + // Either socket readiness or an adapter wake may enable more work. // Deadlines must also wake an otherwise idle or blocked connection. let timeout = active @@ -471,7 +510,9 @@ fn pump( } } })(); - // Settle the outstanding flush before waking the reader with the ending result. + + // Settle the outstanding flush before waking the reader with the ending + // result if let Some(frame) = active { let error = result .as_ref() @@ -487,12 +528,16 @@ fn pump( /// Blocking reader over buffered binary input from the socket worker. struct Reader { - shared: Arc, // Input queue and closure notifications - deadline: Option, // Deadline bounding the next wait for input + /// Input queue and closure notifications shared with the worker. + shared: Arc, + /// Deadline bounding the next wait for input. + deadline: Option, } impl Read for Reader { - /// Drains buffered binary bytes before reporting closure or the worker error. + /// Drains buffered binary bytes before reporting closure or the worker + /// error. + /// /// Consuming input wakes the worker to resume reads after backpressure. fn read(&mut self, buf: &mut [u8]) -> io::Result { if buf.is_empty() { @@ -555,10 +600,14 @@ impl transport::Read for Reader { /// Blocking writer whose flush waits for the socket worker's completion result. struct Writer { - shared: Arc, // Closure signal and worker wakeup - outgoing: mpsc::Sender, // Frames waiting for the socket worker - pending: Vec, // Bytes accumulated since the last flush - deadline: Option, // One budget for writes and their flush + /// Closure signal and worker wakeup shared with the worker. + shared: Arc, + /// Queue of frames waiting for the socket worker. + outgoing: mpsc::Sender, + /// Bytes accumulated since the last flush. + pending: Vec, + /// Deadline shared by the writes of a frame and their flush. + deadline: Option, } impl Write for Writer { @@ -575,7 +624,9 @@ impl Write for Writer { } /// Submits accumulated bytes and waits for local socket completion under the - /// original write deadline. Success does not acknowledge receipt by the Ark. + /// original write deadline. + /// + /// Success does not acknowledge receipt by the Ark. fn flush(&mut self) -> io::Result<()> { if self.shared.closed.load(Ordering::Acquire) { return Err(closed()); @@ -588,6 +639,8 @@ impl Write for Writer { if self.pending.is_empty() { return Ok(()); } + + // Hand the frame to the worker and wait for its local write result let (done, result) = crossbeam_channel::bounded(1); self.outgoing .send(Outgoing { @@ -631,10 +684,13 @@ mod tests { use std::thread; use tungstenite::protocol::Role; - /// Exercises the actual adapters without a wire session consuming their bytes. + /// Connects a client socket to a local WebSocket server, for exercising the + /// adapters without a wire session consuming their bytes. + /// /// The upgrade's socket timeouts are measured from the clock, which the /// tests never advance through them. fn socket_pair(clock: &Clock) -> (WebSocket, WebSocket) { + // Serve one WebSocket upgrade on a local listener let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); let server = thread::spawn(move || { @@ -643,6 +699,8 @@ mod tests { tcp.set_write_timeout(Some(Duration::from_secs(2))).unwrap(); tungstenite::accept(tcp).unwrap() }); + + // Upgrade the client over the socket type the adapters take over let tcp = TcpStream::connect(addr).unwrap(); let (client, _) = tungstenite::client( format!("ws://{addr}/v1/usb"), @@ -659,6 +717,7 @@ mod tests { /// Ping and Close receive protocol replies while the application is idle. #[test] fn test_control_frames() { + // A ping gets its pong while the application reads nothing let clock = test_clock().clock(); let (socket, mut server) = socket_pair(&clock); let (mut reader, _writer, shutdown) = adapters(socket, &clock).unwrap(); @@ -669,6 +728,8 @@ mod tests { server.read().unwrap(), Message::Pong(Bytes::from_static(b"probe")) ); + + // A close gets its reply and ends the reader's stream server.close(None).unwrap(); assert!(matches!(server.read().unwrap(), Message::Close(_))); assert_eq!(reader.read(&mut [0]).unwrap(), 0); @@ -714,6 +775,8 @@ mod tests { /// Consuming buffered input releases capacity for the remaining messages. #[test] fn test_input_buffering() { + // The peer sends four of the largest messages, twice what the input + // limit holds let clock = test_clock().clock(); let (socket, mut server) = socket_pair(&clock); let (mut reader, _writer, shutdown) = adapters(socket, &clock).unwrap(); @@ -724,6 +787,8 @@ mod tests { } server }); + + // Reading each message frees room for the rest, never passing the limit let mut message = vec![0; MAX_MESSAGE]; for _ in 0..4 { reader.read_exact(&mut message).unwrap(); @@ -734,7 +799,8 @@ mod tests { shutdown(); } - /// Blocked output retains its deadline while incoming traffic still reaches the reader. + /// Blocked output keeps its deadline while incoming traffic still reaches + /// the reader. #[test] fn test_output_backpressure() { // Flood the peer with output it never drains, under one write deadline, @@ -779,9 +845,8 @@ mod tests { shutdown(); } - /// A blocked flush starts the output bound once. Later blocked flushes keep - /// the original deadline, where the bound expires, and a completed flush - /// clears it for the next blockage. + /// A blocked flush starts the output bound once, later ones keep its + /// deadline, and a completed flush clears it for the next blockage. #[test] fn test_backlog() { // The first blocked flush starts the bound @@ -814,11 +879,14 @@ mod tests { assert_eq!(backlog.remaining(clock.now()), Some(limit)); } - // Serves one WebSocket client on the listener the way an emulator does, - // carrying its binary messages into the peer's stream and the stream's - // bytes back out as messages, until either side ends. Each direction has - // its own thread and socket handle and blocks on its own input. + /// Bridges one WebSocket client on the listener to the peer's stream, + /// standing in for an emulator. + /// + /// Binary messages carry the bytes both ways until either side ends. Each + /// direction has its own thread and socket handle and blocks on its own + /// input. fn bridge(listener: TcpListener, stream: Duplex) { + // Split the peer's stream and accept the client's upgrade let (mut reader, mut writer) = stream.into_halves(); let (tcp, _) = listener.accept().unwrap(); let mut socket = tungstenite::accept(tcp).unwrap(); @@ -862,6 +930,8 @@ mod tests { Ok(_) => {} } } + + // Close the peer's input, then check that the client answered the probe drop(writer); sending.join().unwrap(); assert!( @@ -870,10 +940,11 @@ mod tests { ); } - // Tests that a session over an emulator's socket reaches the peer behind - // it, the requests answered and the close ending the socket. + /// A session over an emulator's socket reaches the peer behind it, and + /// closing the session ends the socket. #[test] fn test_socket_session() { + // Bridge a local WebSocket listener to an answering peer let clock = test_clock().clock(); let mut peer = Peer::spawn(&clock, Box::new(answering)); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -881,6 +952,7 @@ mod tests { let stream = peer.stream(); let served = thread::spawn(move || bridge(listener, stream)); + // A request crosses the socket, and closing the session ends the bridge let (ark, _) = connect( &url, &crate::TrustMode::Recover(Box::new(peer.identity.clone())), @@ -899,11 +971,11 @@ mod tests { served.join().unwrap(); } - // Tests that the emulator going away ends the session with the reason, - // the reading object reporting the end of the stream once the socket - // behind it is gone. + /// The emulator going away ends the session as a disconnect once the socket + /// behind it is gone. #[test] fn test_socket_lost() { + // Bridge a local WebSocket listener to a peer that hangs up let clock = test_clock().clock(); let mut peer = Peer::spawn(&clock, hangup()); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -911,6 +983,7 @@ mod tests { let stream = peer.stream(); let served = thread::spawn(move || bridge(listener, stream)); + // The request fails as a disconnect, and the bridge ends let (ark, _) = connect( &url, &crate::TrustMode::Recover(Box::new(peer.identity.clone())), diff --git a/connect/src/execution.rs b/connect/src/execution.rs index 763f747..820ae0b 100644 --- a/connect/src/execution.rs +++ b/connect/src/execution.rs @@ -12,12 +12,16 @@ use darkbio_wire::protocol::{Message, Promise, Requester}; use std::io::{self, Read}; use std::time::Duration; -const CHUNK_SIZE: usize = 2 * 1024 * 1024 - 32 * 1024; // Leave room for sealing and framing +/// Largest upload chunk, 32 KiB short of a 2 MiB frame to leave room for +/// sealing and framing. +const CHUNK_SIZE: usize = 2 * 1024 * 1024 - 32 * 1024; /// Delay between status requests while the Ark retains a pending task. const POLL_INTERVAL: Duration = Duration::from_millis(500); -/// Execution stages reported on the caller's thread. A task ID identifies -/// both the upload and its eventual execution, including cancellation. +/// Execution stages reported on the caller's thread. +/// +/// A task ID identifies both the upload and its eventual execution, including +/// cancellation. #[derive(Clone, Debug, PartialEq, Eq)] pub enum ExecutionProgress { /// Allocating an app upload on the Ark. @@ -36,16 +40,21 @@ pub enum ExecutionProgress { }, /// Requesting companion approval to run the uploaded app. Authorizing, - /// The app is running. Elapsed time starts at the scheduling acknowledgement. + /// The app is running, its elapsed time counted from the scheduling + /// acknowledgment. Running { /// Host time since scheduling succeeded, including status polling waits. elapsed: Duration, }, } -/// Streams at most two outstanding chunks, waits for authorization and retrieves -/// the result once. Scheduling establishes the relay through the caller's client. -/// Deadlines and the running time are measured on the clock of the requester's session. +/// Uploads and runs an app, streaming at most two outstanding chunks, waiting +/// for authorization and retrieving the result once. +/// +/// Scheduling establishes the relay through the caller's client. After a +/// failure it requests the task's cancellation within the remaining deadline, +/// at most 1 s, ignoring cancellation errors. Deadlines and the running time +/// are measured on the clock of the requester's session. pub(crate) fn execute( requester: &Requester, size: u64, @@ -59,6 +68,8 @@ pub(crate) fn execute( if size == 0 { return Err(Error::Execution("app is empty".into())); } + + // Allocate the task, whose ID addresses both the upload and the run progress(ExecutionProgress::Preparing); let taskid = requester .request( @@ -67,12 +78,16 @@ pub(crate) fn execute( )? .wait::()? .taskid; + + // Run the task's steps as one outcome, so any failure can cancel it let result = (|| { progress(ExecutionProgress::Started { taskid }); progress(ExecutionProgress::Uploading { uploaded: 0, total: size, }); + + // Stream the app, checking the source ends at its declared size let mut sent = 0; let mut uploaded = 0; let mut pending: Option<(Promise, u64)> = None; @@ -86,8 +101,9 @@ pub(crate) fn execute( if sent == size { finish_read(reader, clock, timing)?; } - // Submit the next chunk before waiting for the previous one, keeping - // at most two outstanding while device writes overlap transport I/O. + // Submit the next chunk before waiting for the previous one, + // keeping at most two outstanding while device writes overlap + // transport I/O let next = requester.request( schema::ExecutionUploadChunkRequest { taskid, chunk }, timing.io(clock), @@ -110,8 +126,11 @@ pub(crate) fn execute( total: size, }); } + + // Schedule the run, which waits for the owner's approval progress(ExecutionProgress::Authorizing); schedule(taskid)?; + // Retrieving a completed status consumes the result on the Ark. This // workflow is the sole poller and never retries a completed retrieval. let started = clock.now(); @@ -130,6 +149,9 @@ pub(crate) fn execute( timing.pause(clock, POLL_INTERVAL)?; } })(); + + // Request a failed task's cancellation within the remaining deadline, at + // most 1 s, ignoring the outcome if result.is_err() { let cleanup = timing.io(clock).min(clock.now() + Duration::from_secs(1)); let _ = requester @@ -139,8 +161,10 @@ pub(crate) fn execute( result } -/// Check EOF before the final chunk so a growing or misdeclared source never -/// reaches scheduling. Interrupted reads do not indicate the end of a file. +/// Checks EOF before the final chunk, so a growing or misdeclared source never +/// reaches scheduling. +/// +/// Interrupted reads do not count as the end of the file. fn finish_read(reader: &mut impl Read, clock: &Clock, timing: Timing) -> Result<(), Error> { loop { timing.check(clock)?; @@ -164,6 +188,7 @@ fn read_error(error: io::Error) -> Error { } } +/// App execution regressions against a scripted Ark. #[cfg(test)] mod tests { use super::*; @@ -175,15 +200,21 @@ mod tests { use std::sync::{Arc, Mutex, mpsc}; use std::thread; + /// Budget for test I/O that is not exercising expiration. const TIMEOUT: Duration = Duration::from_secs(10); + /// Wire exchange recorded by the scripted peer. #[derive(Default)] struct Observed { + /// Stages the peer served, in arrival order. stages: Vec<&'static str>, + /// Declared app length the upload started with. size: u64, + /// App bytes the upload delivered. bytes: Vec, } + /// Builds an execution result with binary output, successful or not. fn result(success: bool) -> schema::ExecutionResultResponse { schema::ExecutionResultResponse { app_name: "test app".into(), @@ -194,6 +225,10 @@ mod tests { } } + /// Spawns a peer recording the wire exchange, optionally refusing a stage. + /// + /// A refusing peer refuses the cancel too. Status polls take their answers + /// from `reports`. fn peer( clock: &Clock, fail: Option<&'static str>, @@ -256,11 +291,13 @@ mod tests { (peer, observed) } + /// Connects a raw wire session to the peer, pinning its identity key. fn attach(peer: &mut Peer) -> Session { let trust = TrustMode::Recover(Box::new(peer.identity.clone())); protocol::connect(peer.stream(), &trust).unwrap().0 } + /// Runs an app under a fresh budget, scheduling it with a raw request. fn run( requester: &Requester, size: u64, @@ -276,8 +313,8 @@ mod tests { }) } - /// The final acknowledgement precedes scheduling. Polling stops as soon as - /// the result is retrieved, retaining binary output even when the app failed. + /// Scheduling follows the final acknowledgment, and polling stops at the + /// result, keeping binary output even of a failed app. #[test] fn test_execution() { let mut tester = test_clock(); @@ -313,12 +350,16 @@ mod tests { } }); - // End the pause between the two status polls once the runner sleeps in it + // End the pause between the two status polls once the runner + // sleeps in it let poll = clock.now() + POLL_INTERVAL; wait_deadline(&tester, poll); tester.advance_to(poll); let (actual, progress) = running.join().unwrap().unwrap(); assert_eq!(actual, expected); + + // Every byte arrived, polling stopped at the result, and approval + // followed the final acknowledgment let observed = observed.lock().unwrap(); assert_eq!(observed.bytes, bytes); assert_eq!( @@ -345,10 +386,12 @@ mod tests { } } - /// A remote failure keeps its code and message even when cleanup fails too. - /// A refused start has no task ID and must not attempt cancellation. + /// A refused stage keeps its code and message even when cleanup fails too, + /// and a refused start attempts no cancellation. #[test] fn test_refusals() { + // Each refused stage fails the run without a retry, requesting + // cancellation of any task it allocated let clock = test_clock().clock(); for fail in ["start", "chunk", "schedule", "status"] { let (mut peer, observed) = peer( @@ -401,8 +444,8 @@ mod tests { } } - /// Declared lengths are enforced before scheduling, including sources that - /// change after their first chunk. Read failures cancel the allocated task. + /// A source shorter or longer than declared fails before scheduling and + /// cancels the allocated task. #[test] fn test_source_length() { let clock = test_clock().clock(); @@ -427,10 +470,12 @@ mod tests { } } - /// Two requests can be in flight, and acknowledgements can arrive reversed. - /// The last chunk remains outstanding until explicitly released by the test. + /// Two chunks can be in flight with their acknowledgments reversed, and + /// approval waits for the last one. #[test] fn test_upload_window() { + // The peer answers the second chunk before the first, and holds the + // last one until the test releases it let clock = test_clock().clock(); let (notice, notices) = mpsc::channel(); let (release, released) = mpsc::channel(); @@ -488,6 +533,8 @@ mod tests { true }), ); + + // Run a three chunk app, reporting its progress to the test let session = attach(&mut peer); let requester = session.requester(); let (updates, progress) = mpsc::channel(); @@ -502,6 +549,8 @@ mod tests { }, ) }); + + // Approval waits for the last chunk's acknowledgment notices.recv().unwrap(); assert!( !progress @@ -512,10 +561,11 @@ mod tests { assert!(worker.join().unwrap().unwrap().success); } - /// Cancellation can be sent while status is outstanding. Neither request - /// requires an application receive loop or a second connection. + /// A cancel goes out while a status poll is outstanding, with no receive + /// loop or second connection. #[test] fn test_cancellation() { + // The peer holds the status poll until the cancel, then answers both let clock = test_clock().clock(); let (notice, notices) = mpsc::channel(); let mut held = None; @@ -564,6 +614,8 @@ mod tests { true }), ); + + // Cancel from another handle while the runner waits on its status poll let session = attach(&mut peer); let requester = session.requester(); let worker = thread::spawn(move || run(&requester, 1, &mut [42].as_slice(), |_| {})); @@ -580,15 +632,21 @@ mod tests { assert!(!worker.join().unwrap().unwrap().success); } - /// Readers may fragment data or interrupt EOF checks. An expired deadline - /// never allocates a task and a reader timeout remains a timeout error. + /// Fragmented and interrupted reads still run the app, while an expired + /// deadline allocates no task and a reader timeout stays a timeout. #[test] fn test_reads_and_deadlines() { + /// Source serving one byte per read, interrupting the first read past + /// its end. struct Fragmented { + /// Bytes left to serve. bytes: &'static [u8], + /// Flag set once the read past the end was interrupted. interrupted: bool, } impl Read for Fragmented { + /// Serves one byte per call, interrupting once at the end of the + /// source. fn read(&mut self, buffer: &mut [u8]) -> io::Result { if self.bytes.is_empty() && !self.interrupted { self.interrupted = true; @@ -597,6 +655,8 @@ mod tests { self.bytes.read(&mut buffer[..1]) } } + + // Fragmented and interrupted reads still run the app let clock = test_clock().clock(); let (mut peer, _) = peer( &clock, @@ -618,6 +678,7 @@ mod tests { ); assert!(reader.interrupted); + // An expired deadline allocates no task let result = execute( &session.requester(), 1, @@ -628,8 +689,11 @@ mod tests { ); assert!(matches!(result, Err(Error::Timeout))); + // A reader timeout stays a timeout + /// Source whose every read times out. struct TimedOut; impl Read for TimedOut { + /// Fails every read with `TimedOut`. fn read(&mut self, _: &mut [u8]) -> io::Result { Err(io::ErrorKind::TimedOut.into()) } diff --git a/connect/src/hardware/mod.rs b/connect/src/hardware/mod.rs index c3a53f8..7f3b42c 100644 --- a/connect/src/hardware/mod.rs +++ b/connect/src/hardware/mod.rs @@ -5,6 +5,7 @@ // license that can be found in the LICENSE file. //! Discovery of physical Arks attached to the host. +//! //! Connect through [`Device::connect`] to authenticate a discovered Ark. mod usb; @@ -14,7 +15,7 @@ pub(crate) use usb::{connect, name}; use crate::{Device, Error}; use nusb::MaybeFuture; -/// Vendor and product id pairs Arks enumerate with. +/// Vendor and product ID pairs Arks enumerate with. const USB_IDS: &[(u16, u16)] = &[ (0x2e8a, 0x10f1), // Ark I ]; diff --git a/connect/src/hardware/usb.rs b/connect/src/hardware/usb.rs index 9a332b0..f8f9593 100644 --- a/connect/src/hardware/usb.rs +++ b/connect/src/hardware/usb.rs @@ -5,16 +5,18 @@ // license that can be found in the LICENSE file. //! Arks over USB, the wire's byte stream on the bulk endpoints of the vendor -//! interface a plugged in Ark enumerates with. The host claims the interface -//! exclusively, so an Ark held by another program, a browser tab included, -//! cannot be opened until that lets go. +//! interface a plugged-in Ark enumerates with. +//! +//! The host claims the interface exclusively, so an Ark held by another +//! program, a browser tab included, cannot be opened until that program lets +//! go. //! //! Each direction queues a ring of host transfers to keep the bus occupied. //! A zero length packet closes a frame that ended on a packet boundary. Flushes //! reap finished transfers without draining the ring, so consecutive frames -//! can overlap on the bus. -//! Every wait is bounded by the deadline the wire installed, measured on the -//! connection's clock, and ends early once the connection is closed. +//! can overlap on the bus. Every wait ends at the deadline the wire installed, +//! if any, measured on the connection's clock, or early once the connection is +//! closed. use crate::ark::Ark; use crate::{Error, wire}; @@ -31,25 +33,29 @@ use std::task::{Context, Poll, Wake, Waker}; use std::time::Instant; use wire::transport::{self, Verifier}; -/// Class, subclass and protocol of the vendor interface carrying the wire. It -/// distinguishes the interface from the mass storage a development Ark exposes -/// too, which is bulk in both directions as well. +/// Class, subclass and protocol of the vendor interface carrying the wire. +/// +/// It distinguishes the wire's interface from any other one with bulk +/// endpoints in both directions. const VENDOR_INTERFACE: (u8, u8, u8) = (0xff, 1, 2); -/// Separator between the parts of the product string an Ark enumerates -/// with, its carrier, its revision and the name it was given, the last there +/// Separator between the parts of the product string an Ark enumerates with. +/// +/// The parts are its model, its revision and the name it was given, the last /// only when a name was given. const PRODUCT_SEPARATOR: &str = " \u{00b7} "; -/// Bytes per host transfer in either direction. A multiple of every supported -/// bulk endpoint's packet size, keeping partial packets at frame boundaries. +/// Bytes per host transfer in either direction. +/// +/// It is a multiple of every supported bulk endpoint's packet size, keeping +/// partial packets at frame boundaries. const TRANSFER_SIZE: usize = 64 * 1024; /// Host transfers kept in flight per direction to overlap USB and protocol work. const TRANSFERS: usize = 16; -/// Name the Ark was given, carried in its product string after the carrier -/// and the revision. +/// Returns the name the Ark was given, taken from its product string after the +/// model and the revision. pub(crate) fn name(product: &str) -> Option<&str> { product .splitn(3, PRODUCT_SEPARATOR) @@ -58,14 +64,16 @@ pub(crate) fn name(product: &str) -> Option<&str> { } /// Opens the Ark and runs the wire handshake over it, the verifier deciding -/// whether to trust the attestation it presents. The connection measures its -/// deadlines on the clock. +/// whether to trust the attestation it presents. +/// +/// The connection measures its deadlines on the clock. pub(crate) fn connect>( info: &nusb::DeviceInfo, verifier: &V, cloud: impl FnOnce(&crate::Identity) -> Option<(crate::trust::Environment, crate::trust::Realm)>, clock: &Clock, ) -> Result<(Ark, V::Info), Error> { + // Open the device and read its active configuration let device = info.open().wait().map_err(Error::Usb)?; let config = device .active_configuration() @@ -102,9 +110,9 @@ pub(crate) fn connect>( } let (number, alternate, ep_in, ep_out) = found.ok_or(Error::Unsupported)?; - // Claim the interface and open the endpoints, a claim refused for the - // device being held meaning another program has it. The endpoints keep - // the interface claimed and the device open for as long as either lives. + // Claim the interface and open the endpoints. A claim refused as busy + // means another program holds the device. The endpoints keep the + // interface claimed and the device open for as long as either lives. let iface = device .claim_interface(number) .wait() @@ -140,14 +148,15 @@ pub(crate) fn connect>( Ark::attach(stream, verifier, cloud) } -/// Queue of transfers on one endpoint, what a direction of the adapter -/// drives. The tests drive the rings through it without a device. +/// Queue of transfers on one endpoint, which a direction of the adapter drives. +/// +/// The tests drive the rings through it without a device. trait Transfers { - /// Packet size of the endpoint, deciding when a frame needs a zero length - /// packet behind it. + /// Returns the endpoint's packet size, which decides when a frame needs a + /// zero length packet behind it. fn packet_size(&self) -> usize; - /// Transfers queued and not yet taken back. + /// Counts the transfers queued and not yet taken back. fn in_flight(&self) -> usize; /// Queues a transfer behind the ones in flight. @@ -164,7 +173,7 @@ impl Transfers for nusb::Endpoint { self.max_packet_size() } - /// Counts submitted transfers whose completions have not been reaped. + /// Counts the transfers the system USB queue still holds for the endpoint. fn in_flight(&self) -> usize { self.pending() } @@ -174,18 +183,21 @@ impl Transfers for nusb::Endpoint { self.submit(buffer); } - /// Reaps the next system completion or registers the direction's waker. + /// Polls the system USB queue for the endpoint's next finished transfer. fn poll_finished(&mut self, cx: &mut Context<'_>) -> Poll { self.poll_next_complete(cx) } } -/// Wakes a direction waiting on its endpoint, a transfer finishing or the -/// connection closing being what there is to wake for. +/// Wake signal for a direction waiting on its endpoint, raised by a finishing +/// transfer or the closing connection. struct Notifier { - clock: Clock, // clock that the wire's deadlines are measured on - woken: sync::Mutex, // Whether a wake arrived since the wait last looked - wake: sync::Condvar, // Signalled on every wake + /// Clock that the wire's deadlines are measured on. + clock: Clock, + /// Flag set by a wake that arrived since the wait last looked. + woken: sync::Mutex, + /// Condition signaled on every wake. + wake: sync::Condvar, } impl Notifier { @@ -223,9 +235,10 @@ impl Wake for Notifier { } /// Waits for the next transfer of the queue to finish, giving up without one -/// once the deadline passes or the connection is closed. Without a deadline -/// only a finished transfer or the close end the wait. A deadline already -/// passed makes the wait a look at what has finished. +/// once the deadline passes or the connection is closed. +/// +/// Without a deadline only a finished transfer or the close end the wait. A +/// deadline already passed makes the wait a look at what has finished. fn finished( queue: &mut T, notifier: &Arc, @@ -235,9 +248,12 @@ fn finished( let waker = Waker::from(notifier.clone()); let mut cx = Context::from_waker(&waker); loop { + // Take a finished transfer, the poll registering the waker otherwise if let Poll::Ready(completion) = queue.poll_finished(&mut cx) { return Some(completion); } + + // Wait for a wake, giving up at the close or the deadline let mut woken = notifier.woken.lock().expect("USB wake state not poisoned"); while !*woken { if closed.load(Ordering::Acquire) || notifier.expired(deadline) { @@ -270,29 +286,38 @@ fn transfer_error(err: TransferError) -> io::Error { } } -/// The error of output refused once the connection was closed. +/// Returns the error for output refused once the connection is closed. fn closed() -> io::Error { io::Error::new(io::ErrorKind::NotConnected, "device closed") } -/// Reader over the bulk IN endpoint, every transfer of the ring queued ahead -/// so the device never waits for a buffer, each served to the wire once it -/// finishes and queued again once served out. An empty transfer is a zero -/// length packet the device closed a frame with, not the end of the stream. -/// A wait ends at the deadline the wire installed, or with the stream once -/// the connection is closed. +/// Reader over the bulk IN endpoint, serving finished transfers to the wire. +/// +/// Every transfer of the ring is queued ahead, so the device has buffers to +/// fill while the wire reads. Each is served once it finishes and queued again +/// once served out. An empty transfer is a zero length packet the device closed +/// a frame with, not the end of the stream. A wait ends at the deadline the +/// wire installed, if any, or with the end of the stream once the connection +/// is closed. struct Reader { - queue: T, // Transfers in flight on the endpoint - notifier: Arc, // Wakes the wait on a finished transfer or the close - closed: Arc, // Close signal - served: Option, // Finished transfer being served to the wire - offset: usize, // Bytes of it served so far - deadline: Option, // Deadline the wire installed for its reads + /// Transfers in flight on the endpoint. + queue: T, + /// Wake signal for a finished transfer or the close. + notifier: Arc, + /// Close signal shared with the writer and the shutdown. + closed: Arc, + /// Finished transfer being served to the wire. + served: Option, + /// Bytes of the served transfer handed out so far. + offset: usize, + /// Deadline the wire installed for its reads. + deadline: Option, } impl Reader { /// Queues every transfer of the ring on the endpoint. fn new(mut queue: T, notifier: Arc, closed: Arc) -> Self { + // Size each transfer in whole packets, as inbound transfers require let packet = queue.packet_size(); let size = TRANSFER_SIZE.div_ceil(packet) * packet; for _ in 0..TRANSFERS { @@ -310,8 +335,9 @@ impl Reader { } impl Read for Reader { - /// Serves completed bytes before waiting for another transfer. Empty USB - /// packets delimit frames; only closure ends the stream. + /// Serves completed bytes before waiting for another transfer. + /// + /// Empty USB packets delimit frames; only closure ends the stream. fn read(&mut self, buf: &mut [u8]) -> io::Result { if buf.is_empty() { return Ok(0); @@ -331,12 +357,16 @@ impl Read for Reader { self.queue.queue(buffer); self.offset = 0; } + + // Stop at the close, or fail at the deadline the wire installed if self.closed.load(Ordering::Acquire) { return Ok(0); } if self.notifier.expired(self.deadline) { return Err(io::Error::from(io::ErrorKind::TimedOut)); } + + // Take the next finished transfer, where an empty one closes a frame let Some(completion) = finished(&mut self.queue, &self.notifier, &self.closed, self.deadline) else { @@ -372,21 +402,29 @@ impl transport::Read for Reader { } } -/// Writer over the bulk OUT endpoint, each write queued as transfers of the -/// chunk size behind the ones in flight, waiting for room only once the ring -/// is full. A flush closes the frame with a zero length packet if the last -/// write ended on a packet boundary, as the device's read would otherwise -/// wait for the next frame to complete it, then takes back the transfers -/// already finished for their outcome without draining the ring. The -/// deadline the wire installed bounds every wait, the connection closing -/// refuses further output. +/// Writer over the bulk OUT endpoint, queueing each write as transfers of at +/// most [`TRANSFER_SIZE`] behind the ones in flight. +/// +/// A write waits for room only once the ring is full. A flush closes a frame +/// ending on a packet boundary with a zero length packet, since the device's +/// read would otherwise wait for the next frame to complete it. It then takes +/// back the transfers already finished for their outcome, without draining +/// the ring. The deadline the wire installed bounds every wait, and closing +/// the connection refuses further output. struct Writer { - queue: T, // Transfers in flight on the endpoint - notifier: Arc, // Wakes the wait on a finished transfer or the close - closed: Arc, // Close signal - spare: Vec, // Buffers of finished transfers, reused by the next - wrote: usize, // Length of the last write, deciding the zero length packet at flush - deadline: Option, // Deadline the wire installed for its writes + /// Transfers in flight on the endpoint. + queue: T, + /// Wake signal for a finished transfer or the close. + notifier: Arc, + /// Close signal shared with the reader and the shutdown. + closed: Arc, + /// Spare buffers of finished transfers, reused by the next writes. + spare: Vec, + /// Length of the last write, which decides whether a flush owes a zero + /// length packet. + wrote: usize, + /// Deadline the wire installed for its writes. + deadline: Option, } impl Writer { @@ -402,9 +440,11 @@ impl Writer { } } - /// Keeps the buffer of a finished transfer for the next, its failure - /// surfacing. The zero length packets travel in buffers with no room - /// for anything, not worth keeping. + /// Keeps a finished transfer's buffer for reuse, returning the transfer's + /// failure. + /// + /// Zero length packets travel in buffers with no room, so those are not + /// kept. fn finished(&mut self, completion: Completion) -> io::Result<()> { if completion.buffer.capacity() >= TRANSFER_SIZE { self.spare.push(completion.buffer); @@ -438,8 +478,8 @@ impl Writer { Ok(()) } - /// Takes back the transfers already finished, their failures surfacing, - /// without waiting for the rest. + /// Takes back the transfers already finished without waiting for the rest, + /// returning the first failure among them. fn reap(&mut self) -> io::Result<()> { while self.queue.in_flight() > 0 { let now = Some(self.notifier.clock.now()); @@ -454,17 +494,21 @@ impl Writer { } impl Write for Writer { - /// Queues bytes in order, reporting partial acceptance if a later wait fails. + /// Queues bytes in order, reporting partial acceptance if a later wait + /// fails. + /// /// Acceptance means submission to USB; a later reap may report its failure. fn write(&mut self, buf: &[u8]) -> io::Result { + // Refuse output once closed or past the deadline if self.closed.load(Ordering::Acquire) { return Err(closed()); } if self.notifier.expired(self.deadline) { return Err(io::Error::from(io::ErrorKind::TimedOut)); } - // Chunks already queued stay queued in order, so a wait for room - // failing reports what was taken, the rest failing on the next call + + // Chunks already queued stay queued in order, so a failed wait for + // room reports what was taken, and the rest fails on the next call let mut accepted = 0; for chunk in buf.chunks(TRANSFER_SIZE) { match self.send(chunk) { @@ -478,22 +522,28 @@ impl Write for Writer { } /// Terminates an aligned frame and surfaces completed transfer errors. - /// Transfers still in flight remain queued so consecutive frames can overlap. + /// + /// Transfers still in flight remain queued so consecutive frames can + /// overlap. fn flush(&mut self) -> io::Result<()> { + // Refuse output once closed or past the deadline if self.closed.load(Ordering::Acquire) { return Err(closed()); } if self.notifier.expired(self.deadline) { return Err(io::Error::from(io::ErrorKind::TimedOut)); } - // A frame ending on a packet boundary leaves the device's read open, - // only a short packet completes it, so close the frame with an empty - // one. Whether one is owed depends on the last write alone, a short - // one having completed the read whatever came before it. + + // A frame ending on a packet boundary leaves the device's read open + // until a short packet completes it, so close the frame with an empty + // one. Whether one is owed depends on the last write alone, since a + // short one completed the read whatever came before it. if self.wrote > 0 && self.wrote.is_multiple_of(self.queue.packet_size()) { self.room()?; self.queue.queue(Buffer::new(0)); } + + // Take back what finished, leaving the rest in flight self.wrote = 0; self.reap() } @@ -512,6 +562,7 @@ impl transport::Write for Writer { } } +/// USB adapter regressions over a fake endpoint, and product name parsing. #[cfg(test)] mod tests { use super::*; @@ -529,27 +580,36 @@ mod tests { /// thread, waking the waiting direction the way the system does. #[derive(Default)] struct Ring { - queued: VecDeque, // Transfers queued, oldest first - finished: VecDeque, // Transfers finished and not yet taken back - waker: Option, // Waiter to wake on the next finish + /// Transfers queued, oldest first. + queued: VecDeque, + /// Transfers finished and not yet taken back. + finished: VecDeque, + /// Waker of the direction waiting on the next finish. + waker: Option, } + /// Fake endpoint shared between a direction and the test driving it. type Fake = Arc>; impl Transfers for Fake { + /// Returns the fixed `PACKET` size the tests use. fn packet_size(&self) -> usize { PACKET } + /// Counts the queued and finished transfers not yet taken back. fn in_flight(&self) -> usize { let ring = self.lock().unwrap(); ring.queued.len() + ring.finished.len() } + /// Queues the buffer for the test to finish. fn queue(&mut self, buffer: Buffer) { self.lock().unwrap().queued.push_back(buffer); } + /// Takes the oldest finished transfer, or keeps the waker until the + /// test finishes one. fn poll_finished(&mut self, cx: &mut Context<'_>) -> Poll { let mut ring = self.lock().unwrap(); match ring.finished.pop_front() { @@ -562,8 +622,8 @@ mod tests { } } - // Finishes the oldest transfer queued with the status, an inbound one - // filled with the bytes, waking the waiting direction. + /// Finishes the oldest queued transfer with the status, filling an empty + /// inbound buffer with `bytes` bytes, and wakes the waiting direction. fn finish(fake: &Fake, bytes: usize, status: Result<(), TransferError>) { let waker = { let mut ring = fake.lock().unwrap(); @@ -584,9 +644,11 @@ mod tests { } } - // Waits until a direction looked at the ring and then parked on the - // deadline, or without one. The direction is the only thread that waits on - // the clock, and the test clears the ring's waker before it starts. + /// Waits until a direction has looked at the ring and parked on the + /// deadline, or without one. + /// + /// The direction is the only thread that waits on the clock, and the test + /// clears the ring's waker before it starts. fn parked(fake: &Fake, tester: &TestClock, deadline: Option) { while fake.lock().unwrap().waker.is_none() { thread::yield_now(); @@ -600,7 +662,7 @@ mod tests { } } - // Lengths of the transfers queued, oldest first. + /// Returns the lengths of the queued transfers, oldest first. fn queued(fake: &Fake) -> Vec { fake.lock() .unwrap() @@ -610,6 +672,8 @@ mod tests { .collect() } + /// Creates a reader over a fake endpoint, returned with the endpoint and + /// the close flag. fn reader(clock: &Clock) -> (Reader, Fake, Arc) { let fake = Fake::default(); let closed = Arc::new(AtomicBool::new(false)); @@ -617,6 +681,8 @@ mod tests { (reader, fake, closed) } + /// Creates a writer over a fake endpoint, returned with the endpoint and + /// the close flag. fn writer(clock: &Clock) -> (Writer, Fake, Arc) { let fake = Fake::default(); let closed = Arc::new(AtomicBool::new(false)); @@ -624,20 +690,22 @@ mod tests { (writer, fake, closed) } - // Tests that the ring is queued ahead, that finished transfers are - // served as they arrive and queued again once served out, and that an - // empty one is skipped rather than ending the stream. + /// A read serves finished transfers as they arrive, queues them again once + /// served out and skips empty ones. #[test] fn test_read_serves_transfers() { + // The whole ring is queued ahead let (mut reader, fake, _closed) = reader(&test_clock().clock()); assert_eq!(fake.in_flight(), TRANSFERS); + // A finished transfer serves across reads finish(&fake, 3, Ok(())); let mut buf = [0u8; 2]; assert_eq!(reader.read(&mut buf).unwrap(), 2); assert_eq!(buf, [0xab, 0xab]); assert_eq!(reader.read(&mut buf).unwrap(), 1); + // An empty transfer is skipped, and the served-out ones queue again finish(&fake, 0, Ok(())); finish(&fake, 4, Ok(())); let mut buf = [0u8; 8]; @@ -645,11 +713,11 @@ mod tests { assert_eq!(fake.in_flight(), TRANSFERS - 1); } - // Tests that a wait ends with the deadline the wire installed, that a - // transfer finishing meanwhile ends it with data, and that the close - // ends it with the stream. + /// A read's wait ends at the wire's deadline, with a transfer finishing + /// meanwhile, or with the end of the stream at the close. #[test] fn test_read_waits() { + // Read from a fake ring, keeping its notifier to wake the close let mut tester = test_clock(); let clock = tester.clock(); let (mut reader, fake, closed) = reader(&clock); @@ -690,8 +758,8 @@ mod tests { assert_eq!(reading.join().unwrap().unwrap(), 0); } - // Tests that a failed transfer fails the read, the device going away - // reported as the connection lost. + /// A failed transfer fails the read, a vanished device reported as a lost + /// connection. #[test] fn test_read_failure() { let (mut reader, fake, _closed) = reader(&test_clock().clock()); @@ -702,12 +770,11 @@ mod tests { ); } - // Tests that a write is queued as transfers of the chunk size, that a - // full ring waits for a transfer to finish within the deadline, that what - // was taken before the wait ran out is reported as written, and that the - // close refuses output. + /// A write queues transfers of at most the transfer size, waits for room + /// within the deadline, reports what it took, and fails once closed. #[test] fn test_write_chunks() { + // A write splits into transfers, and further writes fill the ring let mut tester = test_clock(); let clock = tester.clock(); let (mut writer, fake, closed) = writer(&clock); @@ -739,8 +806,8 @@ mod tests { let (mut writer, result) = writing.join().unwrap(); assert_eq!(result.unwrap_err().kind(), io::ErrorKind::TimedOut); - // Make one completion available so only the second chunk waits - // for its deadline. + // Make one completion available, so only the second chunk waits for + // its deadline let two = vec![7u8; 2 * TRANSFER_SIZE]; finish(&fake, 0, Ok(())); let deadline = clock.now() + Duration::from_millis(100); @@ -755,6 +822,7 @@ mod tests { let (mut writer, result) = writing.join().unwrap(); assert_eq!(result.unwrap(), TRANSFER_SIZE); + // The close refuses output closed.store(true, Ordering::Release); assert_eq!( writer.write(&chunk).unwrap_err().kind(), @@ -766,24 +834,27 @@ mod tests { ); } - // Tests that a flush closes a frame ending on a packet boundary with a - // zero length packet and leaves a short one alone, that nothing is owed - // for nothing written, and that a failed transfer surfaces at the next - // flush without the flush draining the ring. + /// A flush closes an aligned frame with a zero length packet, owes none for + /// a short frame, and surfaces a failure without draining the ring. #[test] fn test_flush() { + // Write through a fake ring let (mut writer, fake, _closed) = writer(&test_clock().clock()); + // An aligned frame gets one zero length packet, however often it flushes assert_eq!(writer.write(&[1u8; 2 * PACKET]).unwrap(), 2 * PACKET); writer.flush().unwrap(); assert_eq!(queued(&fake), [2 * PACKET, 0]); writer.flush().unwrap(); assert_eq!(queued(&fake), [2 * PACKET, 0]); + // A short frame needs no zero length packet assert_eq!(writer.write(&[1u8; PACKET + 1]).unwrap(), PACKET + 1); writer.flush().unwrap(); assert_eq!(queued(&fake), [2 * PACKET, 0, PACKET + 1]); + // A failed transfer surfaces at the next flush, which leaves the rest + // in flight finish(&fake, 0, Ok(())); finish(&fake, 0, Err(TransferError::Fault)); assert!(writer.flush().is_err()); @@ -791,8 +862,8 @@ mod tests { assert_eq!(writer.spare.len(), 1); } - // Tests that the name is taken from the product string only when a name - // was given, the carrier and the revision never mistaken for one. + /// A name is taken from the product string only when one was given, never + /// mistaking the model or the revision for one. #[test] fn test_name() { assert_eq!(name("Ark I \u{00b7} v1.2"), None); diff --git a/connect/src/identity.rs b/connect/src/identity.rs index 843e77e..b51c07d 100644 --- a/connect/src/identity.rs +++ b/connect/src/identity.rs @@ -5,7 +5,9 @@ // license that can be found in the LICENSE file. //! Device authentication through wire's verifier and the CLI's roots of trust. -//! The returned identity records whether the peer was attested, self-signed or pinned. +//! +//! The returned identity records whether the peer was attested, self-signed or +//! pinned. use darkbio_crypto::xdsa; use darkbio_trust as trust; @@ -21,22 +23,25 @@ pub(crate) const ENVIRONMENTS: &[Environment] = &[ Environment::Develop, ]; -/// Controls which device attestations are accepted during the handshake. +/// Policy for which device attestations the handshake accepts. pub enum TrustMode { - /// Accept Arks attested under the release, staging or develop hardware or - /// emulator roots, or peers presenting a self-signed - /// attestation. Self-signing proves key possession, not provisioning history. + /// Policy accepting Arks attested under the release, staging or develop + /// hardware or emulator roots, or peers presenting a self-signed + /// attestation. + /// + /// Self-signing proves key possession, not provisioning history. RootOrSelf, - /// Skip the attestation and authenticate the handshake against the given - /// identity key instead, recovering Arks with a corrupted or missing one. + /// Policy authenticating the handshake against the given identity key + /// instead of the attestation, for recovering Arks whose attestation is + /// corrupted or missing. Recover(Box), } /// Peer identity established by the handshake and the selected trust policy. #[derive(Clone)] pub enum Identity { - /// Attested under the roots of an environment. + /// Peer attested under the roots of an environment. Attested { /// Environment whose root verified the device attestation. env: Environment, @@ -44,16 +49,21 @@ pub enum Identity { device: Device, }, - /// Self-attested key possession. Provisioning and genuineness are unverified. + /// Peer proving possession of its self-attested key. + /// + /// Provisioning and genuineness are unverified. SelfSigned(xdsa::PublicKey), - /// Pinned by the caller, the attestation was not checked. + /// Peer authenticated against a key the caller pinned, its attestation + /// unchecked. Recovered(xdsa::PublicKey), } impl Identity { - /// Returns the realm established by a trusted attestation. Self-signed and - /// recovered identities have no verified realm, regardless of their transport. + /// Returns the realm established by a trusted attestation. + /// + /// Self-signed and recovered identities have no verified realm, regardless + /// of their transport. pub fn realm(&self) -> Option { match self { Self::Attested { device, .. } => Some(device.realm), @@ -80,19 +90,21 @@ impl Verifier for TrustMode { attestation: &Attestation, now: SystemTime, ) -> Result<(xdsa::PublicKey, Identity), String> { - // Recovery authenticates key possession without consulting the attestation. + // Recovery proves possession of the pinned key, ignoring the attestation if let TrustMode::Recover(key) = self { return Ok((*key.clone(), Identity::Recovered(*key.clone()))); } - // Look the signer up in the roots of every environment. An attestation - // from a known root that fails to verify is a hard error, only unknown - // signers fall through to the self-signed check. Retain their diagnostic - // so an unknown signer is not obscured by the self-signed fallback. + + // Attestations verify at the handshake's wall time, in Unix seconds let now = now .duration_since(UNIX_EPOCH) .map_err(|err| err.to_string())? .as_secs(); + // Look the signer up in the roots of every environment. An attestation + // from a known root that fails to verify is a hard error; only unknown + // signers fall through to the self-signed check. Keep their diagnostic + // so an unknown signer is not obscured by the self-signed fallback. let mut untrusted = None; for &env in ENVIRONMENTS { let hardware = trust::roots::hardware(env); @@ -105,8 +117,9 @@ impl Verifier for TrustMode { Err(err) => return Err(err.to_string()), } } - // No trusted root matched. Only an attestation signed by its own identity - // key can establish a self-signed peer. + + // No trusted root matched. Only an attestation signed by its own + // identity key can establish a self-signed peer. let key = trust::device::verify_self_signed(attestation.as_bytes()).map_err(|err| { match (err, untrusted) { @@ -125,16 +138,16 @@ mod tests { use crate::testing::{self_attestation, test_clock}; use darkbio_crypto::{cbor, cose}; - // Tests that the root trust mode accepts a self-signed attestation with the - // Ark's own identity, refuses one signed by an unknown key, and that the - // recovery mode pins the given identity regardless of the attestation. + /// Root trust accepts a self-signed attestation and refuses a foreign + /// signer, while recovery pins its key whatever the attestation. #[test] fn test_trust_modes() { + // Generate the Ark's identity and a foreign signer let clock = test_clock().clock(); let identity = xdsa::SecretKey::generate(); let foreign = xdsa::SecretKey::generate(); - // Self-signed attestation proves possession of its key only. + // Self-signed attestation proves possession of its key only let attestation = self_attestation(&identity, identity.public_key(), &clock); let (key, info) = TrustMode::RootOrSelf .verify(&attestation, clock.system_time()) @@ -166,16 +179,18 @@ mod tests { /// Known device roots verify signatures instead of accepting claimed fingerprints. #[test] fn test_known_signer() { + // Sign one attestation with an unrelated key, then relabel its signer let clock = test_clock().clock(); let key = xdsa::SecretKey::generate(); let attestation = self_attestation(&key, key.public_key(), &clock); for fingerprint in [ - "8b842c20bb8083a1635140e58675f3b95a100ac0e39ab82fa6cb2ef23eb532fb", // Release hardware - "7d725c5cb3f80ef4e17bb98ea1f14683714a7eaffaa78cded17ff0e2c0c96ffa", // Staging hardware - "456df8b670cbe2c1c95368f2678ddf66671826542d742cfb5aee2f30e194b2ed", // Develop hardware - "4ae00e993329f6f46e350b247a8e2b38b915ade524ceca70d6530c859d1f69ef", // Develop emulator - "9dfa577f0938f11f9df9a5eadcdc8e57353702dca6d23291cf5cc71a403d67a8", // Develop cloud + "8b842c20bb8083a1635140e58675f3b95a100ac0e39ab82fa6cb2ef23eb532fb", // release hardware + "7d725c5cb3f80ef4e17bb98ea1f14683714a7eaffaa78cded17ff0e2c0c96ffa", // staging hardware + "456df8b670cbe2c1c95368f2678ddf66671826542d742cfb5aee2f30e194b2ed", // develop hardware + "4ae00e993329f6f46e350b247a8e2b38b915ade524ceca70d6530c859d1f69ef", // develop emulator + "9dfa577f0938f11f9df9a5eadcdc8e57353702dca6d23291cf5cc71a403d67a8", // develop cloud ] { + // Claim the root's fingerprint in the attestation's header let bytes: [u8; 32] = hex::decode(fingerprint).unwrap().try_into().unwrap(); let claimed = xdsa::Fingerprint::from_bytes(&bytes); let root = trust::roots::identify(&claimed).unwrap(); @@ -184,6 +199,9 @@ mod tests { header.kid = claimed; envelope.protected = cbor::encode(&header).unwrap(); let forged = Attestation::new(cbor::encode(&envelope).unwrap()).unwrap(); + + // A device root fails the signature check, while a cloud root is an + // untrusted signer the error names let error = TrustMode::RootOrSelf .verify(&forged, clock.system_time()) .err() diff --git a/connect/src/incoming.rs b/connect/src/incoming.rs index 7942f5e..5b384eb 100644 --- a/connect/src/incoming.rs +++ b/connect/src/incoming.rs @@ -4,19 +4,24 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -//! Dispatches cloud traffic while retaining other requests for the application. +//! Request dispatch that forwards cloud traffic and queues the rest for the +//! application. use crate::cloud::Services; use darkbio_wire::protocol::{self, Responder, Session, schema}; use std::collections::VecDeque; use std::sync::{Arc, Condvar, Mutex}; -/// Bounded application queue. Closure discards requests and retains the first -/// ending reason for every subsequent receive. +/// Bounded queue of the requests left for the application. +/// +/// Closure discards requests and keeps the first ending reason for every +/// subsequent receive. #[derive(Debug, Default)] pub(crate) struct Incoming { - state: Mutex, // Queue and closure change under the same lock - ready: Condvar, // Wakes the application for a request or closure + /// Queue and closure state, changed under one lock. + state: Mutex, + /// Condition that wakes the application for a request or closure. + ready: Condvar, } /// Queue accounting and closure, changed together under the incoming lock. @@ -24,12 +29,18 @@ pub(crate) struct Incoming { struct State { /// Requests in arrival order, each retaining its unanswered responder. queue: VecDeque<(schema::ark_to_host::Content, Responder)>, - bytes: usize, // Decoded messages charged by their protobuf size - error: Option, // First reason the original session ended + /// Protobuf size of the queued requests, charged against the byte limit. + bytes: usize, + /// First reason the original session ended. + error: Option, } impl Incoming { - /// Takes a request without competing with the relay for wire receives. + /// Takes the next queued request, blocking until one arrives or the + /// session ends. + /// + /// The dispatcher owns the wire receives, so this never competes with + /// relay traffic. A closed queue returns its first ending reason. pub(crate) fn recv( &self, ) -> Result<(schema::ark_to_host::Content, Responder), protocol::Error> { @@ -50,6 +61,8 @@ impl Incoming { } /// Discards queued requests and wakes receivers when the session ends. + /// + /// Only the first ending reason is kept, and later closes change nothing. pub(crate) fn close(&self, error: protocol::Error) { let mut state = self.state.lock().expect("incoming requests not poisoned"); if state.error.is_none() { @@ -60,13 +73,20 @@ impl Incoming { } } - /// Keeps application backlog bounded without holding up relay traffic. - /// Overflow refuses this request with UNAVAILABLE and leaves the session open. + /// Queues a request for the application, refusing it when the backlog is + /// full. + /// + /// A full queue refuses the request with `UNAVAILABLE` and leaves the + /// session open, so relay traffic is never held up. A closed queue drops + /// the request. fn push(&self, request: schema::ark_to_host::Content, responder: Responder) { + // A closed queue drops the request let mut state = self.state.lock().expect("incoming requests not poisoned"); if state.error.is_some() { return; } + + // Refuse a request over either limit, answering it outside the lock let bytes = request.encoded_len(); if state.queue.len() >= protocol::DEFAULT_MAX_INBOUND_REQUESTS || bytes > protocol::DEFAULT_MAX_INBOUND_BYTES.saturating_sub(state.bytes) @@ -80,15 +100,24 @@ impl Incoming { let _ = responder.fail(error, deadline); return; } + + // Queue the request and wake one receiver state.bytes += bytes; state.queue.push_back((request, responder)); self.ready.notify_one(); } } -/// Owns the wire receiver so companion traffic progresses without application I/O. +/// Receives every request of the session, forwarding relay traffic to cloud +/// services and queueing the rest for the application. +/// +/// It owns the wire receiver, so companion traffic progresses without +/// application receives. Once the session ends, it ends cloud setup and the +/// queue with the same reason. pub(crate) fn dispatch(mut session: Session, services: Arc, incoming: Arc) { let error = loop { + // Take the next request, stopping at the session's end or at a message + // that does not convert to an Ark request let (message, responder) = match session.recv() { Ok(request) => request, Err(error) => break error, @@ -97,6 +126,9 @@ pub(crate) fn dispatch(mut session: Session, services: Arc, incoming: Ok(request) => request, Err(error) => break error, }; + + // Relay requests go to cloud services, which return any lacking a + // cloud route if let schema::ark_to_host::Content::RelayReq(request) = request { if let Some((request, responder)) = services.forward(&session.requester(), request, responder) @@ -107,6 +139,8 @@ pub(crate) fn dispatch(mut session: Session, services: Arc, incoming: incoming.push(request, responder); } }; + + // End cloud setup and the application queue with the same reason services.end(error.clone()); incoming.close(error); } diff --git a/connect/src/lib.rs b/connect/src/lib.rs index cd15384..bab0f1a 100644 --- a/connect/src/lib.rs +++ b/connect/src/lib.rs @@ -5,6 +5,7 @@ // license that can be found in the LICENSE file. //! Authenticated connections and protocol workflows for Ark hosts. +//! //! Internal library target of the CLI package. Its Rust API is unstable and is //! not a supported integration interface. //! @@ -16,7 +17,7 @@ //! connection and its companion relay. //! //! The CLI compiles the release, staging and develop device roots. Self-signed -//! and pinned connections remain available; choosing a cloud route does not +//! and pinned connections are available too; choosing a cloud route does not //! change which roots authenticate an attestation. //! //! ```no_run @@ -40,11 +41,12 @@ //! proof triggers one refresh and authentication retry. Relay attachment follows //! only when required and is reused while healthy. [`Client::sync`] explicitly //! refreshes the signed clock and cloud keys; [`Client::attach_relay`] exposes -//! attachment for diagnostics. -//! Status and enrollment work before either step. [`Device::connect_with_env`] -//! selects cloud routing after authentication on the same connection. Self-signed -//! and recovery peers need a caller-selected environment for cloud operations. -//! Routing never changes the handshake's trust result. +//! attachment for diagnostics. Status and enrollment work before either step. +//! +//! [`Device::connect_with_env`] selects cloud routing after authentication on +//! the same connection. Self-signed and recovery peers need a caller-selected +//! environment for cloud operations. Routing never changes the handshake's +//! trust result. //! //! Calls and workflows accept an [`Instant`](std::time::Instant) for one fixed //! deadline or [`Timing`] for an inactivity allowance, optionally combined with @@ -54,15 +56,16 @@ //! supplied. Arbitrary readers and progress callbacks run on the caller's thread //! and must bound their own blocking work. //! -//! [`Client::identify_dataset`] identifies a file without opening an upload session. -//! [`Client::upload_dataset`] streams a [`Dataset`] and waits for processing. -//! [`Client::update_firmware`] streams a [`Firmware`], obtains cloud access keys, -//! verifies and installs it; success acknowledges installation, not the later -//! reboot. [`Client::execute`] uploads an app, obtains approval and retrieves its -//! result. A failed app returns `success: false`, preserving any output the Ark -//! includes. Failed-app streams require developer output to be enabled in the app. -//! Firmware preparation may require approval, so a rejected proof refreshes cloud -//! keys and returns an error for the caller to retry explicitly. +//! [`Client::identify_dataset`] identifies a file without opening an upload +//! session. [`Client::upload_dataset`] streams a [`Dataset`] and waits for +//! processing. [`Client::update_firmware`] streams a [`Firmware`], obtains +//! cloud access keys, verifies and installs it; success acknowledges +//! installation, not the later reboot. [`Client::execute`] uploads an app, +//! obtains approval and retrieves its result. A failed app returns +//! `success: false` and whatever output the Ark includes, which is none unless +//! the app's manifest sets `develop`. Firmware preparation may require +//! approval, so a rejected proof refreshes cloud keys and returns an error for +//! the caller to retry explicitly. //! //! Downloads, package catalogs, version selection, caches, prompts, signal //! handling and reboot waits belong to callers. Connect accepts readers, checks @@ -70,9 +73,9 @@ //! transfer. Progress supplies upload session and execution task IDs for explicit //! cancellation through another client clone. //! -//! [`Client::pair`] forwards the existing cloud pairing exchange. Its progress -//! callback supplies the rendezvous for presentation to the owner. Pairing and -//! relay payloads stay opaque; the Ark and companion authenticate their content. +//! [`Client::pair`] forwards the cloud pairing exchange. Its progress callback +//! supplies the rendezvous for presentation to the owner. Pairing and relay +//! payloads stay opaque; the Ark and companion authenticate their content. //! Connect interprets only rendezvous routing and relay envelope fields. //! //! [`Client::send`] establishes prerequisites and returns a typed [`Pending`] @@ -151,8 +154,9 @@ pub enum Error { #[error("source verification failed: {0}")] Integrity(String), - /// The cloud refused the device proof; identity and timestamp failures share - /// this response deliberately. + /// The cloud answered a request carrying the device proof with HTTP 403. + /// + /// This client keeps no reason for the refusal. #[error("the cloud rejected the device proof")] ProofRejected, @@ -175,6 +179,7 @@ pub enum Error { /// A connection worker could not be started. #[error("failed to start connection worker: {0}")] Worker(io::Error), + /// Neither the attestation nor the caller selected a cloud environment. #[error("cloud environment unknown; specify an environment when connecting")] MissingEnvironment, @@ -221,7 +226,7 @@ pub enum Error { #[error("failed to reach WebSocket endpoint: {0}")] Unreachable(io::Error), - /// The endpoint refused the WebSocket upgrade. + /// The WebSocket URL could not be parsed or the HTTP upgrade failed. #[error("failed to open websocket: {0}")] Upgrade(tungstenite::Error), @@ -234,13 +239,15 @@ pub enum Error { #[error("handshake failed: {0}")] Handshake(protocol::Error), - /// A device or cloud request ran past its deadline, or the handshake past - /// the wire's budget. + /// A request, workflow or handshake ran past its deadline, or a source read + /// timed out. #[error("operation timed out")] Timeout, /// The Ark refused this request with an application or reserved protocol - /// error. These replies do not by themselves end the connection. + /// error. + /// + /// These replies do not by themselves end the connection. #[error("ark error: {} (code {})", .0.msg, .0.code)] Remote(schema::Error), @@ -260,6 +267,7 @@ pub enum Error { impl From for Error { /// Maps a failure of the wire's protocol layer to the connection's error. + /// /// The failures ending the session surface as a disconnect, so a request /// refused after the Ark went away names the reason. fn from(err: protocol::Error) -> Self { diff --git a/connect/src/request.rs b/connect/src/request.rs index fa68ecb..e567b03 100644 --- a/connect/src/request.rs +++ b/connect/src/request.rs @@ -5,7 +5,9 @@ // license that can be found in the LICENSE file. //! Request/response pairings used by typed client calls. -//! Wire checks message direction and content; this table selects the response type. +//! +//! Wire checks message direction and content; this table selects the response +//! type. use crate::timing::{APPROVAL_WINDOW, PAIRING_WINDOW}; use darkbio_wire::protocol::schema::*; @@ -23,30 +25,41 @@ pub enum Setup { Relay, } -/// Request body with the response type selected by [`crate::Client::call`]. -/// Implemented for the public request bodies. Callers may also pair their own -/// wrappers when those wrappers convert into wire's [`Message`]. +/// Request body paired with the response type that [`crate::Client::call`] +/// returns. +/// +/// It is implemented for the public request bodies. Callers may also pair +/// their own wrappers when those wrappers convert into wire's [`Message`]. pub trait Request: Into { /// Body the Ark answers this request with. type Response: TryFrom; - /// Setup required before sending. Wrappers default to cloud synchronization. + /// Setup required before sending. + /// + /// Wrappers default to cloud synchronization. const SETUP: Setup = Setup::Cloud; /// Protocol wait window, including a reply margin, for requests that wait - /// on a person or device formatting. Replaces an inactivity allowance only; - /// an absolute caller deadline still applies. + /// on a person or on the device formatting storage. + /// + /// It replaces an inactivity allowance only; an absolute caller deadline + /// still applies. const WINDOW: Option = None; } /// Pairs request and response bodies with their setup and reply-window policy. +/// /// Protocol direction checks remain in wire; caller convenience belongs here. macro_rules! pairs { ($setup:expr, $window:expr; $($request:ident => $response:ident,)*) => { $( impl Request for $request { + /// Body the Ark answers this request with. type Response = $response; + /// Setup this request needs before sending. const SETUP: Setup = $setup; + /// Wait window of this request, set when it waits on a person + /// or on the device. const WINDOW: Option = $window; } )* diff --git a/connect/src/testing.rs b/connect/src/testing.rs index 090dcba..096740c 100644 --- a/connect/src/testing.rs +++ b/connect/src/testing.rs @@ -33,6 +33,7 @@ pub fn test_clock() -> TestClock { } /// Blocks until the earliest wait or timer on the clock is due at `deadline`. +/// /// The advance that reaches the deadline then wakes it, whenever the test makes /// that advance. pub fn wait_deadline(tester: &TestClock, deadline: Instant) { @@ -41,9 +42,11 @@ pub fn wait_deadline(tester: &TestClock, deadline: Instant) { } } -/// Hardware attestation of an identity, signed by the given key at the clock's -/// wall time. Signed by the identity itself, it is the placeholder of an Ark -/// that was never onboarded. +/// Issues a hardware attestation of an identity, signed by the given key at the +/// clock's wall time. +/// +/// Signed by the identity itself, it is the placeholder of an Ark that was +/// never onboarded. pub fn self_attestation( signer: &xdsa::SecretKey, identity: xdsa::PublicKey, @@ -75,13 +78,15 @@ pub fn self_attestation( Attestation::new(cwt).unwrap() } -/// Handles one peer request, with access to the session for reverse requests. +/// Handler of one peer request, with access to the session for reverse +/// requests. +/// /// Returning false ends the peer's session. pub type Script = Box bool + Send>; -/// Script answering device info requests with a firmware version, unlock -/// requests with a request of the peer's own ahead of the reply, and anything -/// else with the protocol's UNSUPPORTED error. +/// Answers device info requests with a firmware version, unlock requests with a +/// request of the peer's own ahead of the reply, and anything else with the +/// protocol's `UNSUPPORTED` error. pub fn answering(session: &Session, request: Request, responder: Responder) -> bool { let deadline = session.clock().now() + Duration::from_secs(10); let queued = match request { @@ -109,8 +114,8 @@ pub fn answering(session: &Session, request: Request, responder: Responder) -> b queued.is_ok() } -/// Script never answering, holding on to the responders so the wire does not -/// answer for them either. +/// Returns a script that never answers, holding on to the responders so the +/// wire does not answer for them either. pub fn silent() -> Script { let mut held = Vec::new(); Box::new(move |_, _, responder| { @@ -119,8 +124,8 @@ pub fn silent() -> Script { }) } -/// Script hanging up on the first request, holding on to its responder so -/// nothing but the end of the session reaches the client. +/// Returns a script that hangs up on the first request, holding on to its +/// responder so only the end of the session reaches the client. pub fn hangup() -> Script { let mut held = Vec::new(); Box::new(move |_, _, responder| { @@ -129,22 +134,30 @@ pub fn hangup() -> Script { }) } -/// Scripted Ark peer over an in-memory stream. Runs until its script finishes -/// or the host closes, and joins its serving thread on drop. +/// Scripted Ark peer over an in-memory stream. +/// +/// It runs until its script finishes or the host closes, and joins its serving +/// thread on drop. pub struct Peer { - pub identity: xdsa::PublicKey, // Identity key the peer signs its handshake with - stream: Option, // Client's end of the stream, until taken - thread: Option>, // Serving thread, joined on drop + /// Identity key the peer signs its handshake with. + pub identity: xdsa::PublicKey, + /// Host end of the stream, until a test takes it. + stream: Option, + /// Serving thread, joined on drop. + thread: Option>, } impl Peer { - /// Starts a peer serving the client per the script. Both ends of its stream - /// measure their deadlines on the clock. + /// Starts a peer serving the client per the script. + /// + /// Both ends of its stream measure their deadlines on the clock. pub fn spawn(clock: &Clock, mut script: Script) -> Self { + // Sign the handshake with a fresh identity attesting itself let signer = xdsa::SecretKey::generate(); let identity = signer.public_key(); let attestation = self_attestation(&signer, identity.clone(), clock); + // Serve one session, passing each request to the script let (host, ark) = memory::duplex(CAPACITY, clock); let thread = thread::spawn(move || { let mut server = Server::new(ark, signer, attestation); @@ -167,12 +180,21 @@ impl Peer { } } - /// Takes the host stream for attachment or forwarding through another transport. + /// Takes the host stream for attachment or forwarding through another + /// transport. + /// + /// # Panics + /// + /// Panics if the stream was already taken. pub fn stream(&mut self) -> Duplex { self.stream.take().expect("stream already taken") } /// Attaches to the peer using its pinned identity key. + /// + /// # Panics + /// + /// Panics if the stream was already taken. pub fn attach(&mut self) -> Result<(Ark, Identity), Error> { let stream = self.stream(); Ark::attach( @@ -186,7 +208,8 @@ impl Peer { impl Drop for Peer { /// Releases an unused host stream and joins the peer after its session ends. fn drop(&mut self) { - // An untaken host stream must close before joining the peer's receive loop. + // An untaken host stream must close before joining the peer's + // receive loop drop(self.stream.take()); if let Some(thread) = self.thread.take() { let _ = thread.join(); diff --git a/connect/src/timing.rs b/connect/src/timing.rs index 217fb20..eca261d 100644 --- a/connect/src/timing.rs +++ b/connect/src/timing.rs @@ -15,9 +15,11 @@ pub(crate) const APPROVAL_WINDOW: Duration = Duration::from_secs(40); /// Pairing approval window with time for the cloud and device exchanges. pub(crate) const PAIRING_WINDOW: Duration = Duration::from_secs(70); -/// An absolute deadline, an inactivity limit, or both. Each expected I/O wait -/// gets a fresh inactivity allowance; the absolute deadline never moves. -/// Approval requests use their protocol window instead of the inactivity limit. +/// Bound on an operation, as an absolute deadline, an inactivity limit, or both. +/// +/// Each expected I/O wait gets a fresh inactivity allowance; the absolute +/// deadline never moves. Approval requests use their protocol window instead of +/// the inactivity limit. #[derive(Clone, Copy, Debug)] pub struct Timing { /// Fixed bound shared by every step of an operation, when supplied. @@ -27,7 +29,7 @@ pub struct Timing { } impl Timing { - /// Passes the fixed operation bound to caller-owned authentication. + /// Returns the fixed operation bound, for caller-owned authentication. pub(crate) fn deadline(self) -> Option { self.deadline } @@ -41,6 +43,7 @@ impl Timing { } /// Bounds each expected response without limiting the whole operation. + /// /// Readers supplied by the caller must impose their own read timeout. pub fn inactivity(timeout: Duration) -> Self { Self { @@ -55,19 +58,25 @@ impl Timing { self } - /// Deadline for the next machine response, measured on the clock. + /// Returns the deadline for the next machine response, measured on the + /// clock. pub(crate) fn io(self, clock: &Clock) -> Instant { self.bound(clock, self.inactivity) } - /// Approval windows include a small allowance for forwarding and replies. - /// Callers using only an absolute deadline retain that exact bound. + /// Returns the deadline for a request that may wait on an approval. + /// + /// The approval window replaces the inactivity allowance and includes a + /// small allowance for forwarding and replies. A timing with only an + /// absolute deadline keeps that exact bound. pub(crate) fn approval(self, clock: &Clock) -> Instant { self.window(clock, APPROVAL_WINDOW) } - /// Replaces an inactivity allowance with a protocol-specific wait window. - /// An absolute-only timing retains its original deadline. + /// Returns the deadline for a request with its own protocol wait window, + /// which replaces the inactivity allowance. + /// + /// An absolute-only timing keeps its original deadline. pub(crate) fn window(self, clock: &Clock, window: Duration) -> Instant { self.bound(clock, self.inactivity.map(|_| window)) } @@ -77,7 +86,10 @@ impl Timing { self.deadline.map_or(deadline, |bound| bound.min(deadline)) } - /// Caller-supplied readers own their per-read timeout. Only a workflow's + /// Checks the absolute deadline, failing with [`crate::Error::Timeout`] once + /// it has passed. + /// + /// Caller-supplied readers own their per-read timeout, so only a workflow's /// absolute deadline can expire while an otherwise active reader runs. pub(crate) fn check(self, clock: &Clock) -> Result<(), crate::Error> { if self @@ -90,8 +102,10 @@ impl Timing { } } - /// Poll cadence is independent of the response allowance. The pause sleeps - /// on the clock, never past the absolute deadline. + /// Sleeps on the clock between polls, never past the absolute deadline. + /// + /// Poll cadence is independent of the response allowance. A deadline + /// already reached fails with [`crate::Error::Timeout`] without sleeping. pub(crate) fn pause(self, clock: &Clock, interval: Duration) -> Result<(), crate::Error> { let wait = match self.deadline { Some(deadline) => interval.min( @@ -130,13 +144,15 @@ impl From for Timing { /// Time left on a clock before a deadline, as blocking OS calls take it. pub(crate) trait ClockExt { - /// Returns the time left before the deadline as a positive OS timeout. A - /// passed deadline fails with `TimedOut`, since a zero timeout means an - /// unbounded wait on some APIs. + /// Returns the time left before the deadline as a positive OS timeout. + /// + /// A passed deadline fails with `TimedOut`, since the standard socket calls + /// refuse a zero timeout. fn remaining(&self, deadline: Instant) -> io::Result; } impl ClockExt for Clock { + /// Measures the time left from this clock's current instant. fn remaining(&self, deadline: Instant) -> io::Result { deadline .checked_duration_since(self.now()) diff --git a/src/access.rs b/src/access.rs index 40c5636..3e14962 100644 --- a/src/access.rs +++ b/src/access.rs @@ -20,13 +20,18 @@ const POLL: Duration = Duration::from_millis(10); /// Prompt policy copied into a connection without retaining its session owner. pub(crate) struct Login { - clock: Clock, // connection's clock, which its deadlines are measured on - output: crate::output::Output, // Invocation's shared diagnostic stream - interactive: bool, // Whether browser login is permitted + /// Clock of the connection, which measures the deadlines it passes in. + clock: Clock, + /// Shared diagnostic stream of the invocation. + output: crate::output::Output, + /// Whether a browser login may start. + interactive: bool, } impl Login { - /// Captures CLI policy without looking up credentials or contacting a host. + /// Captures the invocation's prompt policy for a connection, without + /// looking up credentials or contacting a host. + /// /// The connection's clock measures the deadlines it passes in. pub fn new(context: &Context, clock: Clock) -> Self { Self { @@ -56,12 +61,16 @@ impl darkbio_connect::CloudAuth for Login { .unwrap_or_default() } - /// Recognizes Access separately from the cloud's device proof refusal. + /// Recognizes a Cloudflare Access refusal, as [`required`] defines it. fn rejected(&self, origin: &str, status: StatusCode, headers: &HeaderMap) -> bool { required(origin, status, headers) } - /// Leaves browser interaction out of noninteractive commands and the connector. + /// Runs a browser login for an internal API host, only when the invocation + /// allows prompts. + /// + /// The login window is 600 s on the connection's clock, cut short by an + /// earlier caller deadline. fn login(&self, origin: &str, deadline: Option) -> Result { if !matches!( origin, @@ -87,7 +96,9 @@ fn headers(token: &str) -> Result { } impl Login { - /// Starts one browser login under the supplied human or absolute window. + /// Runs one browser login through cloudflared, which must end by `deadline`. + /// + /// A noninteractive invocation fails at once, without starting the helper. fn authenticate(&self, origin: &str, deadline: Instant) -> Result { if !self.interactive { return Err(format!("access to {origin} requires login")); @@ -106,7 +117,9 @@ impl Login { } /// Asks cloudflared for a cached application token, treating failure as no -/// credentials. The lookup gets one command budget on the connection's clock. +/// credentials. +/// +/// The lookup gets one command budget on the connection's clock. pub(crate) fn cached(context: &Context, clock: &Clock, origin: &str) -> Option { token( Command::new("cloudflared").args(["access", "token", "--app", origin]), @@ -116,9 +129,11 @@ pub(crate) fn cached(context: &Context, clock: &Clock, origin: &str) -> Option bool { if !matches!( origin, @@ -177,11 +196,14 @@ fn challenge(origin: &str, redirect: &str) -> bool { ) } -/// Captures credentials without forwarding helper output to the terminal. +/// Runs a helper command and returns the application token it prints, never +/// forwarding its output to the terminal. +/// /// Expiration kills and reaps the helper, including while its output is blocked. /// The helper's exit and output arrive as events, which the deadline bounds on -/// the clock. +/// the clock. Output that is not a well-formed token fails without being echoed. fn token(command: &mut Command, clock: &Clock, deadline: Instant) -> Result { + // Start the helper with only its stdout connected, unless the time is up remaining(clock, deadline)?; let mut child = Child( command @@ -200,6 +222,8 @@ fn token(command: &mut Command, clock: &Clock, deadline: Instant) -> Result Result 64 * 1024 @@ -246,8 +273,9 @@ fn token(command: &mut Command, clock: &Clock, deadline: Instant) -> Result Result { deadline .checked_duration_since(clock.now()) @@ -323,24 +354,33 @@ fn remaining(clock: &Clock, deadline: Instant) -> Result } /// Owned helper process, killed and reaped on every exit path. -struct Child(std::process::Child); +struct Child( + /// Helper process, owned until this wrapper drops. + std::process::Child, +); impl Drop for Child { - /// Reaps the helper even when output collection, validation or the deadline failed. + /// Kills and reaps the helper, even when output collection, validation or + /// the deadline failed. fn drop(&mut self) { let _ = self.0.kill(); let _ = self.0.wait(); } } +/// Tests of the Access refusal checks and the cloudflared helper runs. #[cfg(test)] mod tests { use super::*; use crate::testing::wait_deadline; use darkbio_clock::TestClock; + /// Checks that login redirects and HTML refusals on internal hosts count + /// as Access refusals, while JSON refusals and other hosts do not. #[test] fn access_refusals_do_not_include_device_proof_errors() { + // On each internal host, a login redirect and an HTML refusal count, but + // a bare or JSON refusal does not for origin in [ "https://api.darkbio.dev", "https://api.darkbio.xyz", @@ -368,6 +408,8 @@ mod tests { headers.clear(); } } + + // Public, lookalike and plain HTTP hosts never count, even with HTML let headers = HeaderMap::from_iter([( "content-type".parse().unwrap(), "text/html".parse().unwrap(), @@ -382,10 +424,15 @@ mod tests { } } + /// Checks that a noninteractive login fails with the manual login command + /// as its hint, and a public host gets no headers. #[test] fn noninteractive_login_returns_an_action_without_starting_a_helper() { use clap::Parser; use darkbio_connect::CloudAuth; + + // A noninteractive login refuses, and the refusal maps to a + // login-required error with the manual command let options = crate::args::Cli::parse_from(["ark", "--no-input"]).options; let clock = TestClock::new().clock(); let login = Login { @@ -403,6 +450,8 @@ mod tests { error.hints, ["run `cloudflared access login --app https://api.darkbio.dev`"] ); + + // A public host gets no headers, so no helper starts for it assert!( login .headers("https://api.dark.bio", clock.now()) @@ -410,6 +459,8 @@ mod tests { ); } + /// Checks that only the tenant's login page for the exact host counts as a + /// challenge. #[test] fn challenge_is_scoped_to_our_tenant_and_package_host() { assert!(challenge( @@ -425,11 +476,15 @@ mod tests { assert!(!challenge("https://pkg.darkbio.dev", redirect)); } } - /// Helper failures and malformed stdout are reported without including the - /// token. The tests use a local child instead of opening a browser. + + /// Checks that a helper's token comes back, while helper failures and + /// malformed output never echo it. + /// + /// A local shell stands in for cloudflared, so no browser opens. #[cfg(unix)] #[test] fn test_token() { + // A well-formed token comes back without its line ending let clock = TestClock::new().clock(); let deadline = clock.now() + Duration::from_secs(5); assert_eq!( @@ -441,6 +496,8 @@ mod tests { .unwrap(), "e30.e30.signature" ); + + // Empty, malformed, failed and oversized output fails without the token for script in [ "exit 0", "printf 'private-token'", @@ -451,6 +508,8 @@ mod tests { token(Command::new("sh").args(["-c", script]), &clock, deadline).unwrap_err(); assert!(!error.to_string().contains("private-token")); } + + // A missing helper asks for cloudflared to be installed let error = token( &mut Command::new("/nonexistent/ark-test-cloudflared"), &clock, @@ -460,8 +519,8 @@ mod tests { assert!(error.to_string().contains("install it")); } - /// An expired deadline never starts a helper; a running helper is killed - /// promptly instead of surviving until its own login timeout. + /// Checks that an expired deadline starts no helper, and a reached deadline + /// ends a running one instead of waiting out its own login timeout. #[cfg(unix)] #[test] fn test_deadline() { @@ -477,7 +536,8 @@ mod tests { Err(ConnectError::Timeout) )); - // A helper announces its start through a named pipe, then would run for an hour + // A helper announces its start through a named pipe, and would then + // run for an hour let pipe = std::env::temp_dir().join(format!("ark-access-test-{}", std::process::id())); assert!( Command::new("mkfifo") @@ -504,21 +564,20 @@ mod tests { )); } - /// Exit status of a helper that ended with `code`. + /// Builds the exit status of a helper that ended with `code`. #[cfg(unix)] fn exited(code: i32) -> ExitStatus { std::os::unix::process::ExitStatusExt::from_raw(code << 8) } - /// Exit status of a helper that ended with `code`. + /// Builds the exit status of a helper that ended with `code`. #[cfg(windows)] fn exited(code: i32) -> ExitStatus { std::os::windows::process::ExitStatusExt::from_raw(code as u32) } - /// The helper's exit and then its output settle the wait. A failed or - /// unobservable exit fails it without the output, and the deadline ends - /// the wait for either event. + /// Checks that the helper's exit and then its output settle the wait, while + /// a failed exit or the deadline ends it. #[test] fn test_settle() { // Settles one helper's events on a thread, under a deadline a second diff --git a/src/args.rs b/src/args.rs index b915c50..8338d4d 100644 --- a/src/args.rs +++ b/src/args.rs @@ -13,7 +13,7 @@ use clap::{Args, Parser, Subcommand, ValueEnum}; use darkbio_connect::trust::Environment; use std::path::PathBuf; -// Parsed command and global options; cross-level conflicts are checked afterward. +// Parsed command and global options; cross-level conflicts are checked afterward #[derive(Parser)] #[command( name = "ark", @@ -24,7 +24,7 @@ use std::path::PathBuf; propagate_version = false )] pub(crate) struct Cli { - // Options propagated to every command level by clap. + // Options propagated to every command level by clap #[command(flatten)] pub options: Options, /// Tool, connect and wire versions @@ -33,17 +33,21 @@ pub(crate) struct Cli { /// Print help; `ark help --all` prints the manual #[arg(short = 'h', long)] pub help: bool, - // Accept the common --help --all spelling of the manual. + // Accept the common --help --all spelling of the manual #[arg(long, hide = true, requires = "help", conflicts_with = "version")] pub all: bool, - // Absent for top-level help or the standalone version flag. + // Absent for top-level help or the standalone version flag #[command(subcommand)] pub command: Option, } impl Cli { - /// Clap checks conflicts within one parser level. Global values may have - /// been supplied at an ancestor, so verify these after propagation too. + /// Checks the flag conflicts that span command levels, failing with a usage + /// error of class 2. + /// + /// Clap checks conflicts within one parser level, while a global flag may + /// come from any level. This rejects `--dry-run` with `--unlock`, `--quiet` + /// with `--verbose`, and `--version` with a command. pub fn validate(&self) -> Result<(), crate::error::Error> { let dry = matches!( &self.command, @@ -72,7 +76,7 @@ impl Cli { } } -// Invocation-wide presentation and device policy, independent of connect's API. +// Invocation-wide presentation and device policy, independent of connect's API #[derive(Args, Clone)] pub(crate) struct Options { /// Which Ark: locator, unique serial, name, image, or hardware/emulator @@ -107,14 +111,16 @@ pub(crate) struct Options { pub log: Option, } -// Diagnostic detail, independent of step narration. +// Diagnostic detail, independent of step narration #[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq)] pub(crate) enum Log { + // Update and connect events up to debug level Debug, + // Update, connect and wire events at every level Trace, } -// Top-level command palette, shared by parsing, help and shell completion. +// Top-level command palette, shared by parsing, help and shell completion #[derive(Subcommand)] pub(crate) enum Command { /// Find hardware Arks and running emulators @@ -156,7 +162,7 @@ pub(crate) enum Command { }, } -// Explicit identity pin accepted by diagnostics and enrollment recovery. +// Explicit identity pin accepted by diagnostics and enrollment recovery #[derive(Args)] pub(crate) struct Recovery { /// Pin an xDSA public key instead of verifying the attestation @@ -169,18 +175,18 @@ pub(crate) struct Recovery { pub pubkey: Option, } -// Local attestation input and optional identity pin for enrollment. +// Local attestation input and optional identity pin for enrollment #[derive(Args)] pub(crate) struct Enroll { /// Install an existing signed attestation #[arg(long, value_name = "FILE")] pub cwt: Option, - // Recovery can authenticate a device whose stored attestation is unusable. + // Recovery can authenticate a device whose stored attestation is unusable #[command(flatten)] pub recovery: Recovery, } -// Dataset inspection and mutation commands; slot names map to wire IDs below. +// Dataset inspection and mutation commands; slot names map to wire IDs below #[derive(Subcommand)] pub(crate) enum Data { /// Map the data paths an app can read @@ -228,7 +234,7 @@ pub(crate) enum Data { Repair(Change), } -// Shared selection and planning arguments for slot deletion and repair. +// Shared selection and planning arguments for slot deletion and repair #[derive(Args)] pub(crate) struct Change { /// Slot name or id from `ark data list` @@ -239,7 +245,7 @@ pub(crate) struct Change { pub dry_run: bool, } -// App execution and explicit cancellation by the task ID returned by the Ark. +// App execution and explicit cancellation by the task ID returned by the Ark #[derive(Subcommand)] pub(crate) enum App { /// Run an app, approved on your phone; print its report @@ -254,7 +260,7 @@ pub(crate) enum App { }, } -// Published firmware selection and installation, including read-only planning. +// Published firmware selection and installation, including read-only planning #[derive(Subcommand)] pub(crate) enum Firmware { /// Show the installed build and update candidates @@ -273,7 +279,7 @@ pub(crate) enum Firmware { }, } -/// Parses the three explicit cloud routes supported by the CLI. +/// Parses a cloud environment name, one of `release`, `staging` or `develop`. pub(crate) fn parse_env(value: &str) -> Result { match value { "release" => Ok(Environment::Release), @@ -285,7 +291,10 @@ pub(crate) fn parse_env(value: &str) -> Result { } } -/// Protocol names remain exact; numeric IDs keep future slots addressable. +/// Parses a slot selector, either a positive numeric id or an exact slot name. +/// +/// A name must match the spelling [`slot_name`] prints. Any positive id is +/// accepted, so slots this build does not know stay addressable. pub(crate) fn parse_slot(value: &str) -> Result { if let Ok(id) = value.parse::() && id > 0 @@ -311,7 +320,8 @@ pub(crate) fn slot_name(id: i32) -> String { .unwrap_or_else(|_| id.to_string()) } -/// Reject durations that cannot be represented as monotonic deadlines. +/// Parses a `--timeout` value in seconds, rejecting zero and values too large +/// for a deadline on the monotonic clock. #[expect( clippy::disallowed_methods, reason = "flags are parsed before any connection clock exists, and the timeout must fit a deadline on the real monotonic clock that connections run on" @@ -330,10 +340,13 @@ fn parse_timeout(value: &str) -> Result { Ok(seconds) } +/// Tests of the value parsers and the conflict checks across command levels. #[cfg(test)] mod tests { use super::*; + /// Checks that every environment name parses, alone and as a global flag, + /// while an unknown name fails. #[test] fn all_environments_parse() { assert!( @@ -341,6 +354,8 @@ mod tests { .unwrap_err() .starts_with("unknown environment") ); + + // Each name parses directly and through the flag before a command for (name, env) in [ ("release", Environment::Release), ("staging", Environment::Staging), @@ -352,6 +367,8 @@ mod tests { } } + /// Checks that slot names parse only in their exact spelling, while every + /// positive id parses, known or not. #[test] fn slots_are_exact_and_future_ids_remain_addressable() { for id in 1..=4 { @@ -359,6 +376,8 @@ mod tests { assert_eq!(parse_slot(&id.to_string()), Ok(id)); } assert_eq!(parse_slot("2147483647"), Ok(i32::MAX)); + + // Zero, negative, overflowing, unknown and misspelled selectors all fail for invalid in [ "0", "unspecified", @@ -372,6 +391,8 @@ mod tests { } } + /// Checks that `-v` and `--log` set independently, while a repeated `-v` + /// or an unknown log level fails. #[test] fn narration_and_diagnostics_are_independent() { let cli = Cli::try_parse_from(["ark", "-v", "status", "--log", "debug"]).unwrap(); @@ -380,6 +401,8 @@ mod tests { let cli = Cli::try_parse_from(["ark", "--log", "trace", "status"]).unwrap(); assert!(!cli.options.verbose); assert_eq!(cli.options.log, Some(Log::Trace)); + + // Verbosity has no levels, and logs have no info level for args in [ vec!["ark", "-vv"], vec!["ark", "-vvv"], @@ -389,6 +412,8 @@ mod tests { } } + /// Checks that conflicting flags fail in the parser or in validation, + /// whichever command level they arrive at. #[test] fn parser_conflicts_protect_dry_runs_and_explicit_selection() { for args in [ @@ -411,6 +436,8 @@ mod tests { "{args:?}" ); } + + // The largest 64-bit task id still parses assert!(Cli::try_parse_from(["ark", "app", "cancel", "18446744073709551615"]).is_ok()); } } diff --git a/src/context.rs b/src/context.rs index 3517733..baad401 100644 --- a/src/context.rs +++ b/src/context.rs @@ -19,20 +19,23 @@ use std::io::{self, IsTerminal}; use std::path::Path; use std::time::{Duration, Instant}; -/// Invocation policy and shared output, kept outside the reusable connector. +/// Invocation policy and shared output, kept outside the reusable connection +/// library. pub(crate) struct Context { /// Parsed global flags controlling selection, prompts and wait allowances. pub options: Options, /// Result and event streams shared with progress and signal handlers. pub output: Output, - /// Tracks the active session and cancellable work for process interruption. + /// Interruption state tracking the active session and cancelable work. pub interrupt: crate::interrupt::Interrupt, } /// Owned session and snapshots used by one command's policy checks. +/// /// Device info is not refreshed automatically after mutations. pub(crate) struct Connection { - /// Keeps the wire session and its lazy cloud services alive. + /// Session owner that keeps the wire session and its lazy cloud services + /// alive. pub ark: Ark, /// Typed request handle bound to the owned session. pub client: Client, @@ -59,15 +62,18 @@ impl Connection { } impl Context { - /// Gives each machine wait the CLI allowance without bounding the full command. + /// Returns an inactivity timing that bounds each wait by `--timeout`, not + /// the whole command. pub fn timing(&self) -> Timing { Timing::inactivity(Duration::from_secs(self.options.timeout)) } - /// Starts one fixed wait budget on a connection's clock, for operations - /// that need a single bound. + + /// Returns the instant `--timeout` from now on `clock`, for operations that + /// need a single bound. pub fn deadline(&self, clock: &Clock) -> Instant { clock.now() + Duration::from_secs(self.options.timeout) } + /// Permits stdin prompts only for a terminal outside JSON and no-input modes. pub fn interactive(&self) -> bool { !self.options.no_input && !self.output.json() && io::stdin().is_terminal() @@ -84,13 +90,15 @@ impl Context { found } - /// Selects and opens an Ark, then enforces the CLI's firmware compatibility gate. + /// Selects and opens an Ark, then enforces the firmware compatibility gate. pub fn connect(&self, pubkey: Option<&str>) -> Result { let connection = self.connect_recovery(pubkey)?; connection.require_current()?; Ok(connection) } + /// Selects and opens an Ark without the firmware compatibility gate. + /// /// Only diagnostics and the commands that upgrade or enroll an old device /// may cross the compatibility gate. pub fn connect_recovery(&self, pubkey: Option<&str>) -> Result { @@ -99,30 +107,39 @@ impl Context { self.open(device, pubkey) } - /// Opens an already selected endpoint and reads its state without the version gate. + /// Opens a selected endpoint and reads its state without the version gate. pub fn open(&self, device: Device, pubkey: Option<&str>) -> Result { self.open_until(device, pubkey, None) } - /// Opens an endpoint with CLI routing precedence and records it for interruption. - /// An optional reboot deadline bounds device-info I/O; transport establishment - /// and the wire handshake retain their own connection timeouts. The output - /// and the login helper time their waits on the new connection's clock. + /// Opens an endpoint with the CLI's routing precedence and records it for + /// interruption. + /// + /// An optional reboot deadline bounds device-info I/O, while transport + /// establishment and the wire handshake keep their own connection timeouts. + /// The output and the login helper time their waits on the new connection's + /// clock. pub fn open_until( &self, device: Device, pubkey: Option<&str>, deadline: Option, ) -> Result { + // A reboot deadline also bounds the device info request let timing = deadline.map_or(self.timing(), |deadline| { self.timing().with_deadline(deadline) }); + + // Authenticate the Ark and route its cloud by the CLI's precedence let trust = trust(pubkey)?; let (mut ark, identity) = device.connect_with_env(&trust, |identity| { environment(self.options.env, identity, device.env()) })?; let client = ark.client(); self.output.connection(client.clock()); + + // Warn when --env overrides an attested environment, and note a + // non-release one let env = environment(self.options.env, &identity, device.env()); if let Identity::Attested { env: attested, .. } = &identity && self.options.env.is_some_and(|env| env != *attested) @@ -133,9 +150,13 @@ impl Context { ); } self.output.environment(env); + + // Non-release environments log in through Cloudflare Access if env != Environment::Release { ark.set_cloud_auth(crate::access::Login::new(self, client.clock())); } + + // Register the session for interruption before its first request self.interrupt.connection(client.clone(), ark.closer()); let info = client.call(schema::DeviceInfoRequest {}, timing)?; self.output.event("step", "connected and authenticated"); @@ -149,9 +170,11 @@ impl Context { }) } - /// Requires pairing and unlock, optionally prompting or honoring --unlock. - /// A dry run never unlocks implicitly. Successful unlock leaves the original - /// device-info snapshot unchanged; callers can continue the requested operation. + /// Requires pairing and unlock, optionally prompting or honoring `--unlock`. + /// + /// A dry run never unlocks implicitly. A successful unlock leaves the + /// original device-info snapshot unchanged, so callers can continue the + /// requested operation. pub fn require_unlocked(&self, connection: &Connection, dry_run: bool) -> Result<(), Error> { let state = &connection.info; if !state.paired { @@ -160,6 +183,9 @@ impl Context { if state.unlocked { return Ok(()); } + + // A locked Ark unlocks on --unlock or a confirmed prompt, but never for + // a dry run if dry_run { return Err(Error::new(5, "locked", "the Ark is locked") .hint("run `ark unlock` separately before the dry run")); @@ -194,8 +220,10 @@ impl Context { Ok(()) } - /// Prompts for a yes/no answer; EOF and unrecognized input decline. - /// The caller must first check whether this invocation permits input. + /// Prompts for a yes or no answer, where an empty answer takes `default`. + /// + /// End of input and unrecognized answers decline. The caller must first + /// check whether this invocation permits input. pub fn confirm(&self, message: &str, default: bool) -> Result { self.output.prompt(message, default)?; let mut answer = String::new(); @@ -210,6 +238,9 @@ impl Context { } } +/// Picks the cloud environment from the `--env` override, the attestation, the +/// launcher's report or release, in that order. +/// /// Routing does not change the identity established by the handshake. fn environment( overridden: Option, @@ -226,7 +257,8 @@ fn environment( .unwrap_or(Environment::Release) } -/// Parses an explicit recovery key, otherwise accepting enabled roots or self-signing. +/// Parses an explicit recovery key, otherwise accepting enabled roots or +/// self-signing. fn trust(pubkey: Option<&str>) -> Result { let Some(encoded) = pubkey else { return Ok(TrustMode::RootOrSelf); @@ -243,7 +275,9 @@ fn trust(pubkey: Option<&str>) -> Result { } /// Opens a nonempty regular file and returns its current size without reading it. -/// Upload workflows separately detect files that change after this snapshot. +/// +/// Upload workflows reject a file that ends before this size or runs past it. +/// They do not detect a change that keeps the same length. pub(crate) fn open_file(path: &Path) -> Result<(std::fs::File, u64), Error> { let file = std::fs::File::open(path).map_err(|err| { Error::new( @@ -274,6 +308,7 @@ pub(crate) fn open_file(path: &Path) -> Result<(std::fs::File, u64), Error> { Ok((file, meta.len())) } +/// Tests of the cloud environment precedence. #[cfg(test)] mod tests { use super::*; @@ -282,8 +317,12 @@ mod tests { wire::crypto::{cwt::claims::eat, xdsa}, }; + /// Checks that the environment follows the override, then the attestation, + /// then the launcher's report, then release. #[test] fn environment_follows_override_attestation_launcher_release() { + // An attested environment beats the launcher's report, and only the + // override beats it let key = xdsa::SecretKey::generate().public_key(); for env in [ Environment::Release, @@ -311,6 +350,9 @@ mod tests { Environment::Staging ); } + + // Unattested identities follow the override or the launcher's report, + // falling back to release when neither is usable for identity in [Identity::SelfSigned(key.clone()), Identity::Recovered(key)] { assert_eq!( environment(None, &identity, Some("develop")), diff --git a/src/data/cache.rs b/src/data/cache.rs index 762bab9..c15760a 100644 --- a/src/data/cache.rs +++ b/src/data/cache.rs @@ -4,7 +4,9 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -//! Public reference cache. Personal uploads and app results never enter it. +//! Public reference cache, keeping downloads as files named by their digest. +//! +//! Personal uploads and app results never enter it. use crate::output::Output; use serde::{Deserialize, Serialize}; @@ -17,16 +19,20 @@ use std::sync::{ mpsc, }; -/// Persistence unit for resumable prefixes and the background writer's queue. +/// Persistence unit of 1 MiB for resumable prefixes and the background writer's +/// queue. pub(super) const CHUNK: usize = 1024 * 1024; -/// Uses the platform user cache directory, with a temporary-directory fallback. +/// Returns `ark` under the platform user cache directory, or `ark-cache` under +/// the temporary directory when there is none. pub(crate) fn directory() -> PathBuf { directories::BaseDirs::new() .map(|dirs| dirs.cache_dir().join("ark")) .unwrap_or_else(|| std::env::temp_dir().join("ark-cache")) } +/// Checks whether the cache holds a complete file for a SHA-256 digest. +/// /// Only a complete digest can name a cache entry; advertised metadata may /// contain unknown or malformed values even when no download is requested. /// Presence is only a planning hint; replay verifies the file's contents again. @@ -46,6 +52,8 @@ pub(super) struct Metadata { pub size: u64, } +/// Locked cache entry holding the resumable prefix of one reference download. +/// /// A separate lock file permits reading the retained prefix while the worker /// appends. Locking the data file itself would prevent those reads on Windows. /// The worker retains the lock until queued writes and the final sync finish. @@ -59,11 +67,16 @@ pub(super) struct Entry { /// Separate exclusive lock retained through queued writes and final publication. lock: File, } + impl Entry { - /// Locks a digest-named entry without waiting and retains only complete cache chunks. - /// The caller supplies a validated hex digest. Missing or incompatible resume metadata - /// resets the data; an entry already in use is left to its current owner. + /// Locks a digest-named entry without waiting and retains only complete + /// cache chunks. + /// + /// The caller supplies a validated hex digest. Missing or incompatible + /// resume metadata resets the data, and an entry already in use fails the + /// open, leaving it to its current owner. pub fn open(directory: &Path, hash: &str, url: &str, size: u64) -> io::Result { + // Take the lock first, failing at once when the entry is in use fs::create_dir_all(directory)?; let path = directory.join(format!("{hash}.part")); let lock = OpenOptions::new() @@ -73,6 +86,9 @@ impl Entry { .truncate(false) .open(path.with_extension("lock"))?; lock.try_lock().map_err(io::Error::other)?; + + // Open the data file, keeping the old metadata only when it describes + // the same source let file = OpenOptions::new() .read(true) .write(true) @@ -89,6 +105,9 @@ impl Entry { validator: None, size, }); + + // Keep the whole chunks of a validated prefix that fits the source, and + // nothing otherwise let length = file.metadata()?.len(); if meta.validator.is_none() || length > size { file.set_len(0)?; @@ -102,16 +121,19 @@ impl Entry { lock, }) } + /// Returns the retained prefix length after any incomplete tail was discarded. pub fn len(&self) -> io::Result { Ok(self.file.metadata()?.len()) } + /// Discards the prefix and rewinds the file while retaining its exclusive lock. pub fn reset(&mut self) -> io::Result<()> { self.file.set_len(0)?; self.file.rewind()?; Ok(()) } + /// Replaces the resume sidecar through a temporary file in the same directory. pub fn save(&self) -> io::Result<()> { let data = serde_json::to_vec(&self.meta).map_err(io::Error::other)?; @@ -120,17 +142,27 @@ impl Entry { fs::write(&temporary, data)?; fs::rename(temporary, sidecar) } - /// Opens an independent read cursor before the original handle moves to the writer. + + /// Opens an independent read cursor before the original handle moves to + /// the writer. pub fn prefix(&self) -> io::Result { File::open(&self.path) } - /// Moves data and lock ownership to a bounded append worker that syncs each chunk. - /// Write failures warn and discard the partial copy without failing the upload. + + /// Moves data and lock ownership to a bounded append worker that syncs each + /// chunk. + /// + /// Write failures warn and discard the partial copy without failing the + /// upload. pub fn writer(mut self, output: Output) -> io::Result { + // Append after the retained prefix, queueing at most two chunks self.file.seek(io::SeekFrom::End(0))?; let failed = Arc::new(AtomicBool::new(false)); let fault = failed.clone(); let (sender, receiver) = mpsc::sync_channel::>(2); + + // Write and sync each chunk, and on a failure warn, drop the entry and + // remove its files let worker = std::thread::Builder::new() .name("ark-cache".into()) .spawn(move || { @@ -165,28 +197,33 @@ impl Entry { } impl Drop for Entry { - /// Releases the entry lock explicitly, including when a child inherited its descriptor. + /// Releases the entry lock explicitly, including when a child inherited its + /// descriptor. fn drop(&mut self) { - // Release explicitly: a concurrently spawned child may briefly inherit - // the descriptor before exec closes it. + // Release explicitly, since a concurrently spawned child may briefly + // inherit the descriptor before exec closes it let _ = self.lock.unlock(); } } -/// Buffers cache chunks and hands them to one append worker. +/// Buffer that gathers download bytes into cache chunks for one append worker. +/// /// A full queue backpressures the reader; dropping joins outstanding writes. pub(super) struct Writer { /// Bounded chunk queue, closed before joining the worker. sender: Option>>, /// Worker returning the locked entry if every queued write succeeded. worker: Option>>, - /// Incomplete cache chunk retained locally until filled or successful completion. + /// Incomplete cache chunk retained locally until filled or successful + /// completion. buffer: Vec, /// Worker failure signal allowing subsequent cache appends to be skipped. failed: Arc, } + impl Writer { - /// Accumulates network bytes and queues full chunks, waiting if the worker is behind. + /// Accumulates network bytes and queues full chunks, waiting if the worker + /// is behind. pub fn append(&mut self, mut bytes: &[u8]) { if self.failed.load(Ordering::SeqCst) { return; @@ -200,6 +237,7 @@ impl Writer { } } } + /// Queues the current nonempty buffer and starts a fresh persistence chunk. fn flush(&mut self) { if self.buffer.is_empty() { @@ -210,7 +248,10 @@ impl Writer { let _ = sender.send(bytes); } } - /// Joins queued writes and returns the locked entry when persistence succeeded. + + /// Joins queued writes and returns the locked entry when persistence + /// succeeded. + /// /// Only a verified complete source retains the final partial chunk. pub fn finish(mut self, complete: bool) -> Option { if complete { @@ -222,8 +263,10 @@ impl Writer { .and_then(|worker| worker.join().ok().flatten()) } } + impl Drop for Writer { - /// Drops the unfinished tail, drains queued writes and releases the worker's lock. + /// Drops the unfinished tail, drains queued writes and releases the + /// worker's lock. fn drop(&mut self) { self.sender.take(); if let Some(worker) = self.worker.take() { @@ -233,6 +276,7 @@ impl Drop for Writer { } /// Publishes a verified source under its digest while retaining the entry lock. +/// /// The caller has already checked the full length and SHA-256. pub(super) fn complete(entry: Entry, hash: &str) -> io::Result<()> { let target = entry.path.parent().expect("cache parent").join(hash); @@ -241,7 +285,8 @@ pub(super) fn complete(entry: Entry, hash: &str) -> io::Result<()> { Ok(()) } -/// Totals immediate cache entries for diagnostics, including sidecars and partial files. +/// Totals immediate cache entries for diagnostics, including sidecars and +/// partial files. pub(crate) fn size(path: &Path) -> io::Result { if !path.exists() { return Ok(0); @@ -252,6 +297,7 @@ pub(crate) fn size(path: &Path) -> io::Result { }) } +/// Tests of the resumable prefix, its lock and the failure handling. #[cfg(test)] mod tests { use super::*; @@ -259,9 +305,17 @@ mod tests { use std::io::Read; use std::sync::atomic::AtomicU64; + /// Counter that gives each test directory of this process a unique name. static NEXT: AtomicU64 = AtomicU64::new(0); - struct Directory(PathBuf); + + /// Temporary cache directory, removed when dropped. + struct Directory( + /// Path of the temporary directory. + PathBuf, + ); + impl Directory { + /// Creates an empty directory unique to this process and call. fn new() -> Self { let path = std::env::temp_dir().join(format!( "ark-cache-test-{}-{}", @@ -272,14 +326,21 @@ mod tests { Self(path) } } + impl Drop for Directory { + /// Tries to remove the directory and its contents, ignoring failures. fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } + + /// Builds a quiet output, so cache warnings stay off the test's stderr. fn output() -> Output { Output::new(&crate::args::Cli::parse_from(["ark", "--quiet"]).options) } + + /// Opens the test entry for a 4 MiB download and saves a validator, so its + /// prefix can resume. fn entry(directory: &Directory) -> Entry { let mut entry = Entry::open( &directory.0, @@ -293,10 +354,11 @@ mod tests { entry } - /// Only complete durable chunks survive a failed attempt; the disk worker - /// retains the exclusive lock until all queued writes finish. + /// Checks that only complete durable chunks survive a failed attempt, locked + /// until all queued writes finish. #[test] fn interrupted_writer_retains_a_resumable_prefix() { + // A second open fails while the worker holds the lock let directory = Directory::new(); let mut writer = entry(&directory).writer(output()).unwrap(); writer.append(&vec![42; CHUNK + 17]); @@ -309,6 +371,9 @@ mod tests { ) .is_err() ); + + // An incomplete finish drops the partial tail, and a reopen resumes + // after the whole chunk drop(writer.finish(false).unwrap()); let resumed = Entry::open( &directory.0, @@ -323,9 +388,11 @@ mod tests { assert_eq!(bytes, vec![42; CHUNK]); } - /// Incomplete bytes and a changed URL cannot be replayed under old metadata. + /// Checks that incomplete bytes and a changed URL cannot be replayed under + /// old metadata. #[test] fn resume_discards_uncommitted_or_unvalidated_bytes() { + // A reopen drops the incomplete tail after the whole chunk let directory = Directory::new(); let entry = entry(&directory); entry.file.set_len(CHUNK as u64 + 91).unwrap(); @@ -339,6 +406,8 @@ mod tests { .unwrap(); assert_eq!(resumed.len().unwrap(), CHUNK as u64); drop(resumed); + + // A changed URL resets the data and the validator let changed = Entry::open( &directory.0, "hash", @@ -350,6 +419,8 @@ mod tests { assert!(changed.meta.validator.is_none()); } + /// Checks that a completed download keeps its final partial chunk and + /// removes the partial file and its metadata. #[test] fn complete_file_includes_the_last_partial_chunk() { let directory = Directory::new(); @@ -364,10 +435,14 @@ mod tests { assert!(!directory.0.join("hash.meta").exists()); } - /// A failed cache write only disables retention. It must not turn the - /// source reader into a failed Ark upload or deadlock the producer. + /// Checks that a failed cache write only disables retention, without + /// deadlocking the producer. + /// + /// A cache failure must never turn the source reader into a failed Ark + /// upload. #[test] fn disk_failure_releases_the_writer() { + // A read-only handle makes every write fail let directory = Directory::new(); let mut entry = entry(&directory); entry.file = File::open(&entry.path).unwrap(); diff --git a/src/data/mod.rs b/src/data/mod.rs index 671d92d..d754954 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -28,9 +28,12 @@ use serde_json::{Value, json}; use std::io::Seek; /// Dispatches dataset commands after applying CLI unlock and dry-run policy. -/// Path entries, slot metadata and refusal messages come from the Ark; the CLI -/// does not predict whether a requested delete, repair or upload will be accepted. +/// +/// Path entries, slot metadata and refusal messages come from the Ark. The CLI +/// does not predict whether a requested delete, repair or upload will be +/// accepted. pub(crate) fn run(context: &Context, command: args::Data) -> Result<(), Error> { + // An upload opens its file before it connects, so it runs on its own if let args::Data::Upload { file, slot, @@ -39,6 +42,8 @@ pub(crate) fn run(context: &Context, command: args::Data) -> Result<(), Error> { { return upload(context, &file, slot, dry_run); } + + // Every other command needs an unlocked Ark, which a dry run never unlocks let connection = context.connect(None)?; let dry_run = match &command { args::Data::Fetch { dry_run, .. } => *dry_run, @@ -46,12 +51,16 @@ pub(crate) fn run(context: &Context, command: args::Data) -> Result<(), Error> { _ => false, }; context.require_unlocked(&connection, dry_run)?; + + // The path map needs no slot inventory if matches!(command, args::Data::Paths) { let paths = connection .client .call(schema::DatasetPathsRequest {}, context.timing())?; return paths::print(&context.output, &paths.paths); } + + // The rest work from the Ark's slot inventory let slots = connection .client .call(schema::SlotListRequest {}, context.timing())? @@ -63,10 +72,15 @@ pub(crate) fn run(context: &Context, command: args::Data) -> Result<(), Error> { args::Data::Delete(args) | args::Data::Repair(args) => { let slot = select(&slots, args.slot)?; let mut value = json!({"slot":slot_name(slot.kind),"id":slot.kind,"state":state(slot),"changed":false}); + + // A dry run reports the slot and its dependents without changing it if args.dry_run { value["required_by"] = json!(required_by(&slots, slot.kind)); return context.output.document(&value); } + + // Empty the slot once the owner approves, where only a filled or + // damaged slot counts as changed context.output.event( "approve", format!( @@ -111,6 +125,7 @@ pub(crate) fn run(context: &Context, command: args::Data) -> Result<(), Error> { /// Prints the compact slot inventory and hints for missing or damaged data. fn listing(output: &crate::output::Output, slots: &[SlotStatus]) -> Result<(), Error> { + // JSON keeps plain dependency names, and the table shows their state let rows: Vec<_> = slots.iter().map(metadata).collect(); let document = json!({"slots":rows}); let rows: Vec<_> = rows @@ -131,6 +146,9 @@ fn listing(output: &crate::output::Output, slots: &[SlotStatus]) -> Result<(), E ("REQUIRES", "requires"), ], )?; + + // Hint at repairing damage, fetching missing references and filling + // missing dependencies for slot in slots { if slot.state == SlotState::StateDamaged as i32 { output.event( @@ -174,13 +192,18 @@ fn show(output: &crate::output::Output, slots: &[SlotStatus], id: i32) -> Result } /// Identifies a local file, checks an optional target constraint and either plans -/// or uploads it. Rewinds the consumed prefix and preserves progress on failure. +/// or uploads it. +/// +/// The upload rewinds the prefix that identification consumed, and a failure +/// still prints the progress reached. fn upload( context: &Context, path: &std::path::Path, required: Option, dry_run: bool, ) -> Result<(), Error> { + // Open the file and connect to an unlocked Ark, which a dry run never + // unlocks let (mut file, size) = open_file(path)?; let name = path .file_name() @@ -188,6 +211,9 @@ fn upload( .ok_or_else(|| Error::new(1, "file-unreadable", "filename is not valid UTF-8"))?; let connection = context.connect(None)?; context.require_unlocked(&connection, dry_run)?; + + // Let the Ark identify the file, refusing a rejection or a slot other than + // the required one context .output .event("progress", format!("identifying {}", path.display())); @@ -208,6 +234,8 @@ fn upload( ), )); } + + // Report the identification, warning when the Ark is unsure of it let mut value = json!({ "slot": slot_name(identified.kind), "id": identified.kind, @@ -231,6 +259,8 @@ fn upload( "low identification confidence; the Ark will validate during processing", ); } + + // A dry run plans against the target slot's state and dependencies if dry_run { let slots = connection .client @@ -250,6 +280,8 @@ fn upload( .output .document_with(&value, |theme| crate::output::human::document(theme, &view)); } + + // Upload from the start, keeping the partial result current on every stage file.rewind()?; let clock = connection.client.clock(); let started = clock.now(); @@ -272,6 +304,8 @@ fn upload( context.interrupt.partial(value.clone()); }, ); + + // Print the result, complete or partial, before any error context.interrupt.clear(); value["uploaded_bytes"] = json!(progress.uploaded); value["phases"] = json!(progress.phases); @@ -280,7 +314,8 @@ fn upload( result.map_err(Into::into) } -/// Upload presentation and partial-result facts shared by local and reference sources. +/// Upload presentation and partial-result facts shared by local and reference +/// sources. pub(crate) struct Progress<'a> { /// Output and interruption handles for this invocation. context: &'a Context, @@ -297,8 +332,10 @@ pub(crate) struct Progress<'a> { /// Processing phase names from the latest report, in device order. pub phases: Vec, } + impl<'a> Progress<'a> { - /// Starts a new dataset's progress without inheriting a previous transfer's estimates. + /// Starts a new dataset's progress without inheriting a previous transfer's + /// estimates. pub fn new(context: &'a Context, clock: Clock, slot: i32) -> Self { Self { context, @@ -310,7 +347,9 @@ impl<'a> Progress<'a> { phases: Vec::new(), } } - /// Updates cancellation and result facts on every callback, throttling only presentation. + + /// Updates cancellation and result facts on every callback, throttling only + /// presentation. pub fn update(&mut self, stage: UploadProgress) { match stage { UploadProgress::Identifying => { @@ -371,8 +410,10 @@ impl<'a> Progress<'a> { } } -/// Predicts whether to show a phone approval prompt; known public references need none. -/// This is presentation only. The Ark remains responsible for authorization. +/// Predicts whether to show a phone approval prompt for an upload to `slot`. +/// +/// Known public references need none. This is presentation only, and the Ark +/// remains responsible for authorization. fn upload_approval(slot: i32) -> bool { !matches!( schema::SlotKind::try_from(slot), @@ -404,10 +445,12 @@ pub(crate) fn select(slots: &[SlotStatus], id: i32) -> Result<&SlotStatus, Error ) }) } -/// Whether the Ark explicitly reports usable, filled data in this slot. + +/// Checks whether the Ark explicitly reports usable, filled data in this slot. pub(crate) fn filled(slot: &SlotStatus) -> bool { slot.state == SlotState::StateFilled as i32 } + /// Names known slot states while retaining future state numbers. pub(crate) fn state(slot: &SlotStatus) -> String { SlotState::try_from(slot.state) @@ -419,6 +462,7 @@ pub(crate) fn state(slot: &SlotStatus) -> String { }) .unwrap_or_else(|_| slot.state.to_string()) } + /// Lists advertised direct dependents, regardless of whether their slots are filled. fn required_by(slots: &[SlotStatus], id: i32) -> Vec { slots @@ -427,7 +471,9 @@ fn required_by(slots: &[SlotStatus], id: i32) -> Vec { .map(|slot| slot_name(slot.kind)) .collect() } -/// Borrows the advertised URL, length and digest without validating or fetching them. + +/// Borrows the advertised URL, length and digest without validating or +/// fetching them. pub(crate) fn download(slot: &SlotStatus) -> Option<(&str, u64, &str)> { slot.download.as_ref().map(|download| { ( @@ -437,7 +483,11 @@ pub(crate) fn download(slot: &SlotStatus) -> Option<(&str, u64, &str)> { ) }) } -/// Shows dependency state without changing the advertised JSON dependency names. + +/// Marks each dependency in `requires` as filled or not, for the human view. +/// +/// The JSON result is built apart from this view, so it keeps the advertised +/// dependency names. fn dependency_view(mut value: Value, slot: &SlotStatus, slots: &[SlotStatus]) -> Value { value["requires"] = json!( slot.deps @@ -455,7 +505,8 @@ fn dependency_view(mut value: Value, slot: &SlotStatus, slots: &[SlotStatus]) -> value } -/// Builds generic slot output, preserving future IDs and optional advertised details. +/// Builds generic slot output, preserving future IDs and optional advertised +/// details. pub(crate) fn metadata(slot: &SlotStatus) -> Value { let origin = schema::SlotOrigin::try_from(slot.origin) .map(|origin| { @@ -479,10 +530,13 @@ pub(crate) fn metadata(slot: &SlotStatus) -> Value { }) } +/// Tests of the approval prediction and the generic slot metadata. #[cfg(test)] mod tests { use super::*; + /// Checks that uploads to the public reference slots predict no approval + /// prompt, while personal data does. #[test] fn reference_uploads_do_not_request_approval() { use schema::SlotKind::*; @@ -492,9 +546,11 @@ mod tests { assert!(upload_approval(SlotSnpIndelCalls as i32)); } - /// A slot kind this CLI doesn't know keeps its texts and dependency state. + /// Checks that a slot kind this CLI does not know keeps its texts and + /// dependency state. #[test] fn future_slots_keep_generic_metadata() { + // An unknown kind keeps its id as its name and every advertised text let slot = SlotStatus { kind: 42, name: "Future dataset".into(), @@ -516,6 +572,8 @@ mod tests { assert!(value["download"].is_null()); assert_eq!(value["description"], slot.desc); assert_eq!(value["format"], slot.format); + + // The human view marks whether the known dependency is filled let dependency = SlotStatus { kind: 1, state: SlotState::StateFilled as i32, @@ -529,6 +587,8 @@ mod tests { dependency_view(value, &slot, &[])["requires"], json!(["reference-genome (not filled)"]) ); + + // A missing slot is an invalid selection, and a damaged one is not filled let error = select(&[], 5).unwrap_err(); assert_eq!((error.class, error.code), (1, "invalid-slot")); assert!(!filled(&SlotStatus { diff --git a/src/data/paths.rs b/src/data/paths.rs index c0847fe..4b7294e 100644 --- a/src/data/paths.rs +++ b/src/data/paths.rs @@ -24,7 +24,8 @@ pub(super) fn print(output: &Output, paths: &[DatasetPath]) -> Result<(), Error> Ok(()) } -/// Converts one entry to JSON, naming desc description as slot output does. +/// Converts one entry to JSON, naming its `desc` field `description` as slot +/// output does. fn metadata(path: &DatasetPath) -> Value { json!({ "path": path.path, @@ -38,16 +39,19 @@ fn metadata(path: &DatasetPath) -> Value { } /// Renders the entries as a tree, nesting each under its closest listed parent. +/// /// Only the topmost unavailable entry of a subtree carries the mark. Examples /// line up in one column right of the widest row and wrap within it. fn render(theme: &Theme, paths: &[DatasetPath]) -> String { if paths.is_empty() { return format!(" {}", theme.paint(Role::Muted, "No dataset paths")); } + + // Lay out one row per entry, indented under its closest listed parent let mut rows = Vec::new(); let mut parents: Vec<&DatasetPath> = Vec::new(); for path in paths { - // Names shorten only under a listed parent, so a root keeps its full path. + // Names shorten only under a listed parent, so a root keeps its full path while parents.last().is_some_and(|parent| { !path .path @@ -84,6 +88,8 @@ fn render(theme: &Theme, paths: &[DatasetPath]) -> String { parents.push(path); } } + + // Examples start 2 cells right of the widest row, below a legend line let column = rows .iter() .map(|(line, _, _)| console::measure_text_width(line)) @@ -101,6 +107,9 @@ fn render(theme: &Theme, paths: &[DatasetPath]) -> String { theme.width, 2, )]; + + // Rows without examples wrap under their own indent, and examples wrap + // within their column for (line, indent, examples) in rows { if examples.is_empty() { lines.push(style::wrap(&line, theme.width, indent + 2)); @@ -117,16 +126,20 @@ fn render(theme: &Theme, paths: &[DatasetPath]) -> String { lines.join("\n") } +/// Tests of the path tree layout. #[cfg(test)] mod tests { use super::*; use crate::style::Color; - /// Unknown roots, missing parents and long names keep every path component in - /// tree order, unavailable ancestors hide repeated marks, and examples share - /// one column that wraps within itself. + /// Checks that unknown roots, missing parents and long names keep every + /// path component in tree order. + /// + /// Unavailable ancestors hide repeated marks, and examples share one column + /// that wraps within itself. #[test] fn future_paths_keep_their_hierarchy() { + // The layout is exact at 80 columns without color let paths = [ DatasetPath { path: "v1/sample".into(), @@ -171,6 +184,8 @@ mod tests { " ".repeat(46) ) ); + + // Every width and color depth fits the lines and keeps every component for width in [20, 40, 80] { for color in [Color::Off, Color::Basic, Color::True] { let text = render(&Theme::test(width, color, true), &paths); @@ -186,6 +201,8 @@ mod tests { )); } } + + // An empty map prints a placeholder assert_eq!( render(&Theme::test(80, Color::Off, false), &[]), " No dataset paths" diff --git a/src/data/reference.rs b/src/data/reference.rs index a31d40c..3c203f4 100644 --- a/src/data/reference.rs +++ b/src/data/reference.rs @@ -4,7 +4,8 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -//! Download planning, transport recovery and cache replay. +//! Reference data downloads, with their planning, transport recovery and cache +//! replay. //! //! Each attempt starts a new Ark upload and replays the retained bytes from disk. //! The HTTP range request opens only when replay reaches the missing suffix. @@ -29,8 +30,12 @@ use std::path::Path; use std::rc::Rc; use std::time::{Duration, Instant}; -/// Validates the whole reference plan before mutation, then installs in dependency order. -/// Stops at the first failure and reports completed, failed and unattempted slots. +/// Plans the references and validates every download offer before any change, +/// then installs the planned slots. +/// +/// Planning all references also checks their dependencies and orders the work, +/// while a slot picked by `id` is installed alone. Stops at the first failure +/// and reports completed, failed and unattempted slots. A dry run only plans. pub(super) fn fetch( context: &Context, connection: &Connection, @@ -40,6 +45,7 @@ pub(super) fn fetch( directory: Option<&Path>, no_cache: bool, ) -> Result<(), Error> { + // Order the plan and validate every offer it downloads before any change let directory = directory .map(Path::to_path_buf) .unwrap_or_else(cache::directory); @@ -47,6 +53,9 @@ pub(super) fn fetch( for slot in ordered.iter().filter(|slot| !filled(slot)) { offer(slot)?; } + + // Start each row as skipped, planned or not attempted, and keep them as + // the partial result let mut rows:Vec=ordered.iter().map(|slot| { let offer=download(slot); json!({"slot":slot_name(slot.kind),"id":slot.kind,"url":offer.map(|o|o.0),"size_bytes":offer.map(|o|o.1),"sha256":offer.map(|o|o.2), @@ -54,6 +63,8 @@ pub(super) fn fetch( "outcome":if filled(slot) {"skipped"} else if dry_run {"planned"} else {"not-attempted"},"error":null}) }).collect(); context.interrupt.partial(json!({"fetched":rows})); + + // Install in order, skipping filled slots and stopping at the first failure let mut failure = None; for (index, slot) in ordered.iter().enumerate() { if filled(slot) { @@ -94,6 +105,8 @@ pub(super) fn fetch( } context.interrupt.partial(json!({"fetched":rows})); } + + // Print every row, then return the first failure context.output.table( &json!({"fetched":rows}), &rows, @@ -107,11 +120,22 @@ pub(super) fn fetch( failure.map_or(Ok(()), Err) } -/// Dependencies determine order even when future slot kinds are addressed by id. +/// Orders the reference slots after their dependencies, or picks the one slot +/// `id` names. +/// +/// Dependency checks apply only when planning all references, where they +/// decide the order even for slot kinds this tool does not know. There a +/// cycle, or an empty slot's dependency that is neither filled nor planned +/// before it, is refused. A slot `id` names is returned without checking its +/// dependencies. fn plan(slots: &[SlotStatus], id: Option) -> Result, Error> { + // A named slot is fetched alone if let Some(id) = id { return Ok(vec![select(slots, id)?]); } + + // Take the first slot without a pending dependency, since none means a + // cycle let mut pending: Vec<_> = slots .iter() .filter(|slot| slot.origin == darkbio_connect::schema::SlotOrigin::OriginReference as i32) @@ -135,6 +159,8 @@ fn plan(slots: &[SlotStatus], id: Option) -> Result, Error ) })?; let slot = pending.remove(index); + + // An empty slot's dependencies must be filled or planned before it for dependency in slot.deps.iter().filter(|_| !filled(slot)) { if !slots.iter().any(|other| { other.kind == *dependency @@ -161,15 +187,18 @@ fn plan(slots: &[SlotStatus], id: Option) -> Result, Error /// Validated reference offer used by both protocol upload and HTTP/cache handling. struct Source { - /// Exact length, target slot and binary digest passed to connect. + /// Filename, exact length, target slot and binary digest passed to connect. dataset: Dataset, /// Advertised HTTPS download URL without embedded credentials. url: String, /// Canonical lowercase SHA-256 used as a cache basename. hash: String, } -/// Validates a nonempty HTTPS offer and its complete digest before any download or upload. + +/// Validates a nonempty HTTPS offer and its complete digest before any +/// download or upload. fn offer(slot: &SlotStatus) -> Result { + // The slot must advertise a download with a URL that parses let (url, size, hash) = download(slot).ok_or_else(|| { Error::new( 5, @@ -180,6 +209,8 @@ fn offer(slot: &SlotStatus) -> Result { let uri: ureq::http::Uri = url .parse() .map_err(|_| Error::new(1, "file-rejected", "invalid reference URL"))?; + + // Require HTTPS to a host, without credentials in the URL if uri.scheme_str() != Some("https") || uri.host().is_none() || uri @@ -192,6 +223,8 @@ fn offer(slot: &SlotStatus) -> Result { "reference download requires HTTPS without credentials", )); } + + // Require a nonempty length, a complete digest and a filename if size == 0 { return Err(Error::new( 1, @@ -221,6 +254,7 @@ fn offer(slot: &SlotStatus) -> Result { } /// Replays a complete cache or streams a download through a fresh Ark upload. +/// /// At most three network attempts resume retained bytes when possible. Protocol /// refusals are returned; only source failures or a corrupt prefix permit replay. /// A refused HTTP range discards the prefix before the next attempt. @@ -232,6 +266,9 @@ fn install( ) -> Result<(), Error> { let mut directory = directory; let clock = connection.client.clock(); + + // Upload a complete cached copy first, removing one that is corrupt or + // unreadable if let Some(directory) = directory { let path = directory.join(&source.hash); if let Ok(mut file) = File::open(&path) { @@ -268,6 +305,9 @@ fn install( } } } + + // Download in up to three attempts, each locking the cache entry or + // streaming without one when it is unavailable let agent = http::agent(Duration::from_secs(context.options.timeout), 5); for attempt in 0..3 { let entry = match directory { @@ -286,6 +326,9 @@ fn install( } None => None, }; + + // Upload through the reader, stamping each session start and + // acknowledgment for its stall check let mut reader = Reader::new(&agent, source, &context.output, clock.clone(), entry)?; let last_upload = reader.last_upload.clone(); let mut progress = Progress::new( @@ -309,6 +352,7 @@ fn install( }, ); context.interrupt.clear(); + // A verified download is reusable even if the Ark later rejects processing. // Cache completion describes the source, not the slot's resulting state. let valid = reader.read == source.dataset.size @@ -325,6 +369,9 @@ fn install( // reader still owns the entry when no append worker was needed. reader.entry.take() }; + + // Publish a verified copy, drop a full-length corrupt one, and keep a + // shorter prefix for resuming if let Some(entry) = entry { if valid { if let Err(error) = cache::complete(entry, &source.hash) { @@ -339,6 +386,9 @@ fn install( let _ = fs::remove_file(&path); } } + + // Retry a source failure or a corrupt prefix while attempts remain, and + // otherwise end with the outcome match result { Ok(()) => { if let Some(directory) = directory { @@ -372,7 +422,8 @@ fn install( unreachable!("last attempt returns its result") } -/// Requires a partial response covering exactly the advertised remaining byte range. +/// Checks that a partial response covers exactly the bytes from `offset` to +/// the end of the advertised `size`. fn valid_range(response: &ureq::http::Response, offset: u64, size: u64) -> bool { if response.status() != 206 { return false; @@ -398,9 +449,12 @@ fn valid_range(response: &ureq::http::Response, offset: u64, size: u .is_some_and(|last| end.parse::() == Ok(last)) } -/// Replays a retained prefix, then tees network bytes into a best-effort cache. -/// Hashes both sources together and marks network failures for download retry policy. -/// The cache lock stays held across replay, response validation and queued writes. +/// Source reader that replays a retained prefix, then tees network bytes into a +/// best-effort cache. +/// +/// It hashes both sources together and marks source failures for the download +/// retry policy. The cache lock stays held across replay, response validation +/// and queued writes. struct Reader<'a> { /// Connection clock that measures the upload window. clock: Clock, @@ -414,7 +468,8 @@ struct Reader<'a> { entry: Option, /// Retained byte count used for the range request after replay. offset: u64, - /// Session start or latest upload acknowledgement, shared with progress callbacks. + /// Session start or latest upload acknowledgment, shared with progress + /// callbacks. last_upload: Rc>>, /// Retained prefix with an independent cursor capped at the resume offset. prefix: Option>, @@ -431,9 +486,12 @@ struct Reader<'a> { /// Request or cache setup error preserved through connect's reader boundary. error: Option, } + impl<'a> Reader<'a> { - /// Opens only the cached prefix. A live response must not wait through replay. - /// Retains the cache lock even if the reader never reaches the network. + /// Opens only the cached prefix, since a live response must not wait + /// through replay. + /// + /// It retains the cache lock even if the reader never reaches the network. fn new( agent: &'a ureq::Agent, source: &'a Source, @@ -469,10 +527,14 @@ impl<'a> Reader<'a> { }) } - /// Validates the response before accepting any bytes or starting the cache writer. + /// Requests the network source and validates the response before accepting + /// any bytes or starting the cache writer. + /// /// A refused range resets the cache and fails this attempt. Request failures /// permit retry; HTTP status failures on a full download are returned directly. fn open(&mut self) -> Result<(), Error> { + // Ask for the whole file, or for the bytes past a retained prefix under + // the cached validator let mut request = self .agent .get(&self.source.url) @@ -487,10 +549,15 @@ impl<'a> Reader<'a> { request = request.header("If-Range", validator); } } + + // A failed request is a source failure, which permits another attempt let response = request.call().map_err(|error| { self.failed = true; http::error(error) })?; + + // A resume needs an exact range answer, and a full download a plain + // success if self.offset > 0 && !valid_range(&response, self.offset, self.source.dataset.size) { // The Ark already has the prefix. Reset the cache and let connect // cancel this upload before the next attempt starts from zero. @@ -511,6 +578,8 @@ impl<'a> Reader<'a> { format!("reference download returned HTTP {}", response.status()), )); } + + // Keep the new validator for a later resume, and start the cache writer if let Some(entry) = &mut self.entry { entry.meta.validator = response .headers() @@ -532,13 +601,18 @@ impl<'a> Reader<'a> { Ok(()) } } + impl Read for Reader<'_> { /// Serves prefix bytes first, then records and caches network bytes. - /// After a network read, detects an upload window already lost to a source stall. + /// + /// After a network read, it detects an upload window already lost to a + /// source stall. fn read(&mut self, buffer: &mut [u8]) -> io::Result { if buffer.is_empty() { return Ok(0); } + + // Replay the retained prefix first if let Some(prefix) = &mut self.prefix { let count = prefix.read(buffer)?; if count > 0 { @@ -548,6 +622,8 @@ impl Read for Reader<'_> { } self.prefix = None; } + + // Open the network source once the prefix runs out if self.network.is_none() { // A complete partial file may have survived interruption just before // publication. Verify it without requesting a range beyond EOF. @@ -562,6 +638,8 @@ impl Read for Reader<'_> { return Err(failure); } } + + // Read network bytes, failing on a read error or an early end let count = match self.network.as_mut().expect("opened response").read(buffer) { Ok(count) => count, Err(error) => { @@ -576,11 +654,16 @@ impl Read for Reader<'_> { "reference download ended early", )); } + + // Cache, hash and count the bytes if let Some(writer) = &mut self.writer { writer.append(&buffer[..count]); } self.hash.update(&buffer[..count]); self.read += count as u64; + + // Fail a download that stalled past the upload window since the last + // acknowledgment if self .last_upload .get() @@ -596,6 +679,7 @@ impl Read for Reader<'_> { } } +/// Tests of reference planning, offer validation, resume and cache replay. #[cfg(test)] mod tests { use super::*; @@ -608,11 +692,15 @@ mod tests { use std::sync::atomic::{AtomicU64, Ordering}; use std::thread; - /// Keeps parallel fixtures in separate temporary directories. + /// Counter that keeps parallel fixtures in separate temporary directories. static NEXT: AtomicU64 = AtomicU64::new(0); - /// Removes a test's cache files on drop. - struct Directory(PathBuf); + /// Temporary cache directory, removed with its files on drop. + struct Directory( + /// Path of the temporary directory. + PathBuf, + ); + impl Directory { /// Creates an empty cache outside the user's real cache directory. fn new() -> Self { @@ -625,18 +713,22 @@ mod tests { Self(path) } } + impl Drop for Directory { + /// Tries to remove the directory and its contents, ignoring failures. fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } - /// Keeps expected cache diagnostics out of test output. + /// Builds a quiet output, keeping expected cache diagnostics out of the + /// test output. fn output() -> Output { Output::new(&crate::args::Cli::parse_from(["ark", "--quiet"]).options) } - /// Allows loopback HTTP while retaining production status handling. + /// Builds a client that allows loopback HTTP while retaining production + /// status handling. fn agent() -> ureq::Agent { ureq::Agent::config_builder() .proxy(None) @@ -702,6 +794,8 @@ mod tests { }) } + /// Builds a reference slot of kind `id` with its dependencies, filled or + /// empty. fn reference(id: i32, deps: &[i32], filled: bool) -> SlotStatus { SlotStatus { kind: id, @@ -715,6 +809,9 @@ mod tests { ..Default::default() } } + + /// The plan keeps filled slots in dependency order, and refuses cycles and + /// missing dependencies of empty slots. #[test] fn dependency_order_retains_filled_slots_and_detects_missing_inputs() { let slots = [ @@ -741,6 +838,8 @@ mod tests { assert_eq!(plan(&[reference(1, &[2], true)], None).unwrap().len(), 1); } + /// Offers need HTTPS without credentials, a filename and a complete digest, + /// which is normalized to lowercase. #[test] fn offers_require_public_https_and_a_complete_hash() { for url in [ @@ -777,6 +876,7 @@ mod tests { ); } + /// A body that ends early is a source failure, which permits a retry. #[test] fn truncated_sources_are_transport_failures() { let agent = http::agent(Duration::from_secs(1), 0); @@ -790,6 +890,8 @@ mod tests { assert!(reader.failed); assert_eq!(reader.read, 2); } + + /// A resume accepts only a 206 covering exactly the remaining bytes. #[test] fn resume_requires_an_exact_range_response() { for (status, range, valid) in [ @@ -813,8 +915,12 @@ mod tests { ); } } + + /// A download read fails once the upload window has passed since the last + /// acknowledgment. #[test] fn download_stall_tracks_the_upload_window() { + // Read a two-byte body on the test clock let agent = http::agent(Duration::from_secs(1), 0); let output = output(); let source = source("https://example.com/reference.gz".into(), &[42, 43]); @@ -837,8 +943,11 @@ mod tests { assert!(reader.failed); } + /// A resumed download replays the cache before it opens the HTTP range + /// request. #[test] fn restarted_download_opens_http_only_after_cache_replay() { + // Download one chunk and a short tail from a loopback listener let listener = TcpListener::bind("127.0.0.1:0").unwrap(); listener.set_nonblocking(true).unwrap(); let bytes = [vec![42; cache::CHUNK], b"remaining bytes".to_vec()].concat(); @@ -847,6 +956,7 @@ mod tests { &bytes, ); let directory = Directory::new(); + // Keep one committed chunk and discard an interrupted tail. The next // reader has only the files left by the previous invocation. interrupted(&directory.0, &source, &bytes[..cache::CHUNK + 3]); @@ -857,6 +967,8 @@ mod tests { let output = output(); let clock = TestClock::new().clock(); let mut reader = Reader::new(&agent, &source, &output, clock, Some(entry)).unwrap(); + + // The prefix replays without any HTTP request let mut received = vec![0; cache::CHUNK]; reader.read_exact(&mut received).unwrap(); assert_eq!(received, bytes[..cache::CHUNK]); @@ -865,6 +977,8 @@ mod tests { io::ErrorKind::WouldBlock ); + // The rest arrives through a range request, and the complete copy is + // published let server = serve( listener, vec![( @@ -891,15 +1005,21 @@ mod tests { ) .unwrap(); assert_eq!(fs::read(directory.0.join(&source.hash)).unwrap(), bytes); + + // The one request asks for the suffix under the cached validator let requests = server.join().unwrap(); assert_eq!(requests.len(), 1); assert!(requests[0].contains(&format!("\r\nrange: bytes={}-\r\n", cache::CHUNK))); assert!(requests[0].contains("\r\nif-range: \"reference-v1\"\r\n")); } + /// A refused resume discards the prefix, and the retry downloads from the + /// start. #[test] fn refused_resume_discards_the_prefix_before_retry() { for status in [200, 206, 416] { + // Keep one chunk, and script a refused resume and then the whole + // file let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let bytes = [vec![42; cache::CHUNK], b"remaining bytes".to_vec()].concat(); let source = source( @@ -922,6 +1042,8 @@ mod tests { let agent = agent(); let output = output(); let clock = TestClock::new().clock(); + + // The refused resume fails the first attempt after the prefix replays { let entry = cache::Entry::open( &directory.0, @@ -938,6 +1060,9 @@ mod tests { assert!(reader.failed); assert!(reader.writer.is_none()); } + + // The prefix is gone, so the retry downloads everything without a + // range let entry = cache::Entry::open(&directory.0, &source.hash, &source.url, source.dataset.size) .unwrap(); @@ -963,6 +1088,8 @@ mod tests { } } + /// A partial file that already holds every byte completes without an HTTP + /// request. #[test] fn complete_partial_file_needs_no_http_request() { let bytes = vec![42; cache::CHUNK]; diff --git a/src/data/scenarios.rs b/src/data/scenarios.rs index 0d71d74..3b36e51 100644 --- a/src/data/scenarios.rs +++ b/src/data/scenarios.rs @@ -104,6 +104,7 @@ fn slots() -> Vec { /// Runs one scenario in a child test process, capturing its real stdout and /// stderr so results and hints stay distinguishable. fn capture(scenario: &str, json: bool) -> (String, String) { + // Run this test again as a child that prints the scenario without color let output = std::process::Command::new(std::env::current_exe().unwrap()) .args([ "--exact", @@ -116,6 +117,8 @@ fn capture(scenario: &str, json: bool) -> (String, String) { .output() .unwrap(); assert!(output.status.success(), "{output:?}"); + + // Keep only the result between the markers, past the test harness's lines let stdout = String::from_utf8(output.stdout).unwrap(); let stdout = stdout .split_once("\n") @@ -128,10 +131,14 @@ fn capture(scenario: &str, json: bool) -> (String, String) { (stdout, String::from_utf8(output.stderr).unwrap()) } -/// Full, partial and empty path maps and every slot print exact JSON, a readable -/// view and the right hints, with availability marked once per missing subtree. +/// Checks that path maps and slots print exact JSON, a readable view and the +/// right hints. +/// +/// Full, partial and empty path maps are covered, with availability marked once +/// per missing subtree. #[test] fn inventory_output() { + // As the child, print one scenario between result markers if let Ok(scenario) = std::env::var("ARK_TEST_DATA_SCENARIO") { let mut options = Cli::parse_from(["ark"]).options; options.json = std::env::var("ARK_TEST_DATA_JSON").unwrap() == "true"; @@ -156,6 +163,9 @@ fn inventory_output() { writeln!(std::io::stdout(), "").unwrap(); return; } + + // Every path map prints the same entries as JSON or a tree, hinting at the + // slot list when some are unavailable let expected = json!({"paths":[ { "path":"v1/sample", "directory":true, "grantable":true, "available":true, @@ -226,6 +236,9 @@ fn inventory_output() { assert!(!stdout.contains('\x1b')); } } + + // The slot list prints both slots and hints at fetching the missing + // reference let expected = json!({"slots":[ { "slot":"41", "id":41, "name":"Sample A", @@ -258,6 +271,8 @@ fn inventory_output() { " SLOT STATE ORIGIN BUILD VERSION SIZE REQUIRES\n 41 ok filled personal sample-a 1 2.0 KiB none\n 42 - empty reference - - 0 B 41 (filled)\n" ); assert_eq!(stderr, "hint: run `ark data fetch 42`\n"); + + // Each slot's page shows every field, wrapping long texts under their label for (index, id) in [41, 42].into_iter().enumerate() { let scenario = format!("show{id}"); let (stdout, stderr) = capture(&scenario, true); diff --git a/src/device.rs b/src/device.rs index 71de482..5da1721 100644 --- a/src/device.rs +++ b/src/device.rs @@ -16,8 +16,10 @@ use crate::{ use darkbio_connect::{DeviceKind, Identity, schema}; use serde_json::{Value, json}; -/// Lists discovery metadata without opening devices, retaining useful partial results. +/// Lists discovery metadata without opening devices, retaining useful partial +/// results. pub(crate) fn devices(context: &Context) -> Result<(), Error> { + // Nothing found fails only when some discovery source failed too let found = context.discover(); if found.devices.is_empty() && !found.errors.is_empty() { return Err(Error::new( @@ -26,12 +28,16 @@ pub(crate) fn devices(context: &Context) -> Result<(), Error> { "no Arks found; discovery was incomplete", )); } + + // Each row carries discovery metadata alone, since no device is opened let rows: Vec<_> = found.devices.iter().map(|device| json!({ "locator":device.locator().to_string(), "kind":match device.kind() { DeviceKind::Hardware=>"hardware", DeviceKind::Emulator=>"emulator" }, "name":device.name(),"serial":device.serial(),"image":device.image(),"environment":device.env(),"ready":device.ready(), })).collect(); - // environment and ready are launcher metadata; discovery leaves them unset for - // hardware, and a dash under READY reads as "not ready" rather than "not asked". + + // The `environment` and `ready` columns are launcher metadata, which + // discovery leaves unset for hardware. They show only when a row has them, + // since a dash under READY reads as "not ready" rather than "not asked". let mut columns = vec![ ("LOCATOR", "locator"), ("NAME", "name"), @@ -43,6 +49,8 @@ pub(crate) fn devices(context: &Context) -> Result<(), Error> { columns.push((label, key)); } } + + // Print the table, pointing at --device when several Arks answer context .output .table(&json!({"devices":rows}), &rows, &columns)?; @@ -54,7 +62,10 @@ pub(crate) fn devices(context: &Context) -> Result<(), Error> { Ok(()) } -/// Prints offline device state before reporting an outdated firmware error. +/// Prints the Ark's status, then fails on outdated firmware or hints at the +/// next step. +/// +/// The status comes first, so an outdated Ark still shows its state. pub(crate) fn status(context: &Context, recovery: args::Recovery) -> Result<(), Error> { let connection = context.connect_recovery(recovery.pubkey.as_deref())?; let value = status_value(&connection); @@ -65,12 +76,17 @@ pub(crate) fn status(context: &Context, recovery: args::Recovery) -> Result<(), } /// Combines authenticated identity with reported hardware and firmware snapshots. -/// Fields absent from older protocols stay unknown; routing overrides do not become -/// attested environment labels. +/// +/// Sync, pairing and lock state stay unknown on firmware this tool does not +/// support. The environment comes from the attestation alone, since routing +/// overrides do not become attested environment labels. fn status_value(connection: &Connection) -> Value { let current = connection.require_current().is_ok(); let info = &connection.info; let clock = connection.client.clock(); + + // Serial, realm and model come only from an attestation, which also flags a + // reported hardware version that differs from the attested one let reported = format!("{} - {}", info.version_str, info.revision_str); let (trust, serial, realm, model, mismatch, env) = match &connection.identity { Identity::Attested { env, device } => ( @@ -97,6 +113,8 @@ fn status_value(connection: &Connection) -> Value { ), Identity::Recovered(_) => ("pinned", Value::Null, Value::Null, Value::Null, None, None), }; + + // Assemble the document, leaving state that needs supported firmware null json!({"name":connection.device.name(),"serial":serial, "hardware":{"version":info.version_str,"revision":info.revision_str,"model":model}, "firmware":{"version":info.firmware_version,"published":timestamp(info.firmware_publish)}, @@ -105,7 +123,7 @@ fn status_value(connection: &Connection) -> Value { "pubkey":hex::encode(connection.identity.key().to_bytes()),"mismatch":mismatch}) } -/// Uses the compact human status layout with the same complete machine document. +/// Prints a status document, as the compact block for people and whole in JSON. fn print_status(context: &Context, value: &Value) -> Result<(), Error> { context .output @@ -201,10 +219,14 @@ pub(crate) fn unlock(context: &Context) -> Result<(), Error> { .document(&json!({"unlocked":true,"changed":!state.unlocked})) } -/// Forces cloud synchronization and reports registry state before failing an inactive check. +/// Syncs with the cloud, then prints the Ark's registration and fails when it +/// is inactive. pub(crate) fn genuine(context: &Context) -> Result<(), Error> { + // Refresh the cloud keys and signed clock before asking the registry let connection = context.connect(None)?; connection.client.sync(context.timing())?; + + // A refused proof from a self-signed emulator points at enrollment let registration = connection .client .genuine(context.timing()) @@ -220,6 +242,8 @@ pub(crate) fn genuine(context: &Context) -> Result<(), Error> { } error })?; + + // Print the registration first, so an inactive one still shows its flags context.output.document(&json!({"serial":registration.serial,"enrolled":timestamp(registration.enrolled.max(0) as u64), "active":registration.active(),"disabled":registration.disabled,"expired":registration.expired,"superseded":registration.superseded}))?; if !registration.active() { @@ -238,9 +262,11 @@ pub(crate) fn genuine(context: &Context) -> Result<(), Error> { } /// Installs a supplied attestation or directs online enrollment to Ark Hub. -/// After installation, reconnects without recovery pinning to verify the new identity; -/// a reconnect failure still reports that enrollment was acknowledged. +/// +/// After installation, it reconnects without recovery pinning to verify the new +/// identity. A reconnect failure still reports that enrollment was acknowledged. pub(crate) fn enroll(context: &Context, args: args::Enroll) -> Result<(), Error> { + // Read a supplied attestation before connecting let certificate = args .cwt .as_ref() @@ -251,11 +277,17 @@ pub(crate) fn enroll(context: &Context, args: args::Enroll) -> Result<(), Error> }) }) .transpose()?; + + // Installing an attestation crosses the compatibility gate, since it may + // enroll an old device let connection = if certificate.is_some() { context.connect_recovery(args.recovery.pubkey.as_deref())? } else { context.connect(args.recovery.pubkey.as_deref())? }; + + // Without an attestation, an attested Ark is already enrolled, and any + // other one is sent to Ark Hub let Some(certificate) = certificate else { if matches!(connection.identity, Identity::Attested { .. }) { let mut error = Error::new( @@ -289,6 +321,8 @@ pub(crate) fn enroll(context: &Context, args: args::Enroll) -> Result<(), Error> "online enrollment happens at Ark Hub", )); }; + + // Install the attestation, then reconnect without the pin to verify it connection.client.call( schema::OnboardingRequest { device_attestation: certificate, @@ -332,13 +366,17 @@ pub(crate) fn hub(env: darkbio_connect::trust::Environment) -> &'static str { } } +/// Tests of the human status layout. #[cfg(test)] mod tests { use super::*; use crate::style::Color; + /// Checks that the status block marks unknown state with a dash, keeps a + /// mismatch visible and leaves the full public key to JSON. #[test] fn status_distinguishes_unknown_state_and_keeps_mismatches_visible() { + // Unknown state renders as a dash, and a mismatch keeps its own row let theme = Theme::test(80, Color::Basic, true); let mut value = json!({ "name":"Example Ark", "serial":null, @@ -352,9 +390,13 @@ mod tests { status_block(&theme, &value), " \x1b[1mExample Ark\x1b[0m unverified\n Hardware Ark I, revision B, model 01\n\n Firmware 0.11.5, published -\n Trust \x1b[1m! self-signed\x1b[0m\n Environment -\n Realm -\n\n Cloud -\n Pairing \x1b[1m\u{2713} paired\x1b[0m\n Lock \x1b[1m! locked\x1b[0m\n Identity \x1b[1m0123456789abcdef\x1b[0m\n Mismatch \x1b[1mArk II - A\x1b[0m" ); + + // The fingerprint shows, while the full public key does not let rendered = status_block(&theme, &value); assert!(rendered.contains("0123456789abcdef")); assert!(!rendered.contains("abcdef0123456789")); + + // A known negative sync state reads as an attention mark value["synced"] = json!(false); assert!(status_block(&theme, &value).contains("! not synced")); } diff --git a/src/doctor.rs b/src/doctor.rs index 0ebf498..6b2a3f7 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -12,9 +12,12 @@ use darkbio_connect::schema; use serde_json::{Value, json}; /// Collects independent diagnostics, skipping checks whose prerequisites failed. -/// Cloud sync is explicit; doctor never pairs or unlocks the Ark to complete checks. -/// The full checklist is printed before the first failure determines the exit class. +/// +/// Cloud sync is explicit, and doctor never pairs or unlocks the Ark to complete +/// a check. The full checklist is printed before the first failure determines +/// the exit class. pub(crate) fn run(context: &Context) -> Result<(), Error> { + // Start with the tool itself, its versions and whether a newer release exists let mut checks = Checks { context, rows: Vec::new(), @@ -30,6 +33,9 @@ pub(crate) fn run(context: &Context) -> Result<(), Error> { ), ); checks.update(); + + // Each discovery source reports on its own, so one failure keeps the other's + // result let mut discovered = 0; for (name, result) in [ ("usb", darkbio_connect::hardware::list()), @@ -52,6 +58,9 @@ pub(crate) fn run(context: &Context) -> Result<(), Error> { } } checks.ok("devices", &format!("{discovered} Arks discovered")); + + // Connect past the compatibility gate, so outdated firmware is checked too, + // and skip every device check when that fails match context.connect_recovery(None) { Err(error) => { checks.fail("connection", error); @@ -69,6 +78,8 @@ pub(crate) fn run(context: &Context) -> Result<(), Error> { } } Ok(connection) => { + // Report the connection, the firmware's compatibility and the + // environment checks.ok("connection", "connected and authenticated"); let current = match connection.require_current() { Ok(()) => { @@ -89,6 +100,8 @@ pub(crate) fn run(context: &Context) -> Result<(), Error> { .hint("select one with --env"), ); } + + // The firmware catalog and the cloud sync need a known environment if connection.env.is_none() { checks.skip("firmware", "cloud environment unknown"); } else { @@ -124,6 +137,8 @@ pub(crate) fn run(context: &Context) -> Result<(), Error> { } } }; + + // The registry answers only after a successful sync if synced { match connection.client.genuine(context.timing()) { Ok(registration) if registration.active() => checks.ok("registry", "active"), @@ -136,6 +151,9 @@ pub(crate) fn run(context: &Context) -> Result<(), Error> { } else { checks.skip("registry", "cloud sync unavailable"); } + + // The pairing, relay and slot checks need supported firmware, and + // take the pairing and lock state as they find it if !current { for name in ["pairing", "relay", "slots"] { checks.skip(name, "firmware update required"); @@ -176,6 +194,8 @@ pub(crate) fn run(context: &Context) -> Result<(), Error> { } } } + + // Measure the reference cache on this computer let cache = crate::data::cache::directory(); match crate::data::cache::size(&cache) { Ok(size) => checks.ok( @@ -184,23 +204,29 @@ pub(crate) fn run(context: &Context) -> Result<(), Error> { ), Err(error) => checks.fail("cache", error.into()), } + + // Print the whole checklist, then fail with the first failure let mut document = crate::versions(); document["checks"] = json!(checks.rows); context.output.checklist(&document, &checks.rows)?; checks.failure.map_or(Ok(()), Err) } + /// Ordered diagnostic results and the first failure used for command status. struct Checks<'a> { - /// Invocation output used to emit optional step diagnostics. + /// Invocation context, for step diagnostics and the `--timeout` allowance. context: &'a Context, /// Checks in execution order, including explicit skips and local hints. rows: Vec, /// First failed check, retained while later independent checks continue. failure: Option, } + impl Checks<'_> { - /// Looks up the newest ark afresh. A newer one is a warn and a failed - /// lookup a skip, so neither sets the exit code. + /// Looks up the newest ark afresh. + /// + /// A newer one is a warn and a failed lookup a skip, so neither sets the + /// exit code. fn update(&mut self) { // Under CI nothing is looked up if update::disabled() { @@ -219,7 +245,8 @@ impl Checks<'_> { std::time::Duration::from_secs(self.context.options.timeout), ); - // Only a newer version needs action; an unpublished local build is current too + // Only a newer version needs action; an unpublished local build is + // current too match result { Ok(newest) if newest.cmp_precedence(&running).is_gt() => self.warn( "update", @@ -238,14 +265,17 @@ impl Checks<'_> { fn ok(&mut self, name: &str, detail: &str) { self.add(name, "ok", detail, None); } + /// Records something to act on with its hint, never failing the command. fn warn(&mut self, name: &str, detail: &str, hint: &str) { self.add(name, "warn", detail, Some(hint)); } + /// Records an unmet prerequisite without making the command fail by itself. fn skip(&mut self, name: &str, detail: &str) { self.add(name, "skip", detail, None); } + /// Records the failure and first hint, preserving the earliest command error. fn fail(&mut self, name: &str, error: Error) { self.add( @@ -258,6 +288,7 @@ impl Checks<'_> { self.failure = Some(error); } } + /// Appends one structured check and its optional verbose event. fn add(&mut self, name: &str, result: &str, detail: &str, hint: Option<&str>) { self.context diff --git a/src/error.rs b/src/error.rs index a9c05e0..65688c2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -4,13 +4,16 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -//! Stable caller actions around opaque application refusals. +//! CLI errors with stable exit classes and machine codes, around opaque +//! application refusals. use darkbio_connect::{Error as ConnectError, schema, wire}; use serde_json::{Value, json}; /// CLI failure with a stable exit class and machine code, plus actionable hints. -/// Application error numbers and messages remain opaque and are retained verbatim. +/// +/// Application error numbers and messages remain opaque and are retained +/// verbatim. #[derive(Debug)] pub(crate) struct Error { /// Process exit class, shared by errors requiring the same caller action. @@ -36,13 +39,16 @@ impl Error { remote: None, } } + /// Appends a caller action in the order it should be presented. pub fn hint(mut self, hint: impl Into) -> Self { self.hints.push(hint.into()); self } - /// Encodes the result error; hints travel as stderr events instead. The - /// Ark's number is a decimal string, as every 64-bit value is in JSON. + + /// Encodes the error for the JSON result, leaving hints to stderr events. + /// + /// The Ark's number is a decimal string, as every 64-bit value is in JSON. pub fn json(&self) -> Value { let mut value = json!({"code": self.code, "message": self.message}); if let Some(remote) = &self.remote { @@ -58,11 +64,15 @@ impl std::fmt::Display for Error { f.write_str(&self.message) } } + impl std::error::Error for Error {} impl From for Error { - /// Maps connector failures to caller actions without interpreting app codes. - /// Download adapters can carry an existing CLI error through a reader failure. + /// Maps connection library failures to caller actions without interpreting + /// app codes. + /// + /// Download adapters can carry an existing CLI error through a reader + /// failure, which comes back unchanged. fn from(error: ConnectError) -> Self { use ConnectError::*; match error { @@ -181,12 +191,16 @@ impl From for Error { } } +/// Tests of the mapping from connection library failures to CLI errors. #[cfg(test)] mod tests { use super::*; + /// Checks that handshake and pairing failures keep their class, code and + /// message. #[test] fn handshake_and_pairing_errors_retain_caller_actions() { + // A refused handshake reports the reason the handshake gave let err = Error::from(ConnectError::Handshake( wire::transport::Error::HandshakeFailed("attestation signed by an unknown key".into()) .into(), @@ -194,6 +208,9 @@ mod tests { assert_eq!((err.class, err.code), (3, "handshake-failed")); assert_eq!(err.message, "attestation signed by an unknown key"); assert!(err.hints.is_empty()); + + // An expired pairing is an approval timeout, and a failed one keeps its + // message let err = Error::from(ConnectError::PairingExpired); assert_eq!((err.class, err.code), (6, "approval-timeout")); let err = Error::from(ConnectError::Pairing("companion disconnected".into())); @@ -201,8 +218,10 @@ mod tests { assert_eq!(err.message, "companion disconnected"); } - /// An application code can share low bits with a reserved code without - /// being interpreted by the host. Its message and full number survive. + /// Checks that application codes stay opaque, even when their low bits + /// match a reserved code. + /// + /// The message and the full number survive into the JSON result. #[test] fn application_errors_are_opaque() { for code in [ @@ -220,6 +239,8 @@ mod tests { } } + /// Checks that a CLI error carried through a firmware read comes back with + /// its hint, and a read timeout keeps the timeout class. #[test] fn download_error_retains_required_caller_action() { let source = Error::new(4, "login-required", "sign in to the package host") @@ -227,6 +248,9 @@ mod tests { let error: Error = ConnectError::FirmwareRead(std::io::Error::other(source)).into(); assert_eq!(error.code, "login-required"); assert_eq!(error.hints.len(), 1); + + // A read timeout without a CLI error inside is a timeout with its own + // message let error: Error = ConnectError::FirmwareRead(std::io::Error::new( std::io::ErrorKind::TimedOut, "stalled", @@ -235,6 +259,9 @@ mod tests { assert_eq!(error.class, 7); assert_eq!(error.message, "stalled"); } + + /// Checks that a denied and an unconfirmed approval map to distinct codes + /// of the approval class. #[test] fn reserved_approval_outcomes_are_distinct() { for (code, name) in [ diff --git a/src/execution.rs b/src/execution.rs index dbd9364..1e34c8b 100644 --- a/src/execution.rs +++ b/src/execution.rs @@ -4,7 +4,8 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -//! App results and CLI cancellation handles. +//! App commands, which run an app on the Ark and report its result, or cancel +//! a task. use crate::{ args, @@ -18,9 +19,12 @@ use darkbio_connect::{ExecutionProgress, schema}; use serde_json::{Value, json}; /// Cancels an explicit task or uploads and runs a local app after unlock. -/// The connector owns protocol sequencing; the CLI owns progress, partial results -/// and byte-preserving report output. An app failure retains its returned result. +/// +/// The connection library owns protocol sequencing, and the CLI owns progress, +/// partial results and byte-preserving report output. An app failure retains +/// its returned result. pub(crate) fn run(context: &Context, command: args::App) -> Result<(), Error> { + // A cancel request goes straight to the Ark and reports the task it named let args::App::Run { file: path } = command else { let args::App::Cancel { task } = command else { unreachable!() @@ -34,15 +38,23 @@ pub(crate) fn run(context: &Context, command: args::App) -> Result<(), Error> { .output .document(&json!({"task":task.to_string(),"cancelled":true})); }; + + // Open the app before connecting, then require an unlocked Ark let (mut file, size) = open_file(&path)?; let connection = context.connect(None)?; context.require_unlocked(&connection, false)?; + + // The result starts unknown and fills in as the run goes. Running progress + // repeats at most every second on a terminal, and every 5 s elsewhere. let mut value = json!({"task":null,"app":{"name":null,"version":null},"success":null,"stdout":null,"stderr":null,"duration_seconds":null}); let clock = connection.client.clock(); let mut started = None; let mut transfer = Transfer::new(context.output.terminal(), clock.clone()); let report_interval = if context.output.terminal() { 1 } else { 5 }; let mut reported = None; + + // Upload and run the app, registering the task for interruption as soon as + // the Ark names it let result = connection .client @@ -76,6 +88,8 @@ pub(crate) fn run(context: &Context, command: args::App) -> Result<(), Error> { } } }); + + // A failed run still prints the partial JSON result once a task started context.interrupt.clear(); let result = match result { Ok(result) => result, @@ -86,12 +100,16 @@ pub(crate) fn run(context: &Context, command: args::App) -> Result<(), Error> { return Err(error.into()); } }; + + // Complete the result, keeping output that is not UTF-8 as base64 value["app"] = json!({"name":result.app_name,"version":result.app_version}); value["success"] = json!(result.success); let duration = clock.elapsed(started.expect("successful execution reported running")); value["duration_seconds"] = json!(duration.as_secs()); bytes(&mut value, "stdout", &result.stdout); bytes(&mut value, "stderr", &result.stderr); + + // Print the report as it came, then fail when the app reported failure if context.output.json() { context.output.document(&value)?; } else { @@ -113,7 +131,8 @@ pub(crate) fn run(context: &Context, command: args::App) -> Result<(), Error> { } } -/// Stores valid UTF-8 verbatim; other bytes replace the text key with a base64 sibling. +/// Stores valid UTF-8 verbatim; other bytes replace the text key with a base64 +/// sibling. fn bytes(value: &mut Value, name: &str, bytes: &[u8]) { match std::str::from_utf8(bytes) { Ok(text) => value[name] = json!(text), @@ -133,9 +152,13 @@ fn bytes(value: &mut Value, name: &str, bytes: &[u8]) { } } +/// Tests of the app result encoding. #[cfg(test)] mod tests { use super::*; + + /// Checks that output that is not UTF-8 becomes base64 in the same key + /// position, while UTF-8 output stays text. #[test] fn app_output_is_never_lossily_decoded() { let mut result = json!({"task":u64::MAX.to_string(),"stdout":null,"stderr":null}); diff --git a/src/firmware/mod.rs b/src/firmware/mod.rs index 4b7c391..e1fe084 100644 --- a/src/firmware/mod.rs +++ b/src/firmware/mod.rs @@ -21,18 +21,26 @@ use package::Package; use serde_json::{Value, json}; use std::time::Duration; -/// Verification window covering old-session closure and discovery after installation. +/// Verification window covering old-session closure and discovery after +/// installation. pub(crate) const REBOOT_WAIT: Duration = Duration::from_secs(120); /// First firmware version speaking the wire protocol this CLI uses. pub(crate) const MINIMUM_VERSION: &str = "0.11.5"; -/// Bump when current-release changes require developers to rebuild their image. +/// Earliest publish time of a develop build this CLI accepts, in Unix seconds. +/// +/// It moves forward whenever a change on the current release needs developers +/// to rebuild their image. pub(crate) const MINIMUM_DEVELOP_PUBLISH: u64 = 1_789_461_235; // 2026-09-15 08:33:55 UTC -/// Requires the protocol batch and, for mutable develop builds, its publish cutoff. +/// Checks that the firmware is at least [`MINIMUM_VERSION`], and that a +/// mutable develop build was published no earlier than +/// [`MINIMUM_DEVELOP_PUBLISH`]. +/// /// This is CLI compatibility guidance based on reported firmware metadata. pub(crate) fn check_compatibility(info: &schema::DeviceInfoResponse) -> Result<(), Error> { + // A version below the minimum, or one that does not parse, needs an update let minimum = package::Version::parse(&format!("{MINIMUM_VERSION}-develop")) .expect("compiled firmware minimum is valid"); let Some(version) = package::Version::parse(&info.firmware_version) @@ -48,6 +56,8 @@ pub(crate) fn check_compatibility(info: &schema::DeviceInfoResponse) -> Result<( ), )); }; + + // A develop build also needs a publish time past the cutoff if version.is_develop() && info.firmware_publish < MINIMUM_DEVELOP_PUBLISH { return Err(Error::new( 5, @@ -59,9 +69,12 @@ pub(crate) fn check_compatibility(info: &schema::DeviceInfoResponse) -> Result<( } /// Lists candidates or plans and installs a selected firmware archive. +/// /// The CLI owns package retrieval, consent and reboot verification; connect owns the /// authorized update sequence. Partial results distinguish installation from return. pub(crate) fn run(context: &Context, command: args::Firmware) -> Result<(), Error> { + // Connect past the compatibility gate, which an update exists to cross, and + // fetch the published packages let connection = context.connect_recovery(None)?; let clock = connection.client.clock(); let mut packages = Packages::new(context, clock.clone(), connection.env)?; @@ -69,6 +82,9 @@ pub(crate) fn run(context: &Context, command: args::Firmware) -> Result<(), Erro if let args::Firmware::List = command { return listing(context, &connection, &firmwares); } + + // Plan the update, and print the plan alone when nothing is newer or on a + // dry run let args::Firmware::Update { version, dry_run, @@ -102,6 +118,9 @@ pub(crate) fn run(context: &Context, command: args::Firmware) -> Result<(), Erro if dry_run { return context.output.document(&value); } + + // Announce the update, which then needs --yes or a confirmation at the + // prompt context.output.event( "note", format!( @@ -127,11 +146,15 @@ pub(crate) fn run(context: &Context, command: args::Firmware) -> Result<(), Erro ) .hint("add --yes to confirm installation and reboot")); } + + // With --unlock, a locked or unreported Ark unlocks first, so the phone + // approves the update if context.options.unlock && matches!(approval, Some(Approval::Button) | None) { context.unlock(&connection)?; approval = Some(Approval::Phone); value["approval"] = json!(approval); } + // The public archive is opened lazily, after the Ark accepts preparation. // Connect owns authorization, transfer, verification and installation. let mut reader = Download { @@ -170,6 +193,9 @@ pub(crate) fn run(context: &Context, command: args::Firmware) -> Result<(), Erro UpdateProgress::Installing => context.output.event("progress", "installing firmware"), }, ); + + // A failed update prints the plan, with hints for a stale proof or the + // emulator if let Err(error) = result { context.output.document(&value)?; let mut error: Error = error.into(); @@ -185,9 +211,15 @@ pub(crate) fn run(context: &Context, command: args::Firmware) -> Result<(), Erro } return Err(error); } + + // Record the installation, with the running build unknown until the Ark + // returns value["installed"] = json!(true); value["running"] = Value::Null; context.interrupt.partial(value.clone()); + + // Wait for the Ark to return and verify its build, unless --no-wait skips + // that if !no_wait { context .output @@ -212,8 +244,11 @@ pub(crate) fn run(context: &Context, command: args::Firmware) -> Result<(), Erro context.output.document(&value) } -/// Installation acknowledges before scheduling reboot. Observe the old session -/// ending first, including when reinstalling the same build. The reboot window +/// Waits for the Ark to reboot and return running `target`, recording what it +/// finds in `value`. +/// +/// The Ark answers the install request before it reboots, so the old session +/// has to end first, even when the same build is reinstalled. The reboot window /// and the pauses between attempts run on the old connection's clock. fn verify_reboot( context: &Context, @@ -221,6 +256,7 @@ fn verify_reboot( target: &str, value: &mut Value, ) -> Result<(), Error> { + // Poll the old session every 250 ms until it ends, within the reboot window let clock = connection.client.clock(); let deadline = clock.now() + REBOOT_WAIT; loop { @@ -236,6 +272,9 @@ fn verify_reboot( return Err(reboot_timeout()); } } + + // Rediscover the Ark every 500 ms while a handshake still fits in the + // window connection.ark.close(); let device = &connection.device; let key = connection.identity.key(); @@ -276,10 +315,13 @@ fn verify_reboot( } clock.sleep(Duration::from_millis(500)); } + + // Report the timeout only once the whole window has passed clock.sleep_until(deadline); Err(reboot_timeout()) } -/// Reports that installation was not verified within the reboot window. + +/// Builds the error for an Ark that did not return within the reboot window. fn reboot_timeout() -> Error { Error::new(7, "timeout", "the Ark did not return within 120 seconds") } @@ -288,16 +330,21 @@ fn reboot_timeout() -> Error { #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "lowercase")] enum Approval { - /// An unpaired Ark needs no phone or button approval. + /// No phone or button approval, as for an unpaired Ark. None, - /// A paired, unlocked Ark asks its companion for approval. + /// Approval on the owner's phone in Ark Companion, as a paired, unlocked + /// Ark asks for. Phone, - /// A paired, locked Ark asks for physical button approval. + /// Approval by a press of the Ark's button, as a paired, locked Ark asks + /// for. Button, } +/// Predicts the approval an update needs from the Ark's pairing and lock +/// state, leaving it unknown on firmware this CLI does not support. +/// /// Older firmware omits these flags. An absent flag cannot mean "unpaired" on -/// the recovery path, so leave approval unknown and let the Ark handle it. +/// the recovery path, so the approval stays unknown and the Ark handles it. fn approval(info: &schema::DeviceInfoResponse) -> Option { check_compatibility(info).ok()?; Some(if !info.paired { @@ -308,13 +355,16 @@ fn approval(info: &schema::DeviceInfoResponse) -> Option { Approval::Button }) } + /// Finds an exact requested build or the first candidate in a newest-first listing. +/// /// Explicit selection permits reinstallation or downgrade requests; the Ark decides. fn select<'a>( firmwares: &'a [Package], requested: Option<&str>, installed: &str, ) -> Result, Error> { + // A requested build must be a valid version the listing publishes if let Some(version) = requested { package::Version::parse(version)?; return firmwares @@ -329,6 +379,8 @@ fn select<'a>( ) }); } + + // Otherwise the newest candidate wins, since the listing is newest first for firmware in firmwares { if package::candidate(firmware, installed)? { return Ok(Some(firmware)); @@ -336,8 +388,11 @@ fn select<'a>( } Ok(None) } -/// Shows candidates and the installed build, even when that build is no longer published. + +/// Shows the candidates and the installed build, adding the installed one when +/// the listing lacks it. fn listing(context: &Context, connection: &Connection, firmwares: &[Package]) -> Result<(), Error> { + // Keep the candidates and the installed build from the listing let installed = &connection.info.firmware_version; let mut rows = Vec::new(); for firmware in firmwares { @@ -354,6 +409,8 @@ fn listing(context: &Context, connection: &Connection, firmwares: &[Package]) -> })); } } + + // An installed build missing from the listing still gets a row, first if !rows.iter().any(|row| row["installed"] == true) { rows.insert( 0, @@ -361,6 +418,9 @@ fn listing(context: &Context, connection: &Connection, firmwares: &[Package]) -> "summary":null,"installed":true,"candidate":false}), ); } + + // The JSON document names the update and takes the rows before the table + // adds its flags let update = select(firmwares, None, installed)?.map(|firmware| firmware.version.clone()); let document = json!({"installed":installed,"update":update,"firmwares":rows}); for row in &mut rows { @@ -375,6 +435,8 @@ fn listing(context: &Context, connection: &Connection, firmwares: &[Package]) -> .join(", ") ); } + + // Group the table rows by semantic version, without the build suffix let groups: Vec<_> = rows .iter() .map(|row| { @@ -412,8 +474,10 @@ pub(crate) struct Packages { /// Cached Access application token for a protected package host. token: Option, } + impl Packages { - /// Selects the package host and tries cached credentials without prompting for login. + /// Selects the package host and tries cached credentials without prompting + /// for login. pub fn new(context: &Context, clock: Clock, env: Option) -> Result { let origin = match env.ok_or_else(|| { Error::new(4, "environment-unknown", "cloud environment unknown") @@ -436,6 +500,7 @@ impl Packages { }; Ok(result) } + /// Fetches a size-bounded listing and validates every artifact before selection. pub fn list(&mut self, context: &Context) -> Result, Error> { let mut response = self.get(context, "imgs/arkos.pkgs")?; @@ -455,13 +520,17 @@ impl Packages { })? .firmwares() } + /// Fetches one path and retries once after a recognized Access login challenge. + /// /// An unrelated redirect never changes the destination or receives credentials. fn get( &mut self, context: &Context, path: &str, ) -> Result, Error> { + // Send any token as a sensitive header, and log in once after a + // challenge before retrying let fetch = |token: Option<&str>| { let mut request = self.agent.get(format!("{}/{path}", self.origin)); if let Some(token) = token { @@ -479,6 +548,9 @@ impl Packages { } else { response }; + + // A second challenge fails with the manual login command, and any other + // status but success is a cloud failure if access::required(self.origin, response.status(), response.headers()) { return Err(Error::new( 4, @@ -513,8 +585,10 @@ struct Download<'a> { /// HTTP body retained after the first read starts the download. response: Option>, } + impl std::io::Read for Download<'_> { - /// Opens once and streams bytes, preserving CLI login errors through connect's reader API. + /// Opens the download on the first read and streams it, carrying CLI + /// errors such as a login refusal through connect's reader API. fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { if self.response.is_none() { self.response = Some( @@ -533,10 +607,13 @@ impl std::io::Read for Download<'_> { } } +/// Tests of the firmware compatibility gate and the approval prediction. #[cfg(test)] mod tests { use super::*; + /// Builds device info reporting firmware `version`, published at `publish` + /// in Unix seconds. fn info(version: &str, publish: u64) -> schema::DeviceInfoResponse { schema::DeviceInfoResponse { firmware_version: version.into(), @@ -545,6 +622,8 @@ mod tests { } } + /// Builds from the minimum version up pass the gate, and older or malformed + /// versions fail it. #[test] fn compatibility_requires_the_protocol_batch() { for version in [ @@ -572,6 +651,8 @@ mod tests { } } + /// Develop builds need the publish cutoff, and tagged builds pass without + /// it. #[test] fn develop_builds_need_the_cutoff_but_tagged_builds_do_not() { for version in ["0.11.5-develop", "0.12.0-develop"] { @@ -593,6 +674,8 @@ mod tests { } } + /// The predicted approval follows the pairing and lock state, but only on + /// supported firmware. #[test] fn approval_uses_device_state_only_on_supported_firmware() { for (paired, unlocked, expected) in [ diff --git a/src/firmware/package.rs b/src/firmware/package.rs index 959dd46..539d258 100644 --- a/src/firmware/package.rs +++ b/src/firmware/package.rs @@ -24,6 +24,7 @@ pub(crate) struct Package { /// Decoded archive digest used for routing and end-to-end download integrity. pub sha256: [u8; 32], } + impl Package { /// Retains only the metadata connect needs to authorize and verify the update. pub fn firmware(&self) -> darkbio_connect::Firmware { @@ -40,24 +41,34 @@ fn invalid(message: String) -> Error { Error::new(1, "invalid-version", message) } -/// Ark versions carry three u16 components and a seven-character build suffix. +/// Parsed Ark firmware version, three `u16` components and a seven-character +/// build suffix. +/// +/// Versions order by their numbers, then a stable build after develop, then +/// the suffix. #[derive(PartialEq, Eq, PartialOrd, Ord)] pub(super) struct Version { /// Major, minor and patch components compared numerically. numbers: [u16; 3], - stable: bool, // A stable build follows develop at the same semantic version + /// Whether the build is stable, which orders it after develop at the same + /// semantic version. + stable: bool, /// Seven-character suffix, breaking ties after semantic version and stability. commit: String, } impl Version { - /// Whether the suffix names the mutable develop build instead of a commit. + /// Reports whether the suffix names the mutable develop build instead of a + /// commit. pub(super) fn is_develop(&self) -> bool { !self.stable } - /// Requires three u16 components and either develop or seven hexadecimal characters. + /// Parses three `u16` components and a suffix of `develop` or seven + /// hexadecimal characters. pub(super) fn parse(value: &str) -> Result { + // Split off the suffix, and parse three numbers of plain digits that + // fit a `u16` let invalid = || invalid(format!("invalid firmware version {value:?}")); let (version, commit) = value.split_once('-').ok_or_else(invalid)?; let mut parts = version.split('.'); @@ -69,6 +80,9 @@ impl Version { } *number = part.parse().map_err(|_| invalid())?; } + + // Nothing may follow the third number, and the suffix is `develop` or + // a short commit hash if parts.next().is_some() || commit.len() != 7 || (commit != "develop" && !commit.bytes().all(|b| b.is_ascii_hexdigit())) @@ -86,7 +100,7 @@ impl Version { /// Unvalidated package index decoded before artifact routes are accepted. #[derive(Deserialize)] pub(super) struct Listing { - /// Package family, required to be arkos before any artifact is used. + /// Package family, required to be `arkos` before any artifact is used. package: String, /// Published entries awaiting version, length, hash and path checks. artifacts: Vec, @@ -110,12 +124,17 @@ struct Artifact { } impl Listing { - /// Validate routing before any archive or device access. Archive paths must - /// name the same version and hash as the cloud access-key request. + /// Validates every artifact into packages sorted newest first. + /// + /// It runs before any archive download or firmware change. Archive paths + /// must name the same version and hash as the cloud access-key request. pub(super) fn firmwares(self) -> Result, Error> { if self.package != "arkos" { return Err(invalid("package listing is not arkos".into())); } + + // Each artifact needs a valid version and digest, a nonzero size and + // its canonical path let mut firmwares = Vec::new(); for artifact in self.artifacts { let version = Version::parse(&artifact.version)?; @@ -134,6 +153,8 @@ impl Listing { } firmwares.push((version, firmware)); } + + // Sort the packages newest first by their parsed versions firmwares.sort_by(|(a, _), (b, _)| b.cmp(a)); Ok(firmwares .into_iter() @@ -150,7 +171,9 @@ pub(super) fn path(firmware: &Package) -> String { hex::encode(firmware.sha256) ) } -/// Accepts a higher semantic version or any build replacing develop at the same version. + +/// Accepts a higher semantic version or any build replacing develop at the +/// same version. pub(super) fn candidate(firmware: &Package, installed: &str) -> Result { let current = Version::parse(installed)?; let proposed = Version::parse(&firmware.version)?; @@ -158,11 +181,13 @@ pub(super) fn candidate(firmware: &Package, installed: &str) -> Result Package { Package { version: version.into(), @@ -172,11 +197,15 @@ mod tests { sha256: [42; 32], } } + + /// Builds a listing that publishes `package` at its canonical path. fn listing(package: &Package) -> serde_json::Value { json!({"package":"arkos","artifacts":[{"version":package.version,"summary":package.summary, "published":package.published,"size":package.size,"sha256":hex::encode(package.sha256),"path":path(package)}]}) } + /// A listing is refused for a foreign package, a bad version, a short hash, + /// a zero size or a foreign path. #[test] fn package_paths_and_hashes_must_agree() { let package = package("2.0.0-1234567"); @@ -202,8 +231,11 @@ mod tests { } } + /// Listings sort newest first, and candidates are higher versions or builds + /// replacing develop. #[test] fn candidates_follow_semantic_versions_and_replace_develop_builds() { + // A mixed listing sorts newest first, with a stable build above develop let mut value = listing(&package("2.0.0-1234567")); for version in ["1.0.0-develop", "3.0.0-develop", "3.0.0-1234567"] { value["artifacts"] @@ -227,13 +259,17 @@ mod tests { "1.0.0-develop" ] ); + + // A candidate is a higher version, or any build replacing develop at the + // same version let proposed = package("2.0.0-1234567"); assert!(candidate(&proposed, "1.0.0-fffffff").unwrap()); assert!(!candidate(&proposed, "2.0.0-0000000").unwrap()); assert!(!candidate(&proposed, "3.0.0-develop").unwrap()); assert!(candidate(&proposed, "2.0.0-develop").unwrap()); assert!(candidate(&proposed, "not-a-version").is_err()); - // Explicit versions may be a downgrade or reinstallation; the Ark decides. + + // Explicit versions may be a downgrade or reinstallation; the Ark decides assert_eq!( super::super::select(&sorted, Some("1.0.0-develop"), "3.0.0-1234567") .unwrap() diff --git a/src/help.rs b/src/help.rs index d6f7321..a07577b 100644 --- a/src/help.rs +++ b/src/help.rs @@ -4,8 +4,11 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -//! Help is generated from the commands this build actually serves. A page has -//! one shape on a terminal and in a pipe; only color and glyphs differ. +//! Help pages, topics and the manual, generated from the commands this build +//! serves. +//! +//! A page has one shape on a terminal and in a pipe; only color and glyphs +//! differ. use crate::{ args::Cli, @@ -23,13 +26,16 @@ const GLOBAL: [&str; 10] = [ /// Help topics in the order the manual prints them. const TOPICS: [&str; 6] = ["agents", "states", "output", "devices", "datasets", "apps"]; -/// Help is written for reading even when the invocation selects JSON, so the -/// theme never follows that flag; a pipe still loses color and glyphs. +/// Resolves the stdout theme for help, which is written for reading even when +/// the invocation selects JSON. +/// +/// The theme never follows that flag, but a pipe still loses color and glyphs. pub(crate) fn theme() -> Theme { Theme::new(false, false) } -/// Builds the executable command tree with shared styling and command-specific contracts. +/// Builds the executable command tree with shared styling and command-specific +/// contracts. pub(crate) fn command(theme: &Theme) -> clap::Command { let mut command = Cli::command(); decorate(&mut command, "", theme); @@ -38,10 +44,13 @@ pub(crate) fn command(theme: &Theme) -> clap::Command { command } -/// Clap normally expands long help onto two lines per option. Render its short -/// layout once, wrapped to the width, then let the help action select the -/// short or long footer. The shared options drop off every page but the root. +/// Lays out every page in clap's short help layout, wrapped to the width, and +/// hides the shared options on every page but the root. +/// +/// Clap normally expands long help onto two lines per option. The short layout +/// is rendered once, and the help action then selects the short or long footer. fn compact(command: &mut clap::Command, theme: &Theme, root: bool) { + // Hide the shared options below the root if !root { for name in GLOBAL { *command = command @@ -49,6 +58,9 @@ fn compact(command: &mut clap::Command, theme: &Theme, root: bool) { .mut_arg(name, |argument| argument.hide(true)); } } + + // Render the short layout without footers, and use it as the page's + // template in front of the footer the help action selects let mut display = command.clone().after_help(None).after_long_help(None); let scan = display .render_help() @@ -61,14 +73,20 @@ fn compact(command: &mut clap::Command, theme: &Theme, root: bool) { *command = command .clone() .help_template(format!("{}{{after-help}}", scan.trim_end())); + + // Subcommands get the same layout, without the shared options for child in command.get_subcommands_mut() { compact(child, theme, false); } } -/// Adds prerequisites, approval guidance, output fields and examples to each command. +/// Adds prerequisites, approval guidance, output fields and examples to each +/// command. +/// /// The command path selects its contract; clap still owns syntax and argument help. fn decorate(command: &mut clap::Command, parent: &str, theme: &Theme) { + // Style the page, and give each subcommand back the help flag that the + // root disables for its whole tree *command = command .clone() .styles(theme.clap()) @@ -87,6 +105,8 @@ fn decorate(command: &mut clap::Command, parent: &str, theme: &Theme) { .long_help("Print help (see a summary with '-h')"), ); } + + // The command path, without the root's name, selects the contract let path = if parent.is_empty() { command.get_name().to_string() } else { @@ -235,6 +255,8 @@ fn decorate(command: &mut clap::Command, parent: &str, theme: &Theme) { "ark --help\nark help agents", ), }; + + // List the exit classes each command can end with let exits = match key { "devices" => "0 done; 1 local; 2 usage; 3 device", "status" => { @@ -255,6 +277,9 @@ fn decorate(command: &mut clap::Command, parent: &str, theme: &Theme) { } _ => "0 done; 1 local; 2 usage", }; + + // The root gets the overview, a group points to its children, and a leaf + // gets its contract let help = if parent.is_empty() { "Output is formatted for reading; --json keeps complete, exact values. AI agents: read `ark help agents` first. @@ -266,7 +291,7 @@ Topics: agents, states, output, devices, datasets, apps." ) } else { // Clap keeps an option hidden from the short page out of the rendered - // scan too, so the long page lists it by hand ahead of the contract. + // scan too, so the long page lists it by hand ahead of the contract let advanced = if matches!(key, "status" | "enroll") { "Advanced: --pubkey Pin an xDSA public key instead of verifying the attestation @@ -290,6 +315,9 @@ Topics: agents, states, output, devices, datasets, apps." ) ) }; + + // Wrap the text and attach it as the long footer, and on the root as the + // short one too let help = help .lines() .map(|line| style::wrap(&theme.inline(line), theme.width, 0)) @@ -300,16 +328,21 @@ Topics: agents, states, output, devices, datasets, apps." decorated = decorated.after_help(format!("{help}\n")); } *command = decorated; + + // Subcommands get their own contracts under this path for child in command.get_subcommands_mut() { decorate(child, &path, theme); } } /// Prints a command page, an embedded topic or the full manual without discovery. +/// /// Help remains readable text even when the invocation selects JSON. pub(crate) fn run(path: &[String], all: bool) -> Result<(), Error> { let theme = theme(); let mut root = command(&theme); + + // The manual joins every command page and topic, divided by a muted line if all { let mut pages = Vec::new(); collect_help(&mut root, &mut pages); @@ -323,12 +356,16 @@ pub(crate) fn run(path: &[String], all: bool) -> Result<(), Error> { ); return Ok(()); } + + // A single name may be a topic if path.len() == 1 && let Some(topic) = topic(&path[0]) { println!("{}", markdown(&theme, topic)); return Ok(()); } + + // Anything else walks the command tree to the page it names let mut command = &mut root; for name in path { command = command.find_subcommand_mut(name).ok_or_else(|| { @@ -343,9 +380,11 @@ pub(crate) fn run(path: &[String], all: bool) -> Result<(), Error> { Ok(()) } -/// Lays out the contract: labels with a colon padded to one column, values -/// wrapped under themselves, then the examples as bare commands, since a pasted -/// prompt breaks in a shell. +/// Lays out the contract with its labels padded to one column and its values +/// wrapped under themselves. +/// +/// The examples follow as bare commands, since a pasted prompt breaks in a +/// shell. fn footer(theme: &Theme, fields: &[(&str, &str)], examples: &str) -> String { let column = fields .iter() @@ -367,6 +406,8 @@ fn footer(theme: &Theme, fields: &[(&str, &str)], examples: &str) -> String { ) }) .collect::>(); + + // The examples follow under their own heading, styled as commands lines.push(format!("\n{}", theme.paint(Role::Heading, "Examples:"))); lines.extend(examples.lines().map(|line| { style::wrap( @@ -378,19 +419,24 @@ fn footer(theme: &Theme, fields: &[(&str, &str)], examples: &str) -> String { lines.join("\n") } -/// Renders the topic dialect: headings, bullets with their continuation lines, -/// and code that is either fenced or indented by four spaces or more. Indented -/// code drops the block's own indent so long commands stay on one line. +/// Renders a help topic's Markdown dialect for reading. +/// +/// The dialect has headings, bullets with their continuation lines, and code +/// that is either fenced or indented by four spaces or more. Indented code +/// drops the block's own indent so long commands stay on one line. fn markdown(theme: &Theme, text: &str) -> String { let mut fenced = false; let mut bullet = false; let mut block = None; let mut lines = Vec::new(); for line in text.lines() { + // Fences toggle a code block and print nothing themselves if line.starts_with("```") { fenced = !fenced; continue; } + + // Style the line by its kind, keeping the hanging indent its wrap needs let content = line.trim_start(); let indent = line.len() - content.len(); let (line, hanging) = if fenced { @@ -454,10 +500,13 @@ fn topic(name: &str) -> Option<&'static str> { }) } +/// Tests of help pages, contract footers and topic rendering. #[cfg(test)] mod tests { use super::*; + /// The footer aligns its labels and styles its examples, and a topic + /// styles its headings, code and bullets. #[test] fn footer_uses_aligned_labels_and_styled_examples() { let theme = Theme::test(80, Color::Basic, true); @@ -506,6 +555,7 @@ mod tests { } } + /// Help fits a narrow terminal and keeps its example commands whole. #[test] fn help_fits_narrow_terminals_without_losing_commands() { let theme = Theme::test(60, Color::True, true); @@ -525,12 +575,16 @@ mod tests { assert!(rendered.contains("\x1b[")); } + /// Shared options are listed on the root page and hidden on child pages, + /// which keep their help flag. #[test] fn shared_options_are_listed_on_the_root_page_only() { let theme = Theme::test(80, Color::Off, false); let mut root = command(&theme); - // Examples mention the flags too, so the test identifies the listing by - // the text clap prints beside each option. + + // The root page lists the shared options. Examples mention the flags + // too, so the test identifies the listing by the text clap prints + // beside each option. let listed = [ "--timeout ", "Print the complete result as JSON", @@ -542,6 +596,8 @@ mod tests { for option in listed { assert!(page.contains(option), "{option}"); } + + // Child pages hide them, and keep their help flag for path in [vec!["data"], vec!["data", "upload"], vec!["doctor"]] { let mut command = &mut root; for name in &path { @@ -555,10 +611,14 @@ mod tests { } } + /// Group pages point to their children's contracts, and leaf pages carry + /// their own. #[test] fn groups_point_to_child_contracts() { let theme = Theme::test(80, Color::Off, false); let mut root = command(&theme); + + // Group pages point to their children without a contract of their own for name in ["data", "app", "firmware"] { let group = root.find_subcommand_mut(name).unwrap(); let long = group.render_long_help().to_string(); @@ -567,6 +627,8 @@ mod tests { assert!(!long.contains("Approval:")); assert!(!long.contains("Exit:")); } + + // Leaf pages carry their contracts, down to the unlock guidance let status = root.find_subcommand_mut("status").unwrap(); assert!( status @@ -587,6 +649,8 @@ mod tests { } } + /// Topic bullets align their continuation lines, and indented code drops + /// its block indent. #[test] fn markdown_aligns_bullets_and_dedents_indented_code() { let theme = Theme::test(80, Color::Basic, true); diff --git a/src/http.rs b/src/http.rs index 0233823..4c2f7ba 100644 --- a/src/http.rs +++ b/src/http.rs @@ -13,8 +13,11 @@ use ureq::unversioned::{ transport::{Buffers, ConnectionDetails, Connector, DefaultConnector, NextTimeout, Transport}, }; -/// Creates an HTTPS download client with bounded network waits and caller-selected -/// redirect allowance. Active bodies can outlive many inactivity windows. +/// Creates an HTTPS-only download client that bounds each network wait by +/// `timeout` and follows up to `redirects` redirects. +/// +/// Active bodies can outlive many inactivity windows, and HTTP error statuses +/// come back as responses for the caller to judge. pub(crate) fn agent(timeout: Duration, redirects: u32) -> ureq::Agent { agent_over(DefaultConnector::default(), timeout, redirects) } @@ -46,8 +49,11 @@ pub(crate) fn error(error: ureq::Error) -> Error { } } -/// Body readers wrap ureq's typed timeout in io::ErrorKind::Other. -/// Normalize it before handing the reader to a protocol-only workflow. +/// Turns a ureq timeout wrapped in a body read error into a `TimedOut` error, +/// passing other errors through. +/// +/// Body readers wrap ureq's typed timeout in `io::ErrorKind::Other`, so +/// download readers normalize it before a protocol-only workflow sees it. pub(crate) fn normalize_read_error(error: std::io::Error) -> std::io::Error { if matches!( error @@ -61,7 +67,7 @@ pub(crate) fn normalize_read_error(error: std::io::Error) -> std::io::Error { } } -/// Classifies an HTTP body read after recovering any wrapped timeout. +/// Classifies an HTTP body read failure after recovering any wrapped timeout. pub(crate) fn read_error(error: std::io::Error) -> Error { let error = normalize_read_error(error); if error.kind() == std::io::ErrorKind::TimedOut { @@ -71,10 +77,16 @@ pub(crate) fn read_error(error: std::io::Error) -> Error { } } +/// Connector adapter that bounds each transport wait by an inactivity allowance. +/// /// Ureq's body timeout covers the entire body. This adapter instead limits each /// transport wait, preserving any shorter deadline supplied by the HTTP layer. #[derive(Debug)] -struct Inactivity(Duration); +struct Inactivity( + /// Longest a single transport wait may take. + Duration, +); + impl Connector for Inactivity { /// Original transport with a renewed bound on each wait. type Out = Idle; @@ -90,6 +102,7 @@ impl Connector for Inactivity { })) } } + /// HTTP transport that clips each I/O wait to the download inactivity allowance. #[derive(Debug)] struct Idle { @@ -98,6 +111,7 @@ struct Idle { /// Fresh allowance for each transport wait, independent of body length. timeout: Duration, } + impl Idle { /// Preserves an earlier HTTP deadline, otherwise applying the inactivity limit. fn bound(&self, timeout: NextTimeout) -> NextTimeout { @@ -111,29 +125,35 @@ impl Idle { } } } + impl Transport for Idle { /// Uses the original transport buffers without introducing another body copy. fn buffers(&mut self) -> &mut dyn Buffers { self.inner.buffers() } + /// Writes buffered request bytes under the earlier transport bound. fn transmit_output(&mut self, amount: usize, timeout: NextTimeout) -> Result<(), ureq::Error> { self.inner.transmit_output(amount, self.bound(timeout)) } + /// Waits for more response bytes with a renewed inactivity allowance. fn await_input(&mut self, timeout: NextTimeout) -> Result { self.inner.await_input(self.bound(timeout)) } + /// Defers pooled-connection liveness checks to the underlying transport. fn is_open(&mut self) -> bool { self.inner.is_open() } + /// Preserves the transport's TLS status for HTTPS policy checks. fn is_tls(&self) -> bool { self.inner.is_tls() } } +/// Tests of the inactivity bound, the HTTPS policy and the error classes. #[cfg(test)] mod tests { use super::*; @@ -146,12 +166,13 @@ mod tests { use ureq::unversioned::transport::LazyBuffers; /// Transport whose input the test hands over, waited for on the test clock. + /// /// Every wait reports its deadline before it starts. #[derive(Debug)] struct Scripted { /// Clock the input waits run on. clock: Clock, - /// Buffers the client reads and writes through. + /// Input and output buffers the client reads and writes through. buffers: LazyBuffers, /// Input the test hands over. input: crossbeam_channel::Receiver>, @@ -160,14 +181,18 @@ mod tests { } impl Transport for Scripted { + /// Returns the buffers the client reads and writes through. fn buffers(&mut self) -> &mut dyn Buffers { &mut self.buffers } + /// Discards the output, since the test checks only the input side. fn transmit_output(&mut self, _: usize, _: NextTimeout) -> Result<(), ureq::Error> { Ok(()) } + /// Reports the wait's deadline, if any, then receives the next scripted + /// input on the test clock, timing out at that deadline. fn await_input(&mut self, timeout: NextTimeout) -> Result { let deadline = (!timeout.after.is_not_happening()).then(|| self.clock.now() + *timeout.after); @@ -184,22 +209,32 @@ mod tests { Ok(true) } + /// Reports the connection open, so the client keeps using it. fn is_open(&mut self) -> bool { true } + /// Reports TLS, so the fixture can serve an HTTPS request without a + /// TLS handshake. fn is_tls(&self) -> bool { true } } - /// Opens the scripted transport for the one connection a test makes. + /// Connector that opens the scripted transport for the one connection a + /// test makes. #[derive(Debug)] - struct Script(Mutex>); + struct Script( + /// Scripted transport, taken by the first connection. + Mutex>, + ); impl Connector for Script { + /// Scripted transport the test drives. type Out = Scripted; + /// Hands out the scripted transport to the first connection, and + /// nothing to any later one. fn connect( &self, _: &ConnectionDetails, @@ -231,9 +266,8 @@ mod tests { (agent, hand, reported) } - /// Active downloads can take many inactivity windows, since each wait for - /// body input gets the whole allowance. A silent body still expires at the - /// end of its window, and an earlier HTTP deadline wins over the allowance. + /// Checks that each wait for body input gets the whole inactivity allowance, + /// while a silent body and an earlier HTTP deadline still end the wait. #[test] fn body_timeout_measures_each_wait() { // A request's earlier deadline bounds the wait for its response @@ -288,8 +322,8 @@ mod tests { assert_eq!(read_error(result.unwrap_err()).class, 7); } - /// Body readers wrap ureq's timeout in an io::Error, which still classifies - /// as a timeout. + /// Checks that a ureq timeout wrapped in an I/O error still classifies as a + /// timeout, while other read failures do not. #[test] fn test_wrapped_body_timeouts_are_timeouts() { let wrapped = ureq::Error::Timeout(ureq::Timeout::RecvBody).into_io(); @@ -301,6 +335,7 @@ mod tests { ); } + /// Checks that the download client fails a plain HTTP request. #[test] fn public_downloads_require_https() { assert!( diff --git a/src/interrupt.rs b/src/interrupt.rs index e4bbfd3..acb41d5 100644 --- a/src/interrupt.rs +++ b/src/interrupt.rs @@ -14,7 +14,11 @@ use std::time::Duration; /// Shared cancellation registration used by commands and platform signal handlers. #[derive(Clone)] -pub(crate) struct Interrupt(Arc>); +pub(crate) struct Interrupt( + /// Registration shared with the signal handlers. + Arc>, +); + /// Active connection and cancellation target, held stable throughout interruption. #[derive(Default)] struct State { @@ -25,6 +29,7 @@ struct State { /// Latest structured result to preserve if interruption precedes completion. partial: Option, } + /// Device-side work addressable by an explicit cancellation request. #[derive(Clone, Copy)] pub(crate) enum Target { @@ -36,9 +41,14 @@ pub(crate) enum Target { impl Interrupt { /// Registers platform handlers and an initially empty cancellation state. - /// Cancellation runs on a worker or OS callback thread, outside a Unix signal handler. + /// + /// Cancellation runs on a worker or OS callback thread, outside a Unix + /// signal handler. pub fn install(output: Output) -> Result { let interrupt = Self(Arc::new(Mutex::new(State::default()))); + + // Watch SIGINT and SIGTERM on a thread of its own, exiting with the + // shell's codes 130 and 143 #[cfg(unix)] { let handle = interrupt.clone(); @@ -55,6 +65,9 @@ impl Interrupt { } })?; } + + // Windows console callbacks carry no context, so the state lives in a + // static the callback reads #[cfg(windows)] { use windows_sys::Win32::System::Console::SetConsoleCtrlHandler; @@ -63,39 +76,59 @@ impl Interrupt { } // Windows invokes the callback on its own thread. Keep it running // through cancellation; returning from a close event ends the process. + // Registering is sound, since the handler is a plain function that + // lives as long as the process. if unsafe { SetConsoleCtrlHandler(Some(console_handler), 1) } == 0 { return Err(std::io::Error::last_os_error().into()); } } Ok(interrupt) } - /// Registers a new session and clears any cancellation target from the previous one. + + /// Registers a new session and clears any cancellation target from the + /// previous one. pub fn connection(&self, client: Client, closer: Closer) { let mut state = self.0.lock().expect("cancellation not poisoned"); state.connection = Some((client, closer)); state.target = None; } + /// Records device-side work as soon as the Ark returns its cancellation ID. pub fn target(&self, target: Target) { self.0.lock().expect("cancellation not poisoned").target = Some(target); } + /// Replaces the result snapshot used if a signal interrupts the command. pub fn partial(&self, value: Value) { self.0.lock().expect("cancellation not poisoned").partial = Some(value); } + /// Clears completed work while retaining the session and partial result. pub fn clear(&self) { self.0.lock().expect("cancellation not poisoned").target = None; } - /// Synchronizes command completion with a handler already holding cancellation state. + + /// Synchronizes command completion with a handler already holding + /// cancellation state. + /// + /// A cancellation in progress ends the process, so this then never returns. pub fn finished(&self) { drop(self.0.lock().expect("cancellation not poisoned")); } - /// Attempts bounded cancellation, closes the session and exits with the signal code. - /// The state lock prevents commands from replacing the target during cleanup. + + /// Attempts bounded cancellation, closes the session and exits with the + /// signal code. + /// + /// The state lock prevents commands from replacing the target during + /// cleanup. The work gets 5 s to confirm its cancellation, and a JSON + /// invocation ends with the last partial result when there is one. fn cancel(&self, output: Output, code: i32) -> ! { + // Hold the state until the process exits, so commands cannot swap the + // target let state = self.0.lock().expect("cancellation not poisoned"); output.event("note", "interrupted; cancelling active work"); + + // Ask the Ark to cancel the registered work, then close the session if let Some((client, closer)) = &state.connection { let timeout = Duration::from_secs(5); let result = match state.target { @@ -115,6 +148,8 @@ impl Interrupt { } closer.close(); } + + // Report the interruption, keeping a partial JSON result when one exists let error = Error::new( code as u8, if code == 143 { @@ -134,11 +169,19 @@ impl Interrupt { } } -/// Retains callback state for the process lifetime; Windows callbacks have no context pointer. +/// Callback state kept for the process lifetime, since Windows callbacks have +/// no context pointer. #[cfg(windows)] static CONSOLE: std::sync::OnceLock<(Interrupt, Output)> = std::sync::OnceLock::new(); -/// Handles console interruption on the OS callback thread and leaves unknown events unclaimed. +/// Handles console interruption on the OS callback thread and leaves unknown +/// events unclaimed. +/// +/// # Safety +/// +/// Windows calls it as a console control handler, with the control event as +/// its only argument, after [`Interrupt::install`] registers it. It has no +/// other requirements. #[cfg(windows)] unsafe extern "system" fn console_handler(event: u32) -> windows_sys::core::BOOL { use windows_sys::Win32::System::Console::{ @@ -155,17 +198,22 @@ unsafe extern "system" fn console_handler(event: u32) -> windows_sys::core::BOOL 0 } +/// Tests of the Unix signal handling. #[cfg(all(test, unix))] mod tests { use super::*; - /// A signal before a task exists still produces a complete JSON failure and - /// the shell's conventional exit class. Wait for a step event before signalling - /// so this exercises our handler rather than process startup. + /// Checks that a signal before any task still ends with a complete JSON + /// failure and the shell's conventional exit class. + /// + /// The parent waits for a step event before signaling, so the test covers + /// the handler rather than process startup. #[cfg(unix)] #[test] fn signals_finish_the_json_document() { use clap::Parser; + + // As the child, emit one event of each kind, then wait for the signal if std::env::var_os("ARK_TEST_SIGNAL_CHILD").is_some() { let options = crate::args::Cli::parse_from(["ark", "--json", "-v"]).options; let output = Output::new(&options); @@ -178,6 +226,8 @@ mod tests { let _ = std::io::stdin().read_to_end(&mut Vec::new()); panic!("child input closed before signal"); } + + // As the parent, run the child once per signal use std::{ io::{BufRead, BufReader, Read}, process::{Command, Stdio}, @@ -195,6 +245,9 @@ mod tests { .stderr(Stdio::piped()) .spawn() .unwrap(); + + // Every event arrives as one JSON line, the last one marking the + // handler as installed let mut stderr = BufReader::new(child.stderr.take().unwrap()); let mut event = String::new(); for kind in [ @@ -209,6 +262,9 @@ mod tests { serde_json::from_str::(&event).unwrap()["message"], "ready for signal" ); + + // The signal ends the child with one pretty JSON error, the + // signal's code and a final error event assert!( Command::new("kill") .args([signal, &child.id().to_string()]) diff --git a/src/logging.rs b/src/logging.rs index 72cc90f..f8fdb68 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -4,6 +4,8 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. +//! Tracing subscriber that routes allowlisted diagnostics to the CLI's stderr. +//! //! Only update, connect and wire diagnostics enter the CLI's log stream. HTTP //! and subprocess logging is excluded so authorization headers cannot appear. @@ -15,7 +17,9 @@ use tracing::{ }; use tracing_subscriber::{Layer, layer::Context, prelude::*}; -/// Installs an allowlisted subscriber for steps and requested diagnostics. +/// Installs an allowlisted subscriber for steps and requested diagnostics, +/// when `-v` or `--log` asks for either. +/// /// An existing process subscriber is retained if installation is unavailable. pub(crate) fn init(output: Output, verbose: bool, level: Option) { if !verbose && level.is_none() { @@ -29,7 +33,12 @@ pub(crate) fn init(output: Output, verbose: bool, level: Option) { .try_init(); } -/// Rejects every target outside update, connect and wire, regardless of diagnostic level. +/// Rejects every target outside update, connect and wire, regardless of +/// diagnostic level. +/// +/// Setup messages from the connection library pass only with `-v`, whatever the +/// log level. A debug log passes update and connect events up to debug level, +/// and a trace log passes update, connect and wire events at every level. fn enabled(target: &str, severity: tracing::Level, verbose: bool, level: Option) -> bool { if target == "darkbio_connect::setup" { return verbose; @@ -55,13 +64,20 @@ fn enabled(target: &str, severity: tracing::Level, verbose: bool, level: Option< } /// Tracing layer that routes selected events through the CLI's stderr policy. -struct Log(Output); +struct Log( + /// Invocation output the events are written to. + Output, +); + impl Layer for Log { - /// Renders setup messages as steps and other selected events as structured or text logs. + /// Renders setup messages as steps and other selected events as structured + /// or text logs. fn on_event(&self, event: &Event<'_>, _: Context<'_, S>) { let mut fields = Fields(Map::new()); event.record(&mut fields); let metadata = event.metadata(); + + // Setup messages become steps, and other events JSON or text log lines if metadata.target() == "darkbio_connect::setup" { if let Some(message) = fields.0.get("message").and_then(Value::as_str) { self.0.event("step", message); @@ -97,26 +113,35 @@ impl Layer for Log { } /// Collected tracing fields, preserving string values for message rendering. -struct Fields(Map); +struct Fields( + /// Field values by name. + Map, +); + impl Visit for Fields { /// Stores a debug-only field as its printable representation. fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { self.0 .insert(field.name().into(), json!(format!("{value:?}"))); } + /// Retains a string field without adding debug quotes. fn record_str(&mut self, field: &Field, value: &str) { self.0.insert(field.name().into(), json!(value)); } } +/// Tests of the diagnostic allowlist. #[cfg(test)] mod tests { use super::*; - /// Diagnostic selection includes update failures without exposing HTTP or subprocess logs. + /// Checks that diagnostic selection includes update failures without + /// exposing HTTP or subprocess logs. #[test] fn test_diagnostics_are_separate_from_steps_and_exclude_http_and_subprocesses() { + // Update logs follow --log, setup steps follow -v, and foreign targets + // never pass for level in [None, Some(Level::Debug), Some(Level::Trace)] { for verbose in [false, true] { assert_eq!( @@ -149,6 +174,9 @@ mod tests { } } } + + // Debug passes connect up to debug level, and trace adds wire at every + // level assert!(enabled( "darkbio_connect::hardware", tracing::Level::DEBUG, diff --git a/src/main.rs b/src/main.rs index 22e7b87..bc2fb54 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,19 +36,28 @@ use error::Error; use serde_json::{Value, json}; use std::process::ExitCode; -/// Parses the invocation, installs output and interruption, then reports one outcome. -/// Help and usage failures honor stream formatting even before typed parsing succeeds. +/// Parses the invocation, installs output and interruption handling, then +/// reports one outcome. +/// +/// Help and usage failures honor stream formatting even before typed parsing +/// succeeds. fn main() -> ExitCode { + // A hidden sole argument runs the detached release lookup instead let arguments: Vec<_> = std::env::args_os().collect(); if arguments.len() == 2 && arguments[1] == update::ENTRY_POINT { update::run(); return ExitCode::SUCCESS; } + + // Find --json by hand, so a usage failure can still answer in JSON let json = arguments .iter() .skip(1) .take_while(|arg| *arg != "--") .any(|arg| arg == "--json"); + + // Parse with the themed command. A clap display that exits cleanly prints + // as it is, and any other failure is a usage error. let mut command = help::command(&help::theme()); let matches = match command.try_get_matches_from_mut(&arguments) { Ok(matches) => matches, @@ -83,6 +92,8 @@ fn main() -> ExitCode { return ExitCode::from(2); } }; + + // Install output, logging and interruption handling before any work let output = output::Output::new(&cli.options); logging::init(output.clone(), cli.options.verbose, cli.options.log); let interrupt = match interrupt::Interrupt::install(output.clone()) { @@ -92,14 +103,18 @@ fn main() -> ExitCode { return ExitCode::from(error.class); } }; + + // A failed validation becomes the result, reported like any command failure let validation = cli.validate(); let context = Context { options: cli.options, output, interrupt, }; - // Valid commands print the release note, except help, completions, --version, a bare run and doctor. - // The note comes before any connection, so it reads the real clock. + + // Valid commands print the newer release note, except help, completions, + // --version, a bare run and doctor. The note comes before any connection, + // so it reads the real clock. if validation.is_ok() && !cli.help && !cli.version @@ -113,6 +128,8 @@ fn main() -> ExitCode { chrono::DateTime::from(Clock::real().system_time()), ); } + + // Run the command, or print the top-level help or the versions when asked let result = if let Err(error) = validation { Err(error) } else if cli.help { @@ -122,6 +139,7 @@ fn main() -> ExitCode { } else { run(&context, cli.command) }; + // Wait for interruption cleanup already in progress. Stop the live line // before printing an error, preserving a result already emitted by a command. context.interrupt.finished(); @@ -137,7 +155,9 @@ fn main() -> ExitCode { } } } -/// Dispatches one command; an absent command prints top-level help without discovery. + +/// Dispatches one command; an absent command prints top-level help without +/// discovery. fn run(context: &Context, command: Option) -> Result<(), Error> { match command { None => { @@ -166,6 +186,7 @@ fn run(context: &Context, command: Option) -> Result<(), Error> { } } } + /// Reports compiled crate versions and the firmware compatibility baseline. pub(crate) fn versions() -> Value { json!({ diff --git a/src/output.rs b/src/output.rs index 7175ec7..7d7b324 100644 --- a/src/output.rs +++ b/src/output.rs @@ -4,7 +4,8 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -//! A reading view or a JSON result, with diagnostics confined to stderr. +//! Output layer that prints one result as a reading view or JSON, with +//! diagnostics confined to stderr. pub(crate) mod human; @@ -22,10 +23,16 @@ use std::sync::{ use std::thread::JoinHandle; use std::time::{Duration, Instant}; -/// Clonable output handle for one invocation. Result emission is claimed once; -/// events and live stderr lines share terminal state across clones. +/// Clonable output handle for one invocation. +/// +/// The first result claims stdout, and later ones are ignored. Events and live +/// stderr lines share terminal state across clones. #[derive(Clone)] -pub(crate) struct Output(Arc); +pub(crate) struct Output( + /// Output state shared by every clone. + Arc, +); + /// Immutable stream policy and synchronization shared by output handles. struct State { /// Whether stdout uses a JSON document and stderr uses JSON events. @@ -34,7 +41,8 @@ struct State { out: Theme, /// Capabilities of stderr, including live progress support. err: Theme, - /// Suppresses optional events while retaining approvals, errors and hints. + /// Whether optional events are hidden, keeping approvals, errors, hints + /// and logs. quiet: bool, /// Whether step narration is enabled. verbose: bool, @@ -42,27 +50,32 @@ struct State { printed: AtomicBool, /// Whether a non-release route was announced during this command. environment_noted: AtomicBool, - /// Serializes result claims and writes; acquired before the terminal lock. + /// Lock that serializes result claims and writes, taken before the ticker + /// and terminal locks. result: Mutex<()>, /// Clock of the latest connection, which times every wait display. clock: Mutex>, - /// Worker redrawing the active wait. Its lock orders every change of the - /// wait display with the worker's replacement, and comes before the - /// terminal lock. + /// Worker redrawing the active wait. + /// + /// Its lock orders every change of the wait display with the worker's + /// replacement, and comes before the terminal lock. ticker: Mutex>, - /// Serializes stderr line changes and spacing around human result blocks. + /// Terminal layout, whose lock serializes stderr line changes and spacing + /// around human result blocks. + /// /// The redraw worker shares it, but never the rest of the output. terminal: Arc>, } /// Owner of a worker that redraws a wait display once a second on a clock. +/// /// Dropping it stops the worker and waits for it to exit. The worker holds /// only what it draws with, never its owner, so the owner is never dropped on /// the worker's own thread. struct Ticker { - /// Disconnects when dropped, which wakes the worker to exit. + /// Sender of the stop channel, whose drop wakes the worker to exit. stop: Option>, - /// Joined on drop, so no redraw outlives the display. + /// Worker thread, joined on drop so no redraw outlives the display. worker: Option>, } @@ -143,20 +156,22 @@ impl Output { pub fn connection(&self, clock: Clock) { *self.0.clock.lock().expect("output not poisoned") = Some(clock); } - /// Whether the caller requested JSON for both output streams. + /// Reports whether the caller requested JSON for both output streams. pub fn json(&self) -> bool { self.0.json } - /// Whether stderr supports live progress. + /// Reports whether stderr supports live progress. pub fn terminal(&self) -> bool { self.0.err.interactive } - /// Whether a result was claimed, so failure reporting must not emit another. + /// Reports whether a result was claimed, so failure reporting must not + /// emit another. pub fn printed(&self) -> bool { self.0.printed.load(Ordering::SeqCst) } - /// Reconnects share one environment note for the command. + /// Notes a non-release environment once per command, however often it + /// reconnects. pub fn environment(&self, env: Environment) { if let Some(message) = self.environment_note(env) { self.event("note", message); @@ -180,6 +195,7 @@ impl Output { } /// Emits the sole result, invoking the custom renderer only for human stdout. + /// /// Later result attempts are ignored, including after an earlier write failed. pub fn document_with( &self, @@ -208,7 +224,8 @@ impl Output { self.grouped_table(document, rows, columns, &[]) } - /// Adds group labels parallel to rows; JSON keeps all fields. + /// Renders rows as a table under group headings, given one group per row; + /// JSON keeps the complete document. pub fn grouped_table( &self, document: &Value, @@ -225,7 +242,9 @@ impl Output { } /// Ends live stderr activity and writes a complete stdout result block. - /// The caller holds the result lock; terminal state is acquired second. + /// + /// The caller holds the result lock, which comes before the ticker and + /// terminal locks. fn write_result(&self, text: &str) -> Result<(), Error> { let mut terminal = self.end_wait(); close_line(&mut terminal, &mut io::stderr().lock()); @@ -239,18 +258,26 @@ impl Output { Ok(()) } - /// App reports and dataset READMEs are payloads that bypass every layout rule. + /// Emits an app's report as the sole result, copying its bytes unchanged. + /// + /// The report goes to stdout without any layout. A nonempty app stderr + /// follows on stderr, after a note with its size. pub fn app(&self, stdout: &[u8], stderr: &[u8]) -> Result<(), Error> { + // Claim the sole result, ignoring every later attempt let _result = self.0.result.lock().expect("output not poisoned"); if self.0.printed.swap(true, Ordering::SeqCst) { return Ok(()); } + + // End live terminal activity and copy the report to stdout self.finish(); { let mut output = io::stdout().lock(); output.write_all(stdout)?; output.flush()?; } + + // Copy a nonempty app stderr after a note with its size if !stderr.is_empty() { self.event("note", format!("app stderr, {} bytes", stderr.len())); let mut output = io::stderr().lock(); @@ -261,18 +288,25 @@ impl Output { } /// Writes a best-effort stderr event under quiet and verbosity policy. - /// Single-line approvals start a presentation timer on interactive terminals. + /// + /// A single-line approval starts a wait display on an interactive terminal, + /// unless quiet hides it. pub fn event(&self, kind: &str, message: impl AsRef) { + // Quiet hides the optional events, and steps need verbose if self.0.quiet && matches!(kind, "progress" | "note" | "warning" | "step") { return; } if kind == "step" && !self.0.verbose { return; } - let message = message.as_ref(); + // JSON escapes on its own; the reading streams get a printable copy, - // since a device name or verdict must not drive the terminal. + // since a device name or verdict must not drive the terminal + let message = message.as_ref(); let shown = style::printable(message); + + // Progress on a terminal takes the live line of its stage, which the + // text before the first colon names if kind == "progress" && self.terminal() { let theme = &self.0.err; let line = format!( @@ -283,6 +317,9 @@ impl Output { self.progress_line(shown.split(':').next().unwrap_or(&shown), &line); return; } + + // Notes, warnings, steps and logs keep a wait display running, and + // other events end it before their line { let mut terminal = if matches!(kind, "note" | "warning" | "step" | "log") { self.0.terminal.lock().expect("output not poisoned") @@ -302,12 +339,15 @@ impl Output { terminal.err_printed = true; let _ = stderr.flush(); } + + // A single-line approval starts a wait display for its answer if kind == "approve" && !message.contains('\n') { self.wait("waiting", None); } } - /// Selects a live human observation or the established machine progress line. + /// Shows an update as a live terminal line, or elsewhere as a progress + /// event with its stable text. pub fn progress(&self, update: &crate::progress::Update) { if self.terminal() { self.progress_line(&update.stage, &update.render(&self.0.err)); @@ -317,11 +357,14 @@ impl Output { } /// Replaces the same live stage in place, preserving a completed previous stage. + /// /// Redirected human output appends lines without terminal control sequences. fn progress_line(&self, key: &str, line: &str) { if self.0.quiet { return; } + + // Erase the same stage's line for a redraw, or close an earlier one let mut terminal = self.end_wait(); let mut stderr = io::stderr().lock(); if terminal @@ -334,6 +377,9 @@ impl Output { } else { close_line(&mut terminal, &mut stderr); } + + // Fit the line to the width, leaving it open on a terminal for the next + // update separate_result(&mut terminal, &mut stderr); let line = self .0 @@ -349,7 +395,8 @@ impl Output { let _ = stderr.flush(); } - /// Optional human detail has no counterpart in the machine event stream. + /// Emits an event only on a terminal, for human detail that has no + /// counterpart in the machine event stream. pub fn human_event(&self, kind: &str, message: impl AsRef) { if self.terminal() { self.event(kind, message); @@ -402,11 +449,18 @@ impl Output { } } - /// Presents a scan URL and a QR code when terminal width and Unicode permit. - /// The caller uses this renderer only outside JSON, where the URL is structured. + /// Presents a pairing URL to scan, with a QR code when Unicode and the + /// terminal width permit. + /// + /// It announces the approval first and then counts down to `deadline`. The + /// caller uses it only on a terminal; elsewhere the URL goes out in an + /// approval event. pub fn pairing(&self, url: &str, deadline: Instant) { + // Announce the approval and close any live line self.event("approve", "scan in Ark Companion"); self.finish(); + + // Draw the QR code when Unicode is on and every line fits, then the URL { let mut terminal = self.0.terminal.lock().expect("output not poisoned"); let mut stderr = io::stderr().lock(); @@ -442,11 +496,16 @@ impl Output { ); terminal.err_printed = true; } + + // Count down to the scan deadline self.wait("scan", Some(deadline)); } - /// The timer only draws while a wait is active. It never bounds the call. - /// It runs on the latest connection's clock and stays hidden before one. + /// Shows a wait display counting the elapsed time, or down to `until`. + /// + /// The display is presentation only and never bounds the call. It needs an + /// interactive stderr outside quiet mode, runs on the latest connection's + /// clock and stays hidden before a connection exists. pub fn wait(&self, label: &str, until: Option) { if !self.0.err.interactive || self.0.quiet { return; @@ -455,18 +514,21 @@ impl Output { } /// Replaces any earlier wait display with one drawn to `screen`, which the - /// worker then redraws every second. The replacement happens under the - /// ticker lock, so a concurrent change of the display lands before or after - /// it as a whole. + /// worker then redraws every second. + /// + /// The replacement happens under the ticker lock, so a concurrent change of + /// the display lands before or after it as a whole. fn show_wait( &self, label: &str, until: Option, screen: impl Fn() -> W + Send + 'static, ) { + // Without a connection's clock, no wait display shows let Some(clock) = self.0.clock.lock().expect("output not poisoned").clone() else { return; }; + // Stop the earlier worker before touching the terminal, which it draws on let mut ticker = self.0.ticker.lock().expect("output not poisoned"); drop(ticker.take()); @@ -482,6 +544,7 @@ impl Output { }); tick(&self.0.err, &mut terminal, &mut screen(), now); } + // Install the worker before another change can take the ticker lock let theme = self.0.err.clone(); let terminal = self.0.terminal.clone(); @@ -517,6 +580,8 @@ impl Output { /// Reports a failure and its hints on stderr without claiming a stdout result. pub fn error(&self, error: &Error) { + // End live activity, then write the error as a JSON event or a + // printable line self.finish(); if self.json() { self.event_value(json!({"event":"error", "error":error.json()})); @@ -544,19 +609,22 @@ impl Output { } terminal.err_printed = true; } + + // Hints follow as events of their own for hint in &error.hints { self.event("hint", hint); } } /// Writes one preassembled JSON event after ending live terminal activity. - /// The caller is responsible for selecting this path only in JSON mode. + /// + /// The caller uses it only in JSON mode. pub fn event_value(&self, value: Value) { self.finish(); let _ = writeln!(io::stderr().lock(), "{value}"); } - /// Stops the active timer and closes its live line; safe to call repeatedly. + /// Stops the wait display and closes the live line; safe to call repeatedly. pub fn finish(&self) { let mut terminal = self.end_wait(); let mut stderr = io::stderr().lock(); @@ -565,8 +633,10 @@ impl Output { } /// Ends the wait display and returns the terminal for the caller's next - /// write. The worker is stopped under the ticker lock and joined before - /// the terminal lock is taken, since each redraw takes that lock too. + /// write. + /// + /// The worker is stopped under the ticker lock and joined before the + /// terminal lock is taken, since each redraw takes that lock too. fn end_wait(&self) -> MutexGuard<'_, Terminal> { let mut ticker = self.0.ticker.lock().expect("output not poisoned"); drop(ticker.take()); @@ -620,6 +690,8 @@ fn tick(theme: &Theme, terminal: &mut Terminal, output: &mut impl Write, now: In let Some(wait) = &terminal.waiting else { return; }; + + // Count down to the bound, or up from the start let time = match wait.until { Some(until) => format!("{} s left", until.saturating_duration_since(now).as_secs()), None => format!( @@ -635,6 +707,9 @@ fn tick(theme: &Theme, terminal: &mut Terminal, output: &mut impl Write, now: In theme.glyph("\u{00b7}", "-") ), ); + + // Replace the previous frame with a temporary line that the next write + // erases close_line(terminal, output); let _ = write!( output, @@ -646,7 +721,9 @@ fn tick(theme: &Theme, terminal: &mut Terminal, output: &mut impl Write, now: In } /// Formats scalar values and lists without terminal styling or field-specific units. -/// Text is made printable here, since every reading layout passes through it. +/// +/// Text is made printable here, since the document and table layouts pass +/// through it. pub(crate) fn scalar(value: &Value) -> String { match value { Value::Null => "-".into(), @@ -658,6 +735,7 @@ pub(crate) fn scalar(value: &Value) -> String { } } +/// Tests of wait displays, event lines and the environment note. #[cfg(test)] mod tests { use super::*; @@ -668,9 +746,13 @@ mod tests { use std::sync::{TryLockError, mpsc}; use std::thread; - /// Captures the frames that wait displays draw in place of stderr. + /// In-memory screen holding the frames that wait displays draw in place of + /// stderr. #[derive(Clone, Default)] - struct Screen(Arc>>); + struct Screen( + /// Bytes written so far, shared by every clone. + Arc>>, + ); impl Screen { /// Returns the frames drawn so far, oldest first. @@ -685,11 +767,13 @@ mod tests { } impl Write for Screen { + /// Appends every byte to the shared screen buffer. fn write(&mut self, bytes: &[u8]) -> io::Result { self.0.lock().unwrap().extend_from_slice(bytes); Ok(bytes.len()) } + /// Returns at once, since the screen holds no deferred output. fn flush(&mut self) -> io::Result<()> { Ok(()) } @@ -720,9 +804,8 @@ mod tests { } } - /// A wait arriving while another replaces the display goes after it, so its - /// display is the one that its sole worker redraws. Dropping the output then - /// stops that worker while it waits for the next redraw. + /// A wait arriving while another replaces the display lands after it, and + /// its sole worker stops when the output drops. #[test] fn test_concurrent_waits_keep_one_worker() { // Hold the terminal, so the first wait stops inside its replacement @@ -802,7 +885,7 @@ mod tests { assert_eq!(redraws.recv().unwrap(), first); // Pass the stop signal through a helper, which lets the redraw finish - // only once the drop has started cancelling the worker + // only once the drop has started canceling the worker let (tap, tapped) = crossbeam_channel::bounded::<()>(0); let stop = ticker.stop.replace(tap); let releasing = thread::spawn(move || { @@ -819,6 +902,7 @@ mod tests { assert_eq!(tester.next_deadline(), None); } + /// Event lines keep their kind prefix, style inline commands and mark steps. #[test] fn events_keep_prefixes_and_style_inline_commands() { let theme = Theme::test(80, Color::Basic, true); @@ -836,8 +920,11 @@ mod tests { ); } + /// A wait line is erased, while a finished progress line stays, ended by one + /// newline. #[test] fn waiting_line_is_erased_but_completed_progress_stays() { + // The first frame counts up from the start of the wait let theme = Theme::test(80, Color::Off, true); let started = TestClock::new().clock().now(); let mut terminal = Terminal { @@ -860,6 +947,8 @@ mod tests { " waiting \u{00b7} 2 s elapsed" ); output.clear(); + + // A countdown frame erases the previous frame first terminal.waiting.as_mut().unwrap().until = Some(started + Duration::from_secs(20)); tick( &theme, @@ -872,6 +961,8 @@ mod tests { format!("{} waiting \u{00b7} 15 s left", style::CLEAR_LINE) ); output.clear(); + + // Closing erases the wait line, and a tick without a wait draws nothing terminal.waiting = None; close_line(&mut terminal, &mut output); tick( @@ -882,12 +973,17 @@ mod tests { ); assert_eq!(output, style::CLEAR_LINE.as_bytes()); output.clear(); + + // A finished progress line ends with one newline, however often it + // closes terminal.live = Some(("uploading".into(), false)); close_line(&mut terminal, &mut output); close_line(&mut terminal, &mut output); assert_eq!(output, b"\n"); } + /// A non-release environment is noted once per output, and never under + /// quiet. #[test] fn nonrelease_environment_is_noted_once_unless_quiet() { for env in [Environment::Develop, Environment::Staging] { diff --git a/src/output/human.rs b/src/output/human.rs index c5bc717..dae1ed8 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -4,14 +4,18 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -//! Human layouts of result fields. Machine renderings never pass through here. +//! Human layouts of result fields. +//! +//! Machine renderings never pass through here. use crate::style::{self, Role, Theme}; use serde_json::Value; /// Styles a field, retaining explicit absent values. +/// /// Arbitrary strings receive no semantic status color merely because of their text. pub(crate) fn value(theme: &Theme, key: &str, value: &Value) -> String { + // Absent values and empty lists stay visible if value.is_null() { return theme.paint( Role::Muted, @@ -21,6 +25,8 @@ pub(crate) fn value(theme: &Theme, key: &str, value: &Value) -> String { if value.as_array().is_some_and(Vec::is_empty) { return theme.paint(Role::Muted, "none"); } + + // Sizes and durations read in their units, and timestamps in local time if key.ends_with("_bytes") && let Some(bytes) = value.as_u64() { @@ -45,6 +51,8 @@ pub(crate) fn value(theme: &Theme, key: &str, value: &Value) -> String { format!("{seconds} s") }; } + + // Other values read as text, styled by what their key means let text = super::scalar(value); match key { "trust" | "state" | "outcome" | "result" => { @@ -93,6 +101,7 @@ pub(crate) fn value(theme: &Theme, key: &str, value: &Value) -> String { } /// Aligns label/value rows, stacking them when labels consume the available width. +/// /// An empty pair separates groups; an empty label introduces an unlabeled row. pub(crate) fn block(theme: &Theme, rows: &[(String, String)]) -> String { let labels = rows @@ -134,8 +143,10 @@ pub(crate) fn document(theme: &Theme, value: &Value) -> String { block(theme, &rows) } -/// Keeps nesting in labels without exposing machine paths or unit suffixes. +/// Flattens a value into rows, joining nested keys into readable labels +/// without machine paths or unit suffixes. fn fields(theme: &Theme, prefix: &str, key: &str, value: &Value, rows: &mut Vec<(String, String)>) { + // Name the field for reading, dropping the unit suffix its value shows let name = if key == "requires" { "dependencies" } else { @@ -148,6 +159,9 @@ fn fields(theme: &Theme, prefix: &str, key: &str, value: &Value, rows: &mut Vec< let label = format!("{prefix} {}", name.replace('_', " ")) .trim() .to_string(); + + // Objects and lists of structures recurse, numbering list items from one, + // and a leaf becomes one row with a capitalized label match value { Value::Object(object) if !object.is_empty() => { for (key, value) in object { @@ -175,6 +189,7 @@ fn fields(theme: &Theme, prefix: &str, key: &str, value: &Value, rows: &mut Vec< } /// Fits a table by shrinking one free-text column, then falls back to blocks. +/// /// Selectors, hashes and other actionable fields are never ellipsized by the table. pub(super) fn table( theme: &Theme, @@ -182,6 +197,7 @@ pub(super) fn table( columns: &[(&str, &str)], groups: &[String], ) -> String { + // Style every cell and measure each column against its header let cells: Vec> = rows .iter() .map(|row| { @@ -203,6 +219,8 @@ pub(super) fn table( .max(label.len()) }) .collect(); + + // Shrink the one free-text column to fit, never below its header or 8 cells let total = |widths: &[usize]| 2 + widths.iter().sum::() + columns.len().saturating_sub(1) * 2; let flexible = columns @@ -213,6 +231,8 @@ pub(super) fn table( .saturating_sub(total(&widths).saturating_sub(theme.width)) .max(columns[index].0.len().max(8)); } + + // A table that still overflows falls back to one block per row if total(&widths) > theme.width { return cells .iter() @@ -229,6 +249,8 @@ pub(super) fn table( .collect::>() .join("\n\n"); } + + // Lay out a line, right-aligning sizes, durations and numeric columns let line = |cells: &[String], header: bool| { let mut result = String::from(" "); for (i, cell) in cells.iter().enumerate() { @@ -258,6 +280,8 @@ pub(super) fn table( } result }; + + // Print the header, then each row under a label whenever its group changes let mut lines = vec![line( &columns .iter() @@ -305,6 +329,8 @@ pub(super) fn checklist(theme: &Theme, rows: &[Value]) -> String { detail.to_string() }; let name = theme.mark(role, name); + // Pad marked names to one column, which the two-letter ASCII mark + // widens let width = labels + if theme.unicode { 2 } else { 3 }; let line = format!( " {}{} {}", @@ -331,12 +357,15 @@ pub(super) fn checklist(theme: &Theme, rows: &[Value]) -> String { .join("\n") } +/// Tests of the human layouts of values, documents, tables and checklists. #[cfg(test)] mod tests { use super::*; use crate::style::Color; use serde_json::json; + /// Nested fields become labels, absent values stay visible, and free text + /// gets no status color. #[test] fn block_retains_nested_fields_and_absent_values() { let theme = Theme::test(80, Color::Basic, true); @@ -351,6 +380,8 @@ mod tests { assert_eq!(value(&theme, "serial", &Value::Null), "unverified"); } + /// Sizes and durations show in their units, under labels without unit + /// suffixes or machine paths. #[test] fn transformed_values_use_reading_labels() { for width in [32, 80, 160] { @@ -373,6 +404,7 @@ mod tests { } } + /// A table right-aligns sizes and marks states. #[test] fn table_aligns_sizes_and_marks_states() { let theme = Theme::test(80, Color::Basic, true); @@ -392,6 +424,7 @@ mod tests { ); } + /// Tables show sizes in the same units as blocks. #[test] fn tables_and_blocks_agree_on_byte_units() { let theme = Theme::test(80, Color::Off, false); @@ -410,6 +443,7 @@ mod tests { } } + /// A narrow table fits the width and keeps actionable values whole. #[test] fn narrow_tables_keep_actionable_values() { let theme = Theme::test(32, Color::True, true); @@ -439,6 +473,7 @@ mod tests { assert!(!plain.contains('\u{2026}')); } + /// A table truncates only its free-text column to fit. #[test] fn table_truncates_only_free_text() { let theme = Theme::test(40, Color::Off, true); diff --git a/src/pairing.rs b/src/pairing.rs index 70f8639..fe6b116 100644 --- a/src/pairing.rs +++ b/src/pairing.rs @@ -13,9 +13,12 @@ use darkbio_connect::{ }; use serde_json::json; -/// Pairs an unpaired Ark, translating connector stages into the owner's scan and -/// approval instructions. Link construction and terminal presentation stay in the CLI. +/// Pairs an unpaired Ark, translating connection library stages into the +/// owner's scan and approval instructions. +/// +/// Link construction and terminal presentation stay in the CLI. pub(crate) fn run(context: &Context) -> Result<(), Error> { + // Pairing needs an unpaired Ark and a known cloud environment let connection = context.connect(None)?; if connection.info.paired { return Err(Error::new(5, "already-paired", "the Ark is already paired") @@ -25,6 +28,9 @@ pub(crate) fn run(context: &Context) -> Result<(), Error> { Error::new(4, "environment-unknown", "cloud environment unknown") .hint("select one with --env") })?; + + // The link names the environment's app, the signer's fingerprint and the + // realm, falling back to the device kind without an attested one let origin = match env { Environment::Release => "https://app.dark.bio", Environment::Staging => "https://app.darkbio.xyz", @@ -38,6 +44,8 @@ pub(crate) fn run(context: &Context) -> Result<(), Error> { darkbio_connect::DeviceKind::Hardware => Realm::Hardware, darkbio_connect::DeviceKind::Emulator => Realm::Emulator, }); + + // Terminals show the link and each stage live, other outputs plain events let mut previous = None; let result = connection .client @@ -48,7 +56,8 @@ pub(crate) fn run(context: &Context) -> Result<(), Error> { deadline, fingerprint, } => { - // Colo is routing supplied by the cloud, never a URL or a host name. + // Colo is routing supplied by the cloud, never a URL or a host + // name, so anything but letters and digits is percent-encoded let colo: String = colo .bytes() .map(|byte| { @@ -102,12 +111,16 @@ pub(crate) fn run(context: &Context) -> Result<(), Error> { "preparing encrypted storage", ), }); + + // Complete the last stage only when pairing succeeded if result.is_ok() && let Some(name) = previous { context.output.stage(name, true); } result?; + + // Report the serial of an attested Ark, or null for any other let serial = match &connection.identity { darkbio_connect::Identity::Attested { device, .. } => Some(&device.serial), _ => None, @@ -117,7 +130,8 @@ pub(crate) fn run(context: &Context) -> Result<(), Error> { .document(&json!({"serial": serial, "paired": true})) } -/// Completes the previous human stage or emits the corresponding plain progress event. +/// Starts a human stage after completing the previous one, or emits the +/// corresponding plain progress event outside a terminal. fn stage( context: &Context, previous: &mut Option<&'static str>, diff --git a/src/progress.rs b/src/progress.rs index f42fe5e..ab508e6 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -4,7 +4,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -//! Transfer rates and per-step estimates for terminal progress. +//! Transfer rates and per-step estimates for progress reports. use crate::style::{Role, Theme}; use darkbio_clock::Clock; @@ -12,9 +12,10 @@ use darkbio_connect::schema::SlotUploadProcessResponse; use std::collections::VecDeque; use std::time::{Duration, Instant}; -/// Binary megabyte divisor used for byte-rate formatting. +/// Bytes in one MiB, the divisor for displayed sizes and rates. const MIB: f64 = 1024.0 * 1024.0; -/// Rolling rate history, retaining one sample before the boundary. +/// Span of the rolling rate history, which also keeps the last sample from +/// before it. const RATE_WINDOW: Duration = Duration::from_secs(10); /// Minimum observation span before publishing an estimate. const WARMUP: Duration = Duration::from_secs(1); @@ -23,7 +24,8 @@ const HUMAN_REPORT_INTERVAL: Duration = Duration::from_secs(1); /// Maximum silence between machine progress lines when reports keep arriving. const REPORT_INTERVAL: Duration = Duration::from_secs(5); -/// One observation, with the established log line and facts for the terminal. +/// One progress observation, with its stable text line and the facts for a +/// terminal. pub(crate) struct Update { /// Stable text observation used by plain text and JSON events. pub text: String, @@ -33,7 +35,8 @@ pub(crate) struct Update { pub stage_width: usize, /// Completion percentage for this stage, not for the entire workflow. pub percent: u64, - /// Human facts in discard order; the leftmost is dropped first on narrow terminals. + /// Human facts in discard order; the leftmost is dropped first on narrow + /// terminals. pub details: Vec, /// Shared fact column widths, allowing narrower layouts without moving the bars. pub detail_widths: Vec, @@ -42,6 +45,8 @@ pub(crate) struct Update { impl Update { /// Shares label and fact columns with another stage of the same operation. pub(super) fn align(&mut self, other: &mut Self) { + // Measure what each update can show, from all its facts down to the + // last one, unless it already carries shared widths let stage_width = self.stage_width.max(other.stage_width); let mut detail_widths = Vec::new(); for update in [&*self, &*other] { @@ -55,6 +60,9 @@ impl Update { detail_widths.extend_from_slice(&update.detail_widths); } } + + // Both updates take the widest label and every fact width, so their + // bars line up detail_widths.sort_unstable(); detail_widths.dedup(); self.stage_width = stage_width; @@ -65,6 +73,8 @@ impl Update { /// Fits a stage, progress bar and optional facts into one terminal line. pub fn render(&self, theme: &Theme) -> String { + // Pad the stage to its shared column, capped at half the line or 8 + // cells on a narrow one let width = theme.width.saturating_sub(1); let stage_width = self.stage_width.min((width / 2).max(8)); let stage = theme.truncate(&self.stage, stage_width); @@ -72,11 +82,17 @@ impl Update { "{stage}{}", " ".repeat(stage_width.saturating_sub(console::measure_text_width(&stage))) ); + + // Measure the fixed parts, and keep 9 cells for the bar on terminals of + // 60 cells or more let prefix = theme.paint(Role::Muted, "progress:"); let percent = format!("{:3} %", self.percent); let fixed = console::measure_text_width(&format!("progress: {stage} {percent}")); let reserve = if theme.width >= 60 { 9 } else { 0 }; let available = width.saturating_sub(fixed + reserve); + + // Drop facts from the left until the rest fits, in the widest shared + // column that fits when there is one let shared = (!self.detail_widths.is_empty()).then(|| { self.detail_widths .iter() @@ -98,6 +114,8 @@ impl Update { } details.remove(0); }; + + // Draw a bar of up to 40 cells in the space left, or none below 8 cells let used = fixed + facts_width; let size = width.saturating_sub(used + 1).min(40); let bar = if size >= 8 { @@ -117,19 +135,22 @@ impl Update { } } -/// Samples only acknowledged bytes, starting with the first upload report so -/// cloud setup and approval do not enter the rate estimate. +/// Upload rate tracker that samples only acknowledged bytes, from the first +/// upload report on. +/// +/// Cloud setup and approval therefore never enter the rate estimate. pub(super) struct Transfer { /// Connection's clock, which times the samples. clock: Clock, - /// Rolling counter samples, in bytes for uploads and basis points for processing. + /// Rolling samples of acknowledged bytes. rate: Rate, /// Emission cadence, independent of the sampling cadence. report: Report, } impl Transfer { - /// Starts without rate history so setup and approval cannot skew the first estimate. + /// Starts without rate history so setup and approval cannot skew the first + /// estimate. pub(super) fn new(human: bool, clock: Clock) -> Self { Self { clock, @@ -138,18 +159,23 @@ impl Transfer { } } - /// Samples acknowledged bytes and emits an observation only when reporting is due. + /// Samples acknowledged bytes and emits an observation only when reporting + /// is due. pub(super) fn update(&mut self, uploaded: u64, total: u64) -> Option { self.update_at(uploaded, total, self.clock.now()) } - /// Updates byte-rate history at the supplied clock time, even if output is throttled. + /// Updates byte-rate history at the supplied clock time, even if output is + /// throttled. fn update_at(&mut self, uploaded: u64, total: u64, now: Instant) -> Option { + // Sample every report, and emit only when the cadence is due let rate = self.rate.sample(uploaded, now); let percent = percent(uploaded, total); if !self.report.due(percent, now) { return None; } + + // Word the speed, or why there is none yet, and the stable text line let speed = rate.map_or_else( || { if uploaded >= total { @@ -166,6 +192,8 @@ impl Transfer { total as f64 / MIB, eta(total.saturating_sub(uploaded), rate), ); + + // Show the human sizes in GiB from 1 GiB up, and in MiB below let (divisor, unit) = if total >= 1 << 30 { ((1_u64 << 30) as f64, "GiB") } else { @@ -190,12 +218,15 @@ impl Transfer { } } +/// Tracker of the Ark's processing steps, estimating each step on its own. +/// /// Progress percentages belong to individual steps. Device timestamps identify /// a restarted step; elapsed time is measured on the connection's monotonic clock. pub(super) struct Processing { /// Connection's clock, which times the samples. clock: Clock, - /// Last report, retained to finish a phase when the next report advances past it. + /// Last report, retained to finish a phase when the next report advances + /// past it. previous: Option, /// Basis-point progress samples for the current processing step only. rate: Rate, @@ -204,7 +235,8 @@ pub(super) struct Processing { } impl Processing { - /// Starts without a step identity or estimate; the first report establishes both. + /// Starts without a step identity or estimate; the first report establishes + /// both. pub(super) fn new(human: bool, clock: Clock) -> Self { Self { clock, @@ -215,11 +247,15 @@ impl Processing { } /// Finishes an observed phase before starting a later one in the same run. - /// Restarts and failures never imply successful completion of the previous phase. + /// + /// Restarts and failures never imply successful completion of the previous + /// phase. pub(super) fn update( &mut self, status: &SlotUploadProcessResponse, ) -> impl Iterator { + // A later phase of the same run, without a failure, first completes the + // unfinished previous one let completed = self .previous .as_ref() @@ -240,6 +276,7 @@ impl Processing { /// Builds a step-specific estimate from basis points and monotonic clock time. fn update_at(&mut self, status: &SlotUploadProcessResponse, now: Instant) -> Option { + // A new or restarted step starts its estimate and cadence afresh let phase = (status.proc_start, status.phase_in, status.phase_start); if self.previous.as_ref().is_none_or(|previous| { (previous.proc_start, previous.phase_in, previous.phase_start) != phase @@ -247,6 +284,8 @@ impl Processing { self.rate = Rate::default(); self.report.last = None; } + + // Keep and sample every report, and emit only when the cadence is due self.previous = Some(status.clone()); let rate = self.rate.sample(status.phase_progress, now); let percent = percent(status.phase_progress, 10_000); @@ -256,8 +295,11 @@ impl Processing { Some(Self::observation(status, rate)) } - /// Reserves the widest phase label and the initial ETA before rendering any step. + /// Builds a step's observation, reserving room for the widest phase label and + /// ETA so the bars never move. fn observation(status: &SlotUploadProcessResponse, rate: Option) -> Update { + // Word both lines with the step's name, or a generic one when the + // report lists no such phase let percent = percent(status.phase_progress, 10_000); let name = status .phase_in @@ -277,6 +319,9 @@ impl Processing { status.phase_in, status.phases.len() ); + + // Size the stage column for every phase label, and the facts for every + // estimate let stage_width = status .phases .iter() @@ -305,16 +350,20 @@ impl Processing { } } -/// Keep the sample immediately before the rolling window's boundary so a -/// stalled or sparse counter does not retain an old, optimistic speed. +/// Rolling rate of a counter over the last [`RATE_WINDOW`]. +/// +/// It keeps the sample just before the window's start, so a stalled or sparse +/// counter does not hold on to a stale speed. #[derive(Default)] struct Rate { - /// Time and monotonically increasing counter observations around the rolling window. + /// Time and monotonically increasing counter observations around the + /// rolling window. samples: VecDeque<(Instant, u64)>, } impl Rate { - /// Returns units per second after warmup; counter or clock regression resets history. + /// Returns units per second after warmup; counter or clock regression + /// resets history. fn sample(&mut self, value: u64, now: Instant) -> Option { if self .samples @@ -333,10 +382,14 @@ impl Rate { } } -/// Refresh terminal progress once a second as reports arrive. Line output uses -/// ten-percent boundaries or five seconds to keep logs readable. +/// Cadence at which progress observations are emitted. +/// +/// A terminal refreshes once a second as reports arrive, and shows completion +/// at once. Line output waits for the next 10 % boundary or 5 s, which keeps +/// logs readable. struct Report { - /// Selects one-second human cadence instead of sparse log boundaries. + /// Whether the one-second terminal cadence applies instead of sparse log + /// boundaries. human: bool, /// Time and percentage of the last emitted observation. last: Option<(Instant, u64)>, @@ -348,7 +401,8 @@ impl Report { Self { human, last: None } } - /// Claims an emission slot for elapsed cadence or a required completion boundary. + /// Claims an emission slot for elapsed cadence or a required completion + /// boundary. fn due(&mut self, percent: u64, now: Instant) -> bool { if self.last.is_none_or(|(time, previous)| { if self.human { @@ -419,11 +473,14 @@ fn human_eta(remaining: u64, rate: Option) -> String { ) } +/// Tests of progress rendering, rate estimates and report cadence. #[cfg(test)] mod tests { use super::*; use darkbio_clock::TestClock; + /// A progress line fits every width, keeping the bar, speed and ETA from 60 + /// cells up. #[test] fn bar_keeps_speed_and_eta_in_the_available_width() { use crate::style::Color; @@ -460,8 +517,11 @@ mod tests { } } - /// The initial bytes were accepted before sampling began. Counting them as - /// newly transferred would inflate speed and shorten the ETA. + /// A transfer estimate counts only the bytes acknowledged after sampling + /// began. + /// + /// Counting the initial bytes as newly transferred would inflate speed and + /// shorten the ETA. #[test] fn test_transfer_estimate() { let clock = TestClock::new().clock(); @@ -485,6 +545,8 @@ mod tests { assert!(done.ends_with("ETA 0s")); } + /// Builds a report of a two-step run, in `phase` at `progress` basis + /// points. fn status(phase: u64, progress: u64) -> SlotUploadProcessResponse { SlotUploadProcessResponse { proc_start: 100, @@ -501,6 +563,7 @@ mod tests { } } + /// Processing bars stay in one column across phase labels and estimates. #[test] fn processing_bars_align_across_labels_and_estimates() { use crate::style::Color; @@ -531,10 +594,13 @@ mod tests { } } + /// Upload and processing lines share their bar column, and the upload keeps + /// its facts where the width allows. #[test] fn upload_and_processing_share_columns_without_losing_transfer_facts() { use crate::style::Color; for width in [60, 80, 100, 140] { + // Finish an upload and align it with a processing observation let theme = Theme::test(width, Color::True, true); let clock = TestClock::new().clock(); let start = clock.now(); @@ -548,6 +614,9 @@ mod tests { report.phases[0].name = "Compressing".into(); report.phases[1].name = "Indexing".into(); Processing::observation(&report, None).align(&mut upload); + + // The aligned upload keeps its speed and estimate from 100 cells + // wide, and its sizes from 140 cells let rendered = upload.render(&theme); let expected = bar_position(&rendered); assert!(console::measure_text_width(&rendered) < width); @@ -558,6 +627,8 @@ mod tests { if width >= 140 { assert!(rendered.contains("290.0/290.0 MiB")); } + + // Every processing line puts its bar where the upload's is let mut processing = Processing::new(true, clock); for (phase, progress) in [(1, 8200), (2, 9200), (2, 10_000)] { report.phase_in = phase; @@ -572,6 +643,8 @@ mod tests { } } + /// Returns where a line's progress bar starts and how long it is, in + /// terminal cells. fn bar_position(line: &str) -> (usize, usize) { let line = console::strip_ansi_codes(line); let bar = line @@ -586,6 +659,7 @@ mod tests { ) } + /// Advancing to a later phase first completes the previous one at 100 %. #[test] fn advancing_completes_the_previous_phase_before_rendering_the_next() { let mut processing = Processing::new(true, TestClock::new().clock()); @@ -606,6 +680,8 @@ mod tests { assert_eq!(done[0].details, ["eta 0 s"]); } + /// A restarted step, a restarted run or a failure never completes the + /// previous phase. #[test] fn restarts_and_failures_do_not_complete_the_previous_phase() { for next in [ @@ -629,9 +705,12 @@ mod tests { } /// Every step gets its own estimate, even if the first observation arrives - /// partway through it. A restarted step must not inherit its previous rate. + /// partway through it. + /// + /// A restarted step must not inherit its previous rate. #[test] fn test_step_estimate() { + // The first step estimates from its second report on let clock = TestClock::new().clock(); let start = clock.now(); let mut processing = Processing::new(false, clock); @@ -649,6 +728,8 @@ mod tests { .text, "Processing [1/2] Validate: 30% | step ETA ~18s" ); + + // The next step starts a fresh estimate, although first seen at 40 % assert_eq!( processing .update_at(&status(2, 4000), start + Duration::from_secs(6)) @@ -663,6 +744,8 @@ mod tests { .text, "Processing [2/2] Index: 50% | step ETA ~25s" ); + + // A restart drops the step's rate, and a finished step needs none let restarted = SlotUploadProcessResponse { phase_start: 999, ..status(2, 6000) @@ -687,10 +770,13 @@ mod tests { ); } - /// Recent stalls age out a formerly fast rate. A regressing counter starts - /// over instead of underflowing or producing an estimate from another run. + /// Recent stalls age out a formerly fast rate, and a regressing counter + /// starts over. + /// + /// Starting over avoids an underflow and an estimate from another run. #[test] fn test_rate_stall_and_reset() { + // A rate shows once the warmup has passed let start = TestClock::new().clock().now(); let mut rate = Rate::default(); assert_eq!(rate.sample(0, start), None); @@ -698,18 +784,25 @@ mod tests { rate.sample(100, start + Duration::from_secs(1)), Some(100.0) ); + + // A stall across the whole window drops the rate to zero, which gives + // no estimate for seconds in 2..=11 { rate.sample(100, start + Duration::from_secs(seconds)); } let stopped = rate.sample(100, start + Duration::from_secs(12)); assert_eq!(stopped, Some(0.0)); assert_eq!(eta(100, stopped), "estimating..."); + + // A regressing counter starts its history over assert_eq!(rate.sample(50, start + Duration::from_secs(13)), None); assert_eq!(rate.sample(75, start + Duration::from_secs(14)), Some(25.0)); } /// Time-based reporting refreshes a stuck percentage, while short bursts - /// stay quiet. Completion is printed even inside the normal interval. + /// stay quiet. + /// + /// Completion is printed even inside the normal interval. #[test] fn test_report_cadence() { let start = TestClock::new().clock().now(); @@ -721,6 +814,8 @@ mod tests { assert!(!report.due(100, start + REPORT_INTERVAL + Duration::from_millis(2))); } + /// Terminal transfer reports refresh once a second and show completion at + /// once. #[test] fn test_human_transfer_cadence() { let clock = TestClock::new().clock(); @@ -746,6 +841,8 @@ mod tests { ); } + /// Terminal step reports show a new step at once and then refresh once a + /// second. #[test] fn test_human_step_cadence() { let clock = TestClock::new().clock(); diff --git a/src/style.rs b/src/style.rs index 6511483..b8336bf 100644 --- a/src/style.rs +++ b/src/style.rs @@ -59,8 +59,12 @@ pub(crate) struct Theme { } impl Theme { - /// Keeps reading layouts in pipes; only terminals get color and cursor control. + /// Resolves the capabilities of stdout, or of stderr when `stderr` is set. + /// + /// Only an attended terminal outside JSON mode gets color and cursor + /// control, so pipes keep the reading layouts. pub fn new(json: bool, stderr: bool) -> Self { + // Cursor control needs a human layout on an attended terminal let terminal = if stderr { console::Term::stderr() } else { @@ -74,15 +78,19 @@ impl Theme { let human = !json; let term = std::env::var("TERM").unwrap_or_default(); let interactive = human && attended && term != "dumb"; - // Windows needs ANSI processing enabled for colors and live progress. + // Windows needs ANSI processing enabled for colors and live progress #[cfg(windows)] let interactive = interactive && terminal.features().colors_supported(); + + // A native Windows console gets exact colors without COLORTERM let native_console = { #[cfg(windows)] { use std::os::windows::io::AsRawHandle; use windows_sys::Win32::System::Console::GetConsoleMode; let mut mode = 0; + // Sound, since the call takes the stream's own handle and + // writes only the local mode unsafe { GetConsoleMode(terminal.as_raw_handle(), &mut mode) != 0 } } #[cfg(not(windows))] @@ -90,6 +98,8 @@ impl Theme { false } }; + + // Glyphs need a UTF-8 locale when one is set, except on Windows let locale = ["LC_ALL", "LC_CTYPE", "LANG"] .into_iter() .filter_map(|key| std::env::var(key).ok()) @@ -99,6 +109,8 @@ impl Theme { let locale = locale.to_ascii_uppercase().replace('-', ""); locale.contains("UTF8") || cfg!(windows) }); + + // Color honors the opt-outs, then takes the deepest palette on offer let color = if !interactive || std::env::var_os("NO_COLOR").is_some() || std::env::var("CLICOLOR").is_ok_and(|value| value == "0") @@ -114,6 +126,8 @@ impl Theme { } else { Color::Basic }; + + // An unknown width falls back to 80 columns let width = terminal .size_checked() .map_or(80, |(_, width)| usize::from(width).max(1)); @@ -130,6 +144,8 @@ impl Theme { if self.color == Color::Off || matches!(role, Role::Default) { return Style::new(); } + + // Each role has one RGB color, except headings, which are only bold let rgb = match role { Role::Success => (148, 202, 110), Role::Attention => (232, 162, 74), @@ -141,11 +157,17 @@ impl Theme { Role::Heading => return Style::new().bold(), Role::Default => unreachable!(), }; + + // Muted text and environment labels keep regular weight, and the other + // roles are bold let style = if matches!(role, Role::Muted | Role::Staging | Role::Develop) { Style::new() } else { Style::new().bold() }; + + // The 256-color cube rounds each channel to one of its six levels, and + // basic color keeps only the weight match self.color { Color::True => style.fg_color(Some(RgbColor(rgb.0, rgb.1, rgb.2).into())), Color::Ansi256 => { @@ -164,7 +186,8 @@ impl Theme { format!("{style}{}{style:#}", text.as_ref()) } - /// Chooses a decorative glyph without changing the surrounding message. + /// Picks the decorative Unicode glyph when the stream allows it, or its + /// ASCII stand-in otherwise. pub fn glyph<'a>(&self, unicode: &'a str, ascii: &'a str) -> &'a str { if self.unicode { unicode } else { ascii } } @@ -228,6 +251,9 @@ impl Theme { } } +/// Control sequence that returns the cursor to the line start and clears the +/// line. +/// /// Terminal control sequences belong to the writer, never to result content. pub(crate) const CLEAR_LINE: &str = "\r\x1b[2K"; @@ -248,7 +274,8 @@ pub(crate) fn printable(text: &str) -> String { .collect() } -/// Formats a byte count in binary units up to GiB with one decimal place. +/// Formats a byte count in KiB, MiB or GiB with one decimal place, or in whole +/// bytes below 1 KiB. pub(crate) fn bytes(bytes: u64) -> String { for (unit, divisor) in [("GiB", 1_u64 << 30), ("MiB", 1 << 20), ("KiB", 1 << 10)] { if bytes >= divisor { @@ -258,16 +285,23 @@ pub(crate) fn bytes(bytes: u64) -> String { format!("{bytes} B") } -/// Wraps styled text at words, splitting long tokens without dropping bytes. +/// Wraps styled text at words to `width` terminal cells, indenting continued +/// lines by `indent` cells. +/// +/// A token too long for a line splits across lines. Only the whitespace at a +/// break is dropped, and ANSI sequences stay in place. pub(crate) fn wrap(text: &str, width: usize, indent: usize) -> String { + // Keep at least one cell of text beside the indent let width = width.max(1); let indent = indent.min(width.saturating_sub(1)); let mut result = String::new(); let mut column = 0; + + // Append one word, moving it to a new line when it fits there but not here let mut append = |word: &str| { let size = console::measure_text_width(word.trim_end()); if column > indent && column + size > width && size <= width - indent { - // The space that ended the previous word is not part of the line. + // The space that ended the previous word is not part of the line while result.ends_with(' ') { result.pop(); } @@ -275,6 +309,9 @@ pub(crate) fn wrap(text: &str, width: usize, indent: usize) -> String { result.push_str(&" ".repeat(indent)); column = indent; } + + // Copy the word, breaking wherever it still overflows and indenting + // after every newline for (part, ansi) in console::AnsiCodeIterator::new(word) { if ansi { result.push_str(part); @@ -301,6 +338,9 @@ pub(crate) fn wrap(text: &str, width: usize, indent: usize) -> String { } } }; + + // Split the text into words that end at whitespace, keeping ANSI sequences + // inside the word they touch let mut word = String::new(); for (part, ansi) in console::AnsiCodeIterator::new(text) { if ansi { @@ -321,6 +361,8 @@ pub(crate) fn wrap(text: &str, width: usize, indent: usize) -> String { #[cfg(test)] impl Theme { + /// Builds an interactive theme with fixed capabilities, independent of the + /// test's own terminal. pub fn test(width: usize, color: Color, unicode: bool) -> Self { Self { interactive: true, @@ -331,10 +373,13 @@ impl Theme { } } +/// Tests of the capability detection, the palette, wrapping and escaping. #[cfg(test)] mod tests { use super::*; + /// Checks that a native Windows console gets exact colors without any + /// color variable, while the opt-outs still turn them off. #[cfg(windows)] #[test] fn windows_console_enables_color_without_environment() { @@ -344,8 +389,11 @@ mod tests { GetStdHandle, STD_ERROR_HANDLE, STD_OUTPUT_HANDLE, SetConsoleMode, SetStdHandle, }; - // Isolate console handles and environment from the other tests. + /// Environment variable that marks a child run and names its case. const CHILD: &str = "ARK_TEST_WINDOWS_CONSOLE"; + + // Run each case in a child process, isolating console handles and + // environment from the other tests let Ok(case) = std::env::var(CHILD) else { for (name, value) in [ ("", ""), @@ -376,10 +424,14 @@ mod tests { return; }; + // Swap in a fresh console, saving the standard handles to restore. The + // console calls take no pointers, so they are sound. let stdout = unsafe { GetStdHandle(STD_OUTPUT_HANDLE) }; let stderr = unsafe { GetStdHandle(STD_ERROR_HANDLE) }; unsafe { FreeConsole() }; assert_ne!(unsafe { AllocConsole() }, 0); + + // Check both streams, catching a failure so the handles come back first let result = std::panic::catch_unwind(|| { let console = OpenOptions::new() .read(true) @@ -388,6 +440,9 @@ mod tests { .unwrap(); let handle = console.as_raw_handle(); for (stream, stderr) in [(STD_OUTPUT_HANDLE, false), (STD_ERROR_HANDLE, true)] { + // Point the stream at the new console with ANSI processing off. + // The calls use the handle `console` keeps open and write only + // the local mode, so they are sound. assert_ne!(unsafe { SetStdHandle(stream, handle) }, 0); let mut mode = 0; assert_ne!(unsafe { GetConsoleMode(handle, &mut mode) }, 0); @@ -395,6 +450,9 @@ mod tests { unsafe { SetConsoleMode(handle, mode & !ENABLE_VIRTUAL_TERMINAL_PROCESSING) }, 0 ); + + // The theme gets exact colors unless an opt-out or a dumb TERM + // is set let theme = Theme::new(false, stderr); assert_eq!(theme.interactive, case != "TERM"); assert_eq!( @@ -409,11 +467,17 @@ mod tests { theme.paint(Role::Muted, "label").contains("\x1b[38;2;"), case == "color" ); + + // Resolving the theme turns ANSI processing back on, except + // under a dumb TERM. The call writes only the local mode, so it + // is sound. assert_ne!(unsafe { GetConsoleMode(handle, &mut mode) }, 0); assert_eq!( mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0, case != "TERM" ); + + // JSON stays plain, and a second theme resolves the same way let json = Theme::new(true, stderr); assert!(!json.interactive); assert_eq!(json.paint(Role::Muted, "label"), "label"); @@ -422,6 +486,9 @@ mod tests { assert_eq!(repeated.interactive, theme.interactive); } }); + + // Restore the saved standard handles, then rethrow any failure. The + // console calls take no pointers, so they are sound. unsafe { FreeConsole(); SetStdHandle(STD_OUTPUT_HANDLE, stdout); @@ -432,6 +499,8 @@ mod tests { } } + /// Checks that styling degrades from exact colors to the 256-color cube to + /// plain marks, keeping the words. #[test] fn palette_degrades_without_losing_words() { let theme = Theme::test(80, Color::True, true); @@ -457,8 +526,11 @@ mod tests { ); } + /// Checks that wrapping and truncation measure terminal cells, and wrapping + /// keeps every character of a styled link. #[test] fn wrapping_preserves_links_and_measures_terminal_cells() { + // A styled link splits across lines but keeps every character let theme = Theme::test(24, Color::True, true); let url = "https://app.dark.bio/pair/0123456789abcdef"; let wrapped = wrap(&theme.paint(Role::Accent, url), 24, 2); @@ -473,6 +545,8 @@ mod tests { .collect::(), url ); + + // Wide characters count two cells each, and truncation fits its tail let wide = "\u{754c}".repeat(12); let wrapped = wrap(&wide, 10, 2); assert!( @@ -485,6 +559,8 @@ mod tests { assert_eq!(theme.truncate("abcdef", 6), "abcdef"); } + /// Checks that control characters are escaped, while plain text passes + /// unchanged. #[test] fn control_characters_cannot_reach_the_terminal() { assert_eq!(printable("plain name"), "plain name"); @@ -494,6 +570,8 @@ mod tests { ); } + /// Checks that paired backticks become styled spans, while an unmatched one + /// stays literal. #[test] fn inline_code_keeps_unmatched_backticks() { let theme = Theme::test(80, Color::Basic, false); @@ -504,6 +582,7 @@ mod tests { assert_eq!(theme.inline("an unmatched ` stays"), "an unmatched ` stays"); } + /// Checks that inline styling does not move where text wraps. #[test] fn wrapping_ignores_style_boundaries_inside_words() { let theme = Theme::test(40, Color::True, true); diff --git a/src/testing.rs b/src/testing.rs index 12f7bac..7edf697 100644 --- a/src/testing.rs +++ b/src/testing.rs @@ -11,6 +11,7 @@ use std::thread; use std::time::Instant; /// Blocks until the earliest wait or timer on the clock is due at `deadline`. +/// /// The advance that reaches the deadline then wakes it, whenever the test makes /// that advance. pub(crate) fn wait_deadline(tester: &TestClock, deadline: Instant) { diff --git a/src/update.rs b/src/update.rs index c031ea1..9503638 100644 --- a/src/update.rs +++ b/src/update.rs @@ -4,7 +4,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -//! Lookups of the newest published ark and the note that announces it. +//! Lookups of the newest published `ark` and the note that announces it. use crate::output::Output; use chrono::{DateTime, Utc}; @@ -18,7 +18,7 @@ use std::process::{Command, Stdio}; use std::thread; use std::time::{Duration, Instant}; -/// Hidden sole argument that makes ark run the detached lookup and nothing else. +/// Hidden sole argument that makes `ark` run the detached lookup and exit. pub(crate) const ENTRY_POINT: &str = "__update"; /// Largest kept answer read from disk, in bytes. @@ -60,14 +60,17 @@ impl Channel { pub(crate) struct Answer { /// Channel the answer belongs to. pub channel: Channel, - /// When the last lookup started, whether or not it succeeded. + /// Start time of the last lookup, whether or not it succeeded. pub asked: DateTime, /// Newest version found, absent until a lookup succeeds. pub newest: Option, } impl Answer { - /// Reads the kept answer for one channel. An unreadable file counts as no answer. + /// Reads the kept answer for one channel. + /// + /// An unreadable or malformed file, or an answer for the other channel, + /// counts as no answer. pub fn read(directory: &Path, channel: Channel) -> Option { // Read at most 4 KiB, far more than an answer ever takes let file = File::open(directory.join("update.json")).ok()?; @@ -79,8 +82,10 @@ impl Answer { (answer.channel == channel).then_some(answer) } - /// Reports whether a lookup is due. It is when the answer is absent, belongs - /// to the other channel, is stamped in the future, or is an hour old. + /// Reports whether a lookup is due. + /// + /// It is when the answer is absent, belongs to the other channel, is stamped + /// in the future, or is an hour old. pub fn stale(answer: Option<&Self>, channel: Channel, now: DateTime) -> bool { answer.is_none_or(|answer| { answer.channel != channel @@ -129,7 +134,9 @@ pub(crate) fn running() -> Version { } /// Prints the note from the kept answer and starts a background lookup when -/// one is due. The lookup runs in a detached copy of ark. +/// one is due. +/// +/// The lookup runs in a detached copy of `ark`. pub(crate) fn start(output: &Output, now: DateTime) { // Under CI nothing is read, printed or looked up if disabled() { @@ -188,6 +195,7 @@ pub(crate) fn start(output: &Output, now: DateTime) { } /// Runs the lookup in the detached copy, which ends within 30 s whatever happens. +/// /// The copy opens no connection, so its clock is the real one. pub(crate) fn run() { // Under CI the copy does nothing @@ -239,8 +247,9 @@ fn claim(directory: &Path, channel: Channel, now: DateTime) -> io::Result<( .write(directory) } -/// Looks up the newest version and keeps it. A failed lookup leaves the kept -/// answer as it was. +/// Looks up the newest version and keeps it. +/// +/// A failed lookup leaves the kept answer as it was. pub(crate) fn refresh( directory: &Path, channel: Channel, @@ -334,14 +343,16 @@ fn release(location: &str) -> Result { /// Selects the highest semantic version among published prerelease entries. fn develop(bytes: &[u8]) -> Result { - /// Only these public release fields participate in version selection. + /// Public release fields that version selection reads. #[derive(Deserialize)] struct Release { /// Version tag stamped by the publish workflow. tag_name: String, - /// Unpublished drafts never announce an available build. + /// Whether the entry is an unpublished draft, which never announces a + /// build. draft: bool, - /// Stable releases do not belong to the development channel. + /// Whether the entry is a prerelease, since stable releases do not + /// belong to the development channel. prerelease: bool, } @@ -349,7 +360,8 @@ fn develop(bytes: &[u8]) -> Result { let releases: Vec = serde_json::from_slice(bytes).map_err(|_| "GitHub returned an invalid release list")?; - // Ignore unpublished entries and invalid tags before comparing semantic precedence + // Ignore unpublished entries and invalid tags before comparing semantic + // precedence releases .into_iter() .filter(|release| !release.draft && release.prerelease) @@ -385,9 +397,10 @@ pub(crate) fn hint(channel: Channel) -> String { .unwrap_or_else(|| "download it from https://github.com/dark-bio/cli".into()) } -/// Picks the upgrade command for an executable's location. The installer and -/// crates.io carry releases only, so a development build gets a command only -/// from Homebrew. +/// Picks the upgrade command for an executable's location. +/// +/// The installer and crates.io carry releases only, so a development build +/// gets a command only from Homebrew. fn upgrade( channel: Channel, executable: &Path, @@ -444,10 +457,12 @@ mod tests { use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; - /// Distinguishes temporary test directories without relying on timestamps. + /// Counter that distinguishes temporary test directories without relying + /// on timestamps. static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0); - /// Removes a test's cache and installation files on scope exit. + /// Temporary test directory, removed with its cache and installation files + /// on scope exit. struct Directory { /// Isolated root for one test's real filesystem operations. path: PathBuf, @@ -564,10 +579,12 @@ mod tests { )); } - /// Claims keep only the same channel's previous version and require a writable cache. + /// Claims keep only the same channel's previous version and require a + /// writable cache. #[test] fn test_claim_preserves_only_the_same_channels_previous_answer() { - // Publish an expired answer through the same atomic writer used by the worker + // Publish an expired answer through the same atomic writer used by the + // worker let directory = Directory::new(); let now = "2026-09-25T12:00:00Z".parse::>().unwrap(); Answer { @@ -626,7 +643,8 @@ mod tests { "ark 0.3.7 is available, this is 0.3.6; download it from https://github.com/dark-bio/cli" ); - // Numeric prerelease identifiers follow semantic precedence rather than text order + // Numeric prerelease identifiers follow semantic precedence rather than + // text order answer.channel = Channel::Develop; answer.newest = Some(Version::parse("0.3.6-dev.34").unwrap()); assert_eq!( @@ -662,11 +680,12 @@ mod tests { } } - /// Development selection ignores drafts and releases and does not depend on list order. + /// Development selection ignores drafts and releases and does not depend on + /// list order. #[test] fn test_development_selection_uses_the_highest_published_prerelease() { - // Captured 2026-09-25 from https://api.github.com/repos/dark-bio/cli/releases?per_page=10 - // Only unrelated object fields are removed from this public response + // Captured 2026-09-25 from https://api.github.com/repos/dark-bio/cli/releases?per_page=10, + // with only unrelated object fields removed from this public response let bytes = br#"[ {"tag_name":"v0.3.6-dev.34","draft":false,"prerelease":true}, {"tag_name":"v0.3.5","draft":false,"prerelease":false}, @@ -684,7 +703,8 @@ mod tests { Version::parse("0.3.6-dev.34").unwrap() ); - // Reverse the captured order so the winner is neither first nor assumed latest + // Reverse the captured order so the winner is neither first nor assumed + // latest let mut releases: Vec = serde_json::from_slice(bytes).unwrap(); releases.reverse(); assert_eq!( @@ -692,12 +712,15 @@ mod tests { Version::parse("0.3.6-dev.34").unwrap() ); - // Turning just the highest entry into a draft leaves a stable release above the winner + // Turning just the highest entry into a draft leaves a stable release + // above the winner releases.last_mut().unwrap()["draft"] = json!(true); assert_eq!( develop(&serde_json::to_vec(&releases).unwrap()).unwrap(), Version::parse("0.3.5-dev.32").unwrap() ); + + // Only drafts, invalid JSON or only invalid tags give no version for release in &mut releases { release["draft"] = json!(true); } @@ -706,10 +729,12 @@ mod tests { assert!(develop(br#"[{"tag_name":"garbage","draft":false,"prerelease":true}]"#).is_err()); } - /// Install advice follows canonical paths and keeps development builds off release installers. + /// Install advice follows canonical paths and keeps development builds off + /// release installers. #[test] fn test_upgrade_commands_follow_the_installation_layout() { - // Create the installed files because detection resolves the executable itself + // Create the installed files because detection resolves the executable + // itself let directory = Directory::new(); let home = directory.path.join("home"); let cargo = directory.path.join("custom-cargo"); diff --git a/tests/palette.rs b/tests/palette.rs index 815c201..ef6e9a6 100644 --- a/tests/palette.rs +++ b/tests/palette.rs @@ -10,8 +10,11 @@ use serde_json::Value; use std::process::{Command, Output}; use std::sync::Mutex; +/// Lock that keeps the tests' `ark` processes from running at the same time. static PROCESS: Mutex<()> = Mutex::new(()); +/// Runs the `ark` binary with `args` under the process lock, without color and +/// with release lookups off. fn ark(args: &[&str]) -> Output { let _process = PROCESS.lock().unwrap(); Command::new(env!("CARGO_BIN_EXE_ark")) @@ -22,7 +25,8 @@ fn ark(args: &[&str]) -> Output { .unwrap() } -/// The private update entry point does nothing and prints nothing under CI. +/// Checks that the private update entry point does nothing and prints nothing +/// under CI. #[test] fn test_update_entry_point_is_silent_under_ci() { let output = ark(&["__update"]); @@ -31,8 +35,10 @@ fn test_update_entry_point_is_silent_under_ci() { assert!(output.stderr.is_empty()); } -/// Stamps a kept update answer as asked now. The spawned ark judges the -/// answer's age against the real wall time, so the stamp reads it too. +/// Stamps a kept update answer as asked now. +/// +/// The spawned ark judges the answer's age against the real wall time, so the +/// stamp reads it too. #[cfg(unix)] #[expect( clippy::disallowed_methods, @@ -42,28 +48,32 @@ fn asked_now() -> String { chrono::Utc::now().to_rfc3339() } -/// A fresh isolated answer produces one stderr note while help and invalid invocations stay quiet. +/// Checks that a fresh isolated answer produces one stderr note, while help and +/// invalid invocations stay quiet. #[cfg(unix)] #[test] fn test_update_note_preserves_command_output_and_excludes_noncommands() { - /// Removes the subprocess home and cache even after an assertion failure. + /// Home and cache of the spawned processes, removed even after an assertion + /// failure. struct Directory( - /// Isolated root used for both HOME and XDG_CACHE_HOME. + /// Isolated root holding the `HOME` and `XDG_CACHE_HOME` directories. std::path::PathBuf, ); + impl Drop for Directory { - /// Cleans up files owned by this process test. + /// Removes the whole isolated root. fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } - // macOS uses Library/Caches while other Unix targets use XDG_CACHE_HOME + // Keep an answer that names the next major version in an isolated cache let _process = PROCESS.lock().unwrap(); let directory = Directory(std::env::temp_dir().join(format!("ark-update-palette-{}", std::process::id()))); let home = directory.0.join("home"); let xdg_cache = directory.0.join("cache"); + // macOS uses Library/Caches while other Unix targets use XDG_CACHE_HOME let cache = if cfg!(target_os = "macos") { home.join("Library/Caches/ark") } else { @@ -80,6 +90,8 @@ fn test_update_note_preserves_command_output_and_excludes_noncommands() { })) .unwrap(); std::fs::write(cache.join("update.json"), &answer).unwrap(); + + // Invocations run in the isolated home, with CI set only when asked let invoke = |args: &[&str], ci: Option<&str>| { let mut command = Command::new(env!("CARGO_BIN_EXE_ark")); command @@ -147,7 +159,8 @@ fn test_update_note_preserves_command_output_and_excludes_noncommands() { assert!(!String::from_utf8_lossy(&baseline.stderr).contains("is available")); } - // None of these paths may announce or refresh a release, even with a known newer build + // None of these paths may announce or refresh a release, even with a known + // newer build for args in [ vec!["help"], vec!["help", "--all"], @@ -214,6 +227,11 @@ fn json_output(output: &Output) -> Value { document } +/// Checks that an invocation exits alike with and without `--json`, and both +/// outputs keep their format contracts. +/// +/// Human output carries no escape sequences, and no label ends in a unit suffix +/// such as `_bytes` or `_seconds`. fn conformance(args: &[&str]) { let mut invocation = args.to_vec(); invocation.push("--json"); @@ -232,6 +250,8 @@ fn conformance(args: &[&str]) { } } +/// Checks that every command's help page and output follow the shared +/// conventions. #[test] fn command_tree_output_conforms() { for (path, page) in commands() { @@ -244,11 +264,15 @@ fn command_tree_output_conforms() { } assert!(!page.contains("--format"), "{path:?}"); assert!(page.contains("-h, --help"), "{path:?}"); + + // Every command rejects --format as a usage error, reported in JSON let mut rejected = args.clone(); rejected.extend(["--json", "--format", "json"]); let output = ark(&rejected); assert_eq!(output.status.code(), Some(2), "{path:?}"); assert_eq!(json_output(&output)["error"]["code"], "usage"); + + // The root and every command that requires nothing run in both formats if path.is_empty() { conformance(&["--version"]); } else if page.contains("Requires: nothing") { @@ -273,7 +297,7 @@ fn command_tree_output_conforms() { } _ => { let mut args = args; - // Never open an attached Ark during conformance tests. + // Never open an attached Ark during conformance tests args.extend(["--device", "hardware:palette-no-device", "--no-input", "-v"]); conformance(&args); } @@ -282,6 +306,8 @@ fn command_tree_output_conforms() { } } +/// Checks that short help differs from long help exactly when it points at +/// `--help` for more. #[test] fn help_differs_exactly_where_it_promises_more() { for (path, long) in commands() { @@ -296,6 +322,8 @@ fn help_differs_exactly_where_it_promises_more() { "{path:?}: {short}" ); } + + // Every spelling of the manual prints it, and --all alone is a usage error for args in [["--help", "--all"], ["--all", "--help"], ["-h", "--all"]] { let output = ark(&args); assert!(output.status.success()); @@ -304,10 +332,12 @@ fn help_differs_exactly_where_it_promises_more() { assert_eq!(ark(&["--all"]).status.code(), Some(2)); } +/// Checks that usage errors print the documented text prefix on stderr and +/// nothing on stdout. #[test] fn documented_usage_errors_keep_the_text_prefix() { // Topics render in a pipe as on a terminal, so code spans lose their - // backtick markers there and keep only their text. + // backtick markers there and keep only their text let help = String::from_utf8(ark(&["help", "output"]).stdout).unwrap(); assert!(help.contains("Exit 2, usage")); for args in [ @@ -326,6 +356,8 @@ fn documented_usage_errors_keep_the_text_prefix() { assert!(stderr.starts_with("error[usage]: "), "{args:?}: {stderr}"); assert!(!stderr.contains('\x1b')); } + + // A missing device is the documented no-device error let output = ark(&["status", "--device", "hardware:palette-no-device"]); assert_eq!(output.status.code(), Some(3)); assert!(help.contains("no-device: no Ark found")); @@ -337,6 +369,8 @@ fn documented_usage_errors_keep_the_text_prefix() { ); } +/// Checks that usage errors under `--json` print a JSON error on stdout and an +/// error event first on stderr. #[test] fn usage_errors_are_json_in_both_streams() { for args in [ @@ -362,6 +396,8 @@ fn usage_errors_are_json_in_both_streams() { } } +/// Checks that `--version --json` prints one document with the versions and +/// the firmware minimums, and nothing on stderr. #[test] fn version_is_a_single_structured_result() { let output = ark(&["--version", "--json"]); @@ -377,6 +413,7 @@ fn version_is_a_single_structured_result() { assert!(output.stderr.is_empty()); } +/// Checks that piped output keeps the human layout without escape sequences. #[test] fn default_pipes_have_layout_without_terminal_escapes() { let output = ark(&["--version"]); @@ -389,6 +426,7 @@ fn default_pipes_have_layout_without_terminal_escapes() { ); } +/// Checks that color variables cannot force escape sequences into a pipe. #[test] fn pipes_cannot_force_color() { let _process = PROCESS.lock().unwrap(); @@ -407,6 +445,8 @@ fn pipes_cannot_force_color() { assert!(!output.stderr.contains(&0x1b)); } +/// Checks that help prints the same page wherever `--json` appears, and +/// unsupported flags fail as usage errors. #[test] fn json_selection_applies_before_help_and_usage_errors() { let help = ark(&["data", "fetch", "--help", "--json"]); @@ -415,6 +455,8 @@ fn json_selection_applies_before_help_and_usage_errors() { ark(&["--json", "data", "fetch", "--help"]).stdout ); assert_eq!(help.stdout, ark(&["data", "fetch", "--help"]).stdout); + + // Output format flags and repeated verbosity are not part of the palette for flag in [ "--format", "--format=json", @@ -431,12 +473,18 @@ fn json_selection_applies_before_help_and_usage_errors() { } } +/// Checks that the help pages carry their contract fields and advertise only +/// the supported commands. #[test] fn help_matches_the_supported_palette() { + // The root page stays within 42 lines let output = ark(&["--help"]); assert!(output.status.success()); let root = String::from_utf8(output.stdout).unwrap(); assert!(root.lines().count() <= 42, "{root}"); + + // Each long page carries every contract field and matches its help topic, + // and its short page leaves the contract out for path in [ "status", "data paths", @@ -461,7 +509,7 @@ fn help_matches_the_supported_palette() { assert!(long.contains(field), "{path}: {long}"); } // The contract block keeps one shape in a pipe: colon labels, wrapped - // values, and examples as bare commands with no prompt. + // values, and examples as bare commands with no prompt for line in long.lines() { assert!(line.chars().count() <= 80, "{path}: {line}"); assert!(!line.trim_start().starts_with("$ "), "{path}: {line}"); @@ -477,6 +525,9 @@ fn help_matches_the_supported_palette() { .contains("Requires:") ); } + + // The manual advertises no unsupported command, and only long help shows + // the advanced options let manual = String::from_utf8(ark(&["help", "--all"]).stdout).unwrap(); for absent in ["ark app check", "ark lock"] { assert!(!manual.contains(absent), "{absent} advertised prematurely"); @@ -493,11 +544,14 @@ fn help_matches_the_supported_palette() { ); } -/// The manual names the example apps and the emulator, the two other corners -/// of the loop a reader arrives in, and the root page lists the shared options. +/// Checks that the manual names the example apps and the emulator, and the +/// root page lists the shared options. +/// +/// The example apps and the emulator are the two other corners of the loop a +/// reader arrives in. #[test] fn manual_carries_the_cross_references() { - // Wrapped, so a phrase is looked for across line breaks. + // Wrapped, so a phrase is looked for across line breaks let manual = String::from_utf8(ark(&["help", "--all"]).stdout).unwrap(); let manual = manual.split_whitespace().collect::>().join(" "); for link in [ @@ -507,14 +561,19 @@ fn manual_carries_the_cross_references() { ] { assert!(manual.contains(link), "{link}"); } + + // The root page lists the shared options let root = String::from_utf8(ark(&["--help"]).stdout).unwrap(); assert!(root.contains("--timeout ")); assert!(root.contains("--json")); } -/// Every error code the source can emit, read from the source itself, so the -/// output topic is checked against what the tool does and not a second list. +/// Collects every error code the source can emit, reading the source itself. +/// +/// The output topic is then checked against what the tool does, not against a +/// second list. fn emitted_codes() -> std::collections::BTreeSet { + /// Adds the codes found in every Rust file under `dir` to `codes`. fn visit(dir: &std::path::Path, codes: &mut std::collections::BTreeSet) { for entry in std::fs::read_dir(dir).unwrap() { let path = entry.unwrap().path(); @@ -525,6 +584,7 @@ fn emitted_codes() -> std::collections::BTreeSet { if path.extension().is_none_or(|extension| extension != "rs") { continue; } + // Constructors name the code as the string after the exit class let text = std::fs::read_to_string(&path).unwrap(); for prefix in ["Error::new(", "Self::new("] { for (index, _) in text.match_indices(prefix) { @@ -541,7 +601,7 @@ fn emitted_codes() -> std::collections::BTreeSet { } } } - // The Ark's reserved verdicts map to codes in match arms. + // The Ark's reserved verdicts map to codes in match arms if path.file_name().is_some_and(|name| name == "error.rs") { for (index, _) in text.match_indices("=> \"") { let rest = &text[index + 4..]; @@ -561,6 +621,7 @@ fn emitted_codes() -> std::collections::BTreeSet { codes } +/// Checks that the output topic documents every error code the source emits. #[test] fn every_error_code_is_documented() { let topic = std::fs::read_to_string( @@ -575,6 +636,8 @@ fn every_error_code_is_documented() { } } +/// Checks that completions generate for every supported shell under the `ark` +/// name. #[test] fn completions_are_generated_for_the_binary_name() { for shell in ["bash", "zsh", "fish", "powershell", "elvish"] {