summaryrefslogtreecommitdiffstats
path: root/src/protocol.rs
diff options
context:
space:
mode:
authormurilo ijanc2026-03-25 23:23:10 -0300
committermurilo ijanc2026-03-25 23:23:10 -0300
commita96da5f0704c50e8a4e4f047dcd3fb7c73fdf600 (patch)
treef98bb20411dbc6a49e8c286b054a88d89eea795f /src/protocol.rs
downloadtesseras-url-main.tar.gz
Initial implementation of tesseras-urlHEADmain
Decentralized URL shortener built on tesseras-dht. Includes: - tud daemon: DHT node, Unix socket API, HTTP 302 redirect server - tu CLI: shorten, resolve, del, list, status commands - Auto-generated slugs (8-byte SHA256, base58) or custom slugs - TTL support (default: forever) - Automatic re-join of bootstrap nodes when routing table is empty - OpenBSD pledge(2) and unveil(2) sandboxing - DNS SRV bootstrap discovery - Verbose mode (-v) for both binaries
Diffstat (limited to 'src/protocol.rs')
-rw-r--r--src/protocol.rs158
1 files changed, 158 insertions, 0 deletions
diff --git a/src/protocol.rs b/src/protocol.rs
new file mode 100644
index 0000000..60d2fa9
--- /dev/null
+++ b/src/protocol.rs
@@ -0,0 +1,158 @@
+//! Unix socket protocol for daemon ↔ CLI.
+//!
+//! Simple line-oriented text protocol:
+//! SHORTEN <ttl_secs> <slug|auto> <url>\n
+//! RESOLVE <slug>\n
+//! DEL <slug>\n
+//! LIST\n
+//! STATUS\n
+//! SHUTDOWN\n
+//!
+//! Responses:
+//! OK <data>\n
+//! ERR <message>\n
+
+/// A parsed request received from the CLI over the Unix socket.
+pub enum Request {
+ Shorten {
+ ttl_secs: u64,
+ slug: String,
+ target_url: String,
+ },
+ Resolve {
+ slug: String,
+ },
+ Del {
+ slug: String,
+ },
+ List,
+ Status,
+ Shutdown,
+}
+
+/// A response sent back to the CLI over the Unix socket.
+pub enum Response {
+ Ok(String),
+ Err(String),
+}
+
+/// Parse a single protocol line into a [`Request`].
+pub fn parse_request(line: &str) -> Result<Request, String> {
+ let line = line.trim();
+ if line.is_empty() {
+ return Err("empty request".into());
+ }
+
+ let mut parts = line.splitn(4, ' ');
+ let cmd = parts.next().unwrap();
+
+ match cmd {
+ "SHORTEN" => {
+ let ttl_str = parts
+ .next()
+ .ok_or("SHORTEN requires: SHORTEN <ttl> <slug> <url>")?;
+ let slug = parts
+ .next()
+ .ok_or("SHORTEN requires: SHORTEN <ttl> <slug> <url>")?;
+ let target_url = parts
+ .next()
+ .ok_or("SHORTEN requires: SHORTEN <ttl> <slug> <url>")?;
+ let ttl_secs: u64 =
+ ttl_str.parse().map_err(|_| "invalid TTL number")?;
+ Ok(Request::Shorten {
+ ttl_secs,
+ slug: slug.to_string(),
+ target_url: target_url.to_string(),
+ })
+ }
+ "RESOLVE" => {
+ let slug = parts.next().ok_or("RESOLVE requires: RESOLVE <slug>")?;
+ Ok(Request::Resolve {
+ slug: slug.to_string(),
+ })
+ }
+ "DEL" => {
+ let slug = parts.next().ok_or("DEL requires: DEL <slug>")?;
+ Ok(Request::Del {
+ slug: slug.to_string(),
+ })
+ }
+ "LIST" => Ok(Request::List),
+ "STATUS" => Ok(Request::Status),
+ "SHUTDOWN" => Ok(Request::Shutdown),
+ _ => Err(format!("unknown command: {cmd}")),
+ }
+}
+
+/// Serialize a [`Response`] into a protocol line.
+pub fn format_response(r: &Response) -> String {
+ match r {
+ Response::Ok(data) => format!("OK {data}\n"),
+ Response::Err(msg) => format!("ERR {msg}\n"),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parse_shorten() {
+ let r =
+ parse_request("SHORTEN 0 auto https://example.com").unwrap();
+ match r {
+ Request::Shorten {
+ ttl_secs,
+ slug,
+ target_url,
+ } => {
+ assert_eq!(ttl_secs, 0);
+ assert_eq!(slug, "auto");
+ assert_eq!(target_url, "https://example.com");
+ }
+ _ => panic!("expected Shorten"),
+ }
+ }
+
+ #[test]
+ fn parse_shorten_custom_slug() {
+ let r =
+ parse_request("SHORTEN 3600 myslug https://example.com").unwrap();
+ match r {
+ Request::Shorten { slug, .. } => assert_eq!(slug, "myslug"),
+ _ => panic!("expected Shorten"),
+ }
+ }
+
+ #[test]
+ fn parse_resolve() {
+ let r = parse_request("RESOLVE abc123").unwrap();
+ match r {
+ Request::Resolve { slug } => assert_eq!(slug, "abc123"),
+ _ => panic!("expected Resolve"),
+ }
+ }
+
+ #[test]
+ fn parse_status() {
+ assert!(matches!(
+ parse_request("STATUS").unwrap(),
+ Request::Status
+ ));
+ }
+
+ #[test]
+ fn parse_list() {
+ assert!(matches!(parse_request("LIST").unwrap(), Request::List));
+ }
+
+ #[test]
+ fn parse_empty_fails() {
+ assert!(parse_request("").is_err());
+ }
+
+ #[test]
+ fn parse_unknown_fails() {
+ assert!(parse_request("FOOBAR").is_err());
+ }
+}