diff --git a/Cargo.lock b/Cargo.lock index a80c869..67c44c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1448,10 +1448,12 @@ dependencies = [ [[package]] name = "kinode_process_lib" -version = "0.8.5" +version = "0.9.0" dependencies = [ "alloy", "alloy-primitives", + "alloy-sol-macro", + "alloy-sol-types", "anyhow", "bincode", "http", diff --git a/Cargo.toml b/Cargo.toml index 272a0b7..485e750 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,14 +1,16 @@ [package] name = "kinode_process_lib" description = "A library for writing Kinode processes in Rust." -version = "0.8.5" +version = "0.9.0" edition = "2021" license-file = "LICENSE" homepage = "https://kinode.org" repository = "https://github.com/kinode-dao/process_lib" [dependencies] -alloy-primitives = "0.7.0" +alloy-primitives = "0.7.6" +alloy-sol-macro = "0.7.6" +alloy-sol-types = "0.7.6" alloy = { version = "0.1.1", features = [ "json-rpc", "rpc-types", diff --git a/LICENSE b/LICENSE index 6c5d1d1..a3fe352 100644 --- a/LICENSE +++ b/LICENSE @@ -175,7 +175,7 @@ END OF TERMS AND CONDITIONS - Copyright 2024 Unzentrum DAO + Copyright 2024 Sybil Technologies AG Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/src/eth.rs b/src/eth.rs index bf5a534..3e54900 100644 --- a/src/eth.rs +++ b/src/eth.rs @@ -10,10 +10,6 @@ pub use alloy_primitives::{Address, BlockHash, BlockNumber, Bytes, TxHash, U128, use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; -// -// types mirrored from runtime module -// - /// The Action and Request type that can be made to eth:distro:sys. Any process with messaging /// capabilities can send this action to the eth provider. /// @@ -555,6 +551,16 @@ impl Provider { self.send_request_and_parse_response::(action) } + /// Returns a Kimap instance with the default address using this provider. + pub fn kimap(&self) -> crate::kimap::Kimap { + crate::kimap::Kimap::default(self.request_timeout) + } + + /// Returns a Kimap instance with a custom address using this provider. + pub fn kimap_with_address(self, address: Address) -> crate::kimap::Kimap { + crate::kimap::Kimap::new(self, address) + } + /// Sends a raw transaction to the network. /// /// # Parameters @@ -617,6 +623,24 @@ impl Provider { } } + /// Subscribe in a loop until successful + pub fn subscribe_loop(&self, sub_id: u64, filter: Filter) { + loop { + match self.subscribe(sub_id, filter.clone()) { + Ok(()) => break, + Err(_) => { + crate::print_to_terminal( + 0, + "failed to subscribe to chain! trying again in 5s...", + ); + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + } + } + crate::print_to_terminal(0, "subscribed to logs successfully"); + } + /// Unsubscribes from a previously created subscription. /// /// # Parameters diff --git a/src/homepage.rs b/src/homepage.rs index 42d197e..770caee 100644 --- a/src/homepage.rs +++ b/src/homepage.rs @@ -3,6 +3,8 @@ use crate::Request; /// Add a new icon and/or widget to the Kinode homepage. Note that the process calling this /// function must have the `homepage:homepage:sys` messaging capability. /// +/// This should be called upon process startup to ensure that the process is added to the homepage. +/// /// An icon must be a base64 encoded SVG. /// /// A path will be automatically placed underneath the namespace of the process. For example, @@ -26,3 +28,14 @@ pub fn add_to_homepage(label: &str, icon: Option<&str>, path: Option<&str>, widg .send() .unwrap(); } + +/// Remove the caller process from the Kinode homepage. Note that the process calling this function +/// must have the `homepage:homepage:sys` messaging capability. +/// +/// This usually isn't necessary as processes are not persisted on homepage between boots. +pub fn remove_from_homepage() { + Request::to(("our", "homepage", "homepage", "sys")) + .body("\"Remove\"") + .send() + .unwrap(); +} diff --git a/src/http.rs b/src/http.rs deleted file mode 100644 index c5298b6..0000000 --- a/src/http.rs +++ /dev/null @@ -1,1114 +0,0 @@ -use crate::kernel_types::MessageType; -use crate::vfs::{FileType, VfsAction, VfsRequest, VfsResponse}; -use crate::{ - get_blob, Address, LazyLoadBlob as KiBlob, Message, Request as KiRequest, - Response as KiResponse, -}; -pub use http::*; -use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, VecDeque}; -use std::path::Path; -use std::str::FromStr; -use thiserror::Error; - -// -// these types are a copy of the types used in http module of runtime. -// - -/// HTTP Request type that can be shared over Wasm boundary to apps. -/// This is the one you receive from the `http_server:distro:sys` service. -#[derive(Debug, Serialize, Deserialize)] -pub enum HttpServerRequest { - Http(IncomingHttpRequest), - /// Processes will receive this kind of request when a client connects to them. - /// If a process does not want this websocket open, they should issue a *request* - /// containing a [`type@HttpServerAction::WebSocketClose`] message and this channel ID. - WebSocketOpen { - path: String, - channel_id: u32, - }, - /// Processes can both SEND and RECEIVE this kind of request - /// (send as [`type@HttpServerAction::WebSocketPush`]). - /// When received, will contain the message bytes as lazy_load_blob. - WebSocketPush { - channel_id: u32, - message_type: WsMessageType, - }, - /// Receiving will indicate that the client closed the socket. Can be sent to close - /// from the server-side, as [`type@HttpServerAction::WebSocketClose`]. - WebSocketClose(u32), -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct IncomingHttpRequest { - source_socket_addr: Option, // will parse to SocketAddr - method: String, // will parse to http::Method - url: String, // will parse to url::Url - bound_path: String, // the matching path that was bound - headers: HashMap, // will parse to http::HeaderMap - url_params: HashMap, - query_params: HashMap, - // BODY is stored in the lazy_load_blob, as bytes -} - -/// HTTP Response type that can be shared over Wasm boundary to apps. -/// Respond to [`IncomingHttpRequest`] with this type. -#[derive(Debug, Serialize, Deserialize)] -pub struct HttpResponse { - pub status: u16, - pub headers: HashMap, - // BODY is stored in the lazy_load_blob, as bytes -} - -/// Request type sent to `http_server:distro:sys` in order to configure it. -/// You can also send [`type@HttpServerAction::WebSocketPush`], which -/// allows you to push messages across an existing open WebSocket connection. -/// -/// If a response is expected, all HttpServerActions will return a Response -/// with the shape Result<(), HttpServerActionError> serialized to JSON. -#[derive(Debug, Serialize, Deserialize)] -pub enum HttpServerAction { - /// Bind expects a lazy_load_blob if and only if `cache` is TRUE. The lazy_load_blob should - /// be the static file to serve at this path. - Bind { - path: String, - /// Set whether the HTTP request needs a valid login cookie, AKA, whether - /// the user needs to be logged in to access this path. - authenticated: bool, - /// Set whether requests can be fielded from anywhere, or only the loopback address. - local_only: bool, - /// Set whether to bind the lazy_load_blob statically to this path. That is, take the - /// lazy_load_blob bytes and serve them as the response to any request to this path. - cache: bool, - }, - /// SecureBind expects a lazy_load_blob if and only if `cache` is TRUE. The lazy_load_blob should - /// be the static file to serve at this path. - /// - /// SecureBind is the same as Bind, except that it forces requests to be made from - /// the unique subdomain of the process that bound the path. These requests are - /// *always* authenticated, and *never* local_only. The purpose of SecureBind is to - /// serve elements of an app frontend or API in an exclusive manner, such that other - /// apps installed on this node cannot access them. Since the subdomain is unique, it - /// will require the user to be logged in separately to the general domain authentication. - SecureBind { - path: String, - /// Set whether to bind the lazy_load_blob statically to this path. That is, take the - /// lazy_load_blob bytes and serve them as the response to any request to this path. - cache: bool, - }, - /// Unbind a previously-bound HTTP path - Unbind { path: String }, - /// Bind a path to receive incoming WebSocket connections. - /// Doesn't need a cache since does not serve assets. - WebSocketBind { - path: String, - authenticated: bool, - encrypted: bool, - extension: bool, - }, - /// SecureBind is the same as Bind, except that it forces new connections to be made - /// from the unique subdomain of the process that bound the path. These are *always* - /// authenticated. Since the subdomain is unique, it will require the user to be - /// logged in separately to the general domain authentication. - WebSocketSecureBind { - path: String, - encrypted: bool, - extension: bool, - }, - /// Unbind a previously-bound WebSocket path - WebSocketUnbind { path: String }, - /// Processes will RECEIVE this kind of request when a client connects to them. - /// If a process does not want this websocket open, they should issue a *request* - /// containing a [`type@HttpServerAction::WebSocketClose`] message and this channel ID. - WebSocketOpen { path: String, channel_id: u32 }, - /// When sent, expects a lazy_load_blob containing the WebSocket message bytes to send. - WebSocketPush { - channel_id: u32, - message_type: WsMessageType, - }, - /// When sent, expects a `lazy_load_blob` containing the WebSocket message bytes to send. - /// Modifies the `lazy_load_blob` by placing into `WebSocketExtPushData` with id taken from - /// this `KernelMessage` and `kinode_message_type` set to `desired_reply_type`. - WebSocketExtPushOutgoing { - channel_id: u32, - message_type: WsMessageType, - desired_reply_type: MessageType, - }, - /// For communicating with the ext. - /// Kinode's http_server sends this to the ext after receiving `WebSocketExtPushOutgoing`. - /// Upon receiving reply with this type from ext, http_server parses, setting: - /// * id as given, - /// * message type as given (Request or Response), - /// * body as HttpServerRequest::WebSocketPush, - /// * blob as given. - WebSocketExtPushData { - id: u64, - kinode_message_type: MessageType, - blob: Vec, - }, - /// Sending will close a socket the process controls. - WebSocketClose(u32), -} - -/// The possible message types for WebSocketPush. Ping and Pong are limited to 125 bytes -/// by the WebSockets protocol. Text will be sent as a Text frame, with the lazy_load_blob bytes -/// being the UTF-8 encoding of the string. Binary will be sent as a Binary frame containing -/// the unmodified lazy_load_blob bytes. -#[derive(Debug, PartialEq, Serialize, Deserialize)] -pub enum WsMessageType { - Text, - Binary, - Ping, - Pong, - Close, -} - -/// Part of the Response type issued by http_server -#[derive(Error, Debug, Serialize, Deserialize)] -pub enum HttpServerError { - #[error( - "http_server: request could not be parsed to HttpServerAction: {}.", - req - )] - BadRequest { req: String }, - #[error("http_server: action expected lazy_load_blob")] - NoBlob, - #[error("http_server: path binding error: {:?}", error)] - PathBindError { error: String }, - #[error("http_server: WebSocket error: {:?}", error)] - WebSocketPushError { error: String }, -} - -/// Structure sent from client websocket to this server upon opening a new connection. -/// After this is sent, depending on the `encrypted` flag, the channel will either be -/// open to send and receive plaintext messages or messages encrypted with a symmetric -/// key derived from the JWT. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct WsRegister { - pub auth_token: String, - pub target_process: String, - pub encrypted: bool, // TODO symmetric key exchange here if true -} - -/// Structure sent from this server to client websocket upon opening a new connection. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct WsRegisterResponse { - pub channel_id: u32, - // TODO symmetric key exchange here -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct JwtClaims { - pub username: String, - pub expiration: u64, -} - -impl HttpServerRequest { - /// Parse a byte slice into an HttpServerRequest. - pub fn from_bytes(bytes: &[u8]) -> serde_json::Result { - serde_json::from_slice(bytes) - } - - /// Filter the general-purpose [`HttpServerRequest`], which contains HTTP requests - /// and WebSocket messages, into just the HTTP request. Consumes the original request - /// and returns `None` if the request was WebSocket-related. - pub fn request(self) -> Option { - match self { - HttpServerRequest::Http(req) => Some(req), - _ => None, - } - } -} - -impl IncomingHttpRequest { - pub fn url(&self) -> anyhow::Result { - url::Url::parse(&self.url).map_err(|e| anyhow::anyhow!("couldn't parse url: {:?}", e)) - } - - pub fn method(&self) -> anyhow::Result { - http::Method::from_bytes(self.method.as_bytes()) - .map_err(|e| anyhow::anyhow!("couldn't parse method: {:?}", e)) - } - - pub fn source_socket_addr(&self) -> anyhow::Result { - match &self.source_socket_addr { - Some(addr) => addr - .parse() - .map_err(|_| anyhow::anyhow!("Invalid format for socket address: {}", addr)), - None => Err(anyhow::anyhow!("No source socket address provided")), - } - } - - /// Returns the path that was originally bound, with an optional prefix stripped. - /// The prefix would normally be the process ID as a &str, but it could be anything. - pub fn bound_path(&self, process_id_to_strip: Option<&str>) -> &str { - match process_id_to_strip { - Some(process_id) => self - .bound_path - .strip_prefix(&format!("/{}", process_id)) - .unwrap_or(&self.bound_path), - None => &self.bound_path, - } - } - - pub fn path(&self) -> anyhow::Result { - let url = url::Url::parse(&self.url)?; - // skip the first path segment, which is the process ID. - let path = url - .path_segments() - .ok_or(anyhow::anyhow!("url path missing process ID!"))? - .skip(1) - .collect::>() - .join("/"); - Ok(format!("/{}", path)) - } - - pub fn headers(&self) -> HeaderMap { - let mut header_map = HeaderMap::new(); - for (key, value) in self.headers.iter() { - let key_bytes = key.as_bytes(); - let Ok(key_name) = HeaderName::from_bytes(key_bytes) else { - continue; - }; - let Ok(value_header) = HeaderValue::from_str(&value) else { - continue; - }; - header_map.insert(key_name, value_header); - } - header_map - } - - pub fn url_params(&self) -> &HashMap { - &self.url_params - } - - pub fn query_params(&self) -> &HashMap { - &self.query_params - } -} - -/// Request type that can be shared over Wasm boundary to apps. -/// This is the one you send to the `http_client:distro:sys` service. -#[derive(Debug, Serialize, Deserialize)] -pub enum HttpClientAction { - Http(OutgoingHttpRequest), - WebSocketOpen { - url: String, - headers: HashMap, - channel_id: u32, - }, - WebSocketPush { - channel_id: u32, - message_type: WsMessageType, - }, - WebSocketClose { - channel_id: u32, - }, -} - -/// HTTP Request type that can be shared over Wasm boundary to apps. -/// This is the one you send to the `http_client:distro:sys` service. -#[derive(Debug, Serialize, Deserialize)] -pub struct OutgoingHttpRequest { - pub method: String, // must parse to http::Method - pub version: Option, // must parse to http::Version - pub url: String, // must parse to url::Url - pub headers: HashMap, - // BODY is stored in the lazy_load_blob, as bytes - // TIMEOUT is stored in the message expect_response -} - -/// WebSocket Client Request type that can be shared over Wasm boundary to apps. -/// This comes from an open websocket client connection in the `http_client:distro:sys` service. -#[derive(Debug, Serialize, Deserialize)] -pub enum HttpClientRequest { - WebSocketPush { - channel_id: u32, - message_type: WsMessageType, - }, - WebSocketClose { - channel_id: u32, - }, -} - -/// HTTP Client Response type that can be shared over Wasm boundary to apps. -/// This is the one you receive from the `http_client:distro:sys` service. -#[derive(Debug, Serialize, Deserialize)] -pub enum HttpClientResponse { - Http(HttpResponse), - WebSocketAck, -} - -#[derive(Error, Debug, Serialize, Deserialize)] -pub enum HttpClientError { - // HTTP errors, may also be applicable to OutgoingWebSocketClientRequest::Open - #[error("http_client: request is not valid HttpClientRequest: {}.", req)] - BadRequest { req: String }, - #[error("http_client: http method not supported: {}", method)] - BadMethod { method: String }, - #[error("http_client: url could not be parsed: {}", url)] - BadUrl { url: String }, - #[error("http_client: http version not supported: {}", version)] - BadVersion { version: String }, - #[error("http_client: failed to execute request {}", error)] - RequestFailed { error: String }, - - // WebSocket errors - #[error("websocket_client: failed to open connection {}", url)] - WsOpenFailed { url: String }, - #[error("websocket_client: failed to send message {}", req)] - WsPushFailed { req: String }, - #[error("websocket_client: failed to close connection {}", channel_id)] - WsCloseFailed { channel_id: u32 }, -} - -/// Register a new path with the HTTP server. This will cause the HTTP server to -/// forward any requests on this path to the calling process. -pub fn bind_http_path( - path: T, - authenticated: bool, - local_only: bool, -) -> std::result::Result<(), HttpServerError> -where - T: Into, -{ - let res = KiRequest::to(("our", "http_server", "distro", "sys")) - .body( - serde_json::to_vec(&HttpServerAction::Bind { - path: path.into(), - authenticated, - local_only, - cache: false, - }) - .unwrap(), - ) - .send_and_await_response(5) - .unwrap(); - let Ok(Message::Response { body, .. }) = res else { - return Err(HttpServerError::PathBindError { - error: "http_server timed out".to_string(), - }); - }; - let Ok(resp) = serde_json::from_slice::>(&body) else { - return Err(HttpServerError::PathBindError { - error: "http_server gave unexpected response".to_string(), - }); - }; - resp -} - -/// Register a new path with the HTTP server, and serve a static file from it. -/// The server will respond to GET requests on this path with the given file. -pub fn bind_http_static_path( - path: T, - authenticated: bool, - local_only: bool, - content_type: Option, - content: Vec, -) -> std::result::Result<(), HttpServerError> -where - T: Into, -{ - let res = KiRequest::to(("our", "http_server", "distro", "sys")) - .body( - serde_json::to_vec(&HttpServerAction::Bind { - path: path.into(), - authenticated, - local_only, - cache: true, - }) - .unwrap(), - ) - .blob(crate::kinode::process::standard::LazyLoadBlob { - mime: content_type, - bytes: content, - }) - .send_and_await_response(5) - .unwrap(); - let Ok(Message::Response { body, .. }) = res else { - return Err(HttpServerError::PathBindError { - error: "http_server timed out".to_string(), - }); - }; - let Ok(resp) = serde_json::from_slice::>(&body) else { - return Err(HttpServerError::PathBindError { - error: "http_server gave unexpected response".to_string(), - }); - }; - resp -} - -pub fn unbind_http_path(path: T) -> std::result::Result<(), HttpServerError> -where - T: Into, -{ - let res = KiRequest::to(("our", "http_server", "distro", "sys")) - .body(serde_json::to_vec(&HttpServerAction::Unbind { path: path.into() }).unwrap()) - .send_and_await_response(5) - .unwrap(); - let Ok(Message::Response { body, .. }) = res else { - return Err(HttpServerError::PathBindError { - error: "http_server timed out".to_string(), - }); - }; - let Ok(resp) = serde_json::from_slice::>(&body) else { - return Err(HttpServerError::PathBindError { - error: "http_server gave unexpected response".to_string(), - }); - }; - resp -} - -/// Register a WebSockets path with the HTTP server. Your app must do this -/// in order to receive incoming WebSocket connections. -pub fn bind_ws_path( - path: T, - authenticated: bool, - encrypted: bool, -) -> std::result::Result<(), HttpServerError> -where - T: Into, -{ - let res = KiRequest::to(("our", "http_server", "distro", "sys")) - .body( - serde_json::to_vec(&HttpServerAction::WebSocketBind { - path: path.into(), - authenticated, - encrypted, - extension: false, - }) - .unwrap(), - ) - .send_and_await_response(5) - .unwrap(); - let Ok(Message::Response { body, .. }) = res else { - return Err(HttpServerError::PathBindError { - error: "http_server timed out".to_string(), - }); - }; - let Ok(resp) = serde_json::from_slice::>(&body) else { - return Err(HttpServerError::PathBindError { - error: "http_server gave unexpected response".to_string(), - }); - }; - resp -} - -/// Register a new path with the HTTP server. This will cause the HTTP server to -/// forward any requests on this path to the calling process. -/// -/// Instead of binding at just a path, this function tells the HTTP server to -/// generate a *subdomain* with our package ID (with non-ascii-alphanumeric -/// characters converted to `-`) and bind at that subdomain. -pub fn secure_bind_http_path(path: T) -> std::result::Result<(), HttpServerError> -where - T: Into, -{ - let res = KiRequest::to(("our", "http_server", "distro", "sys")) - .body( - serde_json::to_vec(&HttpServerAction::SecureBind { - path: path.into(), - cache: false, - }) - .unwrap(), - ) - .send_and_await_response(5) - .unwrap(); - let Ok(Message::Response { body, .. }) = res else { - return Err(HttpServerError::PathBindError { - error: "http_server timed out".to_string(), - }); - }; - let Ok(resp) = serde_json::from_slice::>(&body) else { - return Err(HttpServerError::PathBindError { - error: "http_server gave unexpected response".to_string(), - }); - }; - resp -} - -/// Register a new path with the HTTP server, and serve a static file from it. -/// The server will respond to GET requests on this path with the given file. -/// -/// Instead of binding at just a path, this function tells the HTTP server to -/// generate a *subdomain* with our package ID (with non-ascii-alphanumeric -/// characters converted to `-`) and bind at that subdomain. -pub fn secure_bind_http_static_path( - path: T, - content_type: Option, - content: Vec, -) -> std::result::Result<(), HttpServerError> -where - T: Into, -{ - let res = KiRequest::to(("our", "http_server", "distro", "sys")) - .body( - serde_json::to_vec(&HttpServerAction::SecureBind { - path: path.into(), - cache: true, - }) - .unwrap(), - ) - .blob(KiBlob { - mime: content_type, - bytes: content, - }) - .send_and_await_response(5) - .unwrap(); - let Ok(Message::Response { body, .. }) = res else { - return Err(HttpServerError::PathBindError { - error: "http_server timed out".to_string(), - }); - }; - let Ok(resp) = serde_json::from_slice::>(&body) else { - return Err(HttpServerError::PathBindError { - error: "http_server gave unexpected response".to_string(), - }); - }; - resp -} - -/// Register a WebSockets path with the HTTP server. Your app must do this -/// in order to receive incoming WebSocket connections. -/// -/// Instead of binding at just a path, this function tells the HTTP server to -/// generate a *subdomain* with our package ID (with non-ascii-alphanumeric -/// characters converted to `-`) and bind at that subdomain. -pub fn secure_bind_ws_path(path: T, encrypted: bool) -> std::result::Result<(), HttpServerError> -where - T: Into, -{ - let res = KiRequest::to(("our", "http_server", "distro", "sys")) - .body( - serde_json::to_vec(&HttpServerAction::WebSocketSecureBind { - path: path.into(), - encrypted, - extension: false, - }) - .unwrap(), - ) - .send_and_await_response(5) - .unwrap(); - let Ok(Message::Response { body, .. }) = res else { - return Err(HttpServerError::PathBindError { - error: "http_server timed out".to_string(), - }); - }; - let Ok(resp) = serde_json::from_slice::>(&body) else { - return Err(HttpServerError::PathBindError { - error: "http_server gave unexpected response".to_string(), - }); - }; - resp -} - -/// Serve index.html -/// -/// Instead of binding at just a path, this function tells the HTTP server to -/// generate a *subdomain* with our package ID (with non-ascii-alphanumeric -/// characters converted to `-`) and bind at that subdomain. -pub fn secure_serve_index_html( - our: &Address, - directory: &str, - paths: Vec<&str>, -) -> anyhow::Result<()> { - KiRequest::to(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&VfsRequest { - path: format!("/{}/pkg/{}/index.html", our.package_id(), directory), - action: VfsAction::Read, - })?) - .send_and_await_response(5)??; - - let Some(blob) = get_blob() else { - return Err(anyhow::anyhow!("serve_index_html: no index.html blob")); - }; - - let index = String::from_utf8(blob.bytes)?; - - for path in paths { - secure_bind_http_static_path( - path, - Some("text/html".to_string()), - index.to_string().as_bytes().to_vec(), - )?; - } - - Ok(()) -} - -/// Serve static files from a given directory by binding all of them -/// in http_server to their filesystem path. -/// -/// Instead of binding at just a path, this function tells the HTTP server to -/// generate a *subdomain* with our package ID (with non-ascii-alphanumeric -/// characters converted to `-`) and bind at that subdomain. -pub fn secure_serve_ui(our: &Address, directory: &str, paths: Vec<&str>) -> anyhow::Result<()> { - secure_serve_index_html(our, directory, paths)?; - - let initial_path = format!("{}/pkg/{}", our.package_id(), directory); - - let mut queue = VecDeque::new(); - queue.push_back(initial_path.clone()); - - while let Some(path) = queue.pop_front() { - let Ok(directory_response) = KiRequest::to(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&VfsRequest { - path, - action: VfsAction::ReadDir, - })?) - .send_and_await_response(5)? - else { - return Err(anyhow::anyhow!("serve_ui: no response for path")); - }; - - let directory_body = serde_json::from_slice::(directory_response.body())?; - - // Determine if it's a file or a directory and handle appropriately - match directory_body { - VfsResponse::ReadDir(directory_info) => { - for entry in directory_info { - match entry.file_type { - // If it's a file, serve it statically - FileType::File => { - KiRequest::to(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&VfsRequest { - path: entry.path.clone(), - action: VfsAction::Read, - })?) - .send_and_await_response(5)??; - - let Some(blob) = get_blob() else { - return Err(anyhow::anyhow!( - "serve_ui: no blob for {}", - entry.path - )); - }; - - let content_type = get_mime_type(&entry.path); - - secure_bind_http_static_path( - entry.path.replace(&initial_path, ""), - Some(content_type), - blob.bytes, - )?; - } - FileType::Directory => { - // Push the directory onto the queue - queue.push_back(entry.path); - } - _ => {} - } - } - } - _ => { - return Err(anyhow::anyhow!( - "serve_ui: unexpected response for path: {:?}", - directory_body - )) - } - }; - } - - Ok(()) -} - -/// Register a WebSockets path with the HTTP server to send and -/// receive system messages from a runtime extension. Only use -/// this if you are writing a runtime extension. -pub fn bind_ext_path(path: T) -> std::result::Result<(), HttpServerError> -where - T: Into, -{ - let res = KiRequest::to(("our", "http_server", "distro", "sys")) - .body( - serde_json::to_vec(&HttpServerAction::WebSocketBind { - path: path.into(), - authenticated: false, - encrypted: false, - extension: true, - }) - .unwrap(), - ) - .send_and_await_response(5) - .unwrap(); - let Ok(Message::Response { body, .. }) = res else { - return Err(HttpServerError::PathBindError { - error: "http_server timed out".to_string(), - }); - }; - let Ok(resp) = serde_json::from_slice::>(&body) else { - return Err(HttpServerError::PathBindError { - error: "http_server gave unexpected response".to_string(), - }); - }; - resp -} - -pub fn unbind_ws_path(path: T) -> std::result::Result<(), HttpServerError> -where - T: Into, -{ - let res = KiRequest::to(("our", "http_server", "distro", "sys")) - .body(serde_json::to_vec(&HttpServerAction::WebSocketUnbind { path: path.into() }).unwrap()) - .send_and_await_response(5) - .unwrap(); - let Ok(Message::Response { body, .. }) = res else { - return Err(HttpServerError::PathBindError { - error: "http_server timed out".to_string(), - }); - }; - let Ok(resp) = serde_json::from_slice::>(&body) else { - return Err(HttpServerError::PathBindError { - error: "http_server gave unexpected response".to_string(), - }); - }; - resp -} - -/// Send an HTTP response to the incoming HTTP request. -pub fn send_response(status: StatusCode, headers: Option>, body: Vec) { - KiResponse::new() - .body( - serde_json::to_vec(&HttpResponse { - status: status.as_u16(), - headers: headers.unwrap_or_default(), - }) - .unwrap(), - ) - .blob_bytes(body) - .send() - .unwrap() -} - -/// Fire off an HTTP request. If a timeout is given, the response will -/// come in the main event loop, otherwise none will be given. -/// -/// Note that the response type is [`type@HttpClientResponse`], which, if -/// it originated from this request, will be of the variant [`type@HttpClientResponse::Http`]. -/// It will need to be parsed and the body of the response will be stored in the LazyLoadBlob. -pub fn send_request( - method: Method, - url: url::Url, - headers: Option>, - timeout: Option, - body: Vec, -) { - let req = KiRequest::to(("our", "http_client", "distro", "sys")) - .body( - serde_json::to_vec(&HttpClientAction::Http(OutgoingHttpRequest { - method: method.to_string(), - version: None, - url: url.to_string(), - headers: headers.unwrap_or_default(), - })) - .unwrap(), - ) - .blob_bytes(body); - if let Some(timeout) = timeout { - req.expects_response(timeout).send().unwrap() - } else { - req.send().unwrap() - } -} - -/// Make an HTTP request using http_client and await its response. -/// -/// Returns [`Response`] from the `http` crate if successful, with the body type as bytes. -pub fn send_request_await_response( - method: Method, - url: url::Url, - headers: Option>, - timeout: u64, - body: Vec, -) -> std::result::Result>, HttpClientError> { - let res = KiRequest::to(("our", "http_client", "distro", "sys")) - .body( - serde_json::to_vec(&HttpClientAction::Http(OutgoingHttpRequest { - method: method.to_string(), - version: None, - url: url.to_string(), - headers: headers.unwrap_or_default(), - })) - .map_err(|e| HttpClientError::BadRequest { - req: format!("{e:?}"), - })?, - ) - .blob_bytes(body) - .send_and_await_response(timeout) - .unwrap(); - let Ok(Message::Response { body, .. }) = res else { - return Err(HttpClientError::RequestFailed { - error: "http_client timed out".to_string(), - }); - }; - let resp = match serde_json::from_slice::< - std::result::Result, - >(&body) - { - Ok(Ok(HttpClientResponse::Http(resp))) => resp, - Ok(Ok(HttpClientResponse::WebSocketAck)) => { - return Err(HttpClientError::RequestFailed { - error: "http_client gave unexpected response".to_string(), - }) - } - Ok(Err(e)) => return Err(e), - Err(e) => { - return Err(HttpClientError::RequestFailed { - error: format!("http_client gave invalid response: {e:?}"), - }) - } - }; - let mut http_response = http::Response::builder() - .status(http::StatusCode::from_u16(resp.status).unwrap_or_default()); - let headers = http_response.headers_mut().unwrap(); - for (key, value) in &resp.headers { - let Ok(key) = http::header::HeaderName::from_str(key) else { - return Err(HttpClientError::RequestFailed { - error: format!("http_client gave invalid header key: {key}"), - }); - }; - let Ok(value) = http::header::HeaderValue::from_str(value) else { - return Err(HttpClientError::RequestFailed { - error: format!("http_client gave invalid header value: {value}"), - }); - }; - headers.insert(key, value); - } - Ok(http_response - .body(get_blob().unwrap_or_default().bytes) - .unwrap()) -} - -pub fn get_mime_type(filename: &str) -> String { - let file_path = Path::new(filename); - - let extension = file_path - .extension() - .and_then(|ext| ext.to_str()) - .unwrap_or("octet-stream"); - - mime_guess::from_ext(extension) - .first_or_octet_stream() - .to_string() -} - -/// Serve index.html -pub fn serve_index_html( - our: &Address, - directory: &str, - authenticated: bool, - local_only: bool, - paths: Vec<&str>, -) -> anyhow::Result<()> { - KiRequest::to(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&VfsRequest { - path: format!("/{}/pkg/{}/index.html", our.package_id(), directory), - action: VfsAction::Read, - })?) - .send_and_await_response(5)??; - - let Some(blob) = get_blob() else { - return Err(anyhow::anyhow!("serve_index_html: no index.html blob")); - }; - - let index = String::from_utf8(blob.bytes)?; - - for path in paths { - bind_http_static_path( - path, - authenticated, - local_only, - Some("text/html".to_string()), - index.to_string().as_bytes().to_vec(), - )?; - } - - Ok(()) -} - -/// Serve static files from a given directory by binding all of them -/// in http_server to their filesystem path. -pub fn serve_ui( - our: &Address, - directory: &str, - authenticated: bool, - local_only: bool, - paths: Vec<&str>, -) -> anyhow::Result<()> { - serve_index_html(our, directory, authenticated, local_only, paths)?; - - let initial_path = format!("{}/pkg/{}", our.package_id(), directory); - - let mut queue = VecDeque::new(); - queue.push_back(initial_path.clone()); - - while let Some(path) = queue.pop_front() { - let Ok(directory_response) = KiRequest::to(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&VfsRequest { - path, - action: VfsAction::ReadDir, - })?) - .send_and_await_response(5)? - else { - return Err(anyhow::anyhow!("serve_ui: no response for path")); - }; - - let directory_body = serde_json::from_slice::(directory_response.body())?; - - // Determine if it's a file or a directory and handle appropriately - match directory_body { - VfsResponse::ReadDir(directory_info) => { - for entry in directory_info { - match entry.file_type { - // If it's a file, serve it statically - FileType::File => { - KiRequest::to(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&VfsRequest { - path: entry.path.clone(), - action: VfsAction::Read, - })?) - .send_and_await_response(5)??; - - let Some(blob) = get_blob() else { - return Err(anyhow::anyhow!( - "serve_ui: no blob for {}", - entry.path - )); - }; - - let content_type = get_mime_type(&entry.path); - - bind_http_static_path( - entry.path.replace(&initial_path, ""), - authenticated, // Must be authenticated - local_only, // Is not local-only - Some(content_type), - blob.bytes, - )?; - } - FileType::Directory => { - // Push the directory onto the queue - queue.push_back(entry.path); - } - _ => {} - } - } - } - _ => { - return Err(anyhow::anyhow!( - "serve_ui: unexpected response for path: {:?}", - directory_body - )) - } - }; - } - - Ok(()) -} - -pub fn handle_ui_asset_request(our: &Address, directory: &str, path: &str) -> anyhow::Result<()> { - let parts: Vec<&str> = path.split(&our.process.to_string()).collect(); - let after_process = parts.get(1).unwrap_or(&""); - - let target_path = format!("{}/{}", directory, after_process.trim_start_matches('/')); - - KiRequest::to(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&VfsRequest { - path: format!("{}/pkg/{}", our.package_id(), target_path), - action: VfsAction::Read, - })?) - .send_and_await_response(5)??; - - let mut headers = HashMap::new(); - let content_type = get_mime_type(path); - headers.insert("Content-Type".to_string(), content_type); - - KiResponse::new() - .body( - serde_json::json!(HttpResponse { - status: 200, - headers, - }) - .to_string() - .as_bytes() - .to_vec(), - ) - .inherit(true) - .send()?; - - Ok(()) -} - -pub fn send_ws_push(channel_id: u32, message_type: WsMessageType, blob: KiBlob) { - KiRequest::to(("our", "http_server", "distro", "sys")) - .body( - serde_json::to_vec(&HttpServerRequest::WebSocketPush { - channel_id, - message_type, - }) - .unwrap(), - ) - .blob(blob) - .send() - .unwrap() -} - -pub fn open_ws_connection( - url: String, - headers: Option>, - channel_id: u32, -) -> std::result::Result<(), HttpClientError> { - let Ok(Ok(Message::Response { body, .. })) = - KiRequest::to(("our", "http_client", "distro", "sys")) - .body( - serde_json::to_vec(&HttpClientAction::WebSocketOpen { - url: url.clone(), - headers: headers.unwrap_or(HashMap::new()), - channel_id, - }) - .unwrap(), - ) - .send_and_await_response(5) - else { - return Err(HttpClientError::WsOpenFailed { url }); - }; - match serde_json::from_slice(&body) { - Ok(Ok(HttpClientResponse::WebSocketAck)) => Ok(()), - Ok(Err(e)) => Err(e), - _ => Err(HttpClientError::WsOpenFailed { url }), - } -} - -pub fn send_ws_client_push(channel_id: u32, message_type: WsMessageType, blob: KiBlob) { - KiRequest::to(("our", "http_client", "distro", "sys")) - .body( - serde_json::to_vec(&HttpClientAction::WebSocketPush { - channel_id, - message_type, - }) - .unwrap(), - ) - .blob(blob) - .send() - .unwrap() -} - -pub fn close_ws_connection(channel_id: u32) -> std::result::Result<(), HttpClientError> { - let Ok(Ok(Message::Response { body, .. })) = - KiRequest::to(("our", "http_client", "distro", "sys")) - .body( - serde_json::json!(HttpClientAction::WebSocketClose { channel_id }) - .to_string() - .as_bytes() - .to_vec(), - ) - .send_and_await_response(5) - else { - return Err(HttpClientError::WsCloseFailed { channel_id }); - }; - match serde_json::from_slice(&body) { - Ok(Ok(HttpClientResponse::WebSocketAck)) => Ok(()), - Ok(Err(e)) => Err(e), - _ => Err(HttpClientError::WsCloseFailed { channel_id }), - } -} diff --git a/src/http/client.rs b/src/http/client.rs new file mode 100644 index 0000000..3fc00a4 --- /dev/null +++ b/src/http/client.rs @@ -0,0 +1,254 @@ +pub use super::server::{HttpResponse, WsMessageType}; +use crate::{get_blob, LazyLoadBlob as KiBlob, Message, Request as KiRequest}; +use http::Method; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::str::FromStr; +use thiserror::Error; + +/// Request type sent to the `http_client:distro:sys` service in order to open a +/// WebSocket connection, send a WebSocket message on an existing connection, or +/// send an HTTP request. +/// +/// You will receive a Response with the format `Result`. +/// +/// Always serialized/deserialized as JSON. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum HttpClientAction { + Http(OutgoingHttpRequest), + WebSocketOpen { + url: String, + headers: HashMap, + channel_id: u32, + }, + WebSocketPush { + channel_id: u32, + message_type: WsMessageType, + }, + WebSocketClose { + channel_id: u32, + }, +} + +/// HTTP Request type that can be shared over Wasm boundary to apps. +/// This is the one you send to the `http_client:distro:sys` service. +/// +/// BODY is stored in the lazy_load_blob, as bytes +/// +/// TIMEOUT is stored in the message expect_response value +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct OutgoingHttpRequest { + /// must parse to [`http::Method`] + pub method: String, + /// must parse to [`http::Version`] + pub version: Option, + /// must parse to [`url::Url`] + pub url: String, + pub headers: HashMap, +} + +/// Request that comes from an open WebSocket client connection in the +/// `http_client:distro:sys` service. Be prepared to receive these after +/// using a [`HttpClientAction::WebSocketOpen`] to open a connection. +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +pub enum HttpClientRequest { + WebSocketPush { + channel_id: u32, + message_type: WsMessageType, + }, + WebSocketClose { + channel_id: u32, + }, +} + +/// Response type received from the `http_client:distro:sys` service after +/// sending a successful [`HttpClientAction`] to it. +#[derive(Debug, Serialize, Deserialize)] +pub enum HttpClientResponse { + Http(HttpResponse), + WebSocketAck, +} + +#[derive(Error, Debug, Serialize, Deserialize)] +pub enum HttpClientError { + // HTTP errors + #[error("http_client: request is not valid HttpClientRequest: {req}.")] + BadRequest { req: String }, + #[error("http_client: http method not supported: {method}.")] + BadMethod { method: String }, + #[error("http_client: url could not be parsed: {url}.")] + BadUrl { url: String }, + #[error("http_client: http version not supported: {version}.")] + BadVersion { version: String }, + #[error("http_client: failed to execute request {error}.")] + RequestFailed { error: String }, + + // WebSocket errors + #[error("websocket_client: failed to open connection {url}.")] + WsOpenFailed { url: String }, + #[error("websocket_client: failed to send message {req}.")] + WsPushFailed { req: String }, + #[error("websocket_client: failed to close connection {channel_id}.")] + WsCloseFailed { channel_id: u32 }, +} + +/// Fire off an HTTP request. If a timeout is given, the response will +/// come in the main event loop, otherwise none will be given. +/// +/// Note that the response type is [`type@HttpClientResponse`], which, if +/// it originated from this request, will be of the variant [`type@HttpClientResponse::Http`]. +/// It will need to be parsed and the body of the response will be stored in the LazyLoadBlob. +pub fn send_request( + method: Method, + url: url::Url, + headers: Option>, + timeout: Option, + body: Vec, +) { + let req = KiRequest::to(("our", "http_client", "distro", "sys")) + .body( + serde_json::to_vec(&HttpClientAction::Http(OutgoingHttpRequest { + method: method.to_string(), + version: None, + url: url.to_string(), + headers: headers.unwrap_or_default(), + })) + .unwrap(), + ) + .blob_bytes(body); + if let Some(timeout) = timeout { + req.expects_response(timeout).send().unwrap() + } else { + req.send().unwrap() + } +} + +/// Make an HTTP request using http_client and await its response. +/// +/// Returns [`Response`] from the `http` crate if successful, with the body type as bytes. +pub fn send_request_await_response( + method: Method, + url: url::Url, + headers: Option>, + timeout: u64, + body: Vec, +) -> std::result::Result>, HttpClientError> { + let res = KiRequest::to(("our", "http_client", "distro", "sys")) + .body( + serde_json::to_vec(&HttpClientAction::Http(OutgoingHttpRequest { + method: method.to_string(), + version: None, + url: url.to_string(), + headers: headers.unwrap_or_default(), + })) + .map_err(|e| HttpClientError::BadRequest { + req: format!("{e:?}"), + })?, + ) + .blob_bytes(body) + .send_and_await_response(timeout) + .unwrap(); + let Ok(Message::Response { body, .. }) = res else { + return Err(HttpClientError::RequestFailed { + error: "http_client timed out".to_string(), + }); + }; + let resp = match serde_json::from_slice::< + std::result::Result, + >(&body) + { + Ok(Ok(HttpClientResponse::Http(resp))) => resp, + Ok(Ok(HttpClientResponse::WebSocketAck)) => { + return Err(HttpClientError::RequestFailed { + error: "http_client gave unexpected response".to_string(), + }) + } + Ok(Err(e)) => return Err(e), + Err(e) => { + return Err(HttpClientError::RequestFailed { + error: format!("http_client gave invalid response: {e:?}"), + }) + } + }; + let mut http_response = http::Response::builder() + .status(http::StatusCode::from_u16(resp.status).unwrap_or_default()); + let headers = http_response.headers_mut().unwrap(); + for (key, value) in &resp.headers { + let Ok(key) = http::header::HeaderName::from_str(key) else { + return Err(HttpClientError::RequestFailed { + error: format!("http_client gave invalid header key: {key}"), + }); + }; + let Ok(value) = http::header::HeaderValue::from_str(value) else { + return Err(HttpClientError::RequestFailed { + error: format!("http_client gave invalid header value: {value}"), + }); + }; + headers.insert(key, value); + } + Ok(http_response + .body(get_blob().unwrap_or_default().bytes) + .unwrap()) +} + +pub fn open_ws_connection( + url: String, + headers: Option>, + channel_id: u32, +) -> std::result::Result<(), HttpClientError> { + let Ok(Ok(Message::Response { body, .. })) = + KiRequest::to(("our", "http_client", "distro", "sys")) + .body( + serde_json::to_vec(&HttpClientAction::WebSocketOpen { + url: url.clone(), + headers: headers.unwrap_or(HashMap::new()), + channel_id, + }) + .unwrap(), + ) + .send_and_await_response(5) + else { + return Err(HttpClientError::WsOpenFailed { url }); + }; + match serde_json::from_slice(&body) { + Ok(Ok(HttpClientResponse::WebSocketAck)) => Ok(()), + Ok(Err(e)) => Err(e), + _ => Err(HttpClientError::WsOpenFailed { url }), + } +} + +/// Send a WebSocket push message on an open WebSocket channel. +pub fn send_ws_client_push(channel_id: u32, message_type: WsMessageType, blob: KiBlob) { + KiRequest::to(("our", "http_client", "distro", "sys")) + .body( + serde_json::to_vec(&HttpClientAction::WebSocketPush { + channel_id, + message_type, + }) + .unwrap(), + ) + .blob(blob) + .send() + .unwrap() +} + +/// Close a WebSocket connection. +pub fn close_ws_connection(channel_id: u32) -> std::result::Result<(), HttpClientError> { + let Ok(Ok(Message::Response { body, .. })) = + KiRequest::to(("our", "http_client", "distro", "sys")) + .body( + serde_json::json!(HttpClientAction::WebSocketClose { channel_id }) + .to_string() + .as_bytes() + .to_vec(), + ) + .send_and_await_response(5) + else { + return Err(HttpClientError::WsCloseFailed { channel_id }); + }; + match serde_json::from_slice(&body) { + Ok(Ok(HttpClientResponse::WebSocketAck)) => Ok(()), + Ok(Err(e)) => Err(e), + _ => Err(HttpClientError::WsCloseFailed { channel_id }), + } +} diff --git a/src/http/mod.rs b/src/http/mod.rs new file mode 100644 index 0000000..2c91221 --- /dev/null +++ b/src/http/mod.rs @@ -0,0 +1,3 @@ +pub mod client; +pub mod server; +pub use http::{HeaderMap, HeaderName, HeaderValue, Method, Response, StatusCode}; diff --git a/src/http/server.rs b/src/http/server.rs new file mode 100644 index 0000000..4489086 --- /dev/null +++ b/src/http/server.rs @@ -0,0 +1,1061 @@ +use crate::vfs::{FileType, VfsAction, VfsRequest, VfsResponse}; +use crate::{ + get_blob, Address, LazyLoadBlob as KiBlob, Message, Request as KiRequest, + Response as KiResponse, +}; +use http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use thiserror::Error; + +/// HTTP Request received from the `http_server:distro:sys` service as a +/// result of either an HTTP or WebSocket binding, created via [`HttpServerAction`]. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum HttpServerRequest { + Http(IncomingHttpRequest), + /// Processes will receive this kind of request when a client connects to them. + /// If a process does not want this websocket open, they should issue a *request* + /// containing a [`HttpServerAction::WebSocketClose`] message and this channel ID. + WebSocketOpen { + path: String, + channel_id: u32, + }, + /// Processes can both SEND and RECEIVE this kind of request + /// (send as [`HttpServerAction::WebSocketPush`]). + /// When received, will contain the message bytes as lazy_load_blob. + WebSocketPush { + channel_id: u32, + message_type: WsMessageType, + }, + /// Receiving will indicate that the client closed the socket. Can be sent to close + /// from the server-side, as [`type@HttpServerAction::WebSocketClose`]. + WebSocketClose(u32), +} + +impl HttpServerRequest { + /// Parse a byte slice into an HttpServerRequest. + pub fn from_bytes(bytes: &[u8]) -> serde_json::Result { + serde_json::from_slice(bytes) + } + + /// Filter the general-purpose [`HttpServerRequest`], which contains HTTP requests + /// and WebSocket messages, into just the HTTP request. Consumes the original request + /// and returns `None` if the request was WebSocket-related. + pub fn request(self) -> Option { + match self { + HttpServerRequest::Http(req) => Some(req), + _ => None, + } + } +} + +/// An HTTP request routed to a process as a result of a binding. +/// +/// BODY is stored in the lazy_load_blob, as bytes. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct IncomingHttpRequest { + /// will parse to [`std::net::SocketAddr`] + source_socket_addr: Option, + /// will parse to [`http::Method`] + method: String, + /// will parse to [`url::Url`] + url: String, + /// the matching path that was bound + bound_path: String, + /// will parse to [`http::HeaderMap`] + headers: HashMap, + url_params: HashMap, + query_params: HashMap, +} + +impl IncomingHttpRequest { + pub fn url(&self) -> Result { + url::Url::parse(&self.url) + } + + pub fn method(&self) -> Result { + http::Method::from_bytes(self.method.as_bytes()) + } + + pub fn source_socket_addr(&self) -> Result { + match &self.source_socket_addr { + Some(addr) => addr.parse(), + None => "".parse(), + } + } + + /// Returns the path that was originally bound, with an optional prefix stripped. + /// The prefix would normally be the process ID as a &str, but it could be anything. + pub fn bound_path(&self, process_id_to_strip: Option<&str>) -> &str { + match process_id_to_strip { + Some(process_id) => self + .bound_path + .strip_prefix(&format!("/{}", process_id)) + .unwrap_or(&self.bound_path), + None => &self.bound_path, + } + } + + pub fn path(&self) -> Result { + let url = url::Url::parse(&self.url)?; + // skip the first path segment, which is the process ID. + let Some(path) = url.path_segments() else { + return Err(url::ParseError::InvalidDomainCharacter); + }; + let path = path.skip(1).collect::>().join("/"); + Ok(format!("/{}", path)) + } + + pub fn headers(&self) -> HeaderMap { + let mut header_map = HeaderMap::new(); + for (key, value) in self.headers.iter() { + let key_bytes = key.as_bytes(); + let Ok(key_name) = HeaderName::from_bytes(key_bytes) else { + continue; + }; + let Ok(value_header) = HeaderValue::from_str(&value) else { + continue; + }; + header_map.insert(key_name, value_header); + } + header_map + } + + pub fn url_params(&self) -> &HashMap { + &self.url_params + } + + pub fn query_params(&self) -> &HashMap { + &self.query_params + } +} + +/// The possible message types for [`HttpServerRequest::WebSocketPush`]. +/// Ping and Pong are limited to 125 bytes by the WebSockets protocol. +/// Text will be sent as a Text frame, with the lazy_load_blob bytes +/// being the UTF-8 encoding of the string. Binary will be sent as a +/// Binary frame containing the unmodified lazy_load_blob bytes. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub enum WsMessageType { + Text, + Binary, + Ping, + Pong, + Close, +} + +/// Request type sent to `http_server:distro:sys` in order to configure it. +/// +/// If a response is expected, all actions will return a Response +/// with the shape `Result<(), HttpServerActionError>` serialized to JSON. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum HttpServerAction { + /// Bind expects a lazy_load_blob if and only if `cache` is TRUE. The lazy_load_blob should + /// be the static file to serve at this path. + Bind { + path: String, + /// Set whether the HTTP request needs a valid login cookie, AKA, whether + /// the user needs to be logged in to access this path. + authenticated: bool, + /// Set whether requests can be fielded from anywhere, or only the loopback address. + local_only: bool, + /// Set whether to bind the lazy_load_blob statically to this path. That is, take the + /// lazy_load_blob bytes and serve them as the response to any request to this path. + cache: bool, + }, + /// SecureBind expects a lazy_load_blob if and only if `cache` is TRUE. The lazy_load_blob should + /// be the static file to serve at this path. + /// + /// SecureBind is the same as Bind, except that it forces requests to be made from + /// the unique subdomain of the process that bound the path. These requests are + /// *always* authenticated, and *never* local_only. The purpose of SecureBind is to + /// serve elements of an app frontend or API in an exclusive manner, such that other + /// apps installed on this node cannot access them. Since the subdomain is unique, it + /// will require the user to be logged in separately to the general domain authentication. + SecureBind { + path: String, + /// Set whether to bind the lazy_load_blob statically to this path. That is, take the + /// lazy_load_blob bytes and serve them as the response to any request to this path. + cache: bool, + }, + /// Unbind a previously-bound HTTP path + Unbind { path: String }, + /// Bind a path to receive incoming WebSocket connections. + /// Doesn't need a cache since does not serve assets. + WebSocketBind { + path: String, + authenticated: bool, + encrypted: bool, + extension: bool, + }, + /// SecureBind is the same as Bind, except that it forces new connections to be made + /// from the unique subdomain of the process that bound the path. These are *always* + /// authenticated. Since the subdomain is unique, it will require the user to be + /// logged in separately to the general domain authentication. + WebSocketSecureBind { + path: String, + encrypted: bool, + extension: bool, + }, + /// Unbind a previously-bound WebSocket path + WebSocketUnbind { path: String }, + /// When sent, expects a lazy_load_blob containing the WebSocket message bytes to send. + WebSocketPush { + channel_id: u32, + message_type: WsMessageType, + }, + /// When sent, expects a `lazy_load_blob` containing the WebSocket message bytes to send. + /// Modifies the `lazy_load_blob` by placing into `WebSocketExtPushData` with id taken from + /// this `KernelMessage` and `kinode_message_type` set to `desired_reply_type`. + WebSocketExtPushOutgoing { + channel_id: u32, + message_type: WsMessageType, + desired_reply_type: MessageType, + }, + /// For communicating with the ext. + /// Kinode's http_server sends this to the ext after receiving `WebSocketExtPushOutgoing`. + /// Upon receiving reply with this type from ext, http_server parses, setting: + /// * id as given, + /// * message type as given (Request or Response), + /// * body as HttpServerRequest::WebSocketPush, + /// * blob as given. + WebSocketExtPushData { + id: u64, + kinode_message_type: MessageType, + blob: Vec, + }, + /// Sending will close a socket the process controls. + WebSocketClose(u32), +} + +/// HTTP Response type that can be shared over Wasm boundary to apps. +/// Respond to [`IncomingHttpRequest`] with this type. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct HttpResponse { + pub status: u16, + pub headers: HashMap, + // BODY is stored in the lazy_load_blob, as bytes +} + +impl HttpResponse { + pub fn new(status: T) -> Self + where + T: Into, + { + Self { + status: status.into(), + headers: HashMap::new(), + } + } + + pub fn set_status(mut self, status: u16) -> Self { + self.status = status; + self + } + + pub fn header(mut self, key: T, value: U) -> Self + where + T: Into, + U: Into, + { + self.headers.insert(key.into(), value.into()); + self + } + + pub fn set_headers(mut self, headers: HashMap) -> Self { + self.headers = headers; + self + } +} + +/// Part of the Response type issued by http_server +#[derive(Clone, Debug, Error, Serialize, Deserialize)] +pub enum HttpServerError { + #[error("request could not be parsed to HttpServerAction: {req}.")] + BadRequest { req: String }, + #[error("action expected lazy_load_blob")] + NoBlob, + #[error("path binding error: {error}")] + PathBindError { error: String }, + #[error("WebSocket error: {error}")] + WebSocketPushError { error: String }, + /// Not actually issued by `http_server:distro:sys`, just this library + #[error("timeout")] + Timeout, + /// Not actually issued by `http_server:distro:sys`, just this library + #[error("unexpected response from http_server")] + UnexpectedResponse, +} + +/// Whether the WebSocketPush is a request or a response. +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +pub enum MessageType { + Request, + Response, +} + +/// A representation of the HTTP server as configured by your process. +#[derive(Clone, Debug)] +pub struct HttpServer { + http_paths: HashMap, + ws_paths: HashMap, + /// A mapping of WebSocket paths to the channels that are open on them. + ws_channels: HashMap>, + /// The timeout given for `http_server:distro:sys` to respond to a configuration request. + pub timeout: u64, +} + +/// Configuration for a HTTP binding. +/// +/// `authenticated` is set to true by default and means that the HTTP server will +/// require a valid login cookie to access this path. +/// +/// `local_only` is set to false by default and means that the HTTP server will +/// only accept requests from the loopback address. +/// +/// If `static_content` is set, the HTTP server will serve the static content at the +/// given path. Otherwise, the HTTP server will forward requests on this path to the +/// calling process. +/// +/// If `secure_subdomain` is set, the HTTP server will serve requests on this path +/// from the unique subdomain of the process that bound the path. These requests are +/// *always* authenticated, and *never* local_only. The purpose of SecureBind is to +/// serve elements of an app frontend or API in an exclusive manner, such that other +/// apps installed on this node cannot access them. Since the subdomain is unique, it +/// will require the user to be logged in separately to the general domain authentication. +#[derive(Clone, Debug)] +pub struct HttpBindingConfig { + authenticated: bool, + local_only: bool, + secure_subdomain: bool, + static_content: Option, +} + +impl HttpBindingConfig { + /// Create a new HttpBindingConfig with default values. + /// + /// Authenticated, not local only, not a secure subdomain, no static content. + pub fn default() -> Self { + Self { + authenticated: true, + local_only: false, + secure_subdomain: false, + static_content: None, + } + } + + /// Create a new HttpBindingConfig with the given values. + pub fn new( + authenticated: bool, + local_only: bool, + secure_subdomain: bool, + static_content: Option, + ) -> Self { + Self { + authenticated, + local_only, + secure_subdomain, + static_content, + } + } + + /// Set whether the HTTP server will require a valid login cookie to access this path. + pub fn authenticated(mut self, authenticated: bool) -> Self { + self.authenticated = authenticated; + self + } + + /// Set whether the HTTP server will only accept requests from the loopback address. + pub fn local_only(mut self, local_only: bool) -> Self { + self.local_only = local_only; + self + } + + /// Set whether the HTTP server will serve requests on this path from the unique + /// subdomain of the process that bound the path. These requests are *always* + /// authenticated, and *never* local_only. The purpose of SecureBind is to + /// serve elements of an app frontend or API in an exclusive manner, such that other + /// apps installed on this node cannot access them. Since the subdomain is unique, it + /// will require the user to be logged in separately to the general domain authentication. + pub fn secure_subdomain(mut self, secure_subdomain: bool) -> Self { + self.secure_subdomain = secure_subdomain; + self + } + + /// Set the static content to serve at this path. If set, the HTTP server will + /// not forward requests on this path to the process, and will instead serve the + /// static content directly and only in response to GET requests. + pub fn static_content(mut self, static_content: Option) -> Self { + self.static_content = static_content; + self + } +} + +/// Configuration for a WebSocket binding. +/// +/// `authenticated` is set to true by default and means that the WebSocket server will +/// require a valid login cookie to access this path. +/// +/// `encrypted` is set to false by default and means that the WebSocket server will +/// not apply a custom encryption to the WebSocket connection using the login cookie. +/// +/// `extension` is set to false by default and means that the WebSocket will +/// not use the WebSocket extension protocol to connect with a runtime extension. +#[derive(Clone, Copy, Debug)] +pub struct WsBindingConfig { + pub authenticated: bool, + pub encrypted: bool, + pub extension: bool, +} + +impl WsBindingConfig { + /// Create a new WsBindingConfig with default values. + /// + /// Authenticated, not encrypted, not an extension. + pub fn default() -> Self { + Self { + authenticated: true, + encrypted: false, + extension: false, + } + } + + /// Create a new WsBindingConfig with the given values. + pub fn new(authenticated: bool, encrypted: bool, extension: bool) -> Self { + Self { + authenticated, + encrypted, + extension, + } + } + + /// Set whether the WebSocket server will require a valid login cookie to access this path. + pub fn authenticated(mut self, authenticated: bool) -> Self { + self.authenticated = authenticated; + self + } + + /// Set whether the WebSocket server will apply a custom encryption to the WebSocket + /// connection using the login cookie. + pub fn encrypted(mut self, encrypted: bool) -> Self { + self.encrypted = encrypted; + self + } + + /// Set whether the WebSocket server will be used for a runtime extension. + pub fn extension(mut self, extension: bool) -> Self { + self.extension = extension; + self + } +} + +impl HttpServer { + /// Create a new HttpServer with the given timeout. + pub fn new(timeout: u64) -> Self { + Self { + http_paths: HashMap::new(), + ws_paths: HashMap::new(), + ws_channels: HashMap::new(), + timeout, + } + } + + /// Register a new path with the HTTP server configured using [`HttpBindingConfig`]. + pub fn bind_http_path( + &mut self, + path: T, + config: HttpBindingConfig, + ) -> Result<(), HttpServerError> + where + T: Into, + { + let path: String = path.into(); + let cache = config.static_content.is_some(); + let req = KiRequest::to(("our", "http_server", "distro", "sys")).body( + serde_json::to_vec(&if config.secure_subdomain { + HttpServerAction::SecureBind { + path: path.clone(), + cache, + } + } else { + HttpServerAction::Bind { + path: path.clone(), + authenticated: config.authenticated, + local_only: config.local_only, + cache, + } + }) + .unwrap(), + ); + let res = match config.static_content.clone() { + Some(static_content) => req + .blob(static_content) + .send_and_await_response(self.timeout), + None => req.send_and_await_response(self.timeout), + }; + let Ok(Message::Response { body, .. }) = res.unwrap() else { + return Err(HttpServerError::Timeout); + }; + let Ok(resp) = serde_json::from_slice::>(&body) else { + return Err(HttpServerError::UnexpectedResponse); + }; + if resp.is_ok() { + self.http_paths.insert(path, config); + } + resp + } + + /// Register a new path with the HTTP server configured using [`WsBindingConfig`]. + pub fn bind_ws_path( + &mut self, + path: T, + config: WsBindingConfig, + ) -> Result<(), HttpServerError> + where + T: Into, + { + let path: String = path.into(); + let res = KiRequest::to(("our", "http_server", "distro", "sys")) + .body( + serde_json::to_vec(&HttpServerAction::WebSocketBind { + path: path.clone(), + authenticated: config.authenticated, + encrypted: config.encrypted, + extension: config.extension, + }) + .unwrap(), + ) + .send_and_await_response(self.timeout); + let Ok(Message::Response { body, .. }) = res.unwrap() else { + return Err(HttpServerError::Timeout); + }; + let Ok(resp) = serde_json::from_slice::>(&body) else { + return Err(HttpServerError::UnexpectedResponse); + }; + resp + } + + /// Register a new path with the HTTP server, and serve a static file from it. + /// The server will respond to GET requests on this path with the given file. + pub fn bind_http_static_path( + &mut self, + path: T, + authenticated: bool, + local_only: bool, + content_type: Option, + content: Vec, + ) -> Result<(), HttpServerError> + where + T: Into, + { + let path: String = path.into(); + let res = KiRequest::to(("our", "http_server", "distro", "sys")) + .body( + serde_json::to_vec(&HttpServerAction::Bind { + path: path.clone(), + authenticated, + local_only, + cache: true, + }) + .unwrap(), + ) + .blob(crate::kinode::process::standard::LazyLoadBlob { + mime: content_type.clone(), + bytes: content.clone(), + }) + .send_and_await_response(self.timeout) + .unwrap(); + let Ok(Message::Response { body, .. }) = res else { + return Err(HttpServerError::Timeout); + }; + let Ok(resp) = serde_json::from_slice::>(&body) else { + return Err(HttpServerError::UnexpectedResponse); + }; + if resp.is_ok() { + self.http_paths.insert( + path, + HttpBindingConfig { + authenticated, + local_only, + secure_subdomain: false, + static_content: Some(KiBlob { + mime: content_type, + bytes: content, + }), + }, + ); + } + resp + } + + /// Register a new path with the HTTP server. This will cause the HTTP server to + /// forward any requests on this path to the calling process. + /// + /// Instead of binding at just a path, this function tells the HTTP server to + /// generate a *subdomain* with our package ID (with non-ascii-alphanumeric + /// characters converted to `-`, although will not be needed if package ID is + /// a genuine kimap entry) and bind at that subdomain. + pub fn secure_bind_http_path(&mut self, path: T) -> Result<(), HttpServerError> + where + T: Into, + { + let path: String = path.into(); + let res = KiRequest::to(("our", "http_server", "distro", "sys")) + .body( + serde_json::to_vec(&HttpServerAction::SecureBind { + path: path.clone(), + cache: false, + }) + .unwrap(), + ) + .send_and_await_response(self.timeout) + .unwrap(); + let Ok(Message::Response { body, .. }) = res else { + return Err(HttpServerError::Timeout); + }; + let Ok(resp) = serde_json::from_slice::>(&body) else { + return Err(HttpServerError::UnexpectedResponse); + }; + if resp.is_ok() { + self.http_paths.insert( + path, + HttpBindingConfig { + authenticated: true, + local_only: false, + secure_subdomain: true, + static_content: None, + }, + ); + } + resp + } + + /// Register a new WebSocket path with the HTTP server. Any client connections + /// made on this path will be forwarded to this process. + /// + /// Instead of binding at just a path, this function tells the HTTP server to + /// generate a *subdomain* with our package ID (with non-ascii-alphanumeric + /// characters converted to `-`, although will not be needed if package ID is + /// a genuine kimap entry) and bind at that subdomain. + pub fn secure_bind_ws_path(&mut self, path: T) -> Result<(), HttpServerError> + where + T: Into, + { + let path: String = path.into(); + let res = KiRequest::to(("our", "http_server", "distro", "sys")) + .body( + serde_json::to_vec(&HttpServerAction::WebSocketBind { + path: path.clone(), + authenticated: true, + encrypted: false, + extension: false, + }) + .unwrap(), + ) + .send_and_await_response(self.timeout); + let Ok(Message::Response { body, .. }) = res.unwrap() else { + return Err(HttpServerError::Timeout); + }; + let Ok(resp) = serde_json::from_slice::>(&body) else { + return Err(HttpServerError::UnexpectedResponse); + }; + if resp.is_ok() { + self.ws_paths.insert( + path, + WsBindingConfig { + authenticated: true, + encrypted: false, + extension: false, + }, + ); + } + resp + } + + /// Modify a previously-bound HTTP path. + pub fn modify_http_path( + &mut self, + path: &str, + config: HttpBindingConfig, + ) -> Result<(), HttpServerError> + where + T: Into, + { + let entry = self + .http_paths + .get_mut(path) + .ok_or(HttpServerError::PathBindError { + error: "path not found".to_string(), + })?; + let res = KiRequest::to(("our", "http_server", "distro", "sys")) + .body( + serde_json::to_vec(&HttpServerAction::Bind { + path: path.to_string(), + authenticated: config.authenticated, + local_only: config.local_only, + cache: true, + }) + .unwrap(), + ) + .send_and_await_response(self.timeout) + .unwrap(); + let Ok(Message::Response { body, .. }) = res else { + return Err(HttpServerError::Timeout); + }; + let Ok(resp) = serde_json::from_slice::>(&body) else { + return Err(HttpServerError::UnexpectedResponse); + }; + if resp.is_ok() { + entry.authenticated = config.authenticated; + entry.local_only = config.local_only; + entry.secure_subdomain = config.secure_subdomain; + entry.static_content = config.static_content; + } + resp + } + + /// Modify a previously-bound WS path + pub fn modify_ws_path( + &mut self, + path: &str, + config: WsBindingConfig, + ) -> Result<(), HttpServerError> { + let entry = self + .ws_paths + .get_mut(path) + .ok_or(HttpServerError::PathBindError { + error: "path not found".to_string(), + })?; + let res = KiRequest::to(("our", "http_server", "distro", "sys")) + .body( + serde_json::to_vec(&HttpServerAction::WebSocketBind { + path: path.to_string(), + authenticated: config.authenticated, + encrypted: config.encrypted, + extension: config.extension, + }) + .unwrap(), + ) + .send_and_await_response(self.timeout) + .unwrap(); + let Ok(Message::Response { body, .. }) = res else { + return Err(HttpServerError::Timeout); + }; + let Ok(resp) = serde_json::from_slice::>(&body) else { + return Err(HttpServerError::UnexpectedResponse); + }; + if resp.is_ok() { + entry.authenticated = config.authenticated; + entry.encrypted = config.encrypted; + entry.extension = config.extension; + } + resp + } + + /// Unbind a previously-bound HTTP path. + pub fn unbind_http_path(&mut self, path: T) -> Result<(), HttpServerError> + where + T: Into, + { + let path: String = path.into(); + let res = KiRequest::to(("our", "http_server", "distro", "sys")) + .body(serde_json::to_vec(&HttpServerAction::Unbind { path: path.clone() }).unwrap()) + .send_and_await_response(self.timeout) + .unwrap(); + let Ok(Message::Response { body, .. }) = res else { + return Err(HttpServerError::Timeout); + }; + let Ok(resp) = serde_json::from_slice::>(&body) else { + return Err(HttpServerError::UnexpectedResponse); + }; + if resp.is_ok() { + self.http_paths.remove(&path); + } + resp + } + + /// Unbind a previously-bound WebSocket path. + pub fn unbind_ws_path(&mut self, path: T) -> Result<(), HttpServerError> + where + T: Into, + { + let path: String = path.into(); + let res = KiRequest::to(("our", "http_server", "distro", "sys")) + .body( + serde_json::to_vec(&HttpServerAction::WebSocketUnbind { path: path.clone() }) + .unwrap(), + ) + .send_and_await_response(self.timeout) + .unwrap(); + let Ok(Message::Response { body, .. }) = res else { + return Err(HttpServerError::Timeout); + }; + let Ok(resp) = serde_json::from_slice::>(&body) else { + return Err(HttpServerError::UnexpectedResponse); + }; + if resp.is_ok() { + self.ws_paths.remove(&path); + } + resp + } + + /// Serve a file from the given directory within our package drive at the given paths. + /// + /// The directory is relative to the `pkg` folder within this package's drive. + /// + /// The config `static_content` field will be ignored in favor of the file content. + /// An error will be returned if the file does not exist. + pub fn serve_file( + &mut self, + our: &Address, + file_path: &str, + paths: Vec<&str>, + config: HttpBindingConfig, + ) -> Result<(), HttpServerError> { + let _res = KiRequest::to(("our", "vfs", "distro", "sys")) + .body( + serde_json::to_vec(&VfsRequest { + path: format!( + "/{}/pkg/{}", + our.package_id(), + file_path.trim_start_matches('/') + ), + action: VfsAction::Read, + }) + .map_err(|e| HttpServerError::BadRequest { req: e.to_string() })?, + ) + .send_and_await_response(self.timeout) + .unwrap(); + + let Some(mut blob) = get_blob() else { + return Err(HttpServerError::NoBlob); + }; + + let content_type = get_mime_type(&file_path); + blob.mime = Some(content_type); + + for path in paths { + self.bind_http_path(path, config.clone().static_content(Some(blob.clone())))?; + } + + Ok(()) + } + + /// Serve a file from the given absolute directory. + /// + /// The config `static_content` field will be ignored in favor of the file content. + /// An error will be returned if the file does not exist. + pub fn serve_file_raw_path( + &mut self, + file_path: &str, + paths: Vec<&str>, + config: HttpBindingConfig, + ) -> Result<(), HttpServerError> { + let _res = KiRequest::to(("our", "vfs", "distro", "sys")) + .body( + serde_json::to_vec(&VfsRequest { + path: file_path.to_string(), + action: VfsAction::Read, + }) + .map_err(|e| HttpServerError::BadRequest { req: e.to_string() })?, + ) + .send_and_await_response(self.timeout) + .unwrap(); + + let Some(mut blob) = get_blob() else { + return Err(HttpServerError::NoBlob); + }; + + let content_type = get_mime_type(&file_path); + blob.mime = Some(content_type); + + for path in paths { + self.bind_http_path(path, config.clone().static_content(Some(blob.clone())))?; + } + + Ok(()) + } + + /// Serve static files from a given directory by binding all of them + /// in http_server to their filesystem path. + /// + /// The directory is relative to the `pkg` folder within this package's drive. + /// + /// The config `static_content` field will be ignored in favor of the files' contents. + /// An error will be returned if the file does not exist. + pub fn serve_ui( + &mut self, + our: &Address, + directory: &str, + roots: Vec<&str>, + config: HttpBindingConfig, + ) -> Result<(), HttpServerError> { + let initial_path = format!("{}/pkg/{}", our.package_id(), directory); + + let mut queue = std::collections::VecDeque::new(); + queue.push_back(initial_path.clone()); + + while let Some(path) = queue.pop_front() { + let Ok(directory_response) = KiRequest::to(("our", "vfs", "distro", "sys")) + .body( + serde_json::to_vec(&VfsRequest { + path, + action: VfsAction::ReadDir, + }) + .unwrap(), + ) + .send_and_await_response(self.timeout) + .unwrap() + else { + return Err(HttpServerError::PathBindError { + error: format!("no ui directory to serve: {initial_path}"), + }); + }; + + let directory_body = serde_json::from_slice::(directory_response.body()) + .map_err(|_e| HttpServerError::UnexpectedResponse)?; + + // determine if it's a file or a directory and handle appropriately + let VfsResponse::ReadDir(directory_info) = directory_body else { + return Err(HttpServerError::UnexpectedResponse); + }; + + for entry in directory_info { + match entry.file_type { + FileType::Directory => { + // push the directory onto the queue + queue.push_back(entry.path); + } + FileType::File => { + // if it's a file, serve it statically at its path + // if it's `index.html`, serve additionally as the root + if entry.path.ends_with("index.html") { + for root in &roots { + self.serve_file_raw_path( + &entry.path, + vec![root, &entry.path.replace(&initial_path, "")], + config.clone(), + )?; + } + } else { + self.serve_file_raw_path( + &entry.path, + vec![&entry.path.replace(&initial_path, "")], + config.clone(), + )?; + } + } + _ => { + // ignore symlinks and other + } + } + } + } + + Ok(()) + } + + /// Handle a WebSocket open event from the HTTP server. + pub fn handle_websocket_open(&mut self, path: &str, channel_id: u32) { + self.ws_channels + .entry(path.to_string()) + .or_insert(HashSet::new()) + .insert(channel_id); + } + + /// Handle a WebSocket close event from the HTTP server. + pub fn handle_websocket_close(&mut self, channel_id: u32) { + self.ws_channels.iter_mut().for_each(|(_, channels)| { + channels.remove(&channel_id); + }); + } + + pub fn parse_request(&self, body: &[u8]) -> Result { + let request = serde_json::from_slice::(body) + .map_err(|e| HttpServerError::BadRequest { req: e.to_string() })?; + Ok(request) + } + + /// Handle an incoming request from the HTTP server. + pub fn handle_request( + &mut self, + server_request: HttpServerRequest, + mut http_handler: impl FnMut(IncomingHttpRequest) -> (HttpResponse, Option), + mut ws_handler: impl FnMut(u32, WsMessageType, KiBlob), + ) { + match server_request { + HttpServerRequest::Http(http_request) => { + let (response, blob) = http_handler(http_request); + let response = KiResponse::new().body(serde_json::to_vec(&response).unwrap()); + if let Some(blob) = blob { + response.blob(blob).send().unwrap(); + } else { + response.send().unwrap(); + } + } + HttpServerRequest::WebSocketPush { + channel_id, + message_type, + } => ws_handler(channel_id, message_type, get_blob().unwrap_or_default()), + HttpServerRequest::WebSocketOpen { path, channel_id } => { + self.handle_websocket_open(&path, channel_id); + } + HttpServerRequest::WebSocketClose(channel_id) => { + self.handle_websocket_close(channel_id); + } + } + } + + /// Push a WebSocket message to all channels on a given path. + pub fn ws_push_all_channels(&self, path: &str, message_type: WsMessageType, blob: KiBlob) { + if let Some(channels) = self.ws_channels.get(path) { + channels.iter().for_each(|channel_id| { + send_ws_push(*channel_id, message_type, blob.clone()); + }); + } + } +} + +/// Send an HTTP response to an incoming HTTP request ([`HttpServerRequest::Http`]). +pub fn send_response(status: StatusCode, headers: Option>, body: Vec) { + KiResponse::new() + .body( + serde_json::to_vec(&HttpResponse { + status: status.as_u16(), + headers: headers.unwrap_or_default(), + }) + .unwrap(), + ) + .blob_bytes(body) + .send() + .unwrap() +} + +/// Send a WebSocket push message on an open WebSocket channel. +pub fn send_ws_push(channel_id: u32, message_type: WsMessageType, blob: KiBlob) { + KiRequest::to(("our", "http_server", "distro", "sys")) + .body( + serde_json::to_vec(&HttpServerRequest::WebSocketPush { + channel_id, + message_type, + }) + .unwrap(), + ) + .blob(blob) + .send() + .unwrap() +} + +/// Guess the MIME type of a file from its extension. +pub fn get_mime_type(filename: &str) -> String { + let file_path = std::path::Path::new(filename); + + let extension = file_path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or("octet-stream"); + + mime_guess::from_ext(extension) + .first_or_octet_stream() + .to_string() +} diff --git a/src/kernel_types.rs b/src/kernel_types.rs index f2cd487..69636e8 100644 --- a/src/kernel_types.rs +++ b/src/kernel_types.rs @@ -2,6 +2,7 @@ use crate::kinode::process::standard as wit; use crate::{Address, ProcessId}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; +use std::hash::{Hash, Hasher}; // // process-facing kernel types, used for process @@ -41,12 +42,55 @@ pub enum Message { Response((Response, Option)), } -#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct Capability { pub issuer: Address, pub params: String, // JSON-string } +impl Eq for Capability {} + +impl PartialEq for Capability { + fn eq(&self, other: &Self) -> bool { + let self_json_params: serde_json::Value = + serde_json::from_str(&self.params).unwrap_or_default(); + let other_json_params: serde_json::Value = + serde_json::from_str(&other.params).unwrap_or_default(); + self.issuer == other.issuer && self_json_params == other_json_params + } +} + +impl Hash for Capability { + fn hash(&self, state: &mut H) { + self.issuer.hash(state); + let params: serde_json::Value = serde_json::from_str(&self.params).unwrap_or_default(); + params.hash(state); + } +} + +impl Capability { + pub fn new(issuer: T, params: U) -> Self + where + T: Into
, + U: Into, + { + Capability { + issuer: issuer.into(), + params: params.into(), + } + } + + pub fn messaging(issuer: T) -> Self + where + T: Into
, + { + Capability { + issuer: issuer.into(), + params: "\"messaging\"".into(), + } + } +} + impl std::fmt::Display for Capability { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( @@ -453,9 +497,3 @@ pub fn en_wit_send_error_kind(kind: SendErrorKind) -> wit::SendErrorKind { SendErrorKind::Timeout => wit::SendErrorKind::Timeout, } } - -#[derive(Debug, Serialize, Deserialize)] -pub enum MessageType { - Request, - Response, -} diff --git a/src/kimap.rs b/src/kimap.rs new file mode 100644 index 0000000..4a58f21 --- /dev/null +++ b/src/kimap.rs @@ -0,0 +1,569 @@ +use crate::eth::{EthError, Provider}; +use crate::kimap::contract::getCall; +use crate::net; +use alloy::rpc::types::request::{TransactionInput, TransactionRequest}; +use alloy::{hex, primitives::keccak256}; +use alloy_primitives::{Address, Bytes, FixedBytes, B256}; +use alloy_sol_types::{SolCall, SolEvent, SolValue}; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +/// kimap deployment address on optimism +pub const KIMAP_ADDRESS: &'static str = "0xcA92476B2483aBD5D82AEBF0b56701Bb2e9be658"; +/// optimism chain id +pub const KIMAP_CHAIN_ID: u64 = 10; +/// first block of kimap deployment on optimism +pub const KIMAP_FIRST_BLOCK: u64 = 123_908_000; +/// the root hash of kimap, empty bytes32 +pub const KIMAP_ROOT_HASH: &'static str = + "0x0000000000000000000000000000000000000000000000000000000000000000"; + +/// Sol structures for Kimap requests +pub mod contract { + use alloy_sol_macro::sol; + + sol! { + /// Emitted when a new namespace entry is minted. + /// - parenthash: The hash of the parent namespace entry. + /// - childhash: The hash of the minted namespace entry's full path. + /// - labelhash: The hash of only the label (the final entry in the path). + /// - label: The label (the final entry in the path) of the new entry. + event Mint( + bytes32 indexed parenthash, + bytes32 indexed childhash, + bytes indexed labelhash, + bytes label + ); + + /// Emitted when a fact is created on an existing namespace entry. + /// Facts are immutable and may only be written once. A fact label is + /// prepended with an exclamation mark (!) to indicate that it is a fact. + /// - parenthash The hash of the parent namespace entry. + /// - facthash The hash of the newly created fact's full path. + /// - labelhash The hash of only the label (the final entry in the path). + /// - label The label of the fact. + /// - data The data stored at the fact. + event Fact( + bytes32 indexed parenthash, + bytes32 indexed facthash, + bytes indexed labelhash, + bytes label, + bytes data + ); + + /// Emitted when a new note is created on an existing namespace entry. + /// Notes are mutable. A note label is prepended with a tilde (~) to indicate + /// that it is a note. + /// - parenthash: The hash of the parent namespace entry. + /// - notehash: The hash of the newly created note's full path. + /// - labelhash: The hash of only the label (the final entry in the path). + /// - label: The label of the note. + /// - data: The data stored at the note. + event Note( + bytes32 indexed parenthash, + bytes32 indexed notehash, + bytes indexed labelhash, + bytes label, + bytes data + ); + + /// Emitted when a gene is set for an existing namespace entry. + /// A gene is a specific TBA implementation which will be applied to all + /// sub-entries of the namespace entry. + /// - entry: The namespace entry's namehash. + /// - gene: The address of the TBA implementation. + event Gene(bytes32 indexed entry, address indexed gene); + + /// Emitted when the zeroth namespace entry is minted. + /// Occurs exactly once at initialization. + /// - zeroTba: The address of the zeroth TBA + event Zero(address indexed zeroTba); + + /// Emitted when a namespace entry is transferred from one address + /// to another. + /// - from: The address of the sender. + /// - to: The address of the recipient. + /// - id: The namehash of the namespace entry (converted to uint256). + event Transfer( + address indexed from, + address indexed to, + uint256 indexed id + ); + + /// Emitted when a namespace entry is approved for transfer. + /// - owner: The address of the owner. + /// - spender: The address of the spender. + /// - id: The namehash of the namespace entry (converted to uint256). + event Approval( + address indexed owner, + address indexed spender, + uint256 indexed id + ); + + /// Emitted when an operator is approved for all of an owner's + /// namespace entries. + /// - owner: The address of the owner. + /// - operator: The address of the operator. + /// - approved: Whether the operator is approved. + event ApprovalForAll( + address indexed owner, + address indexed operator, + bool approved + ); + + /// Retrieves information about a specific namespace entry. + /// - namehash The namehash of the namespace entry to query. + /// + /// Returns: + /// - tba: The address of the token-bound account associated + /// with the entry. + /// - owner: The address of the entry owner. + /// - data: The note or fact bytes associated with the entry + /// (empty if not a note or fact). + function get( + bytes32 namehash + ) external view returns (address tba, address owner, bytes memory data); + + /// Mints a new namespace entry and creates a token-bound account for + /// it. Must be called by a parent namespace entry token-bound account. + /// - who: The address to own the new namespace entry. + /// - label: The label to mint beneath the calling parent entry. + /// - initialization: Initialization calldata applied to the new + /// minted entry's token-bound account. + /// - erc721Data: ERC-721 data -- passed to comply with + /// `ERC721TokenReceiver.onERC721Received()`. + /// - implementation: The address of the implementation contract for + /// the token-bound account: this will be overriden by the gene if the + /// parent entry has one set. + /// + /// Returns: + /// - tba: The address of the new entry's token-bound account. + function mint( + address who, + bytes calldata label, + bytes calldata initialization, + bytes calldata erc721Data, + address implementation + ) external returns (address tba); + + /// Sets the gene for the calling namespace entry. + /// - _gene: The address of the TBA implementation to set for all + /// children of the calling namespace entry. + function gene(address _gene) external; + + /// Creates a new fact beneath the calling namespace entry. + /// - fact: The fact label to create. Must be prepended with an + /// exclamation mark (!). + /// - data: The data to be stored at the fact. + /// + /// Returns: + /// - facthash: The namehash of the newly created fact. + function fact( + bytes calldata fact, + bytes calldata data + ) external returns (bytes32 facthash); + + /// Creates a new note beneath the calling namespace entry. + /// - note: The note label to create. Must be prepended with a tilde (~). + /// - data: The data to be stored at the note. + /// + /// Returns: + /// - notehash: The namehash of the newly created note. + function note( + bytes calldata note, + bytes calldata data + ) external returns (bytes32 notehash); + + /// Retrieves the token-bound account address of a namespace entry. + /// - entry: The entry namehash (as uint256) for which to get the + /// token-bound account. + /// + /// Returns: + /// - tba: The token-bound account address of the namespace entry. + function tbaOf(uint256 entry) external view returns (address tba); + + function balanceOf(address owner) external view returns (uint256); + + function getApproved(uint256 entry) external view returns (address); + + function isApprovedForAll( + address owner, + address operator + ) external view returns (bool); + + function ownerOf(uint256 entry) external view returns (address); + + function setApprovalForAll(address operator, bool approved) external; + + function approve(address spender, uint256 entry) external; + + function safeTransferFrom(address from, address to, uint256 id) external; + + function safeTransferFrom( + address from, + address to, + uint256 id, + bytes calldata data + ) external; + + function transferFrom(address from, address to, uint256 id) external; + + function supportsInterface(bytes4 interfaceId) external view returns (bool); + + /// Retrieves the address of the ERC-6551 implementation of the + /// zeroth entry. This is set once at initialization. + /// + /// Returns: + /// - implementation: The address of the ERC-6551 implementation. + function get6551Implementation() external view returns (address); + } +} + +/// A mint log from the kimap, converted to a 'resolved' format using +/// namespace data saved in the kns_indexer. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Mint { + pub name: String, + pub parent_path: String, +} + +/// A note log from the kimap, converted to a 'resolved' format using +/// namespace data saved in the kns_indexer +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Note { + pub note: String, + pub parent_path: String, + pub data: Bytes, +} + +/// A fact log from the kimap, converted to a 'resolved' format using +/// namespace data saved in the kns_indexer +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Fact { + pub fact: String, + pub parent_path: String, + pub data: Bytes, +} + +/// Errors that can occur when decoding a log from the kimap using +/// [`decode_mint_log`] or [`decode_note_log`]. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub enum DecodeLogError { + /// The log's topic is not a mint or note event. + UnexpectedTopic(B256), + /// The name is not valid (according to [`valid_name`]). + InvalidName(String), + /// An error occurred while decoding the log. + DecodeError(String), + /// The parent name could not be resolved with `kns_indexer`. + UnresolvedParent(String), +} + +/// Canonical function to determine if a kimap entry is valid. This should +/// be used whenever reading a new kimap entry from a mints query, because +/// while most frontends will enforce these rules, it is possible to post +/// invalid names to the kimap contract. +/// +/// This checks a **single name**, not the full path-name. A full path-name +/// is comprised of valid names separated by `.` +pub fn valid_entry(entry: &str, note: bool, fact: bool) -> bool { + if note && fact { + return false; + } + if note { + valid_note(entry) + } else if fact { + valid_fact(entry) + } else { + valid_name(entry) + } +} + +pub fn valid_name(name: &str) -> bool { + name.is_ascii() + && name.len() >= 1 + && name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + +pub fn valid_note(note: &str) -> bool { + note.is_ascii() + && note.len() >= 2 + && note.chars().next() == Some('~') + && note + .chars() + .skip(1) + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + +pub fn valid_fact(fact: &str) -> bool { + fact.is_ascii() + && fact.len() >= 2 + && fact.chars().next() == Some('!') + && fact + .chars() + .skip(1) + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + +/// Produce a namehash from a kimap name. +pub fn namehash(name: &str) -> String { + let mut node = B256::default(); + + let mut labels: Vec<&str> = name.split('.').collect(); + labels.reverse(); + + for label in labels.iter() { + let l = keccak256(label); + node = keccak256((node, l).abi_encode_packed()); + } + format!("0x{}", hex::encode(node)) +} + +/// Decode a mint log from the kimap into a 'resolved' format. +/// +/// Uses `valid_name` to check if the name is valid. +pub fn decode_mint_log(log: &crate::eth::Log) -> Result { + let contract::Note::SIGNATURE_HASH = log.topics()[0] else { + return Err(DecodeLogError::UnexpectedTopic(log.topics()[0])); + }; + let decoded = contract::Mint::decode_log_data(log.data(), true) + .map_err(|e| DecodeLogError::DecodeError(e.to_string()))?; + let name = String::from_utf8_lossy(&decoded.label).to_string(); + if !valid_name(&name) { + return Err(DecodeLogError::InvalidName(name)); + } + match resolve_parent(log, None) { + Some(parent_path) => Ok(Mint { name, parent_path }), + None => Err(DecodeLogError::UnresolvedParent(name)), + } +} + +/// Decode a note log from the kimap into a 'resolved' format. +/// +/// Uses `valid_name` to check if the name is valid. +pub fn decode_note_log(log: &crate::eth::Log) -> Result { + let contract::Note::SIGNATURE_HASH = log.topics()[0] else { + return Err(DecodeLogError::UnexpectedTopic(log.topics()[0])); + }; + let decoded = contract::Note::decode_log_data(log.data(), true) + .map_err(|e| DecodeLogError::DecodeError(e.to_string()))?; + let note = String::from_utf8_lossy(&decoded.label).to_string(); + if !valid_note(¬e) { + return Err(DecodeLogError::InvalidName(note)); + } + match resolve_parent(log, None) { + Some(parent_path) => Ok(Note { + note, + parent_path, + data: decoded.data, + }), + None => Err(DecodeLogError::UnresolvedParent(note)), + } +} + +pub fn decode_fact_log(log: &crate::eth::Log) -> Result { + let contract::Fact::SIGNATURE_HASH = log.topics()[0] else { + return Err(DecodeLogError::UnexpectedTopic(log.topics()[0])); + }; + let decoded = contract::Fact::decode_log_data(log.data(), true) + .map_err(|e| DecodeLogError::DecodeError(e.to_string()))?; + let fact = String::from_utf8_lossy(&decoded.label).to_string(); + if !valid_fact(&fact) { + return Err(DecodeLogError::InvalidName(fact)); + } + match resolve_parent(log, None) { + Some(parent_path) => Ok(Fact { + fact, + parent_path, + data: decoded.data, + }), + None => Err(DecodeLogError::UnresolvedParent(fact)), + } +} + +/// Given a [`crate::eth::Log`] (which must be a log from kimap), resolve the parent name +/// of the new entry or note. +pub fn resolve_parent(log: &crate::eth::Log, timeout: Option) -> Option { + let parent_hash = log.topics()[1].to_string(); + net::get_name(&parent_hash, log.block_number, timeout) +} + +/// Given a [`crate::eth::Log`] (which must be a log from kimap), resolve the full name +/// of the new entry or note. +/// +/// Uses `valid_name` to check if the name is valid. +pub fn resolve_full_name(log: &crate::eth::Log, timeout: Option) -> Option { + let parent_hash = log.topics()[1].to_string(); + let parent_name = net::get_name(&parent_hash, log.block_number, timeout)?; + let log_name = match log.topics()[0] { + contract::Mint::SIGNATURE_HASH => { + let decoded = contract::Mint::decode_log_data(log.data(), true).unwrap(); + decoded.label + } + contract::Note::SIGNATURE_HASH => { + let decoded = contract::Note::decode_log_data(log.data(), true).unwrap(); + decoded.label + } + contract::Fact::SIGNATURE_HASH => { + let decoded = contract::Fact::decode_log_data(log.data(), true).unwrap(); + decoded.label + } + _ => return None, + }; + let name = String::from_utf8_lossy(&log_name); + if !valid_entry( + &name, + log.topics()[0] == contract::Note::SIGNATURE_HASH, + log.topics()[0] == contract::Fact::SIGNATURE_HASH, + ) { + return None; + } + Some(format!("{name}.{parent_name}")) +} + +/// Helper struct for reading from the kimap. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Kimap { + pub provider: Provider, + address: Address, +} + +impl Kimap { + /// Creates a new Kimap instance with a specified address. + /// + /// # Arguments + /// * `provider` - A reference to the Provider. + /// * `address` - The address of the Kimap contract. + pub fn new(provider: Provider, address: Address) -> Self { + Self { provider, address } + } + + /// Creates a new Kimap instance with the default address and chain ID. + pub fn default(timeout: u64) -> Self { + let provider = Provider::new(KIMAP_CHAIN_ID, timeout); + Self::new(provider, Address::from_str(KIMAP_ADDRESS).unwrap()) + } + + /// Returns the in-use Kimap contract address. + pub fn address(&self) -> &Address { + &self.address + } + + /// Gets an entry from the Kimap by its string-formatted name. + /// + /// # Parameters + /// - `path`: The name-path to get from the Kimap. + /// # Returns + /// A `Result<(Address, Address, Option), EthError>` representing the TBA, owner, + /// and value if the entry exists and is a note. + pub fn get(&self, path: &str) -> Result<(Address, Address, Option), EthError> { + let get_call = getCall { + namehash: FixedBytes::<32>::from_str(&namehash(path)) + .map_err(|_| EthError::InvalidParams)?, + } + .abi_encode(); + + let tx_req = TransactionRequest::default() + .input(TransactionInput::new(get_call.into())) + .to(self.address); + + let res_bytes = self.provider.call(tx_req, None)?; + + let res = getCall::abi_decode_returns(&res_bytes, false) + .map_err(|_| EthError::RpcMalformedResponse)?; + + let note_data = if res.data == Bytes::default() { + None + } else { + Some(res.data) + }; + + Ok((res.tba, res.owner, note_data)) + } + + /// Gets an entry from the Kimap by its hash. + /// + /// # Parameters + /// - `entryhash`: The entry to get from the Kimap. + /// # Returns + /// A `Result<(Address, Address, Option), EthError>` representing the TBA, owner, + /// and value if the entry exists and is a note. + pub fn get_hash(&self, entryhash: &str) -> Result<(Address, Address, Option), EthError> { + let get_call = getCall { + namehash: FixedBytes::<32>::from_str(entryhash).map_err(|_| EthError::InvalidParams)?, + } + .abi_encode(); + + let tx_req = TransactionRequest::default() + .input(TransactionInput::new(get_call.into())) + .to(self.address); + + let res_bytes = self.provider.call(tx_req, None)?; + + let res = getCall::abi_decode_returns(&res_bytes, false) + .map_err(|_| EthError::RpcMalformedResponse)?; + + let note_data = if res.data == Bytes::default() { + None + } else { + Some(res.data) + }; + + Ok((res.tba, res.owner, note_data)) + } + + /// Create a filter for all mint events. + pub fn mint_filter(&self) -> crate::eth::Filter { + crate::eth::Filter::new() + .address(self.address) + .event(contract::Mint::SIGNATURE) + } + + /// Create a filter for all note events. + pub fn note_filter(&self) -> crate::eth::Filter { + crate::eth::Filter::new() + .address(self.address) + .event(contract::Note::SIGNATURE) + } + + /// Create a filter for all fact events. + pub fn fact_filter(&self) -> crate::eth::Filter { + crate::eth::Filter::new() + .address(self.address) + .event(contract::Fact::SIGNATURE) + } + + /// Create a filter for a given set of specific notes. This function will + /// hash the note labels and use them as the topic3 filter. + /// + /// Example: + /// ```rust + /// let filter = kimap.notes_filter(&["~note1", "~note2"]); + /// ``` + pub fn notes_filter(&self, notes: &[&str]) -> crate::eth::Filter { + self.note_filter().topic3( + notes + .into_iter() + .map(|note| keccak256(note)) + .collect::>(), + ) + } + + /// Create a filter for a given set of specific facts. This function will + /// hash the fact labels and use them as the topic3 filter. + /// + /// Example: + /// ```rust + /// let filter = kimap.facts_filter(&["!fact1", "!fact2"]); + /// ``` + pub fn facts_filter(&self, facts: &[&str]) -> crate::eth::Filter { + self.fact_filter().topic3( + facts + .into_iter() + .map(|fact| keccak256(fact)) + .collect::>(), + ) + } +} diff --git a/src/lib.rs b/src/lib.rs index 088d111..c6583d1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,7 +14,6 @@ //! For blobs, we recommend bincode to serialize and deserialize to bytes. //! pub use crate::kinode::process::standard::*; -use serde::{Deserialize, Serialize}; use serde_json::Value; wit_bindgen::generate!({ @@ -27,26 +26,45 @@ wit_bindgen::generate!({ pub mod eth; /// Interact with the system homepage. /// -/// Note that your process must have the capability to message +/// Your process must have the capability to message /// `homepage:homepage:sys` to use this module. pub mod homepage; /// Interact with the HTTP server and client modules. /// Contains types from the `http` crate to use as well. +/// +/// Your process must have the capability to message and receive messages from +/// `http_server:distro:sys` and/or `http_client:distro:sys` to use this module. pub mod http; /// The types that the kernel itself uses -- warning -- these will /// be incompatible with WIT types in some cases, leading to annoying errors. /// Use only to interact with the kernel or runtime in certain ways. pub mod kernel_types; +/// Interact with kimap, the onchain namespace +pub mod kimap; /// Interact with the key_value module +/// +/// Your process must have the capability to message and receive messages from +/// `kv:distro:sys` to use this module. pub mod kv; /// Interact with the networking module /// For configuration, debugging, and creating signatures with networking key. +/// +/// Your process must have the capability to message and receive messages from +/// `net:distro:sys` to use this module. pub mod net; /// Interact with the sqlite module +/// +/// Your process must have the capability to message and receive messages from +/// `sqlite:distro:sys` to use this module. pub mod sqlite; /// Interact with the timer runtime module. +/// +/// The `timer:distro:sys` module is public, so no special capabilities needed. pub mod timer; /// Interact with the virtual filesystem +/// +/// Your process must have the capability to message and receive messages from +/// `vfs:distro:sys` to use this module. pub mod vfs; /// A set of types and macros for writing "script" processes. @@ -104,7 +122,7 @@ macro_rules! println { /// /// Example: /// ```no_run -/// use kinode_process_lib::await_message; +/// use kinode_process_lib::{await_message, println}; /// /// loop { /// match await_message() { @@ -133,7 +151,8 @@ pub fn await_next_message_body() -> Result, SendError> { } } -/// Simple wrapper over spawn() in WIT to make use of our good types +/// Spawn a new process. This function is a wrapper around the standard `spawn()` function +/// provided in `kinode::process::standard` (which is generated by the WIT file). pub fn spawn( name: Option<&str>, wasm_path: &str, @@ -174,9 +193,10 @@ pub fn spawn( /// /// make_blob(&my_type, |t| Ok(bincode::serialize(t)?)); /// ``` -pub fn make_blob(blob: &T, serializer: F) -> anyhow::Result +pub fn make_blob(blob: &T, serializer: F) -> Result where - F: Fn(&T) -> anyhow::Result>, + F: Fn(&T) -> Result, E>, + E: std::error::Error, { Ok(LazyLoadBlob { mime: None, @@ -206,9 +226,10 @@ where /// field_two: HashSet::new(), /// }); /// ``` -pub fn get_typed_blob(deserializer: F) -> Option +pub fn get_typed_blob(deserializer: F) -> Option where - F: Fn(&[u8]) -> anyhow::Result, + F: Fn(&[u8]) -> Result, + E: std::error::Error, { match crate::get_blob() { Some(blob) => match deserializer(&blob.bytes) { @@ -241,9 +262,10 @@ where /// field_two: HashSet::new(), /// }); /// ``` -pub fn get_typed_state(deserializer: F) -> Option +pub fn get_typed_state(deserializer: F) -> Option where - F: Fn(&[u8]) -> anyhow::Result, + F: Fn(&[u8]) -> Result, + E: std::error::Error, { match crate::get_state() { Some(bytes) => match deserializer(&bytes) { @@ -263,13 +285,10 @@ pub fn can_message(address: &Address) -> bool { } /// Get a capability in our store -pub fn get_capability(our: &Address, params: &str) -> Option { +pub fn get_capability(issuer: &Address, params: &str) -> Option { let params = serde_json::from_str::(params).unwrap_or_default(); - crate::our_capabilities() - .iter() - .find(|cap| { - let cap_params = serde_json::from_str::(&cap.params).unwrap_or_default(); - cap.issuer == *our && params == cap_params - }) - .cloned() + crate::our_capabilities().into_iter().find(|cap| { + let cap_params = serde_json::from_str::(&cap.params).unwrap_or_default(); + cap.issuer == *issuer && params == cap_params + }) } diff --git a/src/net.rs b/src/net.rs index 9ed287b..b17cdc0 100644 --- a/src/net.rs +++ b/src/net.rs @@ -3,9 +3,11 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; // -// Networking protocol types +// Networking protocol types and functions for interacting with it // +/// The data structure used by `net:distro:sys` and the rest of the runtime to +/// represent node identities in the KNS (Kinode Name System). #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Identity { pub name: NodeId, @@ -13,9 +15,14 @@ pub struct Identity { pub routing: NodeRouting, } +/// Routing information for a node identity. Produced from kimap data entries +/// and used to create net connections between nodes. #[derive(Clone, Debug, Serialize, Deserialize)] pub enum NodeRouting { + /// Indirect nodes have routers which resolve connections for them. Routers(Vec), + /// Direct nodes publish an IP address and a set of ports corresponding + /// to protocols they support for incoming connections. Direct { ip: String, ports: BTreeMap, @@ -23,15 +30,21 @@ pub enum NodeRouting { } impl Identity { + /// Check if an identity is a direct node. pub fn is_direct(&self) -> bool { matches!(&self.routing, NodeRouting::Direct { .. }) } + /// Get the port used by a direct node for a given protocol, + /// if the node is direct and supports the protocol. + /// + /// Protocols are represented by a string code such as "ws", "tcp", "udp". pub fn get_protocol_port(&self, protocol: &str) -> Option { match &self.routing { NodeRouting::Routers(_) => None, NodeRouting::Direct { ports, .. } => ports.get(protocol).cloned(), } } + /// Get the list of routers for an indirect node. pub fn routers(&self) -> Option<&Vec> { match &self.routing { NodeRouting::Routers(routers) => Some(routers), @@ -40,68 +53,96 @@ impl Identity { } } -/// Must be parsed from message pack vector. -/// all Get actions must be sent from local process. used for debugging +/// Must be parsed from message pack vector (use `rmp-serde`). +/// All "Get" actions must be sent from a local process. Used for debugging. +/// +/// Sending a NetAction requires messaging capabilities to `net:distro:sys`. #[derive(Clone, Debug, Serialize, Deserialize)] pub enum NetAction { - /// Received from a router of ours when they have a new pending passthrough for us. - /// We should respond (if we desire) by using them to initialize a routed connection - /// with the NodeId given. + /// Received from a router of ours when they have a new pending passthrough + /// for us. We should respond (if we desire) by using them to initialize a + /// routed connection with the NodeId given. + /// + /// This cannot be sent locally. ConnectionRequest(NodeId), - /// can only receive from trusted source, for now just ourselves locally, - /// in the future could get from remote provider + /// Can only receive from trusted source: requires net root capability. KnsUpdate(KnsUpdate), + /// Can only receive from trusted source: requires net root capability. KnsBatchUpdate(Vec), - /// get a list of peers we are connected to + /// Get a list of peers with whom we have an open connection. GetPeers, - /// get the [`Identity`] struct for a single peer + /// Get the [`Identity`] struct for a single peer. GetPeer(String), - /// get the [`NodeId`] associated with a given namehash, if any - GetName(String), - /// get a user-readable diagnostics string containing networking inforamtion + /// Get a user-readable diagnostics string containing networking information. GetDiagnostics, - /// sign the attached blob payload, sign with our node's networking key. - /// **only accepted from our own node** - /// **the source [`Address`] will always be prepended to the payload** + /// Sign the attached blob payload with our node's networking key. + /// **Only accepted from our own node.** + /// **The source [`Address`] will always be prepended to the payload.** Sign, - /// given a message in blob payload, verify the message is signed by - /// the given source. if the signer is not in our representation of + /// Given a message in blob payload, verify the message is signed by + /// the given source. If the signer is not in our representation of /// the PKI, will not verify. - /// **the `from` [`Address`] will always be prepended to the payload** - Verify { - from: Address, - signature: Vec, - }, + /// **The `from` [`Address`] will always be prepended to the payload.** + Verify { from: Address, signature: Vec }, } -/// For now, only sent in response to a ConnectionRequest. -/// Must be parsed from message pack vector +/// Must be parsed from message pack vector (use `rmp-serde`). #[derive(Clone, Debug, Serialize, Deserialize)] pub enum NetResponse { + /// Response to [`NetAction::ConnectionRequest`]. Accepted(NodeId), + /// Response to [`NetAction::ConnectionRequest`]. Rejected(NodeId), - /// response to [`NetAction::GetPeers`] + /// Response to [`NetAction::GetPeers`] Peers(Vec), - /// response to [`NetAction::GetPeer`] + /// Response to [`NetAction::GetPeer`] Peer(Option), - /// response to [`NetAction::GetName`] - Name(Option), - /// response to [`NetAction::GetDiagnostics`]. a user-readable string. + /// Response to [`NetAction::GetDiagnostics`]. a user-readable string. Diagnostics(String), - /// response to [`NetAction::Sign`]. contains the signature in blob + /// Response to [`NetAction::Sign`]. Contains the signature in blob. Signed, - /// response to [`NetAction::Verify`]. boolean indicates whether - /// the signature was valid or not. note that if the signer node + /// Response to [`NetAction::Verify`]. Boolean indicates whether + /// the signature was valid or not. Note that if the signer node /// cannot be found in our representation of PKI, this will return false, /// because we cannot find the networking public key to verify with. Verified(bool), } +/// Request performed to `kns_indexer:kns_indexer:sys`, a userspace process +/// installed by default. +/// +/// Other requests exist but are only used internally. +#[derive(Debug, Serialize, Deserialize)] +pub enum IndexerRequests { + /// Get the name associated with a namehash. This is used to resolve namehashes + /// from events in the `kimap` contract. + NamehashToName(NamehashToNameRequest), +} + +/// Request to resolve a namehash to a name. Hash is a namehash from `kimap`. +/// Block is optional, and if provided will return the name at that block number. +/// If not provided, the latest knowledge will be returned. +/// +/// If receiving event in real-time, make sure to use `block` to give indexer +/// a cue to wait for the next block to respond. +#[derive(Clone, Debug, Serialize, Deserialize, Hash, Eq, PartialEq)] +pub struct NamehashToNameRequest { + pub hash: String, + pub block: Option, +} + +/// Response from `kns_indexer:kns_indexer:sys`. +#[derive(Debug, Serialize, Deserialize)] +pub enum IndexerResponses { + /// Response to [`IndexerRequests::NamehashToName`]. + Name(Option), +} + +/// Update type used to convert kimap entries into node identities. +/// Only currently used in userspace for `eth:distro:sys` configuration. #[derive(Clone, Debug, Serialize, Deserialize, Hash, Eq, PartialEq)] pub struct KnsUpdate { - pub name: String, // actual username / domain name - pub owner: String, - pub node: String, // hex namehash of node + pub name: String, pub public_key: String, pub ips: Vec, pub ports: BTreeMap, @@ -114,10 +155,17 @@ impl KnsUpdate { } } -// -// Helpers -// - +/// Sign a message with the node's networking key. This may be used to prove +/// identity to other parties outside of using the networking protocol. +/// +/// Note that the given message will be prepended with the source [`Address`] +/// of this message. This is done in order to not allow different processes +/// on the same node to sign messages for/as one another. The receiver of +/// the signed message should use [`verify`] to verify the signature, which +/// takes a `from` address to match against that prepended signing [`Address`]. +/// +/// This function uses a 30-second timeout to reach `net:distro:sys`. If more +/// control over the timeout is needed, create a [`Request`] directly. pub fn sign(message: T) -> Result, SendError> where T: Into>, @@ -130,6 +178,14 @@ where .map(|_resp| get_blob().unwrap().bytes) } +/// Verify a signature on a message. +/// +/// The receiver of a signature created using [`sign`] should use this function +/// to verify the signature, which takes a `from` address to match against +/// the prepended signing [`Address`] of the source process. +/// +/// This function uses a 30-second timeout to reach `net:distro:sys`. If more +/// control over the timeout is needed, create a [`Request`] directly. pub fn verify(from: T, message: U, signature: V) -> Result where T: Into
, @@ -157,49 +213,32 @@ where }) } -/// take a DNSwire-formatted node ID from chain and convert it to a String -pub fn dnswire_decode(wire_format_bytes: &[u8]) -> Result { - let mut i = 0; - let mut result = Vec::new(); - - while i < wire_format_bytes.len() { - let len = wire_format_bytes[i] as usize; - if len == 0 { - break; - } - let end = i + len + 1; - let mut span = match wire_format_bytes.get(i + 1..end) { - Some(span) => span.to_vec(), - None => return Err(DnsDecodeError::FormatError), - }; - span.push('.' as u8); - result.push(span); - i = end; - } - - let flat: Vec<_> = result.into_iter().flatten().collect(); - - let name = String::from_utf8(flat).map_err(|e| DnsDecodeError::Utf8Error(e))?; - - // Remove the trailing '.' if it exists (it should always exist) - if name.ends_with('.') { - Ok(name[0..name.len() - 1].to_string()) - } else { - Ok(name) - } -} +/// Get a [`kimap::Kimap`] entry name from its namehash. +/// +/// Default timeout is 30 seconds. Note that the responsiveness of the indexer +/// will depend on the block option used. The indexer will wait until it has +/// seen the block given to respond. +pub fn get_name(namehash: T, block: Option, timeout: Option) -> Option +where + T: Into, +{ + let res = Request::to(("our", "kns_indexer", "kns_indexer", "sys")) + .body( + serde_json::to_vec(&IndexerRequests::NamehashToName(NamehashToNameRequest { + hash: namehash.into(), + block, + })) + .unwrap(), + ) + .send_and_await_response(timeout.unwrap_or(30)) + .unwrap() + .ok()?; -#[derive(Clone, Debug, thiserror::Error)] -pub enum DnsDecodeError { - Utf8Error(std::string::FromUtf8Error), - FormatError, -} + let Ok(IndexerResponses::Name(maybe_name)) = + serde_json::from_slice::(res.body()) + else { + return None; + }; -impl std::fmt::Display for DnsDecodeError { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match self { - DnsDecodeError::Utf8Error(e) => write!(f, "UTF-8 error: {}", e), - DnsDecodeError::FormatError => write!(f, "Format error"), - } - } + maybe_name } diff --git a/src/scripting/mod.rs b/src/scripting/mod.rs index 885fed4..4b2257f 100644 --- a/src/scripting/mod.rs +++ b/src/scripting/mod.rs @@ -23,8 +23,7 @@ macro_rules! script { else { return; }; - let body_string = - format!("{} {}", our.process(), std::str::from_utf8(&body).unwrap()); + let body_string = String::from_utf8_lossy(&body).to_string(); let response_string: String = $init_func(our, body_string); if expects_response.is_some() { Response::new() @@ -32,10 +31,63 @@ macro_rules! script { .send() .unwrap(); } else { - println!("{response_string}"); + if !response_string.is_empty() { + println!("{response_string}"); + } } } } export!(Component); }; } + +#[macro_export] +/// A macro for writing a process that serves a widget and completes. +/// This process should be identified in your package `manifest.json` with `on_exit` set to `None`. +/// +/// Make sure the process has requested capability to message `homepage:homepage:sys`! +/// +/// Example: +/// ```no_run +/// wit_bindgen::generate!({ +/// path: "target/wit", +/// world: "process-v0", +/// }); +/// +/// kinode_process_lib::widget!("My widget", create_widget); +/// +/// fn create_widget() -> String { +/// return r#" +/// +/// +/// +/// +/// +///

Hello World!

+/// +/// "#.to_string(); +/// } +/// ``` +macro_rules! widget { + ($widget_label:expr, $create_widget_func:ident) => { + struct Component; + impl Guest for Component { + fn init(_our: String) { + use kinode_process_lib::Request; + Request::to(("our", "homepage", "homepage", "sys")) + .body( + serde_json::json!({ + "Add": { + "label": $widget_label, + "widget": $create_widget_func(), + } + }) + .to_string(), + ) + .send() + .unwrap(); + } + } + export!(Component); + }; +} diff --git a/src/timer.rs b/src/timer.rs index 9670799..febe861 100644 --- a/src/timer.rs +++ b/src/timer.rs @@ -1,54 +1,42 @@ -use crate::*; -use anyhow::Result; +use crate::{Context, Message, Request, SendError}; +use serde::{Deserialize, Serialize}; +/// The body field for requests to `timer:distro:sys`, a runtime module that allows processes +/// to set timers with a duration specified in milliseconds. +/// +/// The timer module is public, so no particular capabilities are required to use it. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum TimerAction { Debug, SetTimer(u64), } +impl Into> for TimerAction { + fn into(self) -> Vec { + serde_json::to_vec(&self).unwrap() + } +} + /// Set a timer using the runtime that will return a Response after the specified duration. /// The duration should be a number of milliseconds. pub fn set_timer(duration: u64, context: Option) { - match context { - None => { - Request::new() - .target(Address::new( - "our", - ProcessId::new(Some("timer"), "distro", "sys"), - )) - .body(serde_json::to_vec(&TimerAction::SetTimer(duration)).unwrap()) - .expects_response((duration / 1000) + 1) - // safe to unwrap this call when we know we've set both target and body - .send() - .unwrap(); - } - Some(context) => { - Request::new() - .target(Address::new( - "our", - ProcessId::new(Some("timer"), "distro", "sys"), - )) - .body(serde_json::to_vec(&TimerAction::SetTimer(duration)).unwrap()) - .expects_response((duration / 1000) + 1) - .context(context) - // safe to unwrap this call when we know we've set both target and body - .send() - .unwrap(); - } + let mut request = Request::to(("our", "timer", "distro", "sys")) + .body(TimerAction::SetTimer(duration)) + .expects_response((duration / 1000) + 1); + + if let Some(context) = context { + request = request.context(context); } + // safe to unwrap this call when we know we've set both target and body + request.send().unwrap(); } /// Set a timer using the runtime that will return a Response after the specified duration, /// then wait for that timer to resolve. The duration should be a number of milliseconds. pub fn set_and_await_timer(duration: u64) -> Result { - Request::new() - .target(Address::new( - "our", - ProcessId::new(Some("timer"), "distro", "sys"), - )) - .body(serde_json::to_vec(&TimerAction::SetTimer(duration)).unwrap()) - // safe to unwrap this call when we know we've set both target and body + Request::to(("our", "timer", "distro", "sys")) + .body(TimerAction::SetTimer(duration)) .send_and_await_response((duration / 1000) + 1) + // safe to unwrap this call when we know we've set both target and body .unwrap() } diff --git a/src/types/address.rs b/src/types/address.rs index 7d5d7e3..c6d1660 100644 --- a/src/types/address.rs +++ b/src/types/address.rs @@ -151,11 +151,12 @@ where } } -impl From<(&str, T)> for Address +impl From<(T, U)> for Address where - T: Into, + T: Into, + U: Into, { - fn from(input: (&str, T)) -> Self { + fn from(input: (T, U)) -> Self { Address::new(input.0, input.1) } } diff --git a/src/types/capability.rs b/src/types/capability.rs index db0949c..356051a 100644 --- a/src/types/capability.rs +++ b/src/types/capability.rs @@ -5,9 +5,9 @@ use std::hash::{Hash, Hasher}; /// Capability is defined in the wit bindings, but constructors and methods here. /// A `Capability` is a combination of an Address and a set of Params (a serialized -/// JSON string). Capabilities are attached to messages to either share that capability -/// with the receiving process, or to prove that a process has authority to perform a -/// certain action. +/// JSON string by convention). Capabilities are attached to messages to either share +/// that capability with the receiving process, or to prove that a process has +/// authority to perform a certain action. impl Capability { /// Create a new `Capability`. Takes a node ID and a process ID. pub fn new(address: T, params: U) -> Capability @@ -172,15 +172,16 @@ impl PartialEq for Capability { impl From<&Capability> for Capability { fn from(input: &Capability) -> Self { - input.clone() + input.to_owned() } } -impl From<(T, &str)> for Capability +impl From<(T, U)> for Capability where T: Into
, + U: Into, { - fn from(input: (T, &str)) -> Self { + fn from(input: (T, U)) -> Self { Capability::new(input.0, input.1) } } @@ -190,3 +191,30 @@ impl std::fmt::Display for Capability { write!(f, "{}({})", self.issuer, self.params) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProcessId; + + #[test] + fn test_capability() { + let cap = Capability::new( + Address::new("test", ProcessId::new(None, "test", "test")), + r#"{"test": "params"}"#, + ); + let serialized = serde_json::to_string(&cap).unwrap(); + let deserialized: Capability = serde_json::from_str(&serialized).unwrap(); + assert_eq!(cap, deserialized); + } + + #[test] + fn test_capability_json() { + let cap = Capability::new( + Address::new("test", ProcessId::new(None, "test", "test")), + r#"{"test": "params"}"#, + ); + let json = cap.params_json().unwrap(); + assert_eq!(json, serde_json::json!({"test": "params"})); + } +} diff --git a/src/types/message.rs b/src/types/message.rs index d167c97..8e7ccb0 100644 --- a/src/types/message.rs +++ b/src/types/message.rs @@ -25,6 +25,23 @@ pub enum Message { }, } +#[derive(Debug, Serialize, Deserialize)] +pub enum BuildError { + NoBody, + NoTarget, +} + +impl std::fmt::Display for BuildError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + BuildError::NoBody => write!(f, "no body set for message"), + BuildError::NoTarget => write!(f, "no target set for message"), + } + } +} + +impl std::error::Error for BuildError {} + impl Message { /// Get the source of a message. pub fn source(&self) -> &Address { diff --git a/src/types/on_exit.rs b/src/types/on_exit.rs index 5647f22..81d02c7 100644 --- a/src/types/on_exit.rs +++ b/src/types/on_exit.rs @@ -1,4 +1,4 @@ -use crate::{Address, LazyLoadBlob, Request}; +use crate::{types::message::BuildError, Address, LazyLoadBlob, Request}; #[derive(Clone, Debug)] pub enum OnExit { @@ -63,24 +63,25 @@ impl OnExit { OnExit::Requests(reqs) => Some(reqs), } } - /// Add a request to this OnExit if it is Requests, fail otherwise - pub fn add_request(&mut self, new: Request) -> anyhow::Result<()> { - match self { - OnExit::None => Err(anyhow::anyhow!("cannot add request to None")), - OnExit::Restart => Err(anyhow::anyhow!("cannot add request to Restart")), - OnExit::Requests(ref mut reqs) => { - reqs.push(new); - Ok(()) - } + /// Add a request to this OnExit if it is of variant `Requests` + pub fn add_request(&mut self, new: Request) { + if let OnExit::Requests(ref mut reqs) = self { + reqs.push(new); } } - /// Set the OnExit behavior for this process - pub fn set(self) -> anyhow::Result<()> { + /// Set the OnExit behavior for this process. + /// + /// Will return a [`BuildError`] if any requests within the `Requests` behavior are + /// not valid (by not having a `body` and/or `target` set). + pub fn set(self) -> Result<(), BuildError> { crate::kinode::process::standard::set_on_exit(&self._to_standard()?); Ok(()) } - /// Convert this OnExit to the kernel's OnExit type - pub fn _to_standard(self) -> anyhow::Result { + /// Convert this OnExit to the kernel's OnExit type. + /// + /// Will return a [`BuildError`] if any requests within the `Requests` behavior are + /// not valid (by not having a `body` and/or `target` set). + pub fn _to_standard(self) -> Result { match self { OnExit::None => Ok(crate::kinode::process::standard::OnExit::None), OnExit::Restart => Ok(crate::kinode::process::standard::OnExit::Restart), @@ -92,14 +93,11 @@ impl OnExit { )> = Vec::with_capacity(reqs.len()); for req in reqs { kernel_reqs.push(( - req.target - .ok_or(anyhow::anyhow!("request without target given"))?, + req.target.ok_or(BuildError::NoTarget)?, crate::kinode::process::standard::Request { inherit: req.inherit, expects_response: None, - body: req - .body - .ok_or(anyhow::anyhow!("request without body given"))?, + body: req.body.ok_or(BuildError::NoBody)?, metadata: req.metadata, capabilities: req.capabilities, }, diff --git a/src/types/request.rs b/src/types/request.rs index f5fb687..c70a90f 100644 --- a/src/types/request.rs +++ b/src/types/request.rs @@ -1,10 +1,10 @@ use crate::{ Address, Capability, LazyLoadBlob, Message, SendError, _wit_message_to_message, - _wit_send_error_to_send_error, + _wit_send_error_to_send_error, types::message::BuildError, }; -/// Request builder. Use [`Request::new()`] to start a request, then build it, -/// then call [`Request::send()`] on it to fire. +/// Request builder. Use [`Request::new()`] or [`Request::to()`] to start a request, +/// then build it, then call [`Request::send()`] on it to fire. #[derive(Clone, Debug)] pub struct Request { pub target: Option
, @@ -110,9 +110,10 @@ impl Request { /// type that's got an implementation of [`TryInto`] for `Vec`. It's best /// to define an IPC body type within your app, then implement TryFrom/TryInto for /// all IPC body serialization/deserialization. - pub fn try_body(mut self, body: T) -> anyhow::Result + pub fn try_body(mut self, body: T) -> Result where - T: TryInto, Error = anyhow::Error>, + T: TryInto, Error = E>, + E: std::error::Error, { self.body = Some(body.try_into()?); Ok(self) @@ -183,9 +184,10 @@ impl Request { } /// Set the blob's bytes with a type that implements `TryInto>` /// and may or may not successfully be set. - pub fn try_blob_bytes(mut self, bytes: T) -> anyhow::Result + pub fn try_blob_bytes(mut self, bytes: T) -> Result where - T: TryInto, Error = anyhow::Error>, + T: TryInto, Error = E>, + E: std::error::Error, { if self.blob.is_none() { self.blob = Some(LazyLoadBlob { @@ -219,9 +221,10 @@ impl Request { /// Attempt to set the context field of the request with a type that implements /// `TryInto>`. It's best to define a context type within your app, /// then implement TryFrom/TryInto for all context serialization/deserialization. - pub fn try_context(mut self, context: T) -> anyhow::Result + pub fn try_context(mut self, context: T) -> Result where - T: TryInto, Error = anyhow::Error>, + T: TryInto, Error = E>, + E: std::error::Error, { self.context = Some(context.try_into()?); Ok(self) @@ -240,48 +243,52 @@ impl Request { } /// Attempt to send the request. This will only fail if the `target` or `body` /// fields have not been set. - pub fn send(self) -> anyhow::Result<()> { - if let (Some(target), Some(body)) = (self.target, self.body) { - crate::send_request( - &target, - &crate::kinode::process::standard::Request { - inherit: self.inherit, - expects_response: self.timeout, - body, - metadata: self.metadata, - capabilities: self.capabilities, - }, - self.context.as_ref(), - self.blob.as_ref(), - ); - Ok(()) - } else { - Err(anyhow::anyhow!("missing fields")) - } + pub fn send(self) -> Result<(), BuildError> { + let Some(target) = self.target else { + return Err(BuildError::NoTarget); + }; + let Some(body) = self.body else { + return Err(BuildError::NoBody); + }; + crate::send_request( + &target, + &crate::kinode::process::standard::Request { + inherit: self.inherit, + expects_response: self.timeout, + body, + metadata: self.metadata, + capabilities: self.capabilities, + }, + self.context.as_ref(), + self.blob.as_ref(), + ); + Ok(()) } /// Attempt to send the request, then await its response or error (timeout, offline node). /// This will only fail if the `target` or `body` fields have not been set. pub fn send_and_await_response( self, timeout: u64, - ) -> anyhow::Result> { - if let (Some(target), Some(body)) = (self.target, self.body) { - match crate::send_and_await_response( - &target, - &crate::kinode::process::standard::Request { - inherit: self.inherit, - expects_response: Some(timeout), - body, - metadata: self.metadata, - capabilities: self.capabilities, - }, - self.blob.as_ref(), - ) { - Ok((source, message)) => Ok(Ok(_wit_message_to_message(source, message))), - Err(send_err) => Ok(Err(_wit_send_error_to_send_error(send_err, self.context))), - } - } else { - Err(anyhow::anyhow!("missing fields")) + ) -> Result, BuildError> { + let Some(target) = self.target else { + return Err(BuildError::NoTarget); + }; + let Some(body) = self.body else { + return Err(BuildError::NoBody); + }; + match crate::send_and_await_response( + &target, + &crate::kinode::process::standard::Request { + inherit: self.inherit, + expects_response: Some(timeout), + body, + metadata: self.metadata, + capabilities: self.capabilities, + }, + self.blob.as_ref(), + ) { + Ok((source, message)) => Ok(Ok(_wit_message_to_message(source, message))), + Err(send_err) => Ok(Err(_wit_send_error_to_send_error(send_err, self.context))), } } } diff --git a/src/types/response.rs b/src/types/response.rs index b0ee16c..d173482 100644 --- a/src/types/response.rs +++ b/src/types/response.rs @@ -1,4 +1,4 @@ -use crate::*; +use crate::{types::message::BuildError, Capability, LazyLoadBlob}; /// Response builder. Use [`Response::new()`] to start a response, then build it, /// then call [`Response::send()`] on it to fire. @@ -52,9 +52,10 @@ impl Response { /// type that's got an implementation of [`TryInto`] for `Vec`. It's best /// to define an IPC body type within your app, then implement TryFrom/TryInto for /// all IPC body serialization/deserialization. - pub fn try_body(mut self, body: T) -> anyhow::Result + pub fn try_body(mut self, body: T) -> Result where - T: TryInto, Error = anyhow::Error>, + T: TryInto, Error = E>, + E: std::error::Error, { self.body = Some(body.try_into()?); Ok(self) @@ -125,9 +126,10 @@ impl Response { } /// Set the blob's bytes with a type that implements `TryInto>` /// and may or may not successfully be set. - pub fn try_blob_bytes(mut self, bytes: T) -> anyhow::Result + pub fn try_blob_bytes(mut self, bytes: T) -> Result where - T: TryInto, Error = anyhow::Error>, + T: TryInto, Error = E>, + E: std::error::Error, { if self.blob.is_none() { self.blob = Some(LazyLoadBlob { @@ -150,7 +152,7 @@ impl Response { } /// Attempt to send the response. This will only fail if the IPC body field of /// the response has not yet been set using `body()` or `try_body()`. - pub fn send(self) -> anyhow::Result<()> { + pub fn send(self) -> Result<(), BuildError> { if let Some(body) = self.body { crate::send_response( &crate::kinode::process::standard::Response { @@ -163,7 +165,7 @@ impl Response { ); Ok(()) } else { - Err(anyhow::anyhow!("missing IPC body")) + Err(BuildError::NoBody) } } } diff --git a/src/vfs/directory.rs b/src/vfs/directory.rs index 6cc8154..55ecdc7 100644 --- a/src/vfs/directory.rs +++ b/src/vfs/directory.rs @@ -1,5 +1,4 @@ -use super::{DirEntry, VfsAction, VfsRequest, VfsResponse}; -use crate::{Message, Request}; +use super::{parse_response, vfs_request, DirEntry, VfsAction, VfsError, VfsResponse}; /// Vfs helper struct for a directory. /// Opening or creating a directory will give you a Result. @@ -12,33 +11,29 @@ pub struct Directory { impl Directory { /// Iterates through children of directory, returning a vector of DirEntries. /// DirEntries contain the path and file type of each child. - pub fn read(&self) -> anyhow::Result> { - let request = VfsRequest { - path: self.path.clone(), - action: VfsAction::ReadDir, - }; - let message = Request::new() - .target(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&request)?) - .send_and_await_response(self.timeout)?; + pub fn read(&self) -> Result, VfsError> { + let message = vfs_request(&self.path, VfsAction::ReadDir) + .send_and_await_response(self.timeout) + .unwrap() + .map_err(|e| VfsError::IOError { + error: e.to_string(), + path: self.path.clone(), + })?; - match message { - Ok(Message::Response { body, .. }) => { - let response = serde_json::from_slice::(&body)?; - match response { - VfsResponse::ReadDir(entries) => Ok(entries), - VfsResponse::Err(e) => Err(e.into()), - _ => Err(anyhow::anyhow!("vfs: unexpected response: {:?}", response)), - } - } - _ => Err(anyhow::anyhow!("vfs: unexpected message: {:?}", message)), + match parse_response(message.body())? { + VfsResponse::ReadDir(entries) => Ok(entries), + VfsResponse::Err(e) => Err(e), + _ => Err(VfsError::ParseError { + error: "unexpected response".to_string(), + path: self.path.clone(), + }), } } } /// Opens or creates a directory at path. /// If trying to create an existing directory, will just give you the path. -pub fn open_dir(path: &str, create: bool, timeout: Option) -> anyhow::Result { +pub fn open_dir(path: &str, create: bool, timeout: Option) -> Result { let timeout = timeout.unwrap_or(5); if !create { return Ok(Directory { @@ -46,55 +41,46 @@ pub fn open_dir(path: &str, create: bool, timeout: Option) -> anyhow::Resul timeout, }); } - let request = VfsRequest { - path: path.to_string(), - action: VfsAction::CreateDir, - }; - let message = Request::new() - .target(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&request)?) - .send_and_await_response(timeout)?; + let message = vfs_request(path, VfsAction::CreateDir) + .send_and_await_response(timeout) + .unwrap() + .map_err(|e| VfsError::IOError { + error: e.to_string(), + path: path.to_string(), + })?; - match message { - Ok(Message::Response { body, .. }) => { - let response = serde_json::from_slice::(&body)?; - match response { - VfsResponse::Ok => Ok(Directory { - path: path.to_string(), - timeout, - }), - VfsResponse::Err(e) => Err(e.into()), - _ => Err(anyhow::anyhow!("vfs: unexpected response: {:?}", response)), - } - } - _ => Err(anyhow::anyhow!("vfs: unexpected message: {:?}", message)), + match parse_response(message.body())? { + VfsResponse::Ok => Ok(Directory { + path: path.to_string(), + timeout, + }), + VfsResponse::Err(e) => Err(e), + _ => Err(VfsError::ParseError { + error: "unexpected response".to_string(), + path: path.to_string(), + }), } } /// Removes a dir at path, errors if path not found or path is not a directory. -pub fn remove_dir(path: &str, timeout: Option) -> anyhow::Result<()> { +pub fn remove_dir(path: &str, timeout: Option) -> Result<(), VfsError> { let timeout = timeout.unwrap_or(5); - let request = VfsRequest { - path: path.to_string(), - action: VfsAction::RemoveDir, - }; - - let message = Request::new() - .target(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&request)?) - .send_and_await_response(timeout)?; + let message = vfs_request(path, VfsAction::RemoveDir) + .send_and_await_response(timeout) + .unwrap() + .map_err(|e| VfsError::IOError { + error: e.to_string(), + path: path.to_string(), + })?; - match message { - Ok(Message::Response { body, .. }) => { - let response = serde_json::from_slice::(&body)?; - match response { - VfsResponse::Ok => Ok(()), - VfsResponse::Err(e) => Err(e.into()), - _ => Err(anyhow::anyhow!("vfs: unexpected response: {:?}", response)), - } - } - _ => Err(anyhow::anyhow!("vfs: unexpected message: {:?}", message)), + match parse_response(message.body())? { + VfsResponse::Ok => Ok(()), + VfsResponse::Err(e) => Err(e), + _ => Err(VfsError::ParseError { + error: "unexpected response".to_string(), + path: path.to_string(), + }), } } diff --git a/src/vfs/file.rs b/src/vfs/file.rs index 6d38e82..861d8ea 100644 --- a/src/vfs/file.rs +++ b/src/vfs/file.rs @@ -1,371 +1,324 @@ -use super::{FileMetadata, SeekFrom, VfsAction, VfsRequest, VfsResponse}; -use crate::{get_blob, Message, PackageId, Request}; +use super::{ + parse_response, vfs_request, FileMetadata, SeekFrom, VfsAction, VfsError, VfsResponse, +}; +use crate::{get_blob, PackageId}; /// Vfs helper struct for a file. -/// Opening or creating a file will give you a Result. -/// You can call it's impl functions to interact with it. +/// Opening or creating a file will give you a `Result`. +/// You can call its impl functions to interact with it. pub struct File { pub path: String, pub timeout: u64, } impl File { + /// Create a new file-manager struct with the given path and timeout. + pub fn new>(path: T, timeout: u64) -> Self { + Self { + path: path.into(), + timeout, + } + } + /// Reads the entire file, from start position. /// Returns a vector of bytes. - pub fn read(&self) -> anyhow::Result> { - let request = VfsRequest { - path: self.path.clone(), - action: VfsAction::Read, - }; - let message = Request::new() - .target(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&request)?) - .send_and_await_response(self.timeout)?; - - match message { - Ok(Message::Response { body, .. }) => { - let response = serde_json::from_slice::(&body)?; - match response { - VfsResponse::Read => { - let data = match get_blob() { - Some(bytes) => bytes.bytes, - None => return Err(anyhow::anyhow!("vfs: no read blob")), - }; - Ok(data) + pub fn read(&self) -> Result, VfsError> { + let message = vfs_request(&self.path, VfsAction::Read) + .send_and_await_response(self.timeout) + .unwrap() + .map_err(|e| VfsError::IOError { + error: e.to_string(), + path: self.path.clone(), + })?; + + match parse_response(message.body())? { + VfsResponse::Read => { + let data = match get_blob() { + Some(bytes) => bytes.bytes, + None => { + return Err(VfsError::ParseError { + error: "no blob".to_string(), + path: self.path.clone(), + }) } - VfsResponse::Err(e) => Err(e.into()), - _ => Err(anyhow::anyhow!("vfs: unexpected response: {:?}", response)), - } + }; + Ok(data) } - _ => Err(anyhow::anyhow!("vfs: unexpected message: {:?}", message)), + VfsResponse::Err(e) => Err(e.into()), + _ => Err(VfsError::ParseError { + error: "unexpected response".to_string(), + path: self.path.clone(), + }), } } /// Reads the entire file, from start position, into buffer. /// Returns the amount of bytes read. - pub fn read_into(&self, buffer: &mut [u8]) -> anyhow::Result { - let request = VfsRequest { - path: self.path.clone(), - action: VfsAction::Read, - }; - let message = Request::new() - .target(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&request)?) - .send_and_await_response(self.timeout)?; - - match message { - Ok(Message::Response { body, .. }) => { - let response = serde_json::from_slice::(&body)?; - match response { - VfsResponse::Read => { - let data = match get_blob() { - Some(bytes) => bytes.bytes, - None => return Err(anyhow::anyhow!("vfs: no read blob")), - }; - let len = std::cmp::min(data.len(), buffer.len()); - buffer[..len].copy_from_slice(&data[..len]); - Ok(len) - } - VfsResponse::Err(e) => Err(e.into()), - _ => Err(anyhow::anyhow!("vfs: unexpected response: {:?}", response)), - } + pub fn read_into(&self, buffer: &mut [u8]) -> Result { + let message = vfs_request(&self.path, VfsAction::Read) + .send_and_await_response(self.timeout) + .unwrap() + .map_err(|e| VfsError::IOError { + error: e.to_string(), + path: self.path.clone(), + })?; + + match parse_response(message.body())? { + VfsResponse::Read => { + let data = get_blob().unwrap_or_default().bytes; + let len = std::cmp::min(data.len(), buffer.len()); + buffer[..len].copy_from_slice(&data[..len]); + Ok(len) } - _ => Err(anyhow::anyhow!("vfs: unexpected message: {:?}", message)), + VfsResponse::Err(e) => Err(e.into()), + _ => Err(VfsError::ParseError { + error: "unexpected response".to_string(), + path: self.path.clone(), + }), } } /// Read into buffer from current cursor position /// Returns the amount of bytes read. - pub fn read_at(&self, buffer: &mut [u8]) -> anyhow::Result { + pub fn read_at(&self, buffer: &mut [u8]) -> Result { let length = buffer.len(); - let request = VfsRequest { - path: self.path.clone(), - action: VfsAction::ReadExact(length as u64), - }; - let message = Request::new() - .target(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&request)?) - .send_and_await_response(self.timeout)?; - - match message { - Ok(Message::Response { body, .. }) => { - let response = serde_json::from_slice::(&body)?; - match response { - VfsResponse::Read => { - let data = match get_blob() { - Some(bytes) => bytes.bytes, - None => return Err(anyhow::anyhow!("vfs: no read blob")), - }; - let len = std::cmp::min(data.len(), buffer.len()); - buffer[..len].copy_from_slice(&data[..len]); - Ok(len) - } - VfsResponse::Err(e) => Err(e.into()), - _ => Err(anyhow::anyhow!("vfs: unexpected response: {:?}", response)), - } + + let message = vfs_request(&self.path, VfsAction::ReadExact(length as u64)) + .send_and_await_response(self.timeout) + .unwrap() + .map_err(|e| VfsError::IOError { + error: e.to_string(), + path: self.path.clone(), + })?; + + match parse_response(message.body())? { + VfsResponse::Read => { + let data = get_blob().unwrap_or_default().bytes; + let len = std::cmp::min(data.len(), buffer.len()); + buffer[..len].copy_from_slice(&data[..len]); + Ok(len) } - _ => Err(anyhow::anyhow!("vfs: unexpected message: {:?}", message)), + VfsResponse::Err(e) => Err(e.into()), + _ => Err(VfsError::ParseError { + error: "unexpected response".to_string(), + path: self.path.clone(), + }), } } /// Reads until end of file from current cursor position /// Returns a vector of bytes. - pub fn read_to_end(&self) -> anyhow::Result> { - let request = VfsRequest { - path: self.path.clone(), - action: VfsAction::ReadToEnd, - }; - let message = Request::new() - .target(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&request)?) - .send_and_await_response(self.timeout)?; - - match message { - Ok(Message::Response { body, .. }) => { - let response = serde_json::from_slice::(&body)?; - match response { - VfsResponse::Read => { - let data = match get_blob() { - Some(bytes) => bytes.bytes, - None => return Err(anyhow::anyhow!("vfs: no read blob")), - }; - Ok(data) - } - VfsResponse::Err(e) => Err(e.into()), - _ => Err(anyhow::anyhow!("vfs: unexpected response: {:?}", response)), - } - } - _ => Err(anyhow::anyhow!("vfs: unexpected message: {:?}", message)), + pub fn read_to_end(&self) -> Result, VfsError> { + let message = vfs_request(&self.path, VfsAction::ReadToEnd) + .send_and_await_response(self.timeout) + .unwrap() + .map_err(|e| VfsError::IOError { + error: e.to_string(), + path: self.path.clone(), + })?; + + match parse_response(message.body())? { + VfsResponse::Read => Ok(get_blob().unwrap_or_default().bytes), + VfsResponse::Err(e) => Err(e), + _ => Err(VfsError::ParseError { + error: "unexpected response".to_string(), + path: self.path.clone(), + }), } } /// Reads until end of file from current cursor position, converts to String. /// Throws error if bytes aren't valid utf-8. /// Returns a vector of bytes. - pub fn read_to_string(&self) -> anyhow::Result { - let request = VfsRequest { - path: self.path.clone(), - action: VfsAction::ReadToString, - }; - let message = Request::new() - .target(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&request)?) - .send_and_await_response(self.timeout)?; - - match message { - Ok(Message::Response { body, .. }) => { - let response = serde_json::from_slice::(&body)?; - match response { - VfsResponse::ReadToString(s) => Ok(s), - VfsResponse::Err(e) => Err(e.into()), - _ => Err(anyhow::anyhow!("vfs: unexpected response: {:?}", response)), - } - } - _ => Err(anyhow::anyhow!("vfs: unexpected message: {:?}", message)), + pub fn read_to_string(&self) -> Result { + let message = vfs_request(&self.path, VfsAction::ReadToString) + .send_and_await_response(self.timeout) + .unwrap() + .map_err(|e| VfsError::IOError { + error: e.to_string(), + path: self.path.clone(), + })?; + + match parse_response(message.body())? { + VfsResponse::ReadToString(s) => Ok(s), + VfsResponse::Err(e) => Err(e), + _ => Err(VfsError::ParseError { + error: "unexpected response".to_string(), + path: self.path.clone(), + }), } } /// Write entire slice as the new file. /// Truncates anything that existed at path before. - pub fn write(&self, buffer: &[u8]) -> anyhow::Result<()> { - let request = VfsRequest { - path: self.path.clone(), - action: VfsAction::Write, - }; - - let message = Request::new() - .target(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&request)?) + pub fn write(&self, buffer: &[u8]) -> Result<(), VfsError> { + let message = vfs_request(&self.path, VfsAction::Write) .blob_bytes(buffer) - .send_and_await_response(self.timeout)?; - - match message { - Ok(Message::Response { body, .. }) => { - let response = serde_json::from_slice::(&body)?; - match response { - VfsResponse::Ok => Ok(()), - VfsResponse::Err(e) => Err(e.into()), - _ => Err(anyhow::anyhow!("vfs: unexpected response: {:?}", response)), - } - } - _ => Err(anyhow::anyhow!("vfs: unexpected message: {:?}", message)), + .send_and_await_response(self.timeout) + .unwrap() + .map_err(|e| VfsError::IOError { + error: e.to_string(), + path: self.path.clone(), + })?; + + match parse_response(message.body())? { + VfsResponse::Ok => Ok(()), + VfsResponse::Err(e) => Err(e), + _ => Err(VfsError::ParseError { + error: "unexpected response".to_string(), + path: self.path.clone(), + }), } } /// Write buffer to file at current position, overwriting any existing data. - pub fn write_all(&mut self, buffer: &[u8]) -> anyhow::Result<()> { - let request = VfsRequest { - path: self.path.clone(), - action: VfsAction::WriteAll, - }; - let message = Request::new() - .target(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&request)?) + pub fn write_all(&mut self, buffer: &[u8]) -> Result<(), VfsError> { + let message = vfs_request(&self.path, VfsAction::WriteAll) .blob_bytes(buffer) - .send_and_await_response(self.timeout)?; - - match message { - Ok(Message::Response { body, .. }) => { - let response = serde_json::from_slice::(&body)?; - match response { - VfsResponse::Ok => Ok(()), - VfsResponse::Err(e) => Err(e.into()), - _ => Err(anyhow::anyhow!("vfs: unexpected response: {:?}", response)), - } - } - _ => Err(anyhow::anyhow!("vfs: unexpected message: {:?}", message)), + .send_and_await_response(self.timeout) + .unwrap() + .map_err(|e| VfsError::IOError { + error: e.to_string(), + path: self.path.clone(), + })?; + + match parse_response(message.body())? { + VfsResponse::Ok => Ok(()), + VfsResponse::Err(e) => Err(e), + _ => Err(VfsError::ParseError { + error: "unexpected response".to_string(), + path: self.path.clone(), + }), } } /// Write buffer to the end position of file. - pub fn append(&mut self, buffer: &[u8]) -> anyhow::Result<()> { - let request = VfsRequest { - path: self.path.clone(), - action: VfsAction::Append, - }; - let message = Request::new() - .target(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&request)?) + pub fn append(&mut self, buffer: &[u8]) -> Result<(), VfsError> { + let message = vfs_request(&self.path, VfsAction::Append) .blob_bytes(buffer) - .send_and_await_response(self.timeout)?; - - match message { - Ok(Message::Response { body, .. }) => { - let response = serde_json::from_slice::(&body)?; - match response { - VfsResponse::Ok => Ok(()), - VfsResponse::Err(e) => Err(e.into()), - _ => Err(anyhow::anyhow!("vfs: unexpected response: {:?}", response)), - } - } - _ => Err(anyhow::anyhow!("vfs: unexpected message: {:?}", message)), + .send_and_await_response(self.timeout) + .unwrap() + .map_err(|e| VfsError::IOError { + error: e.to_string(), + path: self.path.clone(), + })?; + + match parse_response(message.body())? { + VfsResponse::Ok => Ok(()), + VfsResponse::Err(e) => Err(e), + _ => Err(VfsError::ParseError { + error: "unexpected response".to_string(), + path: self.path.clone(), + }), } } /// Seek file to position. /// Returns the new position. - pub fn seek(&mut self, pos: SeekFrom) -> anyhow::Result { - let request = VfsRequest { - path: self.path.clone(), - action: VfsAction::Seek { seek_from: pos }, - }; - let message = Request::new() - .target(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&request)?) - .send_and_await_response(self.timeout)?; - - match message { - Ok(Message::Response { body, .. }) => { - let response = serde_json::from_slice::(&body)?; - match response { - VfsResponse::SeekFrom(new_pos) => Ok(new_pos), - VfsResponse::Err(e) => Err(e.into()), - _ => Err(anyhow::anyhow!("vfs: unexpected response: {:?}", response)), - } - } - _ => Err(anyhow::anyhow!("vfs: unexpected message: {:?}", message)), + pub fn seek(&mut self, pos: SeekFrom) -> Result { + let message = vfs_request(&self.path, VfsAction::Seek { seek_from: pos }) + .send_and_await_response(self.timeout) + .unwrap() + .map_err(|e| VfsError::IOError { + error: e.to_string(), + path: self.path.clone(), + })?; + + match parse_response(message.body())? { + VfsResponse::SeekFrom(new_pos) => Ok(new_pos), + VfsResponse::Err(e) => Err(e), + _ => Err(VfsError::ParseError { + error: "unexpected response".to_string(), + path: self.path.clone(), + }), } } /// Copies a file to path, returns a new File. - pub fn copy(&mut self, path: &str) -> anyhow::Result { - let request = VfsRequest { - path: self.path.to_string(), - action: VfsAction::CopyFile { + pub fn copy(&mut self, path: &str) -> Result { + let message = vfs_request( + &self.path, + VfsAction::CopyFile { new_path: path.to_string(), }, - }; - - let message = Request::new() - .target(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&request)?) - .send_and_await_response(5)?; - - match message { - Ok(Message::Response { body, .. }) => { - let response = serde_json::from_slice::(&body)?; - match response { - VfsResponse::Ok => Ok(File { - path: path.to_string(), - timeout: self.timeout, - }), - VfsResponse::Err(e) => Err(e.into()), - _ => Err(anyhow::anyhow!("vfs: unexpected response: {:?}", response)), - } - } - _ => Err(anyhow::anyhow!("vfs: unexpected message: {:?}", message)), + ) + .send_and_await_response(self.timeout) + .unwrap() + .map_err(|e| VfsError::IOError { + error: e.to_string(), + path: self.path.clone(), + })?; + + match parse_response(message.body())? { + VfsResponse::Ok => Ok(File { + path: path.to_string(), + timeout: self.timeout, + }), + VfsResponse::Err(e) => Err(e), + _ => Err(VfsError::ParseError { + error: "unexpected response".to_string(), + path: self.path.clone(), + }), } } /// Set file length, if given size > underlying file, fills it with 0s. - pub fn set_len(&mut self, size: u64) -> anyhow::Result<()> { - let request = VfsRequest { - path: self.path.clone(), - action: VfsAction::SetLen(size), - }; - let message = Request::new() - .target(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&request)?) - .send_and_await_response(self.timeout)?; - - match message { - Ok(Message::Response { body, .. }) => { - let response = serde_json::from_slice::(&body)?; - match response { - VfsResponse::Ok => Ok(()), - VfsResponse::Err(e) => Err(e.into()), - _ => Err(anyhow::anyhow!("vfs: unexpected response: {:?}", response)), - } - } - _ => Err(anyhow::anyhow!("vfs: unexpected message: {:?}", message)), + pub fn set_len(&mut self, size: u64) -> Result<(), VfsError> { + let message = vfs_request(&self.path, VfsAction::SetLen(size)) + .send_and_await_response(self.timeout) + .unwrap() + .map_err(|e| VfsError::IOError { + error: e.to_string(), + path: self.path.clone(), + })?; + + match parse_response(message.body())? { + VfsResponse::Ok => Ok(()), + VfsResponse::Err(e) => Err(e), + _ => Err(VfsError::ParseError { + error: "unexpected response".to_string(), + path: self.path.clone(), + }), } } /// Metadata of a path, returns file type and length. - pub fn metadata(&self) -> anyhow::Result { - let request = VfsRequest { - path: self.path.clone(), - action: VfsAction::Metadata, - }; - let message = Request::new() - .target(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&request)?) - .send_and_await_response(self.timeout)?; - - match message { - Ok(Message::Response { body, .. }) => { - let response = serde_json::from_slice::(&body)?; - match response { - VfsResponse::Metadata(metadata) => Ok(metadata), - VfsResponse::Err(e) => Err(e.into()), - _ => Err(anyhow::anyhow!("vfs: unexpected response: {:?}", response)), - } - } - _ => Err(anyhow::anyhow!("vfs: unexpected message: {:?}", message)), + pub fn metadata(&self) -> Result { + let message = vfs_request(&self.path, VfsAction::Metadata) + .send_and_await_response(self.timeout) + .unwrap() + .map_err(|e| VfsError::IOError { + error: e.to_string(), + path: self.path.clone(), + })?; + + match parse_response(message.body())? { + VfsResponse::Metadata(metadata) => Ok(metadata), + VfsResponse::Err(e) => Err(e), + _ => Err(VfsError::ParseError { + error: "unexpected response".to_string(), + path: self.path.clone(), + }), } } /// Syncs path file buffers to disk. - pub fn sync_all(&self) -> anyhow::Result<()> { - let request = VfsRequest { - path: self.path.clone(), - action: VfsAction::SyncAll, - }; - let message = Request::new() - .target(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&request)?) - .send_and_await_response(self.timeout)?; - - match message { - Ok(Message::Response { body, .. }) => { - let response = serde_json::from_slice::(&body)?; - match response { - VfsResponse::Ok => Ok(()), - VfsResponse::Err(e) => Err(e.into()), - _ => Err(anyhow::anyhow!("vfs: unexpected response: {:?}", response)), - } - } - _ => Err(anyhow::anyhow!("vfs: unexpected message: {:?}", message)), + pub fn sync_all(&self) -> Result<(), VfsError> { + let message = vfs_request(&self.path, VfsAction::SyncAll) + .send_and_await_response(self.timeout) + .unwrap() + .map_err(|e| VfsError::IOError { + error: e.to_string(), + path: self.path.clone(), + })?; + + match parse_response(message.body())? { + VfsResponse::Ok => Ok(()), + VfsResponse::Err(e) => Err(e), + _ => Err(VfsError::ParseError { + error: "unexpected response".to_string(), + path: self.path.clone(), + }), } } } @@ -377,113 +330,96 @@ pub fn create_drive( package_id: PackageId, drive: &str, timeout: Option, -) -> anyhow::Result { +) -> Result { let timeout = timeout.unwrap_or(5); - let path = format!("/{}/{}", package_id, drive); - let res = Request::new() - .target(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&VfsRequest { + + let message = vfs_request(&path, VfsAction::CreateDrive) + .send_and_await_response(timeout) + .unwrap() + .map_err(|e| VfsError::IOError { + error: e.to_string(), path: path.clone(), - action: VfsAction::CreateDrive, - })?) - .send_and_await_response(timeout)?; - - match res { - Ok(Message::Response { body, .. }) => { - let response = serde_json::from_slice::(&body)?; - match response { - VfsResponse::Ok => Ok(path), - VfsResponse::Err(e) => Err(e.into()), - _ => Err(anyhow::anyhow!("vfs: unexpected response: {:?}", response)), - } - } - _ => Err(anyhow::anyhow!("vfs: unexpected message: {:?}", res)), + })?; + + match parse_response(message.body())? { + VfsResponse::Ok => Ok(path), + VfsResponse::Err(e) => Err(e), + _ => Err(VfsError::ParseError { + error: "unexpected response".to_string(), + path, + }), } } /// Opens a file at path, if no file at path, creates one if boolean create is true. -pub fn open_file(path: &str, create: bool, timeout: Option) -> anyhow::Result { +pub fn open_file(path: &str, create: bool, timeout: Option) -> Result { let timeout = timeout.unwrap_or(5); - let request = VfsRequest { - path: path.to_string(), - action: VfsAction::OpenFile { create }, - }; - - let message = Request::new() - .target(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&request)?) - .send_and_await_response(timeout)?; - - match message { - Ok(Message::Response { body, .. }) => { - let response = serde_json::from_slice::(&body)?; - match response { - VfsResponse::Ok => Ok(File { - path: path.to_string(), - timeout, - }), - VfsResponse::Err(e) => Err(e.into()), - _ => Err(anyhow::anyhow!("vfs: unexpected response: {:?}", response)), - } - } - _ => Err(anyhow::anyhow!("vfs: unexpected message: {:?}", message)), + let message = vfs_request(path, VfsAction::OpenFile { create }) + .send_and_await_response(timeout) + .unwrap() + .map_err(|e| VfsError::IOError { + error: e.to_string(), + path: path.to_string(), + })?; + + match parse_response(message.body())? { + VfsResponse::Ok => Ok(File { + path: path.to_string(), + timeout, + }), + VfsResponse::Err(e) => Err(e), + _ => Err(VfsError::ParseError { + error: "unexpected response".to_string(), + path: path.to_string(), + }), } } /// Creates a file at path, if file found at path, truncates it to 0. -pub fn create_file(path: &str, timeout: Option) -> anyhow::Result { +pub fn create_file(path: &str, timeout: Option) -> Result { let timeout = timeout.unwrap_or(5); - let request = VfsRequest { - path: path.to_string(), - action: VfsAction::CreateFile, - }; - - let message = Request::new() - .target(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&request)?) - .send_and_await_response(timeout)?; - - match message { - Ok(Message::Response { body, .. }) => { - let response = serde_json::from_slice::(&body)?; - match response { - VfsResponse::Ok => Ok(File { - path: path.to_string(), - timeout, - }), - VfsResponse::Err(e) => Err(e.into()), - _ => Err(anyhow::anyhow!("vfs: unexpected response: {:?}", response)), - } - } - _ => Err(anyhow::anyhow!("vfs: unexpected message: {:?}", message)), + + let message = vfs_request(path, VfsAction::CreateFile) + .send_and_await_response(timeout) + .unwrap() + .map_err(|e| VfsError::IOError { + error: e.to_string(), + path: path.to_string(), + })?; + + match parse_response(message.body())? { + VfsResponse::Ok => Ok(File { + path: path.to_string(), + timeout, + }), + VfsResponse::Err(e) => Err(e), + _ => Err(VfsError::ParseError { + error: "unexpected response".to_string(), + path: path.to_string(), + }), } } /// Removes a file at path, errors if path not found or path is not a file. -pub fn remove_file(path: &str, timeout: Option) -> anyhow::Result<()> { +pub fn remove_file(path: &str, timeout: Option) -> Result<(), VfsError> { let timeout = timeout.unwrap_or(5); - let request = VfsRequest { - path: path.to_string(), - action: VfsAction::RemoveFile, - }; - - let message = Request::new() - .target(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&request)?) - .send_and_await_response(timeout)?; - - match message { - Ok(Message::Response { body, .. }) => { - let response = serde_json::from_slice::(&body)?; - match response { - VfsResponse::Ok => Ok(()), - VfsResponse::Err(e) => Err(e.into()), - _ => Err(anyhow::anyhow!("vfs: unexpected response: {:?}", response)), - } - } - _ => Err(anyhow::anyhow!("vfs: unexpected message: {:?}", message)), + let message = vfs_request(path, VfsAction::RemoveFile) + .send_and_await_response(timeout) + .unwrap() + .map_err(|e| VfsError::IOError { + error: e.to_string(), + path: path.to_string(), + })?; + + match parse_response(message.body())? { + VfsResponse::Ok => Ok(()), + VfsResponse::Err(e) => Err(e.into()), + _ => Err(VfsError::ParseError { + error: "unexpected response".to_string(), + path: path.to_string(), + }), } } diff --git a/src/vfs/mod.rs b/src/vfs/mod.rs index 5f0aa5f..3f705b1 100644 --- a/src/vfs/mod.rs +++ b/src/vfs/mod.rs @@ -1,4 +1,4 @@ -use crate::{Message, Request}; +use crate::Request; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -109,7 +109,6 @@ pub enum VfsError { CreateDirError { path: String, error: String }, } -#[allow(dead_code)] impl VfsError { pub fn kind(&self) -> &str { match *self { @@ -126,41 +125,57 @@ impl VfsError { } } +pub fn vfs_request(path: T, action: VfsAction) -> Request +where + T: Into, +{ + Request::new().target(("our", "vfs", "distro", "sys")).body( + serde_json::to_vec(&VfsRequest { + path: path.into(), + action, + }) + .expect("failed to serialize VfsRequest"), + ) +} + /// Metadata of a path, returns file type and length. -pub fn metadata(path: &str, timeout: Option) -> anyhow::Result { +pub fn metadata(path: &str, timeout: Option) -> Result { let timeout = timeout.unwrap_or(5); - let request = VfsRequest { - path: path.to_string(), - action: VfsAction::Metadata, - }; - let message = Request::new() - .target(("our", "vfs", "distro", "sys")) - .body(serde_json::to_vec(&request)?) - .send_and_await_response(timeout)?; - - match message { - Ok(Message::Response { body, .. }) => { - let response = serde_json::from_slice::(&body)?; - match response { - VfsResponse::Metadata(metadata) => Ok(metadata), - VfsResponse::Err(e) => Err(e.into()), - _ => Err(anyhow::anyhow!("vfs: unexpected response: {:?}", response)), - } - } - _ => Err(anyhow::anyhow!("vfs: unexpected message: {:?}", message)), + let message = vfs_request(path, VfsAction::Metadata) + .send_and_await_response(timeout) + .unwrap() + .map_err(|e| VfsError::IOError { + error: e.to_string(), + path: path.to_string(), + })?; + + match parse_response(message.body())? { + VfsResponse::Metadata(metadata) => Ok(metadata), + VfsResponse::Err(e) => Err(e), + _ => Err(VfsError::ParseError { + error: "unexpected response".to_string(), + path: path.to_string(), + }), } } /// Removes a path, if it's either a directory or a file. -pub fn remove_path(path: &str, timeout: Option) -> anyhow::Result<()> { +pub fn remove_path(path: &str, timeout: Option) -> Result<(), VfsError> { let meta = metadata(path, timeout)?; + match meta.file_type { FileType::Directory => remove_dir(path, timeout), FileType::File => remove_file(path, timeout), - _ => Err(anyhow::anyhow!( - "vfs: path is not a file or directory: {}", - path - )), + _ => Err(VfsError::ParseError { + error: "path is not a file or directory".to_string(), + path: path.to_string(), + }), } } + +pub fn parse_response(body: &[u8]) -> Result { + serde_json::from_slice::(body).map_err(|e| VfsError::BadJson { + error: e.to_string(), + }) +}