Commit Diff


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' <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
@@ -0,0 +1,59 @@
+#
+# 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
@@ -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' <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
@@ -0,0 +1,456 @@
+// 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
+}