freenet_stdlib/client_api.rs
1//! A node client API. Intended to be used from applications (web or otherwise) using the
2//! node capabilities to execute contract, delegate, etc. instructions and communicating
3//! over the network.
4//!
5//! Communication, independent of the transport, revolves around the [`ClientRequest`]
6//! and [`HostResponse`] types.
7//!
8//! Currently the clients available are:
9//! - `websocket`:
10//! - `regular` (native): Using TCP transport directly, for native applications programmed in Rust.
11//! - `browser` (wasm): Via wasm-bindgen (and by extension web-sys).
12//! (In order to use this client from JS/Typescript refer to the Typescript std lib).
13mod client_events;
14
15#[cfg(all(any(unix, windows), feature = "net"))]
16mod regular;
17#[cfg(all(any(unix, windows), feature = "net"))]
18pub use regular::*;
19
20#[cfg(all(target_family = "wasm", feature = "net"))]
21mod browser;
22#[cfg(all(target_family = "wasm", feature = "net"))]
23pub use browser::*;
24
25#[cfg(feature = "net")]
26pub mod streaming;
27
28pub use client_events::*;
29
30#[cfg(feature = "net")]
31type HostResult = Result<HostResponse, ClientError>;
32
33#[derive(thiserror::Error, Debug)]
34#[non_exhaustive]
35pub enum Error {
36 #[error(transparent)]
37 Deserialization(#[from] bincode::Error),
38 #[error("channel closed")]
39 ChannelClosed,
40 #[cfg(all(any(unix, windows), feature = "net"))]
41 #[error(transparent)]
42 ConnectionError(#[from] tokio_tungstenite::tungstenite::Error),
43 #[cfg(all(target_family = "wasm", feature = "net"))]
44 #[error("request error: {0}")]
45 ConnectionError(serde_json::Value),
46 #[error("connection closed")]
47 ConnectionClosed,
48 #[error("unhandled error: {0}")]
49 OtherError(Box<dyn std::error::Error + Send + Sync>),
50}
51
52pub trait TryFromFbs<T>: Sized {
53 fn try_decode_fbs(value: T) -> Result<Self, WsApiError>;
54}
55
56/// Read a fixed-size byte field out of a verified flatbuffer, rejecting a wrong
57/// length instead of panicking.
58///
59/// **Every** fixed-size wire field must go through this. The flatbuffers
60/// verifier checks that a `(required)` vector is PRESENT; it does NOT check its
61/// LENGTH (`Verifiable for Vector<T>` runs `verify_vector_range` and nothing
62/// else). So `flatbuffers::root` happily accepts an 8-byte field declared
63/// `(required)`, and any decoder that then assumes 32 bytes — via
64/// `try_into().unwrap()`, `<[u8; N]>::try_from(..).unwrap()`, or
65/// `copy_from_slice` — panics on it. Nothing catches unwind on the decode path
66/// and `panic = "abort"` is not set, so that unwinds and kills the client's
67/// connection task: a remote, wire-reachable panic.
68///
69/// `field` is the schema path of the offending field (e.g.
70/// `"ContractKey.instance"`), so the error names the exact thing the client got
71/// wrong rather than saying "invalid data".
72pub(crate) fn fixed_size_field<const N: usize>(
73 field: &str,
74 data: &[u8],
75) -> Result<[u8; N], WsApiError> {
76 // The message goes over the wire to the client, so it carries what the
77 // client can act on — the field, the expected length, the observed length —
78 // and nothing else. The reason this check has to exist at all is stdlib's
79 // business and lives in the doc comment above.
80 data.try_into().map_err(|_| {
81 WsApiError::deserialization(format!(
82 "{field} must be exactly {N} bytes; got {} bytes",
83 data.len()
84 ))
85 })
86}
87
88/// Error text for an unknown flatbuffers union discriminant.
89///
90/// Every generated union verifier ends in `_ => Ok(())`, so a discriminant the
91/// schema does not define passes verification and reaches the decoder's match.
92/// Matching such a value with `unreachable!()` turns one crafted request into a
93/// panic that downs the connection handler, so every union match returns this
94/// instead.
95///
96/// Be precise about which values actually get here, because the obvious guess
97/// is wrong. A `NONE` (0) discriminant does NOT reach the decoder: both the
98/// Rust and TypeScript builders elide a field equal to its default
99/// (`FlatBufferBuilder::push_slot`), so `NONE` is written as ABSENT, and
100/// `Verifier::visit_union` rejects the resulting present-value/absent-type pair
101/// as an inconsistent union before any decoder runs. What reaches here is an
102/// out-of-range discriminant, or a `NONE` written explicitly by an encoder that
103/// forces defaults or is not flatc-generated. That is enough — it is still
104/// wire-reachable — but "the TypeScript SDK defaults to NONE" is not the
105/// mechanism, and a test that only checks NONE pins nothing.
106pub(crate) fn unknown_union_discriminant(union: &str, discriminant: u8) -> WsApiError {
107 WsApiError::deserialization(format!("unknown {union} discriminant: {discriminant}"))
108}
109
110#[derive(thiserror::Error, Debug)]
111#[non_exhaustive]
112pub enum WsApiError {
113 #[error("Unsupported contract version")]
114 UnsupportedContractVersion,
115 #[error("Failed unpacking contract container")]
116 UnpackingContractContainerError(Box<dyn std::error::Error + Send + Sync + 'static>),
117 #[error("Failed decoding message from client request: {cause}")]
118 DeserError { cause: String },
119}
120
121impl WsApiError {
122 pub fn deserialization(cause: String) -> Self {
123 Self::DeserError { cause }
124 }
125
126 pub fn into_fbs_bytes(self) -> Vec<u8> {
127 use crate::generated::host_response::{
128 finish_host_response_buffer, Error, ErrorArgs, HostResponse, HostResponseArgs,
129 HostResponseType,
130 };
131 let mut builder = flatbuffers::FlatBufferBuilder::new();
132 let as_msg = format!("{self}");
133 let msg_offset = builder.create_string(&as_msg);
134 let err_offset = Error::create(
135 &mut builder,
136 &ErrorArgs {
137 msg: Some(msg_offset),
138 },
139 );
140 let res = HostResponse::create(
141 &mut builder,
142 &HostResponseArgs {
143 response_type: HostResponseType::Error,
144 response: Some(err_offset.as_union_value()),
145 },
146 );
147 finish_host_response_buffer(&mut builder, res);
148 builder.finished_data().to_vec()
149 }
150}
151
152/// Source-scrape pins for the decode-boundary conventions.
153///
154/// The conventions this file establishes — every fixed-size wire field goes
155/// through [`fixed_size_field`], every union match ends in
156/// [`unknown_union_discriminant`] — are worth nothing if the next decoder added
157/// to the crate ignores them. Behavioural tests cannot catch that: they only
158/// cover decoders that exist. These scrape the source of every file hosting a
159/// `TryFromFbs` impl, so a new decoder that reintroduces either shape fails CI
160/// even though nobody wrote a test for it.
161///
162/// This is what makes "covered by construction" true rather than aspirational.
163#[cfg(test)]
164mod decode_boundary_conventions {
165 /// Every file with a `TryFromFbs` impl. Add new ones here.
166 const DECODER_SOURCES: [(&str, &str); 5] = [
167 (
168 "client_api/client_events.rs",
169 include_str!("client_api/client_events.rs"),
170 ),
171 (
172 "contract_interface/update.rs",
173 include_str!("contract_interface/update.rs"),
174 ),
175 (
176 "contract_interface/key.rs",
177 include_str!("contract_interface/key.rs"),
178 ),
179 (
180 "delegate_interface.rs",
181 include_str!("delegate_interface.rs"),
182 ),
183 ("versioning.rs", include_str!("versioning.rs")),
184 ];
185
186 /// Lines of real code, with `//` comments dropped — the prose in this crate
187 /// discusses `unreachable!()` at length and must not trip the scan.
188 fn code_lines(src: &str) -> impl Iterator<Item = (usize, &str)> {
189 src.lines()
190 .enumerate()
191 .map(|(i, l)| (i + 1, l.split("//").next().unwrap_or("")))
192 .filter(|(_, l)| !l.trim().is_empty())
193 }
194
195 /// No wire field may be read with a conversion that panics on the wrong
196 /// length. `.bytes()` is the flatbuffers accessor for a wire vector, so a
197 /// line combining it with `unwrap` or `copy_from_slice` is the shape that
198 /// produced four of this change's nine bugs.
199 ///
200 /// The needle is split so this test cannot match its own source if these
201 /// files are ever scraped by a future pin.
202 #[test]
203 fn no_wire_field_is_read_with_a_panicking_conversion() {
204 let wire = concat!(".by", "tes()");
205 let bad = [concat!("unwr", "ap()"), concat!("copy_from_", "slice")];
206 let mut hits = vec![];
207 for (name, src) in DECODER_SOURCES {
208 for (line_no, line) in code_lines(src) {
209 if line.contains(wire) && bad.iter().any(|b| line.contains(b)) {
210 hits.push(format!("{name}:{line_no}: {}", line.trim()));
211 }
212 }
213 }
214 assert!(
215 hits.is_empty(),
216 "a wire field is read with a conversion that panics on the wrong length. \
217 The flatbuffers verifier checks that a `(required)` field is PRESENT, not \
218 that it is the right LENGTH, so this is reachable from any client and kills \
219 the connection task. Use `client_api::fixed_size_field` instead.\n{}",
220 hits.join("\n")
221 );
222 }
223
224 /// No union match may treat an unrecognized discriminant as impossible.
225 /// Every generated union verifier ends in `_ => Ok(())`, so it is not.
226 #[test]
227 fn union_matches_never_use_unreachable() {
228 let needle = concat!("unreach", "able!");
229 let mut hits = vec![];
230 for (name, src) in DECODER_SOURCES {
231 for (line_no, line) in code_lines(src) {
232 if line.contains(needle) {
233 hits.push(format!("{name}:{line_no}: {}", line.trim()));
234 }
235 }
236 }
237 assert!(
238 hits.is_empty(),
239 "a decoder treats an unrecognized value as impossible. Every generated \
240 flatbuffers union verifier ends in `_ => Ok(())`, so any discriminant a \
241 client sets reaches the match and this panics the connection task. Return \
242 `client_api::unknown_union_discriminant` instead.\n{}",
243 hits.join("\n")
244 );
245 }
246}