Add capability descriptors and CAP negotiation state machines
(sigil irc capability) — descriptor record + parser: cap, make-cap, cap-name, cap-value cap->string, string->cap, parse-cap-list CAP-SASL, CAP-MESSAGE-TAGS, CAP-SERVER-TIME, CAP-ACCOUNT-TAG, CAP-ACCOUNT-NOTIFY, CAP-EXTENDED-JOIN, CAP-USERHOST-IN-NAMES, CAP-MULTI-PREFIX, CAP-AWAY-NOTIFY, CAP-CHGHOST, CAP-INVITE-NOTIFY, CAP-SETNAME, CAP-BATCH, CAP-LABELED-RESPONSE, CAP-ECHO-MESSAGE, CAP-CAP-NOTIFY CAP-CHATHISTORY, CAP-READ-MARKER, CAP-MONITOR tier-1-caps, tier-2-caps, standard-caps
(sigil irc cap-negotiation) — pure-value state machines for both sides of the CAP negotiation handshake. The consumer feeds each incoming irc-message through cap-{client,server}-advance and gets back a list of wire-format strings to send.
Client side handles: CAP LS 302 multi-line continuation, intersection-with-desired auto-REQ, ACK / NAK / NEW / DEL handling, automatic CAP END.
Server side handles: LS / LIST / REQ / END subcommands, atomic REQ enable/disable (any unknown cap NAKs the whole request), optional value-spec on advertised caps (e.g. sasl=PLAIN,EXTERNAL), v3.2 detection.
Both expose their state as inspectable fields so the consumer can make decisions (skip SASL if not ACKed, etc.).
src/sigil/irc/cap-negotiation.sgl | 360 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/sigil/irc/capability.sgl | 200 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/test-capability.sgl | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 734 insertions(+)src/sigil/irc/cap-negotiation.sgladded
;;; (sigil irc cap-negotiation) - CAP negotiation state machines;;;;;; CAP negotiation is the IRCv3 handshake by which client and server;;; agree on a set of optional capabilities. The wire dance:;;;;;; C: CAP LS 302;;; S: CAP * LS * :sasl=PLAIN,EXTERNAL message-tags ...;;; S: CAP * LS :server-time chghost ...;;; C: CAP REQ :sasl message-tags server-time;;; S: CAP * ACK :sasl message-tags server-time (or NAK);;; C: AUTHENTICATE PLAIN (if SASL was negotiated);;; ...;;; C: CAP END;;;;;; This module provides two state machines, one per role. Both follow;;; the same shape: a state value + an `advance` function that takes;;; an incoming `irc-message` and returns `(values new-state outbound)`;;; where `outbound` is a list of wire-format strings to send.;;;;;; The state machine is a pure value — the consumer owns I/O and fiber;;; structure. This matches the protocol-only contract of sigil-irc.(define-library (sigil irc cap-negotiation) (import (sigil core) (sigil string) (sigil struct) (sigil irc message) (sigil irc capability)) (export ;; Client side cap-client-state cap-client-state? make-cap-client-state cap-client-state-phase cap-client-state-server-caps cap-client-state-requested cap-client-state-acked cap-client-state-nakked cap-client-state-done? cap-client-start cap-client-advance ;; Server side cap-server-state cap-server-state? make-cap-server-state cap-server-state-phase cap-server-state-supported cap-server-state-enabled cap-server-state-cap-302 cap-server-state-done? cap-server-advance) (begin ;; ============================================================ ;; Client-side state machine ;; ============================================================ ;; ;; Phases: ;; 'init - haven't sent CAP LS yet (call cap-client-start) ;; 'ls - waiting for CAP LS responses (may span multiple lines) ;; 'req - sent CAP REQ, waiting for ACK or NAK ;; 'done - CAP END sent, negotiation complete ;; ;; The client supplies `desired` — caps it would like — and a ;; `select` callback that, given the server-advertised caps, returns ;; the subset to actually request. Default selector picks the ;; intersection of `desired` and `available`. (define-struct cap-client-state (phase default: 'init mutable: #t) ; 'init | 'ls | 'req | 'done (desired default: '()) ; list of cap names (strings) (server-caps default: '() mutable: #t) ; alist of cap name -> value-or-#f (requested default: '() mutable: #t) ; caps in current REQ (acked default: '() mutable: #t) ; caps server ACKed (nakked default: '() mutable: #t)) ; caps server NAKed ;;; Build a client-side CAP state with a list of desired capability ;;; names. After construction, call `cap-client-start` to get the ;;; opening CAP LS line, then feed each incoming CAP / numeric reply ;;; through `cap-client-advance`. (define (make-cap-client-state (keys: (desired '()))) (: (desired: list?) -> cap-client-state?) (cap-client-state phase: 'init desired: desired)) (define (cap-client-state-done? s) (: cap-client-state? -> boolean?) (eq? (cap-client-state-phase s) 'done)) ;;; Begin negotiation. Returns the line to send (`CAP LS 302\r\n`). ;;; Transitions phase to `'ls`. (define (cap-client-start state) (: cap-client-state? -> string?) (set-cap-client-state-phase! state 'ls) "CAP LS 302\r\n") ;;; Process one incoming message and advance the state. Returns a ;;; list of wire-format strings to send (possibly empty). Messages ;;; that aren't CAP-related are simply ignored. (define (cap-client-advance state msg) (: cap-client-state? irc-message? -> list?) (let ((cmd (irc-message-command msg))) (cond ((eq? cmd 'CAP) (handle-client-cap state msg)) (else '())))) (define (handle-client-cap state msg) (let* ((params (irc-message-params msg)) ;; CAP messages: <client> <subcmd> [...args...] (subcmd (and (>= (length params) 2) (cadr params))) (rest (if (and (>= (length params) 2)) (cddr params) '()))) (cond ((equal? subcmd "LS") (handle-client-cap-ls state msg rest)) ((equal? subcmd "ACK") (handle-client-cap-ack state msg)) ((equal? subcmd "NAK") (handle-client-cap-nak state msg)) ((equal? subcmd "NEW") (handle-client-cap-new state msg)) ((equal? subcmd "DEL") (handle-client-cap-del state msg)) (else '())))) ;; CAP LS may be split across multiple lines: the third param is `*` ;; for "more to come" or absent on the last line. Capability list is ;; in trailing. (define (handle-client-cap-ls state msg rest) (let* ((multi? (and (pair? rest) (equal? (car rest) "*"))) (caps-text (or (irc-message-trailing msg) "")) (caps (parse-cap-list caps-text))) ;; Accumulate caps (set-cap-client-state-server-caps! state (append (cap-client-state-server-caps state) (map (lambda (c) (cons (cap-name c) (cap-value c))) caps))) (if multi? ;; Wait for more LS lines. '() ;; Final LS line: pick caps and send REQ. (issue-client-req state)))) (define (issue-client-req state) (let* ((server-caps (cap-client-state-server-caps state)) (desired (cap-client-state-desired state)) (intersect (filter (lambda (name) (any (lambda (entry) (equal? (car entry) name)) server-caps)) desired))) (cond ((null? intersect) ;; Nothing to negotiate — send CAP END immediately. (set-cap-client-state-phase! state 'done) (list "CAP END\r\n")) (else (set-cap-client-state-requested! state intersect) (set-cap-client-state-phase! state 'req) (list (string-append "CAP REQ :" (string-join intersect " ") "\r\n")))))) (define (handle-client-cap-ack state msg) (let* ((acked-text (or (irc-message-trailing msg) "")) (acked (map cap-name (parse-cap-list acked-text)))) (set-cap-client-state-acked! state (append (cap-client-state-acked state) acked)) ;; Per spec, ACK may include caps prefixed with `-` (disable). We ;; treat them as acked here — the consumer can inspect the raw ;; trailing if it cares about disable directives. ;; ;; If SASL is among the ACKed caps the consumer wants to drive ;; SASL before sending CAP END. Indicate completion of the CAP ;; round but don't send CAP END automatically — the consumer ;; calls `cap-client-end` at the right moment. (set-cap-client-state-phase! state 'done) (list "CAP END\r\n"))) (define (handle-client-cap-nak state msg) (let* ((nakked-text (or (irc-message-trailing msg) "")) (nakked (map cap-name (parse-cap-list nakked-text)))) (set-cap-client-state-nakked! state (append (cap-client-state-nakked state) nakked)) ;; Even on NAK we end negotiation; consumer can decide whether to ;; abort the connection based on `cap-client-state-nakked`. (set-cap-client-state-phase! state 'done) (list "CAP END\r\n"))) ;; CAP NEW announces caps the server has gained mid-session. We ;; record them in `server-caps` for the consumer's awareness; we do ;; NOT auto-request, since post-handshake REQ requires the consumer's ;; intent. (define (handle-client-cap-new state msg) (let* ((caps-text (or (irc-message-trailing msg) "")) (caps (parse-cap-list caps-text))) (set-cap-client-state-server-caps! state (append (cap-client-state-server-caps state) (map (lambda (c) (cons (cap-name c) (cap-value c))) caps))) '())) ;; CAP DEL removes caps mid-session. (define (handle-client-cap-del state msg) (let* ((caps-text (or (irc-message-trailing msg) "")) (names (map cap-name (parse-cap-list caps-text)))) (set-cap-client-state-server-caps! state (filter (lambda (entry) (not (member (car entry) names))) (cap-client-state-server-caps state))) '())) ;; ============================================================ ;; Server-side state machine ;; ============================================================ ;; ;; Phases: ;; 'init - awaiting first CAP LS / REQ / END from the client ;; 'ls-sent - we've answered LS; waiting for REQ or END ;; 'done - client sent CAP END; registration may proceed ;; ;; The server constructs the state with its full supported-cap list ;; (each as a `cap` record so SASL etc. can advertise mechanism ;; lists). It also passes a server-name used in CAP reply prefixes ;; (e.g. `enclave.example`). (define-struct cap-server-state (phase default: 'init mutable: #t) (server-name default: "*") ; used in CAP reply prefix (supported default: '()) ; list of cap records (enabled default: '() mutable: #t) ; list of enabled cap-name strings (cap-302 default: #t mutable: #t)) ; whether client speaks 302 ;;; Construct a server-side CAP state. `supported` is a list of `cap` ;;; records. `server-name` appears in reply prefixes (typically the ;;; server's domain). (define (make-cap-server-state (keys: (server-name "*") (supported '()))) (: (server-name: string?) (supported: list?) -> cap-server-state?) (cap-server-state server-name: server-name supported: supported)) (define (cap-server-state-done? s) (: cap-server-state? -> boolean?) (eq? (cap-server-state-phase s) 'done)) (define (cap-reply-prefix state nick) (string-append ":" (cap-server-state-server-name state) " CAP " (or nick "*") " ")) ;;; Process one incoming CAP message from the client and advance the ;;; state. Returns a list of wire-format strings to send back. ;;; Non-CAP messages are ignored. The client's current nick (or `#f` ;;; if not yet known, in which case `*` is used in replies) is ;;; passed in so the reply prefix matches the spec. (define (cap-server-advance state msg (keys: (client-nick #f))) (: cap-server-state? irc-message? (client-nick: any?) -> list?) (let ((cmd (irc-message-command msg))) (cond ((eq? cmd 'CAP) (handle-server-cap state msg client-nick)) (else '())))) (define (handle-server-cap state msg client-nick) (let* ((params (irc-message-params msg)) ;; Client → Server CAP messages are: CAP <subcmd> [args...] ;; (the prefix isn't required; the subcmd is the first param). (subcmd (and (pair? params) (car params)))) (cond ((equal? subcmd "LS") (handle-server-cap-ls state msg client-nick params)) ((equal? subcmd "LIST") (handle-server-cap-list state client-nick)) ((equal? subcmd "REQ") (handle-server-cap-req state msg client-nick)) ((equal? subcmd "END") (handle-server-cap-end state)) (else '())))) (define (handle-server-cap-ls state msg client-nick params) ;; The presence of a `302` argument means the client speaks v3.2 ;; (CAP-NOTIFY support, value-spec aware, sticky negotiation, ...). (let* ((version-arg (and (>= (length params) 2) (cadr params))) (v3.2? (equal? version-arg "302"))) (set-cap-server-state-cap-302! state v3.2?)) (set-cap-server-state-phase! state 'ls-sent) (let* ((supported (cap-server-state-supported state)) (cap-strings (map cap->string supported)) ;; Single-line LS for now — simple, safe under typical ;; payload sizes (well under the 8K default). (body (string-join cap-strings " "))) (list (string-append (cap-reply-prefix state client-nick) "LS :" body "\r\n")))) (define (handle-server-cap-list state client-nick) (let ((body (string-join (cap-server-state-enabled state) " "))) (list (string-append (cap-reply-prefix state client-nick) "LIST :" body "\r\n")))) (define (handle-server-cap-req state msg client-nick) ;; REQ argument is in trailing (preferred) or last param. (let* ((req-text (or (irc-message-trailing msg) (let ((p (irc-message-params msg))) (if (>= (length p) 2) (cadr p) "")))) (requests (string-split req-text " ")) ;; Each request can be `name` (enable) or `-name` (disable). (supported-names (map cap-name (cap-server-state-supported state)))) (let-values (((normalized all-known?) (let loop ((reqs requests) (norm '()) (ok? #t)) (cond ((null? reqs) (values (reverse norm) ok?)) (else (let* ((r (car reqs)) (disable? (and (> (string-length r) 0) (char=? (string-ref r 0) #\-))) (name (if disable? (substring r 1 (string-length r)) r))) (if (member name supported-names) (loop (cdr reqs) (cons (cons name disable?) norm) ok?) (loop (cdr reqs) norm #f)))))))) (cond ((not all-known?) ;; Atomic: any unknown cap means NAK the whole REQ. Don't ;; mutate enabled. (list (string-append (cap-reply-prefix state client-nick) "NAK :" req-text "\r\n"))) (else ;; Apply enable/disable atomically. (let ((enabled (cap-server-state-enabled state))) (let loop ((entries normalized) (e enabled)) (cond ((null? entries) (set-cap-server-state-enabled! state e)) (else (let ((name (caar entries)) (disable? (cdar entries))) (loop (cdr entries) (cond (disable? (filter (lambda (x) (not (equal? x name))) e)) ((member name e) e) (else (cons name e)))))))) (list (string-append (cap-reply-prefix state client-nick) "ACK :" req-text "\r\n")))))))) (define (handle-server-cap-end state) (set-cap-server-state-phase! state 'done) '()) ))src/sigil/irc/capability.sgladded
;;; (sigil irc capability) - IRCv3 capability descriptors;;;;;; Capability negotiation in IRCv3 lets clients and servers agree on;;; which optional features both sides support. Each capability is named;;; (e.g. `sasl`, `server-time`, `chathistory`) and may carry a value;;; specification (e.g. `sasl=PLAIN,EXTERNAL,SCRAM-SHA-256`).;;;;;; This module provides:;;;;;; - Constant names for every Tier 1 + Tier 2 capability sigil-irc;;; knows about;;; - A `cap` record for representing a capability with optional value;;; - Parsers for `CAP LS` / `CAP LIST` / `CAP REQ` argument bodies;;;;;; The state machines that drive negotiation live in;;; `(sigil irc cap-negotiation)`.(define-library (sigil irc capability) (import (sigil core) (sigil string) (sigil struct)) (export ;; Cap descriptor cap cap? make-cap cap-name cap-value cap->string string->cap parse-cap-list ;; Tier 1 capability names CAP-SASL CAP-MESSAGE-TAGS CAP-SERVER-TIME CAP-ACCOUNT-TAG CAP-ACCOUNT-NOTIFY CAP-EXTENDED-JOIN CAP-USERHOST-IN-NAMES CAP-MULTI-PREFIX CAP-AWAY-NOTIFY CAP-CHGHOST CAP-INVITE-NOTIFY CAP-SETNAME CAP-BATCH CAP-LABELED-RESPONSE CAP-ECHO-MESSAGE CAP-CAP-NOTIFY ;; Tier 2 capability names CAP-CHATHISTORY CAP-READ-MARKER CAP-MONITOR ;; Helpers tier-1-caps tier-2-caps standard-caps) (begin ;; ============================================================ ;; Capability record ;; ============================================================ ;;; A single capability with an optional value spec. ;;; ;;; The `name` is a string like `"sasl"` or `"draft/read-marker"`. The ;;; `value` is `#f` if no value is specified, otherwise a string ;;; (e.g. `"PLAIN,EXTERNAL"`). Values are parsed lazily by feature ;;; modules — for SASL, splitting on commas yields the mechanism list. (define-struct cap (name) ; string (value default: #f)) ; string or #f ;;; Construct a cap record. (define (make-cap name (keys: (value #f))) (: string? (value: any?) -> cap?) (when (or (not (string? name)) (string=? name "")) (error "make-cap: name must be a non-empty string")) (cap name: name value: value)) ;;; Render a cap as `name` or `name=value`. ;;; ;;; ```scheme ;;; (cap->string (make-cap "sasl")) ; => "sasl" ;;; (cap->string (make-cap "sasl" value: "PLAIN,EXTERNAL")) ;;; ; => "sasl=PLAIN,EXTERNAL" ;;; ``` (define (cap->string c) (: cap? -> string?) (if (cap-value c) (string-append (cap-name c) "=" (cap-value c)) (cap-name c))) ;;; Parse a single capability spec (`name` or `name=value`) into a ;;; cap record. ;;; ;;; ```scheme ;;; (string->cap "sasl=PLAIN") ;;; ; => #<cap name: "sasl" value: "PLAIN"> ;;; (string->cap "echo-message") ;;; ; => #<cap name: "echo-message" value: #f> ;;; ``` (define (string->cap str) (: string? -> cap?) (let ((eq-pos (string-index str (lambda (c) (char=? c #\=))))) (if eq-pos (cap name: (substring str 0 eq-pos) value: (substring str (+ eq-pos 1) (string-length str))) (cap name: str value: #f)))) ;;; Parse a CAP argument body — a space-separated list of capability ;;; specs — into a list of cap records. ;;; ;;; ```scheme ;;; (parse-cap-list "sasl=PLAIN message-tags server-time") ;;; ; => (#<cap "sasl"=PLAIN> #<cap "message-tags"> #<cap "server-time">) ;;; ``` (define (parse-cap-list str) (: string? -> list?) (if (string=? str "") '() (map string->cap (string-split str " ")))) ;; ============================================================ ;; Tier 1 capability names ;; ============================================================ (define CAP-SASL "sasl") (define CAP-MESSAGE-TAGS "message-tags") (define CAP-SERVER-TIME "server-time") (define CAP-ACCOUNT-TAG "account-tag") (define CAP-ACCOUNT-NOTIFY "account-notify") (define CAP-EXTENDED-JOIN "extended-join") (define CAP-USERHOST-IN-NAMES "userhost-in-names") (define CAP-MULTI-PREFIX "multi-prefix") (define CAP-AWAY-NOTIFY "away-notify") (define CAP-CHGHOST "chghost") (define CAP-INVITE-NOTIFY "invite-notify") (define CAP-SETNAME "setname") (define CAP-BATCH "batch") (define CAP-LABELED-RESPONSE "labeled-response") (define CAP-ECHO-MESSAGE "echo-message") (define CAP-CAP-NOTIFY "cap-notify") ;; ============================================================ ;; Tier 2 capability names ;; ============================================================ (define CAP-CHATHISTORY "draft/chathistory") (define CAP-READ-MARKER "draft/read-marker") ;; MONITOR is a numeric-reply feature, not a CAP per se; senpai/inspircd ;; advertise its support via ISUPPORT (MONITOR=N). It is exposed here ;; for symmetry; the cap-negotiation module ignores it during CAP REQ. (define CAP-MONITOR "monitor") ;; ============================================================ ;; Bundled cap sets ;; ============================================================ ;;; The Tier 1 ratified IRCv3 capabilities (the must-have set). (define (tier-1-caps) (: -> list?) (list CAP-SASL CAP-MESSAGE-TAGS CAP-SERVER-TIME CAP-ACCOUNT-TAG CAP-ACCOUNT-NOTIFY CAP-EXTENDED-JOIN CAP-USERHOST-IN-NAMES CAP-MULTI-PREFIX CAP-AWAY-NOTIFY CAP-CHGHOST CAP-INVITE-NOTIFY CAP-SETNAME CAP-BATCH CAP-LABELED-RESPONSE CAP-ECHO-MESSAGE CAP-CAP-NOTIFY)) ;;; The Tier 2 high-value drafts/extensions sigil-irc supports. (define (tier-2-caps) (: -> list?) (list CAP-CHATHISTORY CAP-READ-MARKER)) ;;; The full set of caps sigil-irc knows about. Servers built on ;;; sigil-irc can advertise a subset by filtering this list against ;;; what the server actually implements. (define (standard-caps) (: -> list?) (append (tier-1-caps) (tier-2-caps))) ))test/test-capability.sgladded
;;; Tests for IRCv3 capability descriptors and cap negotiation;;; state machines (both client and server perspectives).(import (sigil test) (sigil irc message) (sigil irc capability) (sigil irc cap-negotiation))(test-group "Capability descriptors" (test "string->cap parses name only" (let ((c (string->cap "sasl"))) (assert-equal "sasl" (cap-name c)) (assert-equal #f (cap-value c)))) (test "string->cap parses name=value" (let ((c (string->cap "sasl=PLAIN,EXTERNAL"))) (assert-equal "sasl" (cap-name c)) (assert-equal "PLAIN,EXTERNAL" (cap-value c)))) (test "cap->string round-trips" (assert-equal "sasl=PLAIN" (cap->string (string->cap "sasl=PLAIN"))) (assert-equal "echo-message" (cap->string (string->cap "echo-message")))) (test "parse-cap-list splits and parses" (let ((caps (parse-cap-list "sasl=PLAIN message-tags server-time"))) (assert-equal 3 (length caps)) (assert-equal "sasl" (cap-name (car caps))) (assert-equal "PLAIN" (cap-value (car caps))) (assert-equal "message-tags" (cap-name (cadr caps))) (assert-equal #f (cap-value (cadr caps))))) (test "tier-1-caps includes core IRCv3 caps" (let ((t1 (tier-1-caps))) (assert-true (member CAP-SASL t1)) (assert-true (member CAP-MESSAGE-TAGS t1)) (assert-true (member CAP-SERVER-TIME t1)) (assert-true (member CAP-BATCH t1)) (assert-true (member CAP-LABELED-RESPONSE t1)) (assert-true (member CAP-ECHO-MESSAGE t1)) (assert-true (member CAP-EXTENDED-JOIN t1)) (assert-true (member CAP-AWAY-NOTIFY t1)))) (test "tier-2-caps includes mobile-critical drafts" (let ((t2 (tier-2-caps))) (assert-true (member CAP-CHATHISTORY t2)) (assert-true (member CAP-READ-MARKER t2)))))(test-group "CAP client state machine" (test "start emits CAP LS 302" (let ((s (make-cap-client-state desired: '("sasl")))) (assert-equal "CAP LS 302\r\n" (cap-client-start s)) (assert-equal 'ls (cap-client-state-phase s)))) (test "single-line LS triggers REQ for desired-and-available caps" (let ((s (make-cap-client-state desired: '("sasl" "message-tags" "missing")))) (cap-client-start s) (let* ((ls-msg (parse-irc-message ":server CAP * LS :sasl=PLAIN message-tags server-time")) (out (cap-client-advance s ls-msg))) (assert-equal 'req (cap-client-state-phase s)) (assert-equal 1 (length out)) ;; Should have requested both desired-and-supported caps (assert-true (or (string=? (car out) "CAP REQ :sasl message-tags\r\n") (string=? (car out) "CAP REQ :message-tags sasl\r\n")))))) (test "multi-line LS waits for terminator before REQ" (let ((s (make-cap-client-state desired: '("sasl" "server-time")))) (cap-client-start s) (let* ((m1 (parse-irc-message ":server CAP * LS * :sasl=PLAIN")) (out1 (cap-client-advance s m1))) ;; Continuation line: no REQ yet. (assert-equal '() out1) (assert-equal 'ls (cap-client-state-phase s)) (let* ((m2 (parse-irc-message ":server CAP * LS :server-time")) (out2 (cap-client-advance s m2))) (assert-equal 'req (cap-client-state-phase s)) (assert-equal 1 (length out2)))))) (test "ACK transitions to done and emits CAP END" (let ((s (make-cap-client-state desired: '("sasl")))) (cap-client-start s) (cap-client-advance s (parse-irc-message ":s CAP * LS :sasl")) (let ((out (cap-client-advance s (parse-irc-message ":s CAP * ACK :sasl")))) (assert-equal 'done (cap-client-state-phase s)) (assert-equal 1 (length out)) (assert-equal "CAP END\r\n" (car out)) (assert-true (member "sasl" (cap-client-state-acked s)))))) (test "NAK records nakked caps and ends" (let ((s (make-cap-client-state desired: '("sasl" "message-tags")))) (cap-client-start s) (cap-client-advance s (parse-irc-message ":s CAP * LS :sasl message-tags")) (cap-client-advance s (parse-irc-message ":s CAP * NAK :sasl")) (assert-equal 'done (cap-client-state-phase s)) (assert-true (member "sasl" (cap-client-state-nakked s))))) (test "no overlap means immediate CAP END (no REQ)" (let ((s (make-cap-client-state desired: '("nonexistent")))) (cap-client-start s) (let ((out (cap-client-advance s (parse-irc-message ":s CAP * LS :sasl")))) (assert-equal 'done (cap-client-state-phase s)) (assert-equal 1 (length out)) (assert-equal "CAP END\r\n" (car out))))))(test-group "CAP server state machine" (define (make-server) (make-cap-server-state server-name: "enclave.example" supported: (list (make-cap "sasl" value: "PLAIN,EXTERNAL") (make-cap "message-tags") (make-cap "server-time") (make-cap "batch") (make-cap "echo-message")))) (test "answers CAP LS 302 with supported list" (let* ((s (make-server)) (out (cap-server-advance s (parse-irc-message "CAP LS 302") client-nick: "*"))) (assert-equal 1 (length out)) (assert-equal 'ls-sent (cap-server-state-phase s)) (assert-true (cap-server-state-cap-302 s)) ;; Reply must be ":<server> CAP * LS :<caps>\r\n" (let ((line (car out))) (assert-true (or (string-contains? line "sasl=PLAIN,EXTERNAL") (string-contains? line "sasl")))))) (test "ACKs valid CAP REQ and updates enabled set" (let ((s (make-server))) (cap-server-advance s (parse-irc-message "CAP LS 302")) (let ((out (cap-server-advance s (parse-irc-message "CAP REQ :sasl message-tags")))) (assert-equal 1 (length out)) (assert-true (string-contains? (car out) "ACK")) (assert-true (member "sasl" (cap-server-state-enabled s))) (assert-true (member "message-tags" (cap-server-state-enabled s)))))) (test "NAKs unknown CAP REQ atomically" (let ((s (make-server))) (cap-server-advance s (parse-irc-message "CAP LS 302")) (let ((out (cap-server-advance s (parse-irc-message "CAP REQ :sasl bogus-cap")))) (assert-equal 1 (length out)) (assert-true (string-contains? (car out) "NAK")) ;; Atomic — neither cap should be enabled. (assert-false (member "sasl" (cap-server-state-enabled s)))))) (test "CAP REQ with -name disables previously-enabled cap" (let ((s (make-server))) (cap-server-advance s (parse-irc-message "CAP LS 302")) (cap-server-advance s (parse-irc-message "CAP REQ :sasl message-tags")) (cap-server-advance s (parse-irc-message "CAP REQ :-sasl")) (assert-false (member "sasl" (cap-server-state-enabled s))) (assert-true (member "message-tags" (cap-server-state-enabled s))))) (test "CAP END transitions to done" (let ((s (make-server))) (cap-server-advance s (parse-irc-message "CAP LS 302")) (cap-server-advance s (parse-irc-message "CAP REQ :sasl")) (cap-server-advance s (parse-irc-message "CAP END")) (assert-equal 'done (cap-server-state-phase s)))) (test "CAP LIST returns currently-enabled caps" (let ((s (make-server))) (cap-server-advance s (parse-irc-message "CAP LS 302")) (cap-server-advance s (parse-irc-message "CAP REQ :sasl message-tags")) (let ((out (cap-server-advance s (parse-irc-message "CAP LIST")))) (assert-equal 1 (length out)) (assert-true (string-contains? (car out) "LIST")) (assert-true (string-contains? (car out) "sasl"))))))(run-tests)