From b103f5c41f4e179091b88fd7081d01bdc9305480 Mon Sep 17 00:00:00 2001 From: Daniel McCarney Date: Thu, 19 Dec 2024 17:10:57 -0500 Subject: [PATCH 1/5] librustls: add missing API docs A few elements of our public API had no associated docs. This is going to be flagged as an error by the API doc generation tooling, so let's fix it up-front with some short doc additions. --- librustls/src/client.rs | 1 + librustls/src/connection.rs | 2 ++ librustls/src/error.rs | 2 ++ librustls/src/log.rs | 8 ++++++++ librustls/src/rustls.h | 33 +++++++++++++++++++++++++++++++++ 5 files changed, 46 insertions(+) diff --git a/librustls/src/client.rs b/librustls/src/client.rs index 0dee7cd9..3de52ee8 100644 --- a/librustls/src/client.rs +++ b/librustls/src/client.rs @@ -570,6 +570,7 @@ pub struct rustls_verify_server_cert_params<'a> { #[allow(non_camel_case_types)] pub type rustls_verify_server_cert_user_data = *mut libc::c_void; +/// A callback that is invoked to verify a server certificate. // According to the nomicon https://doc.rust-lang.org/nomicon/ffi.html#the-nullable-pointer-optimization): // > Option c_int> is a correct way to represent a // > nullable function pointer using the C ABI (corresponding to the C type int (*)(int)). diff --git a/librustls/src/connection.rs b/librustls/src/connection.rs index ff627701..e161242c 100644 --- a/librustls/src/connection.rs +++ b/librustls/src/connection.rs @@ -93,6 +93,7 @@ impl std::ops::DerefMut for Connection { } box_castable! { + /// A C representation of a Rustls `Connection`. pub struct rustls_connection(Connection); } @@ -288,6 +289,7 @@ impl rustls_connection { } } + /// Returns a `rustls_handshake_kind` describing the `rustls_connection`. #[no_mangle] pub extern "C" fn rustls_connection_handshake_kind( conn: *const rustls_connection, diff --git a/librustls/src/error.rs b/librustls/src/error.rs index 3524cc0c..88ee75bf 100644 --- a/librustls/src/error.rs +++ b/librustls/src/error.rs @@ -46,6 +46,7 @@ macro_rules! u32_enum_builder { } u32_enum_builder! { + /// Numeric error codes returned from rustls-ffi API functions. EnumName: rustls_result; EnumDefault: InvalidParameter; EnumVal{ @@ -242,6 +243,7 @@ impl rustls_result { } } + /// Returns true if the `result` is a certificate related error. #[no_mangle] pub extern "C" fn rustls_result_is_cert_error(result: c_uint) -> bool { use rustls_result::*; diff --git a/librustls/src/log.rs b/librustls/src/log.rs index 27bfce7a..7968acde 100644 --- a/librustls/src/log.rs +++ b/librustls/src/log.rs @@ -42,6 +42,10 @@ pub(crate) fn ensure_log_registered() { log::set_max_level(log::LevelFilter::Debug) } +/// Numeric representation of a log level. +/// +/// Passed as a field of the `rustls_log_params` passed to a log callback. +/// Use with `rustls_log_level_str` to convert to a string label. pub type rustls_log_level = usize; /// Return a rustls_str containing the stringified version of a log level. @@ -58,12 +62,16 @@ pub extern "C" fn rustls_log_level_str(level: rustls_log_level) -> rustls_str<'s rustls_str::from_str_unchecked(s) } +/// Parameter structure passed to a `rustls_log_callback`. #[repr(C)] pub struct rustls_log_params<'a> { + /// The log level the message was logged at. pub level: rustls_log_level, + /// The message that was logged. pub message: rustls_str<'a>, } +/// A callback that is invoked for messages logged by rustls. #[allow(non_camel_case_types)] pub type rustls_log_callback = Option; diff --git a/librustls/src/rustls.h b/librustls/src/rustls.h index 41619f01..ae91726b 100644 --- a/librustls/src/rustls.h +++ b/librustls/src/rustls.h @@ -42,6 +42,9 @@ typedef enum rustls_handshake_kind { RUSTLS_HANDSHAKE_KIND_RESUMED = 3, } rustls_handshake_kind; +/** + * Numeric error codes returned from rustls-ffi API functions. + */ enum rustls_result { RUSTLS_RESULT_OK = 7000, RUSTLS_RESULT_IO = 7001, @@ -272,6 +275,9 @@ typedef struct rustls_client_config rustls_client_config; */ typedef struct rustls_client_config_builder rustls_client_config_builder; +/** + * A C representation of a Rustls `Connection`. + */ typedef struct rustls_connection rustls_connection; /** @@ -550,6 +556,9 @@ typedef struct rustls_verify_server_cert_params { struct rustls_slice_bytes ocsp_response; } rustls_verify_server_cert_params; +/** + * A callback that is invoked to verify a server certificate. + */ typedef uint32_t (*rustls_verify_server_cert_callback)(rustls_verify_server_cert_user_data userdata, const struct rustls_verify_server_cert_params *params); @@ -575,13 +584,31 @@ typedef void (*rustls_keylog_log_callback)(struct rustls_str label, */ typedef int (*rustls_keylog_will_log_callback)(struct rustls_str label); +/** + * Numeric representation of a log level. + * + * Passed as a field of the `rustls_log_params` passed to a log callback. + * Use with `rustls_log_level_str` to convert to a string label. + */ typedef size_t rustls_log_level; +/** + * Parameter structure passed to a `rustls_log_callback`. + */ typedef struct rustls_log_params { + /** + * The log level the message was logged at. + */ rustls_log_level level; + /** + * The message that was logged. + */ struct rustls_str message; } rustls_log_params; +/** + * A callback that is invoked for messages logged by rustls. + */ typedef void (*rustls_log_callback)(void *userdata, const struct rustls_log_params *params); /** @@ -1666,6 +1693,9 @@ bool rustls_connection_wants_write(const struct rustls_connection *conn); */ bool rustls_connection_is_handshaking(const struct rustls_connection *conn); +/** + * Returns a `rustls_handshake_kind` describing the `rustls_connection`. + */ enum rustls_handshake_kind rustls_connection_handshake_kind(const struct rustls_connection *conn); /** @@ -2128,6 +2158,9 @@ struct rustls_str rustls_handshake_kind_str(enum rustls_handshake_kind kind); */ void rustls_error(unsigned int result, char *buf, size_t len, size_t *out_n); +/** + * Returns true if the `result` is a certificate related error. + */ bool rustls_result_is_cert_error(unsigned int result); /** From c8dbf73d63deb55fd0f3d6d04573d354a35772a8 Mon Sep 17 00:00:00 2001 From: Daniel McCarney Date: Thu, 19 Dec 2024 17:51:26 -0500 Subject: [PATCH 2/5] prepare for tools crate to exist outside MSRV In a subsequent commit we're adding a docgen tool to the tools crate with a dep that requires a higher MSRV than the main crate. To make this less painful this commit excludes the tools crate from the workspace default list. --- Cargo.toml | 1 - librustls/cmake/rust.cmake | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6215128d..f8dd64f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,6 @@ members = [ default-members = [ "librustls", - "tools" ] resolver = "2" diff --git a/librustls/cmake/rust.cmake b/librustls/cmake/rust.cmake index ca905ac0..2f2a08d2 100644 --- a/librustls/cmake/rust.cmake +++ b/librustls/cmake/rust.cmake @@ -91,7 +91,7 @@ endif() add_custom_command( TARGET ech-test POST_BUILD - COMMAND cargo run --manifest-path=../Cargo.toml --bin ech_fetch + COMMAND cargo run -p rustls-ffi-tools --bin ech_fetch COMMAND ${CMAKE_COMMAND} -E env RUSTLS_PLATFORM_VERIFIER=1 ${CMAKE_COMMAND} -E env ECH_CONFIG_LIST="research.cloudflare.com.ech.configs.bin" From 2c7cb798d7c4fe19a444aabc6873be09ebb2cd6a Mon Sep 17 00:00:00 2001 From: Daniel McCarney Date: Thu, 19 Dec 2024 17:32:26 -0500 Subject: [PATCH 3/5] add docgen tools binary This commit adds a `docgen` binary to the tools crate. This tool can generate a .json file describing the API based on parsing the `cbindgen` generated `rustls.h` header file using a tree-sitter C grammar. The produced JSON can in turn be used to generate markdown web documentation, or any other format required. Along with generating docs this tool applies some basic policy. In particular it requires that **all** public API items be documented or it will produce an error instead of the JSON data. We can extend this in the future to require (for example) describing arguments and return values in doc comments. The generated JSON has the following structure: ```json { "structs": [ .. ], "functions": : [ .. ], "callbacks": [ .. ], "enums": [ .. ], "externs": [ .. ], "aliases": [ .. ] } ``` Where each key is a general category of item: * Structure definitions * API function prototypes * Callback typedefs * Enum typedefs * Extern typedefs * Simple type alias typdefs Within each category items are described like: ```json { "anchor": "rustls-accepted", "comment": "A parsed ClientHello produced by a ...", "feature": null, "name": "rustls_accepted", "text": "```c\nstruct rustls_accepted\n```" }, ``` The anchor field is useful for creating anchor links that identify the item. The name is the actual name of the item. The comment is the C block comment content (with the block comment syntax removed, but other whitespace left as-is). If the item is surrounded by an ifdef guard for a crate feature, then the required feature name is used as the feature field. Lastly the text field holds the actual text that defines the item in rustls.h (without comment) as a C formatted markdown code block. Backtick references in comments that match anchor names (after replacing `_` with `-` and stripping an optional `()` suffix) are automatically hyperlinked. --- Cargo.lock | 62 ++ Cargo.toml | 5 + tools/Cargo.toml | 9 +- tools/src/bin/docgen/main.rs | 819 ++++++++++++++++++ .../{ech_fetch.rs => bin/ech_fetch/main.rs} | 0 5 files changed, 891 insertions(+), 4 deletions(-) create mode 100644 tools/src/bin/docgen/main.rs rename tools/src/{ech_fetch.rs => bin/ech_fetch/main.rs} (100%) diff --git a/Cargo.lock b/Cargo.lock index c94346ae..59ce8433 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1241,7 +1241,12 @@ version = "0.1.0" dependencies = [ "hickory-resolver", "rustls", + "serde", + "serde_json", "tokio", + "tree-sitter", + "tree-sitter-c", + "tree-sitter-md", ] [[package]] @@ -1307,6 +1312,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + [[package]] name = "same-file" version = "1.0.6" @@ -1380,6 +1391,18 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.8" @@ -1646,6 +1669,45 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree-sitter" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0203df02a3b6dd63575cc1d6e609edc2181c9a11867a271b25cfd2abff3ec5ca" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afd2b1bf1585dc2ef6d69e87d01db8adb059006649dd5f96f31aa789ee6e9c71" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c199356c799a8945965bb5f2c55b2ad9d9aa7c4b4f6e587fe9dea0bc715e5f9c" + +[[package]] +name = "tree-sitter-md" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f968c22a01010b83fc960455ae729db08dbeb6388617d9113897cb9204b030" +dependencies = [ + "cc", + "tree-sitter", + "tree-sitter-language", +] + [[package]] name = "triomphe" version = "0.1.11" diff --git a/Cargo.toml b/Cargo.toml index f8dd64f8..456634ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,3 +22,8 @@ regex = "1.9.6" toml = { version = "0.6.0", default-features = false, features = ["parse"] } hickory-resolver = { version = "=0.25.0-alpha.4", features = ["dns-over-https-rustls", "webpki-roots"] } tokio = { version = "1.42.0", features = ["io-util", "macros", "net", "rt"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tree-sitter = "0.23" # TODO(@cpu): handle breaking API changes for 0.24 +tree-sitter-c = "0.23" +tree-sitter-md = "0.3" diff --git a/tools/Cargo.toml b/tools/Cargo.toml index 4f49f6ae..91c30572 100644 --- a/tools/Cargo.toml +++ b/tools/Cargo.toml @@ -8,7 +8,8 @@ publish = false rustls = { workspace = true } hickory-resolver = { workspace = true } tokio = { workspace = true } - -[[bin]] -name = "ech_fetch" -path = "src/ech_fetch.rs" +serde = { workspace = true } +serde_json = { workspace = true } +tree-sitter = { workspace = true } +tree-sitter-c = { workspace = true } +tree-sitter-md = { workspace = true, features = ["parser"] } diff --git a/tools/src/bin/docgen/main.rs b/tools/src/bin/docgen/main.rs new file mode 100644 index 00000000..269229f3 --- /dev/null +++ b/tools/src/bin/docgen/main.rs @@ -0,0 +1,819 @@ +use std::collections::HashSet; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use std::fs; + +use serde::Serialize; +use tree_sitter::{Node, Parser, Query, QueryCursor}; +use tree_sitter_md::MarkdownParser; + +fn main() -> Result<(), Box> { + // Create a C parser. + let mut parser = Parser::new(); + let language = tree_sitter_c::LANGUAGE; + parser.set_language(&language.into())?; + + // Parse the .h into an AST. + let header_file = fs::read_to_string("librustls/src/rustls.h")?; + //let header_file = fs::read_to_string("test.h")?; + let header_file_bytes = header_file.as_bytes(); + let tree = parser + .parse(&header_file, None) + .ok_or("no tree parsed from input")?; + + // Make sure we have the root node we expect. + let root = tree.root_node(); + require_kind("translation_unit", root, header_file_bytes)?; + + // Collect up all items we want to document. Returns an error if any + // items we expect to be documented are missing associated block comments. + let mut docs = find_doc_items(root, header_file_bytes)?; + + // Cross-link items in comments. + docs.crosslink_comments()?; + + // Render JSON data. + println!("{}", serde_json::to_string_pretty(&docs)?); + + Ok(()) +} + +#[derive(Debug, Default, Serialize)] +struct ApiDocs { + structs: Vec, + functions: Vec, + callbacks: Vec, + enums: Vec, + externs: Vec, + aliases: Vec, +} + +impl ApiDocs { + fn crosslink_comments(&mut self) -> Result<(), Box> { + // Put all anchors into a set. Error for any duplicates. + let mut anchor_set = HashSet::new(); + for a in self.all_anchors() { + if !anchor_set.insert(a.to_string()) { + return Err(format!("duplicate anchor: {a}").into()); + } + } + + // For each item of each type, crosslink its comment. + for s in &mut self.structs { + s.comment.crosslink(&anchor_set)?; + } + for f in &mut self.functions { + f.comment.crosslink(&anchor_set)?; + } + for cb in &mut self.callbacks { + cb.comment.crosslink(&anchor_set)?; + } + for e in &mut self.enums { + e.comment.crosslink(&anchor_set)?; + for v in &mut e.variants { + if let Some(comment) = &mut v.comment { + comment.crosslink(&anchor_set)?; + } + } + } + for e in &mut self.externs { + e.comment.crosslink(&anchor_set)?; + } + for a in &mut self.aliases { + a.comment.crosslink(&anchor_set)?; + } + + Ok(()) + } + + fn all_anchors(&self) -> impl Iterator { + // return all item anchors as a chained iterator + self.structs + .iter() + .map(|s| s.anchor.as_str()) + .chain(self.functions.iter().map(|f| f.anchor.as_str())) + .chain(self.callbacks.iter().map(|cb| cb.anchor.as_str())) + .chain(self.enums.iter().map(|e| e.anchor.as_str())) + .chain(self.externs.iter().map(|e| e.anchor.as_str())) + .chain(self.aliases.iter().map(|a| a.anchor.as_str())) + } +} + +fn find_doc_items(root: Node, source_code: &[u8]) -> Result> { + // We document all: + // * type definitions + // * enum specifiers with an enumerator list (enums outside an inline typedef) + // * declarations (incls. externs) + // + // For bare enums we have to make sure we don't match on just (enum_specifier) because + // this will match the node in a function decl returning an enum. We want an enum + // specifier with an enumerator list. + let query = r#" + ( + [ + (type_definition) + (enum_specifier (type_identifier) (enumerator_list)) + (declaration) + ] + @doc_item + ) + "#; + let language = tree_sitter_c::LANGUAGE; + let query = Query::new(&language.into(), query)?; + + let mut cursor = QueryCursor::new(); + let matches = cursor.matches(&query, root, source_code); + + let mut items = Vec::default(); + let mut errors = 0; + for query_match in matches { + for doc_item_node in query_match.nodes_for_capture_index(0) { + match process_doc_item(doc_item_node, source_code) { + Ok(Some(item)) => items.push(item), + Err(err) => { + eprintln!("{err}"); + errors += 1; + } + _ => {} + } + } + } + if errors > 0 { + return Err(format!("{} errors produced while documenting header file", errors).into()); + } + + let mut api = ApiDocs::default(); + for item in items { + match item { + Item::Enum(e) => api.enums.push(e), + Item::Struct(s) => api.structs.push(s), + Item::TypeAlias(a) => api.aliases.push(a), + Item::Callback(cb) => api.callbacks.push(cb), + Item::Function(f) => api.functions.push(f), + Item::Extern(e) => api.externs.push(e), + } + } + + Ok(api) +} + +fn process_doc_item(item: Node, src: &[u8]) -> Result, Box> { + // Get the item's previous sibling in the tree. + let Some(prev) = item.prev_sibling() else { + return Err(node_error("to-be-documented item without previous item", item, src).into()); + }; + + // If we're looking at an enum node, but it's after a typedef, skip. + // We'll document this enum when we process the typedef. + if item.kind() == "enum_specifier" && prev.kind() == "typedef" { + return Ok(None); + } + + // Try to turn the previous sibling into a comment node. Some items may + // require this to be Some(_) while others may allow None. + let (comment, feat_requirement) = comment_and_requirement(prev, src)?; + + let kind = item.kind(); + // Based on the node kind, convert it to an appropriate Item. + Ok(Some(match kind { + "type_definition" => process_typedef_item(comment, feat_requirement, item, src)?, + "enum_specifier" => Item::from(EnumItem::new(comment, feat_requirement, item, src)?), + "declaration" => process_declaration_item(comment, feat_requirement, item, src)?, + _ => return Err(format!("unexpected item kind: {}", item.kind()).into()), + })) +} + +fn comment_and_requirement( + node: Node, + src: &[u8], +) -> Result<(Option, Option), Box> { + let maybe_comment = Comment::new(node, src).ok(); + + // If prev wasn't a comment, see if it was a feature requirement. + if maybe_comment.is_none() { + return Ok(match Feature::new(node, src).ok() { + Some(feat_req) => (None, Some(feat_req)), + None => (None, None), + }); + } + + // Otherwise, check the prev of the comment for a feature requirement + let Some(prev) = node.prev_named_sibling() else { + return Ok((maybe_comment, None)); + }; + + Ok((maybe_comment, Feature::new(prev, src).ok())) +} + +#[derive(Debug, Default, Serialize)] +struct Feature(String); + +impl Feature { + fn new(node: Node, src: &[u8]) -> Result> { + // Verify we're looking at a preproc_defined node preceded by a preproc_if node. + require_kind("preproc_defined", node, src)?; + if let Some(prev) = node.prev_sibling() { + require_kind("#if", prev, src)?; + } else { + return Err(node_error("feature requirement without previous item", node, src).into()); + } + + let Some(required_define) = node.named_child(0).map(|n| node_text(n, src)) else { + return Err(node_error("feature requirement without identifier", node, src).into()); + }; + + // We assume features have cbindgen defines like "DEFINE_$FEATURE_NAME" and we want to + // extract "$feature_name". + Ok(Self( + required_define + .strip_prefix("DEFINE_") + .unwrap_or(&required_define) + .to_ascii_lowercase(), + )) + } +} + +impl Display for Feature { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// A comment from the header file. +/// +/// The comment text is cleaned up to remove leading/trailing C block comment syntax. +/// The remaining text is unaltered with respect to indentation and newlines within the +/// content. +#[derive(Debug, Default, Serialize)] +struct Comment(String); + +impl Comment { + fn new(node: Node, src: &[u8]) -> Result> { + require_kind("comment", node, src)?; + + // Convert the node to UTF8 text and then strip the block comment syntax and any + // leading/trailing newlines. + let text = node + .utf8_text(src) + .unwrap_or_default() + .lines() + .map(|line| { + line.trim() + .trim_start_matches("/**") + .trim_end_matches("*/") + .trim_start_matches('*') + }) + .collect::>() + .join("\n") + .trim() + .to_string(); + + Ok(Self(text)) + } + + fn crosslink(&mut self, anchors: &HashSet) -> Result<(), Box> { + let Some(parser) = MarkdownParser::default().parse(self.0.as_bytes(), None) else { + return Ok(()); + }; + + // Find all the "code_span" items from each inline tree, potentially replacing some content. + let mut replacements = Vec::new(); + for t in parser.inline_trees() { + let mut cursor = t.walk(); + if !cursor.goto_first_child() { + break; + } + loop { + let node = cursor.node(); + if node.kind() != "code_span" { + if !cursor.goto_next_sibling() { + break; + } + continue; + } + let start = node.start_byte(); + let end = node.end_byte(); + let content = &self.0[start..end].trim_matches('`'); + let anchor = content.trim_end_matches("()").replace('_', "-"); + + // If we found an anchor in backticks, make it a link to that anchor. + if anchors.contains(&anchor) { + replacements.push((start, end, format!("[`{content}`](#{anchor})"))); + } + + if !cursor.goto_next_sibling() { + break; + } + } + } + + // Apply replacements to comment in reverse to maintain correct byte offsets. + for (start, end, replacement) in replacements.into_iter().rev() { + self.0.replace_range(start..end, &replacement); + } + + Ok(()) + } +} + +impl Display for Comment { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +fn process_typedef_item( + maybe_comment: Option, + maybe_feature: Option, + item: Node, + src: &[u8], +) -> Result> { + require_kind("type_definition", item, src)?; + + let typedef_node = item.child_by_field_name("type").unwrap(); + let typedef_kind = typedef_node.kind(); + + let comment = match (&maybe_comment, item.prev_named_sibling()) { + // We allow an uncommented type_definition if the previous node was a bare enum_specifier. + // This happens when an enum has a primitive type repr, like rustls_result. The enum + // appears without typedef (but with comment), and then a typedef uint32_t appears (without + // preceding comment). This is OK and doesn't count as an undocumented error. + // + // It's important we use prev_named_sibling() for finding the enum_specifier that precedes + // the typedef. Using prev_sibling() would return an anonymous ';' node. + (None, Some(sib)) if sib.kind() == "enum_specifier" => Comment::default(), + _ => require_documented(maybe_comment, item, src)?, + }; + + // Convert the particular item being typedef'd based on kind(). + // We treat function typedefs differently - we want those to be considered callbacks. + let func_declarator = item + .child_by_field_name("declarator") + .map(|n| { + n.kind() == "function_declarator" + || (n.kind() == "pointer_declarator" + && n.child_by_field_name("declarator") + .map(|n| n.kind() == "function_declarator") + .unwrap_or_default()) + }) + .unwrap_or_default(); + Ok(match typedef_kind { + // e.g. `typedef enum rustls_handshake_kind { ... } rustls_handshake_kind;` + "enum_specifier" => Item::from(EnumItem::new( + Some(comment), + maybe_feature, + typedef_node, + src, + )?), + + // e.g. `typedef uint32_t (*rustls_verify_server_cert_callback)(...);` + "primitive_type" if func_declarator => { + Item::from(CallbackItem::new(comment, maybe_feature, item, src)?) + } + + // e.g. `typedef rustls_io_result (*rustls_read_callback)(...);` + "type_identifier" if func_declarator => { + Item::from(CallbackItem::new(comment, maybe_feature, item, src)?) + } + + // e.g. `typedef const struct rustls_certified_key *(*rustls_client_hello_callback)(...);` + "struct_specifier" if func_declarator => { + Item::from(CallbackItem::new(comment, maybe_feature, item, src)?) + } + + // e.g. `typedef struct rustls_accepted rustls_accepted;` + "struct_specifier" => { + Item::from(StructItem::new(comment, maybe_feature, typedef_node, src)?) + } + + // e.g. `typedef int rustls_io_result;` + "primitive_type" if !func_declarator => { + Item::from(TypeAliasItem::new(comment, maybe_feature, item, src)) + } + + // e.g. ... well, none so far - but something like `typedef rustls_io_result rustls_funtime_io_result;`. + "type_identifier" if !func_declarator => { + Item::from(TypeAliasItem::new(comment, maybe_feature, item, src)) + } + + _ => return Err(format!("unknown typedef kind: {typedef_kind:?}").into()), + }) +} + +fn process_declaration_item( + comment: Option, + maybe_feature: Option, + item: Node, + src: &[u8], +) -> Result> { + require_kind("declaration", item, src)?; + + let comment = require_documented(comment, item, src)?; + if item.child(0).unwrap().kind() == "storage_class_specifier" { + // extern is a storage_class_specifier. + Ok(Item::from(ExternItem::new( + comment, + maybe_feature, + item, + src, + )?)) + } else { + // other non-extern declarations are functions. + Ok(Item::from(FunctionItem::new( + comment, + maybe_feature, + item, + src, + )?)) + } +} + +/// An item to be documented from a C header file. +#[derive(Debug)] +enum Item { + Enum(EnumItem), + Struct(StructItem), + TypeAlias(TypeAliasItem), + Callback(CallbackItem), + Function(FunctionItem), + Extern(ExternItem), +} + +impl From for Item { + fn from(item: EnumItem) -> Self { + Self::Enum(item) + } +} + +impl From for Item { + fn from(item: StructItem) -> Self { + Self::Struct(item) + } +} + +impl From for Item { + fn from(item: TypeAliasItem) -> Self { + Self::TypeAlias(item) + } +} + +impl From for Item { + fn from(item: CallbackItem) -> Self { + Self::Callback(item) + } +} + +impl From for Item { + fn from(item: FunctionItem) -> Self { + Self::Function(item) + } +} + +impl From for Item { + fn from(item: ExternItem) -> Self { + Self::Extern(item) + } +} + +/// An enum declaration. +/// +/// E.g. `typedef enum rustls_handshake_kind { ... variants ... };` +#[derive(Debug, Serialize)] +struct EnumItem { + anchor: String, + comment: Comment, + feature: Option, + name: String, + variants: Vec, +} + +impl EnumItem { + fn new( + comment: Option, + feature: Option, + enum_spec: Node, + src: &[u8], + ) -> Result> { + let comment = require_documented(comment, enum_spec, src)?; + + let name = enum_spec + .child_by_field_name("name") + .map(|n| node_text(n, src)) + .unwrap(); + + // Get the enumerator_list and walk its children, converting each variant to an + // EnumVariantItem. + let enumeraor_list = enum_spec.child_by_field_name("body").unwrap(); + let mut cursor = enumeraor_list.walk(); + let variants = enumeraor_list + .children(&mut cursor) + .filter_map(|n| EnumVariantItem::new(n, src).ok()) + .collect(); + + Ok(Self { + anchor: name.replace('_', "-").to_ascii_lowercase(), + comment, + feature, + name, + variants, + }) + } +} + +/// A variant of an Enum. +/// +/// E.g. RUSTLS_RESULT_ALERT_UNKNOWN = 7234 +#[derive(Debug, Default, Serialize)] +struct EnumVariantItem { + anchor: String, + comment: Option, // We don't require all enum variants have comments. + name: String, + value: String, +} + +impl EnumVariantItem { + fn new(variant_node: Node, src: &[u8]) -> Result> { + require_kind("enumerator", variant_node, src)?; + + let name = node_text(variant_node.child_by_field_name("name").unwrap(), src); + Ok(Self { + anchor: name.replace('_', "-").to_ascii_lowercase(), + comment: variant_node + .prev_sibling() + .and_then(|n| Comment::new(n, src).ok()), + name, + value: node_text(variant_node.child_by_field_name("value").unwrap(), src), + }) + } +} + +/// A structure typedef. +/// +/// May have fields (not presently parsed) or no fields (e.g. an opaque struct). +/// +/// E.g. `typedef struct rustls_client_config_builder rustls_client_config_builder;` +#[derive(Debug, Serialize)] +struct StructItem { + anchor: String, + comment: Comment, + feature: Option, + name: String, + text: String, +} + +impl StructItem { + fn new( + comment: Comment, + feature: Option, + struct_node: Node, + src: &[u8], + ) -> Result> { + require_kind("struct_specifier", struct_node, src)?; + + let name = node_text(struct_node.child_by_field_name("name").unwrap(), src); + Ok(Self { + anchor: name.replace('_', "-").to_ascii_lowercase(), + comment, + feature, + name, + text: markup_text(struct_node, src), + }) + } +} + +/// A simple typedef type alias. +/// +/// E.g. `typedef int rustls_io_result;` +#[derive(Debug, Serialize)] +struct TypeAliasItem { + anchor: String, + comment: Comment, + feature: Option, + name: String, + text: String, +} + +impl TypeAliasItem { + fn new(comment: Comment, feature: Option, item: Node, src: &[u8]) -> Self { + let language = tree_sitter_c::LANGUAGE; + let query = Query::new(&language.into(), "(type_identifier) @name").unwrap(); + let mut cursor = QueryCursor::new(); + let name = cursor + .matches(&query, item, src) + .next() + .map(|m| node_text(m.captures[0].node, src)) + .unwrap(); + + Self { + // Note: we add a 'alias-' prefix for simple type aliases anchors. + // We do this because otherwise we end up with two 'rustls-result' + // anchors. One for the bare enum, and one for the typedef'd type. + anchor: format!("alias-{}", name.replace("_", "-").to_ascii_lowercase()), + name, + comment, + feature, + text: markup_text(item, src), + } + } +} + +/// A function pointer typedef for a callback function. +/// +/// E.g. `typedef rustls_io_result (*rustls_read_callback)(void *userdata, ...);` +#[derive(Debug, Serialize)] +struct CallbackItem { + anchor: String, + comment: Comment, + feature: Option, + name: String, + text: String, +} + +impl CallbackItem { + fn new( + comment: Comment, + feature: Option, + typedef: Node, + src: &[u8], + ) -> Result> { + require_kind("type_definition", typedef, src)?; + + let name = function_identifier(typedef, src); + Ok(Self { + anchor: name.replace("_'", "-").to_ascii_lowercase(), + comment, + feature, + name, + text: markup_text(typedef, src), + }) + } +} + +/// A function prototype definition. +/// +/// E.g. `void rustls_acceptor_free(struct rustls_acceptor *acceptor);` +#[derive(Debug, Serialize)] +struct FunctionItem { + anchor: String, + comment: Comment, + feature: Option, + name: String, + text: String, +} + +impl FunctionItem { + fn new( + comment: Comment, + feature: Option, + decl_node: Node, + src: &[u8], + ) -> Result> { + require_kind("declaration", decl_node, src)?; + + let name = function_identifier(decl_node, src); + Ok(Self { + anchor: name.replace('_', "-").to_ascii_lowercase(), + comment, + feature, + name, + text: markup_text(decl_node, src), + }) + } +} + +/// An extern constant declaration. +/// +/// E.g. `extern const uint16_t RUSTLS_ALL_VERSIONS[2];` +#[derive(Debug, Serialize)] +struct ExternItem { + anchor: String, + comment: Comment, + feature: Option, + name: String, + text: String, +} + +impl ExternItem { + fn new( + comment: Comment, + feature: Option, + decl_node: Node, + src: &[u8], + ) -> Result> { + require_kind("declaration", decl_node, src)?; + + // Query for the first identifier kind child node. + let language = tree_sitter_c::LANGUAGE; + let query = Query::new(&language.into(), "(identifier) @name").unwrap(); + let mut cursor = QueryCursor::new(); + let name = cursor + .matches(&query, decl_node, src) + .next() + .map(|m| node_text(m.captures[0].node, src)) + .unwrap(); + + Ok(Self { + anchor: name.replace('_', "-").to_ascii_lowercase(), + comment, + feature, + name, + text: markup_text(decl_node, src), + }) + } +} + +/// Return a function's name. +/// +/// Queries for function names from simple function declarators like +/// `rustls_io_result rustls_acceptor_read_tls(...)` +/// or, pointers to functions that have a parenthesized name like +/// `typedef rustls_io_result (*rustls_read_callback)(...)` +/// +/// Panics if there is no match. +fn function_identifier(node: Node, src: &[u8]) -> String { + let language = tree_sitter_c::LANGUAGE; + let query = Query::new( + &language.into(), + r#" + [ + (function_declarator + declarator: (identifier) @name + ) + (function_declarator + declarator: (parenthesized_declarator + (pointer_declarator + (type_identifier) @name + ) + ) + ) + ]"#, + ) + .unwrap(); + let mut cursor = QueryCursor::new(); + let res = cursor + .matches(&query, node, src) + .next() + .map(|m| node_text(m.captures[0].node, src)); + + if res.is_none() { + dbg!(&node.start_position().row); + dbg!(&node); + } + res.unwrap() +} + +/// Require that a `Node` correspond to a specific kind of grammar rule. +/// +/// Returns an error describing the node's position and the expected vs actual +/// kind if there is a mismatch. +/// +/// Once the kind is verified we can lean on the grammar to unwrap() elements +/// we know must exist. +fn require_kind(kind: &str, node: Node, src: &[u8]) -> Result<(), Box> { + let found_kind = node.kind(); + match found_kind == kind { + true => Ok(()), + false => Err(node_error(format!("expected {kind}, found {found_kind}"), node, src).into()), + } +} + +/// Unwrap a required CommentNode or return an error. +/// +/// The error will describe the kind of node that was missing a documentation comment, as well +/// as its location (line/col) in the source code. +fn require_documented( + comment: Option, + item: Node, + src: &[u8], +) -> Result> { + comment.ok_or( + node_error( + format!("undocumented {kind}", kind = item.kind()), + item, + src, + ) + .into(), + ) +} + +fn node_error(prefix: impl Display, n: Node, src: &[u8]) -> String { + format!( + "{prefix} on L{line}:{col}: item: {:?}", + node_text(n, src), + line = n.start_position().row + 1, + col = n.start_position().column, + ) +} + +/// Convert the node to its textual representation in the source code. +/// +/// Returns an empty string if the content isn't valid UTF-8. +fn node_text(node: Node, src: &[u8]) -> String { + node.utf8_text(src).unwrap_or_default().to_string() +} + +/// Convert the node to its textual representation, then decorate it as C +/// markdown code block. +fn markup_text(node: Node, src: &[u8]) -> String { + format!("```c\n{}\n```", node_text(node, src)) +} diff --git a/tools/src/ech_fetch.rs b/tools/src/bin/ech_fetch/main.rs similarity index 100% rename from tools/src/ech_fetch.rs rename to tools/src/bin/ech_fetch/main.rs From 1f20012d7ec9b9b5f3252451544ef4b81c005899 Mon Sep 17 00:00:00 2001 From: Daniel McCarney Date: Thu, 19 Dec 2024 17:43:33 -0500 Subject: [PATCH 4/5] add an API docs website powered by Zola This commit adds a Zola[0] website that can template the API docs JSON data into a nice website showing off our API. The choice of Zola (and the initial CSS/config) are inspired by the https://rustls.dev website which also uses Zola. To use: ``` cargo run --bin docgen > website/static/api.json cd website && zola serve open http://locahost:1111 ``` [0]: https://www.getzola.org/ --- .gitignore | 2 + website/README.md | 5 + website/config.toml | 18 +++ website/content/_index.md | 4 + website/static/GeneralSans-Variable.woff2 | Bin 0 -> 38132 bytes website/static/rustls-ferris.png | Bin 0 -> 498408 bytes website/static/style.css | 176 ++++++++++++++++++++++ website/templates/_api_section.html | 43 ++++++ website/templates/base.html | 15 ++ website/templates/index.html | 34 +++++ website/templates/macros.html | 27 ++++ 11 files changed, 324 insertions(+) create mode 100644 website/README.md create mode 100644 website/config.toml create mode 100644 website/content/_index.md create mode 100644 website/static/GeneralSans-Variable.woff2 create mode 100644 website/static/rustls-ferris.png create mode 100644 website/static/style.css create mode 100644 website/templates/_api_section.html create mode 100644 website/templates/base.html create mode 100644 website/templates/index.html create mode 100644 website/templates/macros.html diff --git a/.gitignore b/.gitignore index cdb92b1e..7bfcff02 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ librustls/cmake-build* .vs debian/usr debian/DEBIAN +/website/static/api.json +/website/public diff --git a/website/README.md b/website/README.md new file mode 100644 index 00000000..7e944229 --- /dev/null +++ b/website/README.md @@ -0,0 +1,5 @@ +Source for the website at `https://ffi.rustls.dev/` + +This uses [Zola](https://www.getzola.org/). + +Run `zola serve` in this directory to run a local copy. diff --git a/website/config.toml b/website/config.toml new file mode 100644 index 00000000..6498c85b --- /dev/null +++ b/website/config.toml @@ -0,0 +1,18 @@ +# The URL the site will be built for +base_url = "https://ffi.rustls.dev/" + +# Whether to automatically compile all Sass files in the sass directory +compile_sass = false + +# Whether to build a search index to be used later on by a JavaScript library +build_search_index = false + +[markdown] +# Whether to do syntax highlighting +# Theme can be customised by setting the `highlight_theme` variable to a theme supported by Zola +highlight_code = true + +[slugify] +paths_keep_dates = true + +[extra] diff --git a/website/content/_index.md b/website/content/_index.md new file mode 100644 index 00000000..20c3eed3 --- /dev/null +++ b/website/content/_index.md @@ -0,0 +1,4 @@ ++++ +title = "rustls-ffi API docs" +template = "index.html" ++++ diff --git a/website/static/GeneralSans-Variable.woff2 b/website/static/GeneralSans-Variable.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..55e906ba3b2d415015f354c0627fbf2462353af4 GIT binary patch literal 38132 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