Skip to main content

bynk_registry_core/
abi.rs

1//! The module boundary (first-party registry track §3.4): a plain C ABI, no
2//! `wasm-bindgen`, so the build is one `.wasm` file and no glue.
3//!
4//! ```text
5//! alloc(len) -> ptr                       reserve `len` bytes of linear memory
6//! dealloc(ptr, len)                       release a buffer from `alloc` or a result
7//! admit(ptr, len, exp_ptr, exp_len) -> u64
8//! call(ptr, len) -> u64                   a JSON request in, a JSON result out
9//! ```
10//!
11//! `admit` takes the upload archive at `ptr..ptr+len` and, optionally, the
12//! expected package as JSON at `exp_ptr..exp_ptr+exp_len` (`{"name":"o/p",
13//! "version":"1.0.0"}`; zero length means none). It returns `ptr << 32 | len`
14//! of a UTF-8 JSON result in linear memory, which the caller reads and then
15//! frees with `dealloc`:
16//!
17//! ```text
18//! {"ok":{"name","version","checksum","deps":[{"package","range"}],
19//!        "npm":[…],"archive":"<standard base64>"}}
20//! {"error":{"code","message"}}
21//! ```
22//!
23//! `call` (first-party registry R2, #2336, decision B) takes a JSON request
24//! `{"op": …, …}` and returns a JSON result the same way, so the hosted
25//! registry signs exactly the bytes clients verify:
26//!
27//! ```text
28//! {"op":"line_payload","line":{name,version,checksum,deps,npm,toolchain},"registry":"<id>"}
29//!     -> {"ok":{"payload":"<base64>"}}          the canonical bytes a line's signature covers
30//! {"op":"line_render","line":{…,"yanked":bool,"withdrawn"?:"<reason>","sig":"<base64>"}}
31//!     -> {"ok":{"text":"<one index line>"}}
32//! {"op":"signer_info","config":"<config.json text>","key":"ed25519:…"}
33//!     -> {"ok":{"id","chain_key","delegated","delegation_payload","delegation_sig","revoked"}}
34//! ```
35//!
36//! `signer_info` does no cryptography: it reports the newest chain key, the
37//! delegation of `key` (its payload and signature) and whether a revocation
38//! names `key`, for the caller to verify with its own crypto.
39//!
40//! [`admit_json`] is the whole of it as a safe function, so the native build
41//! and the wasm module produce the very same bytes for the very same input.
42
43use serde_json::{Value, json};
44
45use crate::admit::{Requirement, admit};
46use crate::protocol::{Config, IndexLine, delegation_payload};
47
48/// Admit `upload` against the `expected` JSON (empty for none) and render the
49/// result as the boundary's JSON.
50pub fn admit_json(upload: &[u8], expected: &[u8]) -> String {
51    let expected = match parse_expected(expected) {
52        Ok(e) => e,
53        Err(message) => return error("bad_request", &message),
54    };
55    match admit(
56        upload,
57        expected.as_ref().map(|(n, v)| (n.as_str(), v.as_str())),
58    ) {
59        Ok(a) => json!({ "ok": {
60            "name": a.name,
61            "version": a.version,
62            "checksum": a.checksum,
63            "deps": requirements(&a.deps),
64            "npm": requirements(&a.npm),
65            "archive": base64(&a.archive),
66        }})
67        .to_string(),
68        Err(r) => error(r.code, &r.message),
69    }
70}
71
72/// Answer one JSON request (`call`); see the module docs.
73pub fn call_json(request: &[u8]) -> String {
74    let v: Value = match serde_json::from_slice(request) {
75        Ok(v) => v,
76        Err(e) => return error("bad_request", &format!("the request isn't JSON: {e}")),
77    };
78    let result = match v.get("op").and_then(Value::as_str) {
79        Some("line_payload") => line_from(&v, false).and_then(|line| {
80            let registry = text(&v, "registry")?;
81            Ok(json!({ "payload": base64(&line.payload(&registry)) }))
82        }),
83        Some("line_render") => line_from(&v, true).map(|line| json!({ "text": line.render() })),
84        Some("signer_info") => signer_info(&v),
85        Some(other) => Err(format!("no operation `{other}`")),
86        None => Err("the request has no `op`".to_string()),
87    };
88    match result {
89        Ok(ok) => json!({ "ok": ok }).to_string(),
90        Err(message) => error("bad_request", &message),
91    }
92}
93
94fn text(v: &Value, k: &str) -> Result<String, String> {
95    v.get(k)
96        .and_then(Value::as_str)
97        .map(str::to_owned)
98        .ok_or_else(|| format!("the request has no string `{k}`"))
99}
100
101/// The request's `line`, as an index line. With `signed`, `yanked` and `sig`
102/// are read too; without, they are left empty (a payload never covers them).
103fn line_from(v: &Value, signed: bool) -> Result<IndexLine, String> {
104    let line = v.get("line").ok_or("the request has no `line`")?;
105    let reqs = |k: &str| -> Result<Vec<Requirement>, String> {
106        line.get(k)
107            .and_then(Value::as_array)
108            .ok_or_else(|| format!("the line has no list `{k}`"))?
109            .iter()
110            .map(|r| {
111                Some(Requirement {
112                    package: r.get("package")?.as_str()?.to_owned(),
113                    range: r.get("range")?.as_str()?.to_owned(),
114                })
115            })
116            .collect::<Option<Vec<_>>>()
117            .ok_or_else(|| format!("the line's `{k}` is malformed"))
118    };
119    Ok(IndexLine {
120        name: text(line, "name")?,
121        version: text(line, "version")?,
122        checksum: text(line, "checksum")?,
123        deps: reqs("deps")?,
124        npm: reqs("npm")?,
125        toolchain: line
126            .get("toolchain")
127            .and_then(Value::as_str)
128            .map(str::to_owned),
129        yanked: signed && line.get("yanked").and_then(Value::as_bool).unwrap_or(false),
130        // #2330: unsigned, like `yanked`, so only a rendered line carries it.
131        withdrawn: if signed {
132            line.get("withdrawn")
133                .and_then(Value::as_str)
134                .map(str::to_owned)
135        } else {
136            None
137        },
138        sig: if signed {
139            text(line, "sig")?
140        } else {
141            String::new()
142        },
143    })
144}
145
146fn signer_info(v: &Value) -> Result<Value, String> {
147    let config = Config::parse(text(v, "config")?.as_bytes())?;
148    let key = text(v, "key")?;
149    let chain_key = config.keys.last().expect("parse checks").key.clone();
150    let delegation = config.delegations.iter().find(|d| d.key == key);
151    let revoked = config.revoked.iter().any(|r| r.key == key);
152    Ok(json!({
153        "id": config.id,
154        "chain_key": chain_key,
155        "delegated": delegation.is_some(),
156        "delegation_payload": base64(&delegation_payload(&config.id, &key)),
157        "delegation_sig": delegation.and_then(|d| d.sig.clone()),
158        "revoked": revoked,
159    }))
160}
161
162fn error(code: &str, message: &str) -> String {
163    json!({ "error": { "code": code, "message": message } }).to_string()
164}
165
166fn requirements(rs: &[Requirement]) -> Vec<Value> {
167    rs.iter()
168        .map(|r| json!({ "package": r.package, "range": r.range }))
169        .collect()
170}
171
172fn parse_expected(bytes: &[u8]) -> Result<Option<(String, String)>, String> {
173    if bytes.is_empty() {
174        return Ok(None);
175    }
176    let v: Value = serde_json::from_slice(bytes)
177        .map_err(|e| format!("the expected package isn't JSON: {e}"))?;
178    let field = |k: &str| {
179        v.get(k)
180            .and_then(Value::as_str)
181            .map(str::to_owned)
182            .ok_or_else(|| format!("the expected package has no string `{k}`"))
183    };
184    Ok(Some((field("name")?, field("version")?)))
185}
186
187const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
188
189fn base64(bytes: &[u8]) -> String {
190    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
191    for chunk in bytes.chunks(3) {
192        let n = chunk
193            .iter()
194            .enumerate()
195            .fold(0u32, |n, (i, b)| n | (u32::from(*b) << (16 - 8 * i)));
196        for i in 0..4 {
197            if i <= chunk.len() {
198                out.push(B64[(n >> (18 - 6 * i) & 63) as usize] as char);
199            } else {
200                out.push('=');
201            }
202        }
203    }
204    out
205}
206
207#[cfg(target_arch = "wasm32")]
208mod exports {
209    /// Reserve `len` bytes for the caller to fill.
210    #[unsafe(no_mangle)]
211    pub extern "C" fn alloc(len: usize) -> *mut u8 {
212        let mut buf = Vec::<u8>::with_capacity(len.max(1));
213        let ptr = buf.as_mut_ptr();
214        std::mem::forget(buf);
215        ptr
216    }
217
218    /// Release a buffer from [`alloc`], or a result `admit` returned.
219    ///
220    /// # Safety
221    /// `ptr` and `len` must be exactly what `alloc(len)` or `admit` produced.
222    #[unsafe(no_mangle)]
223    pub unsafe extern "C" fn dealloc(ptr: *mut u8, len: usize) {
224        // SAFETY: the caller passes back what `alloc` handed out; capacity is
225        // `max(len, 1)` for `alloc`, and exactly `len` for a result.
226        drop(unsafe { Vec::from_raw_parts(ptr, 0, len.max(1)) });
227    }
228
229    /// Admit the archive at `ptr..ptr+len`; see the module docs.
230    ///
231    /// # Safety
232    /// Both ranges must be live buffers of at least the stated lengths.
233    #[unsafe(no_mangle)]
234    pub unsafe extern "C" fn admit(
235        ptr: *const u8,
236        len: usize,
237        expected_ptr: *const u8,
238        expected_len: usize,
239    ) -> u64 {
240        // SAFETY: the caller guarantees the ranges (the function's contract).
241        let upload = unsafe { std::slice::from_raw_parts(ptr, len) };
242        let expected = if expected_len == 0 {
243            &[][..]
244        } else {
245            unsafe { std::slice::from_raw_parts(expected_ptr, expected_len) }
246        };
247        let out = super::admit_json(upload, expected)
248            .into_bytes()
249            .into_boxed_slice();
250        let (ptr, len) = (out.as_ptr() as u64, out.len() as u64);
251        std::mem::forget(out);
252        ptr << 32 | len
253    }
254}
255
256#[cfg(target_arch = "wasm32")]
257mod call_export {
258    /// Answer the JSON request at `ptr..ptr+len`; see the module docs.
259    ///
260    /// # Safety
261    /// The range must be a live buffer of at least `len` bytes.
262    #[unsafe(no_mangle)]
263    pub unsafe extern "C" fn call(ptr: *const u8, len: usize) -> u64 {
264        // SAFETY: the caller guarantees the range (the function's contract).
265        let request = unsafe { std::slice::from_raw_parts(ptr, len) };
266        let out = super::call_json(request).into_bytes().into_boxed_slice();
267        let (ptr, len) = (out.as_ptr() as u64, out.len() as u64);
268        std::mem::forget(out);
269        ptr << 32 | len
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn base64_matches_the_standard_alphabet() {
279        assert_eq!(base64(b""), "");
280        assert_eq!(base64(b"f"), "Zg==");
281        assert_eq!(base64(b"fo"), "Zm8=");
282        assert_eq!(base64(b"foo"), "Zm9v");
283        assert_eq!(base64(b"foob"), "Zm9vYg==");
284    }
285
286    #[test]
287    fn junk_and_a_bad_expectation_render_as_errors() {
288        let junk = admit_json(b"junk", b"");
289        assert!(junk.contains("\"invalid_package\""), "{junk}");
290        let bad = admit_json(b"junk", b"{");
291        assert!(bad.contains("\"bad_request\""), "{bad}");
292    }
293
294    #[test]
295    fn a_line_payload_and_render_match_the_protocol_types() {
296        let line = IndexLine {
297            name: "acme/money".into(),
298            version: "1.4.2".into(),
299            checksum: "sha256-abc".into(),
300            deps: vec![Requirement {
301                package: "acme/fx".into(),
302                range: "^2".into(),
303            }],
304            npm: vec![],
305            toolchain: None,
306            yanked: true,
307            withdrawn: Some("a takedown".into()),
308            sig: "c2ln".into(),
309        };
310        let fields = json!({
311            "name": "acme/money", "version": "1.4.2", "checksum": "sha256-abc",
312            "deps": [{"package": "acme/fx", "range": "^2"}], "npm": [], "toolchain": null,
313            "yanked": true, "withdrawn": "a takedown", "sig": "c2ln",
314        });
315        let payload = call_json(
316            json!({"op": "line_payload", "line": fields, "registry": "acme"})
317                .to_string()
318                .as_bytes(),
319        );
320        assert_eq!(
321            payload,
322            json!({"ok": {"payload": base64(&line.payload("acme"))}}).to_string()
323        );
324        let rendered = call_json(
325            json!({"op": "line_render", "line": fields})
326                .to_string()
327                .as_bytes(),
328        );
329        assert_eq!(rendered, json!({"ok": {"text": line.render()}}).to_string());
330    }
331
332    #[test]
333    fn signer_info_reports_without_verifying() {
334        let config = r#"{"protocol":2,"id":"bynk","dl":"dl","keys":[{"key":"ed25519:ROOT"}],
335            "delegations":[{"key":"ed25519:ONLINE","sig":"c2ln"}],
336            "revoked":[{"key":"ed25519:OLD","sig":"eA=="}]}"#;
337        let info = |key: &str| {
338            call_json(
339                json!({"op": "signer_info", "config": config, "key": key})
340                    .to_string()
341                    .as_bytes(),
342            )
343        };
344        let online: Value = serde_json::from_str(&info("ed25519:ONLINE")).unwrap();
345        assert_eq!(online["ok"]["chain_key"], "ed25519:ROOT");
346        assert_eq!(online["ok"]["delegated"], true);
347        assert_eq!(online["ok"]["delegation_sig"], "c2ln");
348        assert_eq!(
349            online["ok"]["delegation_payload"],
350            base64(&delegation_payload("bynk", "ed25519:ONLINE"))
351        );
352        let old: Value = serde_json::from_str(&info("ed25519:OLD")).unwrap();
353        assert_eq!(old["ok"]["revoked"], true);
354        assert_eq!(old["ok"]["delegated"], false);
355        assert!(call_json(b"{\"op\":\"nope\"}").contains("bad_request"));
356    }
357}