commit 6f43b0407ec7a6da3018fd81c2d678c078d1f84a from: Murilo Ijanc date: Thu Apr 16 19:36:01 2026 UTC Initial import of sm, a minimal Slack message CLI. sm posts a single message to a Slack channel through the chat.postMessage API. TLS is provided by libtls over a raw TCP stream, and JSON is generated and parsed inline so there are no crate dependencies beyond the Rust standard library. The channel id and the message come from argv; a message of "-" is read from standard input. The token is taken from the SLACK_USER_TOKEN environment variable. sm exits 0 on success; on failure it exits non-zero and writes a diagnostic to standard error, distinguishing input, transport, and API errors. commit - /dev/null commit + 6f43b0407ec7a6da3018fd81c2d678c078d1f84a blob - /dev/null blob + 411a5b8a437447565a9a48f32fde3c2aed94b004 (mode 644) --- /dev/null +++ .gitignore @@ -0,0 +1,2 @@ +build +vendor blob - /dev/null blob + df99c69198f5813df5fc3eaa007a2af0e60a7bbd (mode 644) --- /dev/null +++ .rustfmt.toml @@ -0,0 +1 @@ +max_width = 80 blob - /dev/null blob + 32f3292e31bbd5acf75b7671b679e9017828078c (mode 644) --- /dev/null +++ LICENSE @@ -0,0 +1,13 @@ +Copyright (c) 2026 Murilo Ijanc' + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. blob - /dev/null blob + 7dd31e538016cf5c7d220ffe8a2e9628a79da650 (mode 644) --- /dev/null +++ Makefile @@ -0,0 +1,59 @@ +# +# Copyright (c) 2025-2026 Murilo Ijanc' +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +RUSTC ?= $(shell rustup which rustc 2>/dev/null || which rustc) +RUSTFLAGS ?= -C opt-level=2 -C strip=symbols +VERSION = 0.1.0 +PREFIX ?= /usr/local +MANDIR ?= $(PREFIX)/share/man + +BUILD = build +BIN = $(BUILD)/sm +MAIN = sm.rs + +CLIPPY ?= $(shell rustup which clippy-driver 2>/dev/null) +RUSTFMT ?= $(shell rustup which rustfmt 2>/dev/null) + +.PHONY: all clean install ci fmt-check clippy + +all: $(BIN) + +$(BIN): $(MAIN) + mkdir -p $(BUILD) + SM_VERSION=$(VERSION) TMPDIR=/tmp $(RUSTC) --edition 2024 \ + --crate-type bin --crate-name sm $(RUSTFLAGS) \ + -C link-arg=-ltls \ + -o $@ $< + +clean: + rm -rf $(BUILD) + +install: $(BIN) + install -d $(PREFIX)/bin $(MANDIR)/man1 + install -m 755 $(BIN) $(PREFIX)/bin/sm + install -m 644 sm.1 $(MANDIR)/man1/sm.1 + +fmt-check: + $(RUSTFMT) --edition 2024 --check $(MAIN) + +clippy: + SM_VERSION=$(VERSION) TMPDIR=/tmp $(CLIPPY) --edition 2024 \ + --crate-type bin --crate-name sm \ + -C link-arg=-ltls \ + -W clippy::all -o /tmp/sm.clippy $(MAIN) + @rm -f /tmp/sm.clippy + +ci: fmt-check clippy $(BIN) blob - /dev/null blob + 3c35f083919b51bec4fc339989ec8d4ba835e4b3 (mode 644) --- /dev/null +++ README.md @@ -0,0 +1,54 @@ +sm - Slack Message +================== +sm is a minimal binary to send a message to a Slack channel. + + +Requirements +------------ +In order to build sm you need rustc and libtls (LibreSSL). + + +Installation +------------ +Edit Makefile to match your local setup (sm is installed into +the /usr/local/bin namespace by default). + +Afterwards enter the following command to build and install sm: + + make clean install + + +Running sm +---------- +sm takes the channel id and the message, in this order, and posts +the message to that channel as the owner of SLACK_USER_TOKEN. +Exit status is 0 on success and non-zero on failure. + + sm C03DEET2M18 "hello from sm" + +The message may also be read from standard input by passing "-": + + date | sm C03DEET2M18 - + +Print the version: + + sm -V + + +Configuration +------------- +sm reads a single environment variable: + + SLACK_USER_TOKEN Slack user token, e.g. xoxp-... + +The token must have the chat:write scope for the target channel. + + +Download +-------- + got clone ssh://ijanc@ijanc.org/sm + + +License +------- +ISC — see LICENSE. blob - /dev/null blob + 78d449e4670be777d3c063b261a568bbdf260c56 (mode 644) --- /dev/null +++ sm.1 @@ -0,0 +1,99 @@ +.\" +.\" Copyright (c) 2025-2026 Murilo Ijanc' +.\" +.\" Permission to use, copy, modify, and/or distribute this software for any +.\" purpose with or without fee is hereby granted, provided that the above +.\" copyright notice and this permission notice appear in all copies. +.\" +.\" THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +.\" WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +.\" MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +.\" ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +.\" WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +.\" ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +.\" OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +.\" +.Dd $Mdocdate: April 16 2026 $ +.Dt SM 1 +.Os +.Sh NAME +.Nm sm +.Nd send a message to a Slack channel +.Sh SYNOPSIS +.Nm sm +.Ar channel-id +.Ar message +.Nm sm +.Ar channel-id +.Fl +.Nm sm +.Fl V +.Sh DESCRIPTION +.Nm +posts +.Ar message +to the Slack channel identified by +.Ar channel-id +using the token in the +.Ev SLACK_USER_TOKEN +environment variable. +.Pp +If +.Ar message +is a single hyphen +.Pq Sq \- +.Nm +reads the message body from standard input. +.Pp +.Ar channel-id +must start with +.Sq C , +.Sq G +or +.Sq D +followed by uppercase letters or digits. +.Pp +The message must be non-empty and no longer than 40000 bytes. +.Pp +The options are as follows: +.Bl -tag -width Ds +.It Fl V +Print the version and exit. +.El +.Sh ENVIRONMENT +.Bl -tag -width SLACK_USER_TOKEN +.It Ev SLACK_USER_TOKEN +Slack user token +.Pq e.g. Li xoxp-... . +Must carry the +.Li chat:write +scope on the target channel. +.El +.Sh EXIT STATUS +.Nm +exits 0 on success and non-zero on failure. +A diagnostic message is written to standard error. +.Bl -tag -width Ds +.It 0 +Message delivered. +.It 1 +Usage error, invalid input, or missing token. +.It 2 +Network or TLS failure. +.It 3 +Slack API reported an error. +.El +.Sh EXAMPLES +Send a one-line message: +.Bd -literal -offset indent +$ sm C03DEET2M18 "hello" +.Ed +.Pp +Pipe a message from another command: +.Bd -literal -offset indent +$ uname -a | sm C03DEET2M18 \- +.Ed +.Sh SEE ALSO +.Xr tls_init 3 +.Sh AUTHORS +.An Murilo Ijanc' Aq Mt murilo@ijanc.org blob - /dev/null blob + a33466e063fdc3fa28fece729888b6dae3b32507 (mode 644) --- /dev/null +++ sm.rs @@ -0,0 +1,456 @@ +// vim: set tw=79 cc=80 ts=4 sw=4 sts=4 et : +// +// Copyright (c) 2026 Murilo Ijanc' +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +// + +use std::{ + env, + ffi::{CStr, CString}, + fmt::Write as _, + io::{self, Read}, + net::TcpStream, + os::{fd::AsRawFd, raw::c_void}, + process, + time::Duration, +}; + +const HOST: &str = "slack.com"; +const PORT: u16 = 443; +const PATH: &str = "/api/chat.postMessage"; +const MAX_MSG_BYTES: usize = 40_000; +const TIMEOUT_SECS: u64 = 30; + +fn main() { + process::exit(run()); +} + +fn run() -> i32 { + let args: Vec = env::args().collect(); + match args.len() { + 2 if args[1] == "-V" => { + println!("sm {}", env!("SM_VERSION")); + return 0; + } + 3 => {} + _ => { + eprintln!("usage: sm "); + eprintln!(" sm -V"); + return 1; + } + } + + let channel = &args[1]; + if let Err(e) = validate_channel(channel) { + eprintln!("sm: invalid channel: {e}"); + return 1; + } + + let message = if args[2] == "-" { + let mut buf = String::new(); + if let Err(e) = io::stdin().read_to_string(&mut buf) { + eprintln!("sm: read stdin: {e}"); + return 1; + } + buf + } else { + args[2].clone() + }; + if let Err(e) = validate_message(&message) { + eprintln!("sm: invalid message: {e}"); + return 1; + } + + let token = match env::var("SLACK_USER_TOKEN") { + Ok(t) if t.starts_with("xox") => t, + Ok(_) => { + eprintln!("sm: SLACK_USER_TOKEN does not look like a slack token"); + return 1; + } + Err(_) => { + eprintln!("sm: SLACK_USER_TOKEN is not set"); + return 1; + } + }; + + let mut body = String::with_capacity(channel.len() + message.len() + 32); + body.push_str("{\"channel\":\""); + json_escape(&mut body, channel); + body.push_str("\",\"text\":\""); + json_escape(&mut body, &message); + body.push_str("\"}"); + + let resp = match post(&token, &body) { + Ok(r) => r, + Err(e) => { + eprintln!("sm: {e}"); + return 2; + } + }; + + match parse_ok(&resp) { + Ok(()) => 0, + Err(e) => { + eprintln!("sm: slack: {e}"); + 3 + } + } +} + +////////////////////////////////////////////////////////////////////////////// +// Validation +////////////////////////////////////////////////////////////////////////////// + +fn validate_channel(s: &str) -> Result<(), String> { + let b = s.as_bytes(); + if !(9..=32).contains(&b.len()) { + return Err("length out of range (9..=32)".into()); + } + match b[0] { + b'C' | b'G' | b'D' => {} + _ => return Err("must start with C, G or D".into()), + } + for &c in &b[1..] { + if !(c.is_ascii_digit() || c.is_ascii_uppercase()) { + return Err("must match [A-Z0-9]".into()); + } + } + Ok(()) +} + +fn validate_message(s: &str) -> Result<(), String> { + if s.trim().is_empty() { + return Err("empty".into()); + } + if s.len() > MAX_MSG_BYTES { + return Err(format!( + "too long ({} > {} bytes)", + s.len(), + MAX_MSG_BYTES + )); + } + if s.bytes().any(|b| b == 0) { + return Err("contains NUL".into()); + } + Ok(()) +} + +////////////////////////////////////////////////////////////////////////////// +// JSON +////////////////////////////////////////////////////////////////////////////// + +fn json_escape(out: &mut String, s: &str) { + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\x08' => out.push_str("\\b"), + '\x0c' => out.push_str("\\f"), + c if (c as u32) < 0x20 => { + let _ = write!(out, "\\u{:04x}", c as u32); + } + c => out.push(c), + } + } +} + +////////////////////////////////////////////////////////////////////////////// +// libtls FFI +////////////////////////////////////////////////////////////////////////////// + +mod ffi { + use std::os::raw::{c_char, c_int, c_void}; + + #[repr(C)] + pub(super) struct Tls { + _p: [u8; 0], + } + #[repr(C)] + pub(super) struct TlsConfig { + _p: [u8; 0], + } + + pub(super) const TLS_WANT_POLLIN: isize = -2; + pub(super) const TLS_WANT_POLLOUT: isize = -3; + + #[link(name = "tls")] + unsafe extern "C" { + pub(super) fn tls_init() -> c_int; + pub(super) fn tls_config_new() -> *mut TlsConfig; + pub(super) fn tls_config_free(c: *mut TlsConfig); + pub(super) fn tls_client() -> *mut Tls; + pub(super) fn tls_configure( + ctx: *mut Tls, + cfg: *mut TlsConfig, + ) -> c_int; + pub(super) fn tls_connect_socket( + ctx: *mut Tls, + fd: c_int, + servername: *const c_char, + ) -> c_int; + pub(super) fn tls_handshake(ctx: *mut Tls) -> c_int; + pub(super) fn tls_write( + ctx: *mut Tls, + buf: *const c_void, + n: usize, + ) -> isize; + pub(super) fn tls_read( + ctx: *mut Tls, + buf: *mut c_void, + n: usize, + ) -> isize; + pub(super) fn tls_close(ctx: *mut Tls) -> c_int; + pub(super) fn tls_free(ctx: *mut Tls); + pub(super) fn tls_error(ctx: *mut Tls) -> *const c_char; + } +} + +use ffi::*; + +struct Conn { + ctx: *mut Tls, + cfg: *mut TlsConfig, + _sock: TcpStream, +} + +impl Conn { + fn connect(host: &str, port: u16) -> Result { + unsafe { + if tls_init() != 0 { + return Err("tls_init failed".into()); + } + let cfg = tls_config_new(); + if cfg.is_null() { + return Err("tls_config_new failed".into()); + } + let ctx = tls_client(); + if ctx.is_null() { + tls_config_free(cfg); + return Err("tls_client failed".into()); + } + if tls_configure(ctx, cfg) != 0 { + let e = tls_err(ctx); + tls_free(ctx); + tls_config_free(cfg); + return Err(format!("tls_configure: {e}")); + } + + let sock = TcpStream::connect((host, port)) + .map_err(|e| format!("connect {host}:{port}: {e}"))?; + let _ = + sock.set_read_timeout(Some(Duration::from_secs(TIMEOUT_SECS))); + let _ = + sock.set_write_timeout(Some(Duration::from_secs(TIMEOUT_SECS))); + + let chost = CString::new(host).map_err(|e| e.to_string())?; + if tls_connect_socket(ctx, sock.as_raw_fd(), chost.as_ptr()) != 0 { + let e = tls_err(ctx); + tls_free(ctx); + tls_config_free(cfg); + return Err(format!("tls_connect_socket: {e}")); + } + loop { + let r = tls_handshake(ctx) as isize; + if r == 0 { + break; + } + if r == TLS_WANT_POLLIN || r == TLS_WANT_POLLOUT { + continue; + } + let e = tls_err(ctx); + tls_free(ctx); + tls_config_free(cfg); + return Err(format!("tls_handshake: {e}")); + } + Ok(Self { + ctx, + cfg, + _sock: sock, + }) + } + } + + fn write_all(&self, mut buf: &[u8]) -> Result<(), String> { + unsafe { + while !buf.is_empty() { + let r = tls_write( + self.ctx, + buf.as_ptr() as *const c_void, + buf.len(), + ); + if r == TLS_WANT_POLLIN || r == TLS_WANT_POLLOUT { + continue; + } + if r < 0 { + return Err(format!("tls_write: {}", tls_err(self.ctx))); + } + buf = &buf[r as usize..]; + } + Ok(()) + } + } + + fn read_to_end(&self) -> Result, String> { + let mut out = Vec::with_capacity(4096); + let mut tmp = [0u8; 4096]; + unsafe { + loop { + let r = tls_read( + self.ctx, + tmp.as_mut_ptr() as *mut c_void, + tmp.len(), + ); + if r == TLS_WANT_POLLIN || r == TLS_WANT_POLLOUT { + continue; + } + if r < 0 { + return Err(format!("tls_read: {}", tls_err(self.ctx))); + } + if r == 0 { + break; + } + out.extend_from_slice(&tmp[..r as usize]); + } + } + Ok(out) + } +} + +impl Drop for Conn { + fn drop(&mut self) { + unsafe { + if !self.ctx.is_null() { + loop { + let r = tls_close(self.ctx) as isize; + if r == 0 || (r != TLS_WANT_POLLIN && r != TLS_WANT_POLLOUT) + { + break; + } + } + tls_free(self.ctx); + } + if !self.cfg.is_null() { + tls_config_free(self.cfg); + } + } + } +} + +unsafe fn tls_err(ctx: *mut Tls) -> String { + unsafe { + let p = tls_error(ctx); + if p.is_null() { + "(unknown)".into() + } else { + CStr::from_ptr(p).to_string_lossy().into_owned() + } + } +} + +////////////////////////////////////////////////////////////////////////////// +// HTTP +////////////////////////////////////////////////////////////////////////////// + +fn post(token: &str, body: &str) -> Result, String> { + let conn = Conn::connect(HOST, PORT)?; + let req = format!( + "POST {path} HTTP/1.1\r\n\ + Host: {host}\r\n\ + Authorization: Bearer {token}\r\n\ + Content-Type: application/json; charset=utf-8\r\n\ + Content-Length: {len}\r\n\ + Connection: close\r\n\ + User-Agent: sm/{ver}\r\n\ + \r\n\ + {body}", + path = PATH, + host = HOST, + token = token, + len = body.len(), + ver = env!("SM_VERSION"), + body = body, + ); + conn.write_all(req.as_bytes())?; + // TODO(ijanc): we asked for Connection: close and read until EOF. + // Replace with a proper Content-Length / Transfer-Encoding: chunked + // parser so truncation is detected at the transport layer instead of + // relying on parse_ok() to spot a missing "ok" field. + conn.read_to_end() +} + +////////////////////////////////////////////////////////////////////////////// +// Response +////////////////////////////////////////////////////////////////////////////// + +fn parse_ok(resp: &[u8]) -> Result<(), String> { + let sep = + find(resp, b"\r\n\r\n").ok_or("no header terminator in response")?; + let body = &resp[sep + 4..]; + // TODO(ijanc): substring-based matching is enough for Slack's flat + // envelope but would misread nested strings containing "ok":true/false. + // Replace with a minimal JSON scanner when a second caller appears. + if find(body, b"\"ok\":true").is_some() { + return Ok(()); + } + if find(body, b"\"ok\":false").is_none() { + return Err("malformed or truncated response".into()); + } + let mut msg = extract_str(body, b"\"error\":\"") + .unwrap_or_else(|| "unknown error".into()); + if let Some(needed) = extract_str(body, b"\"needed\":\"") { + let _ = write!(msg, " (needed: {needed})"); + } + if let Some(provided) = extract_str(body, b"\"provided\":\"") { + let _ = write!(msg, " (provided: {provided})"); + } + Err(msg) +} + +fn find(hay: &[u8], needle: &[u8]) -> Option { + hay.windows(needle.len()).position(|w| w == needle) +} + +fn extract_str(body: &[u8], key: &[u8]) -> Option { + let start = find(body, key)? + key.len(); + let mut out = String::new(); + let mut i = start; + while i < body.len() { + let b = body[i]; + if b == b'"' { + return Some(out); + } + if b == b'\\' && i + 1 < body.len() { + match body[i + 1] { + b'n' => out.push('\n'), + b'r' => out.push('\r'), + b't' => out.push('\t'), + b'"' => out.push('"'), + b'\\' => out.push('\\'), + b'/' => out.push('/'), + o => { + out.push('\\'); + out.push(o as char); + } + } + i += 2; + } else { + out.push(b as char); + i += 1; + } + } + None +}