Skip to main content

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}