commit - /dev/null
commit + 6f43b0407ec7a6da3018fd81c2d678c078d1f84a
blob - /dev/null
blob + 411a5b8a437447565a9a48f32fde3c2aed94b004 (mode 644)
--- /dev/null
+++ .gitignore
+build
+vendor
blob - /dev/null
blob + df99c69198f5813df5fc3eaa007a2af0e60a7bbd (mode 644)
--- /dev/null
+++ .rustfmt.toml
+max_width = 80
blob - /dev/null
blob + 32f3292e31bbd5acf75b7671b679e9017828078c (mode 644)
--- /dev/null
+++ LICENSE
+Copyright (c) 2026 Murilo Ijanc' <murilo@ijanc.org>
+
+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
+#
+# Copyright (c) 2025-2026 Murilo Ijanc' <murilo@ijanc.org>
+#
+# 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
+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
+.\"
+.\" Copyright (c) 2025-2026 Murilo Ijanc' <murilo@ijanc.org>
+.\"
+.\" 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
+// vim: set tw=79 cc=80 ts=4 sw=4 sts=4 et :
+//
+// Copyright (c) 2026 Murilo Ijanc' <murilo@ijanc.org>
+//
+// 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<String> = env::args().collect();
+ match args.len() {
+ 2 if args[1] == "-V" => {
+ println!("sm {}", env!("SM_VERSION"));
+ return 0;
+ }
+ 3 => {}
+ _ => {
+ eprintln!("usage: sm <channel-id> <message|->");
+ 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<Self, String> {
+ 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<Vec<u8>, 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<Vec<u8>, 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<usize> {
+ hay.windows(needle.len()).position(|w| w == needle)
+}
+
+fn extract_str(body: &[u8], key: &[u8]) -> Option<String> {
+ 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
+}