Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions PROTOCOL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# HAT line protocol

The server speaks a small, line-based text protocol over TCP. Every frame is a
single line terminated by `\n` (a trailing `\r` is accepted and stripped).

## Connecting and authentication

1. The client opens a TCP connection.
2. The server sends `SYS authenticating`.
3. The client sends the access token printed by the server as its first line.
4. On success the server sends `SYS connected; type /help to see available commands`.
On failure it sends `ERR invalid access token` and closes the connection.

The token line is compared exactly (after trailing newline removal). A wrong or
missing token ends the connection.

## Client to server

| Command | Meaning |
| --- | --- |
| `MSG <text>` | Send a chat message. `<text>` must not be empty. |
| `NICK <name>` | Request a nickname change. |

Any other line is answered with `ERR unknown protocol command`. Blank lines are
ignored. Lines longer than 4 KiB are rejected with `ERR message is too long`.

### Nickname rules

- 1 to 16 characters.
- ASCII letters, digits, `_` and `-` only.
- Case-insensitive uniqueness: a nickname in use by another client is rejected.

On success the server replies with `YOU <name>` and tells everyone else
`NICK <old> <new>`. On failure it replies with `ERR <reason>`.

## Server to client

| Message | Meaning |
| --- | --- |
| `SYS <text>` | Informational/server event (join, leave, welcome). |
| `MSG <nick> <text>` | A chat message from `<nick>`. |
| `NICK <old> <new>` | Another client changed nickname. |
| `YOU <nick>` | The recipient's own (possibly new) nickname. |
| `ERR <reason>` | A rejected request or rate-limit notice. |

Nicknames are assigned as `user-<port>` on connect until changed.

## Rate limiting and bans

- At most one accepted message per 250 ms per client.
- Messages sent faster get `ERR you are sending messages too quickly` and count
as a violation. The violation counter resets after an accepted message.
- Reaching 10 violations bans the client's IP for 10 minutes with
`ERR too many rapid messages; you are banned for 10 minutes`, then the
connection is closed.
- A connection from a banned IP receives
`ERR temporarily banned; try again in <seconds> seconds` and is closed.

## Out of scope

This document covers only the stable text protocol migrated to `main`. File
transfer (`PUT`/`GET`/`LS`), remote execution (`EXEC`) and LLM (`LLM`) commands
are not part of this protocol on `main`.
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,15 @@ Start the client in another terminal:
cargo run --bin client
```

The server prints an access token when it starts. Use that token with `/connect` in the client.
The server prints an access token when it starts. In the client, connect with
the token as the third argument:

```text
/connect 127.0.0.1 6969 <token>
```

Use `/nickname <name>` to change your nickname. See [PROTOCOL.md](PROTOCOL.md)
for the full line protocol.

## License
MIT
108 changes: 96 additions & 12 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,21 @@ fn cmd_disconnect(ctx: &mut Ctx, _args: &[&str]) {
}
}

fn cmd_nickname(ctx: &mut Ctx, args: &[&str]) {
if args.len() != 1 {
ctx.message("usage: /nickname <name>");
return;
}
match ctx.stream.as_mut() {
Some(stream) => {
if let Err(error) = stream.write_all(format!("NICK {}\n", args[0]).as_bytes()) {
ctx.message(error.to_string());
}
}
None => ctx.message("not connected"),
}
}

const COMMANDS: &[Command] = &[
Command {
name: "help",
Expand All @@ -77,6 +92,11 @@ const COMMANDS: &[Command] = &[
description: "connect to server",
run: cmd_connect,
},
Command {
name: "nickname",
description: "change your nickname",
run: cmd_nickname,
},
];

fn handle_prompt(ctx: &mut Ctx, prompt: &str) {
Expand All @@ -98,14 +118,34 @@ fn handle_prompt(ctx: &mut Ctx, prompt: &str) {

match ctx.stream.as_mut() {
Some(stream) => {
if let Err(error) = stream.write_all(input.as_bytes()) {
// Regular input is a chat message on the line protocol.
if let Err(error) = stream.write_all(format!("MSG {input}\n").as_bytes()) {
ctx.message(error.to_string());
}
}
None => ctx.message("not connected"),
}
}

// Turn one server line into a chat entry.
fn handle_server_line(ctx: &mut Ctx, line: &str) {
let (kind, rest) = line.split_once(' ').unwrap_or((line, ""));
match kind {
"MSG" => {
let (nick, text) = rest.split_once(' ').unwrap_or((rest, ""));
ctx.message(format!("<{nick}> {text}"));
}
"NICK" => {
let (old, new) = rest.split_once(' ').unwrap_or((rest, ""));
ctx.message(format!("{old} is now known as {new}"));
}
"YOU" => ctx.message(format!("you are now {rest}")),
"SYS" => ctx.message(rest),
"ERR" => ctx.message(format!("error: {rest}")),
_ => ctx.message(line.to_owned()),
}
}

fn chat_window(stdout: &mut impl Write, chat: &[String], boundary: Rect) -> io::Result<()> {
let n = chat.len();
let size = n.checked_sub(boundary.h).unwrap_or(0);
Expand All @@ -123,19 +163,26 @@ fn cmd_connect(ctx: &mut Ctx, args: &[&str]) {
ctx.message("You already connected ");
return;
}
//args is ip port
// args is ip port [token]
if args.len() < 2 {
ctx.message("/connect ip port");
ctx.message("/connect <ip> <port> [token]");
return;
}
let addr = format!("{}:{}", args[0], args[1]);
let stream = match TcpStream::connect(&addr) {
let mut stream = match TcpStream::connect(&addr) {
Ok(stream) => stream,
Err(err) => {
ctx.message(format!("failed to connect to {} : {}", addr, err));
return;
}
};
// The server expects the access token as the first line.
if let Some(token) = args.get(2)
&& let Err(error) = stream.write_all(format!("{token}\n").as_bytes())
{
ctx.message(error.to_string());
return;
}
if let Err(err) = stream.set_nonblocking(true) {
ctx.message(format!("failed to set noblock to {} : {}", addr, err));
return;
Expand Down Expand Up @@ -163,7 +210,8 @@ fn run_client() -> io::Result<()> {
stop: false,
};
let mut prompt = String::new();
let mut buffer = [0; 64];
let mut pending = String::new();
let mut buffer = [0; 4096];

while !ctx.stop {
while poll(Duration::ZERO).unwrap_or(false) {
Expand Down Expand Up @@ -203,15 +251,24 @@ fn run_client() -> io::Result<()> {
Ok(0) => {
ctx.message("disconnected");
ctx.stream = None;
pending.clear();
}
Ok(n) => match from_utf8(&buffer[..n]) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve partial UTF-8 bytes between socket reads

TCP can split a valid UTF-8 MSG or SYS frame at any byte. When a read ends inside a multibyte character, this conversion fails and the error path discards the entire chunk rather than retaining it for the next read, so ordinary Unicode chat messages can lose their prefix or never render; buffer raw bytes through a newline before decoding a complete frame.

Useful? React with 👍 / 👎.

Ok(message) => ctx.message(message),
Ok(text) => {
// The protocol is line-based, so buffer partial reads.
pending.push_str(text);
while let Some(newline) = pending.find('\n') {
let line: String = pending.drain(..=newline).collect();
handle_server_line(&mut ctx, line.trim_end_matches(['\r', '\n']));
}
}
Err(error) => ctx.message(format!("invalid server response: {error}")),
},
Err(error) if error.kind() == ErrorKind::WouldBlock => {}
Err(error) => {
ctx.message(error.to_string());
ctx.stream = None;
pending.clear();
}
}
}
Expand Down Expand Up @@ -241,25 +298,52 @@ fn run_client() -> io::Result<()> {
mod tests {
use super::*;

fn ctx() -> Ctx {
Ctx {
stream: None,
chat: Vec::new(),
stop: false,
}
}

#[test]
fn command_table_contains_only_minimal_commands() {
assert_eq!(
COMMANDS
.iter()
.map(|command| command.name)
.collect::<Vec<_>>(),
vec!["help", "quit", "disconnect", "connect"]
vec!["help", "quit", "disconnect", "connect", "nickname"]
);
}

#[test]
fn unknown_command_is_reported() {
let mut ctx = Ctx {
stream: None,
chat: Vec::new(),
stop: false,
};
let mut ctx = ctx();
handle_prompt(&mut ctx, "/missing");
assert_eq!(ctx.chat, vec!["unknown command: /missing"]);
}

#[test]
fn server_message_is_rendered_with_nickname() {
let mut ctx = ctx();
handle_server_line(&mut ctx, "MSG alice hello there");
assert_eq!(ctx.chat, vec!["<alice> hello there"]);
}

#[test]
fn server_protocol_lines_are_rendered() {
let mut ctx = ctx();
handle_server_line(&mut ctx, "YOU user-1234");
handle_server_line(&mut ctx, "NICK user-1234 alice");
handle_server_line(&mut ctx, "ERR nickname is already in use");
assert_eq!(
ctx.chat,
vec![
"you are now user-1234",
"user-1234 is now known as alice",
"error: nickname is already in use",
]
);
}
}
Loading
Loading