fix(daemon): correct the published contract and harden the control socket
A review pass over the diff found seventeen confirmed defects. The worst was
the contract itself: every method's parameters were published as a single
descriptor named `params`, so a client generated from openrpc.json sent
{"params":{"params":{…}}} and every call failed -32602. Parameters are now
published per field, with a test asserting the published names are the keys
the daemon actually deserializes.
Security and lifecycle:
- download.start no longer lets a caller name a directory, which was an
arbitrary write on the daemon's host.
- The Unix socket is bound under a temporary name and renamed into place; it
was briefly connectable by any local user between bind and chmod, and on
that transport connecting is authenticating.
- Pre-auth reads are capped at 1 MiB with a 30s handshake timeout, and
connections are capped, so an unauthenticated peer cannot exhaust memory or
threads.
- A read error no longer returns past the hub unsubscribe, which leaked a
subscriber and two threads per occurrence.
- A client whose event queue fills is now actually disconnected. It used to be
dropped from the fan-out and left connected, working on but blind to every
event after — the documentation already promised otherwise.
- A client whose daemon vanishes mid-transfer is told at once instead of
waiting out its full timeout and reporting a misleading "gave up".
Contract accuracy: errors carry an explicit null id (a missing one reads as a
notification and gets dropped), a second auth answers with the published
shape, and nullable fields are sent rather than omitted so a generated client
sees one shape.
Also splits proto.rs and server.rs, whose concerns had diverged, and applies
the review's simplifications.
This commit is contained in:
+29
-9
@@ -13,8 +13,13 @@ events behave.
|
||||
## Framing
|
||||
|
||||
Newline-delimited JSON-RPC 2.0. One JSON value per line, in both directions,
|
||||
UTF-8, no length prefix. A line that does not parse gets a `-32700` reply and
|
||||
the connection stays open.
|
||||
UTF-8, no length prefix. A line that does not parse gets a `-32700` reply; on
|
||||
an established connection it stays open, but the *first* line has to be a valid
|
||||
`auth` request, so a parse error there ends the connection.
|
||||
|
||||
Lines are capped at 1 MiB and a connection that has not authenticated within
|
||||
30 seconds is closed. The daemon serves at most 64 control connections at
|
||||
once.
|
||||
|
||||
## Transports and authentication
|
||||
|
||||
@@ -57,9 +62,14 @@ the old shape.
|
||||
|
||||
## Requests
|
||||
|
||||
Parameters are by name — a JSON object, never an array. Every method and its
|
||||
parameter and result schemas are in `openrpc.json`; `rpc.discover` returns
|
||||
that same document from the running daemon.
|
||||
Parameters are by name — a JSON object, never an array. Each method's fields
|
||||
are published as individual OpenRPC content descriptors, so a generated client
|
||||
sends exactly the object the daemon deserializes. `rpc.discover` returns that
|
||||
same document from the running daemon.
|
||||
|
||||
An event is the exception: its `params` object *is* the payload, published as
|
||||
a single `payload` descriptor because several payloads are tagged unions with
|
||||
no fixed field set.
|
||||
|
||||
Requests may be pipelined and are answered out of order, so match replies by
|
||||
`id`. A request without an `id` is a notification and is answered with
|
||||
@@ -71,6 +81,10 @@ silence, including when it fails.
|
||||
{"jsonrpc":"2.0","id":4,"error":{"code":-32000,"message":"no results","data":{"exit":4}}}
|
||||
```
|
||||
|
||||
An error always carries an `id`, explicitly `null` when the request was too
|
||||
malformed to have one — so do not read a missing `id` as "this is a
|
||||
notification" and drop it.
|
||||
|
||||
Standard codes (`-32700` parse, `-32600` invalid request, `-32601` unknown
|
||||
method, `-32602` bad parameters) mean what they always mean. Application
|
||||
failures use `-32000` and carry `data.exit`: the CLI exit status the same
|
||||
@@ -100,6 +114,12 @@ where they appear as entries marked `x-notification` — OpenRPC has no
|
||||
first-class notion of a server-pushed message, so that flag is how they are
|
||||
distinguished from methods you may call.
|
||||
|
||||
`message.history` and `event.message` describe the same conversation in
|
||||
different shapes, deliberately: a live event is the message as the server
|
||||
delivered it (`id`, `timestamp`, `username`, `message`), while history is a
|
||||
chat log and carries a direction (`peer`, `outgoing`, `text`, `at`) because it
|
||||
includes what this account sent.
|
||||
|
||||
**Events are the only way to observe these things, and they are not
|
||||
replayable.** The underlying library hands over its event buffer and empties
|
||||
it, so the daemon drains once and copies to every attached connection. A
|
||||
@@ -118,10 +138,10 @@ matched on `username` + `filename`.
|
||||
|
||||
## Behaviour worth knowing
|
||||
|
||||
**Files land on the daemon's filesystem.** `download_directory` is the
|
||||
daemon's configuration; a remote client cannot see that filesystem and does
|
||||
not choose. `daemon.status` reports the directory in use. There is no method
|
||||
to fetch the bytes over the control socket.
|
||||
**Files land on the daemon's filesystem.** The directory is the daemon's
|
||||
configuration and a client cannot name one — that would be an arbitrary write
|
||||
on the daemon's host. `daemon.status` reports the directory in use. There is
|
||||
no method to fetch the bytes over the control socket.
|
||||
|
||||
**Several clients, no locking.** Last write wins. Two clients cancelling the
|
||||
same transfer is two hands on one keyboard, not an error.
|
||||
|
||||
+172
-60
@@ -115,6 +115,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"listen_port": {
|
||||
"default": null,
|
||||
"format": "uint16",
|
||||
"maximum": 65535,
|
||||
"minimum": 0,
|
||||
@@ -139,7 +140,8 @@
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"shared_files": {
|
||||
"format": "uint32",
|
||||
@@ -197,9 +199,15 @@
|
||||
},
|
||||
"metadata": {
|
||||
"$ref": "#/components/schemas/DownloadMetadataDto",
|
||||
"default": {}
|
||||
"default": {
|
||||
"bitrate": null,
|
||||
"length_seconds": null,
|
||||
"peer_free_slots": null,
|
||||
"peer_upload_speed": null
|
||||
}
|
||||
},
|
||||
"queue_position": {
|
||||
"default": null,
|
||||
"format": "uint32",
|
||||
"minimum": 0,
|
||||
"type": [
|
||||
@@ -237,6 +245,7 @@
|
||||
"DownloadMetadataDto": {
|
||||
"properties": {
|
||||
"bitrate": {
|
||||
"default": null,
|
||||
"format": "uint32",
|
||||
"minimum": 0,
|
||||
"type": [
|
||||
@@ -245,6 +254,7 @@
|
||||
]
|
||||
},
|
||||
"length_seconds": {
|
||||
"default": null,
|
||||
"format": "uint32",
|
||||
"minimum": 0,
|
||||
"type": [
|
||||
@@ -253,6 +263,7 @@
|
||||
]
|
||||
},
|
||||
"peer_free_slots": {
|
||||
"default": null,
|
||||
"format": "uint8",
|
||||
"maximum": 255,
|
||||
"minimum": 0,
|
||||
@@ -262,6 +273,7 @@
|
||||
]
|
||||
},
|
||||
"peer_upload_speed": {
|
||||
"default": null,
|
||||
"format": "uint32",
|
||||
"minimum": 0,
|
||||
"type": [
|
||||
@@ -274,19 +286,17 @@
|
||||
},
|
||||
"DownloadStartParams": {
|
||||
"properties": {
|
||||
"download_directory": {
|
||||
"description": "Where the file lands *on the daemon's* filesystem. Defaults to the\ndaemon's configured download directory, which is the only sensible\nchoice for a remote caller that cannot see that filesystem.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"filename": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"$ref": "#/components/schemas/DownloadMetadataDto",
|
||||
"default": {}
|
||||
"default": {
|
||||
"bitrate": null,
|
||||
"length_seconds": null,
|
||||
"peer_free_slots": null,
|
||||
"peer_upload_speed": null
|
||||
}
|
||||
},
|
||||
"size": {
|
||||
"default": 0,
|
||||
@@ -780,6 +790,7 @@
|
||||
"Seconds": {
|
||||
"properties": {
|
||||
"seconds": {
|
||||
"default": null,
|
||||
"format": "uint32",
|
||||
"minimum": 0,
|
||||
"type": [
|
||||
@@ -1031,7 +1042,8 @@
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"stats": {
|
||||
"anyOf": [
|
||||
@@ -1041,7 +1053,8 @@
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
@@ -1120,7 +1133,8 @@
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
],
|
||||
"default": null
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -1166,10 +1180,24 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"required": true,
|
||||
"name": "protocol",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuthParams"
|
||||
"default": 0,
|
||||
"format": "uint32",
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "token",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"description": "Required on TCP. Omitted on a Unix socket, where the socket's own\npermissions already prove the caller is this user.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1222,10 +1250,10 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/QueryParams"
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1242,10 +1270,10 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/QueryParams"
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1262,10 +1290,10 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/QueryParams"
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1294,10 +1322,40 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "filename",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DownloadStartParams"
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "metadata",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DownloadMetadataDto",
|
||||
"default": {
|
||||
"bitrate": null,
|
||||
"length_seconds": null,
|
||||
"peer_free_slots": null,
|
||||
"peer_upload_speed": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": 0,
|
||||
"format": "uint64",
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1326,10 +1384,17 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "filename",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TransferRef"
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1346,10 +1411,17 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "filename",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TransferRef"
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1366,10 +1438,17 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "filename",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TransferRef"
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1386,10 +1465,17 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "filename",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TransferRef"
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1418,10 +1504,17 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "filename",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TransferRef"
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1438,10 +1531,12 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "slots",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SlotsParams"
|
||||
"format": "uint",
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1494,10 +1589,10 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "room",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RoomRef"
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1514,10 +1609,10 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "room",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RoomRef"
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1534,10 +1629,17 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "message",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SayParams"
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "room",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1554,10 +1656,10 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "room",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RoomRef"
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1574,10 +1676,17 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "message",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MessageParams"
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1606,10 +1715,10 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "username",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UserRef"
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1626,10 +1735,10 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "username",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UserRef"
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1646,10 +1755,10 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "username",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UserRef"
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1678,10 +1787,13 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "directories",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DirectoriesParams"
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1710,7 +1822,7 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "payload",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RoomEventDto"
|
||||
@@ -1731,7 +1843,7 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "payload",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UserMessageDto"
|
||||
@@ -1752,7 +1864,7 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "payload",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UploadInfoDto"
|
||||
@@ -1773,7 +1885,7 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "payload",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DownloadStatusEvent"
|
||||
@@ -1794,7 +1906,7 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "payload",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BrowseEvent"
|
||||
@@ -1815,7 +1927,7 @@
|
||||
"paramStructure": "by-name",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"name": "payload",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SessionLossEvent"
|
||||
|
||||
+13
-11
@@ -39,11 +39,16 @@ pub trait SessionApi: Send + Sync {
|
||||
fn listen_port(&self) -> Option<u16>;
|
||||
fn session_loss(&self) -> Option<SessionLoss>;
|
||||
|
||||
/// Search and wait out the window. Every implementation is
|
||||
/// `search_with_cancel` with nothing to cancel on.
|
||||
fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<SearchResult>>;
|
||||
) -> Result<Vec<SearchResult>> {
|
||||
self.search_with_cancel(query, timeout, None)
|
||||
}
|
||||
|
||||
fn search_with_cancel(
|
||||
&self,
|
||||
query: &str,
|
||||
@@ -91,7 +96,8 @@ pub trait SessionApi: Send + Sync {
|
||||
fn room_members(&self, room: &str) -> Vec<String>;
|
||||
fn take_room_events(&self) -> Vec<RoomEvent>;
|
||||
|
||||
fn send_private_message(&self, username: &str, message: &str) -> Result<()>;
|
||||
fn send_private_message(&self, username: &str, message: &str)
|
||||
-> Result<()>;
|
||||
fn take_private_messages(&self) -> Vec<UserMessage>;
|
||||
|
||||
fn browse_user(&self, username: &str) -> Result<()>;
|
||||
@@ -133,14 +139,6 @@ impl SessionApi for Client {
|
||||
Self::session_loss(self)
|
||||
}
|
||||
|
||||
fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<SearchResult>> {
|
||||
Self::search(self, query, timeout)
|
||||
}
|
||||
|
||||
fn search_with_cancel(
|
||||
&self,
|
||||
query: &str,
|
||||
@@ -266,7 +264,11 @@ impl SessionApi for Client {
|
||||
Self::take_room_events(self)
|
||||
}
|
||||
|
||||
fn send_private_message(&self, username: &str, message: &str) -> Result<()> {
|
||||
fn send_private_message(
|
||||
&self,
|
||||
username: &str,
|
||||
message: &str,
|
||||
) -> Result<()> {
|
||||
Self::send_private_message(self, username, message)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
//! credentials are demanded.
|
||||
|
||||
use crate::daemon::proto::{DaemonStatus, Method};
|
||||
use crate::output::{CliError, CliResult, DaemonStatusRecord, Out, TokenRecord};
|
||||
use crate::output::{
|
||||
CliError, CliResult, DaemonStatusRecord, Out, TokenRecord,
|
||||
};
|
||||
use crate::remote::{Endpoint, RemoteSession};
|
||||
|
||||
/// Print the token a client on another machine needs.
|
||||
|
||||
@@ -14,13 +14,13 @@ pub mod social;
|
||||
pub mod transfer;
|
||||
pub mod wish;
|
||||
|
||||
use crate::api::SessionApi;
|
||||
use crate::cli::{Commands, MessageCommand, RoomCommand};
|
||||
use crate::output::{CliError, CliResult, Exit, Out, PortmapRecord};
|
||||
use crate::port_mapping::{self, PortMapper};
|
||||
use crate::remote::{Endpoint, RemoteSession};
|
||||
use soulseek_rs::types::{RoomEvent, RoomInfo};
|
||||
use soulseek_rs::{Client, ClientSettings, SessionLoss};
|
||||
use crate::api::SessionApi;
|
||||
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
//! `message`.
|
||||
|
||||
use super::{Ctx, Session, confirm_flush};
|
||||
use crate::api::SessionApi;
|
||||
use crate::cli::BrowseArgs;
|
||||
use crate::output::{
|
||||
BrowseRecord, CliError, CliResult, Out, PrivateMessageRecord,
|
||||
RoomEventRecord, RoomMemberRecord, RoomRecord,
|
||||
};
|
||||
use soulseek_rs::types::RoomEvent;
|
||||
use crate::api::SessionApi;
|
||||
use soulseek_rs::SharedDirectory;
|
||||
use soulseek_rs::types::RoomEvent;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How long to wait for the server to acknowledge something we sent.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Finding files and fetching them: `search`, `download`, and `get`.
|
||||
|
||||
use super::{Ctx, Session, social};
|
||||
use crate::api::SessionApi;
|
||||
use crate::cli::{
|
||||
DownloadArgs, FilterArgs, GetArgs, Pick, SearchArgs, SortKey,
|
||||
};
|
||||
@@ -8,7 +9,6 @@ use crate::output::{
|
||||
CliError, CliResult, DownloadRecord, Exit, Out, SearchRecord,
|
||||
};
|
||||
use soulseek_rs::utils::path::expand_tilde;
|
||||
use crate::api::SessionApi;
|
||||
use soulseek_rs::{DownloadStatus, SearchResult};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::io::BufRead;
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
//! set — we only decide which queries to send and what to print.
|
||||
|
||||
use super::{Ctx, Session, settings::Store, transfer};
|
||||
use crate::api::SessionApi;
|
||||
use crate::cli::{SortKey, WishRunArgs};
|
||||
use crate::output::{CliError, CliResult, Out, WishRecord};
|
||||
use crate::persist::config::FileConfig;
|
||||
use crate::api::SessionApi;
|
||||
use soulseek_rs::Client;
|
||||
use std::time::Instant;
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
use super::proto::{
|
||||
BrowseEvent, ChatMessageDto, DownloadStatusEvent, Event, Response,
|
||||
SessionLossEvent, UploadInfoDto, UserMessageDto,
|
||||
SessionLossEvent, UploadInfoDto, UploadStatusDto, UserMessageDto,
|
||||
};
|
||||
use crate::api::SessionApi;
|
||||
use soulseek_rs::DownloadStatus;
|
||||
@@ -39,6 +39,14 @@ const HISTORY_LIMIT: usize = 500;
|
||||
struct Subscriber {
|
||||
id: u64,
|
||||
sender: SyncSender<Response>,
|
||||
/// How to end this client's connection. A stalled client cannot be
|
||||
/// reached any other way, and leaving it attached but silently eventless
|
||||
/// is worse than disconnecting it: it would keep working while missing
|
||||
/// every room message, transfer update and upload it should have seen.
|
||||
///
|
||||
/// A closure rather than the socket itself, so the hub stays ignorant of
|
||||
/// what a connection is made of.
|
||||
disconnect: Box<dyn Fn() + Send + Sync>,
|
||||
}
|
||||
|
||||
/// Fans daemon-side events out to every attached connection.
|
||||
@@ -61,12 +69,20 @@ impl Hub {
|
||||
}
|
||||
|
||||
/// Attach a connection. The returned receiver yields notifications until
|
||||
/// [`Hub::unsubscribe`] or a full queue drops it.
|
||||
pub fn subscribe(&self) -> (u64, Receiver<Response>) {
|
||||
/// [`Hub::unsubscribe`], or until a full queue costs the client its
|
||||
/// connection.
|
||||
pub fn subscribe(
|
||||
&self,
|
||||
disconnect: impl Fn() + Send + Sync + 'static,
|
||||
) -> (u64, Receiver<Response>) {
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (sender, receiver) = sync_channel(QUEUE_DEPTH);
|
||||
if let Ok(mut subscribers) = self.subscribers.lock() {
|
||||
subscribers.push(Subscriber { id, sender });
|
||||
subscribers.push(Subscriber {
|
||||
id,
|
||||
sender,
|
||||
disconnect: Box::new(disconnect),
|
||||
});
|
||||
}
|
||||
(id, receiver)
|
||||
}
|
||||
@@ -78,7 +94,9 @@ impl Hub {
|
||||
}
|
||||
|
||||
pub fn subscribers(&self) -> usize {
|
||||
self.subscribers.lock().map_or(0, |subscribers| subscribers.len())
|
||||
self.subscribers
|
||||
.lock()
|
||||
.map_or(0, |subscribers| subscribers.len())
|
||||
}
|
||||
|
||||
/// Send one event to everyone, dropping any client that has stopped
|
||||
@@ -93,9 +111,14 @@ impl Hub {
|
||||
Ok(()) => true,
|
||||
Err(TrySendError::Full(_)) => {
|
||||
soulseek_rs::warn!(
|
||||
"control client {} is not reading its events; dropping it",
|
||||
"control client {} is not reading its events; \
|
||||
disconnecting it",
|
||||
subscriber.id
|
||||
);
|
||||
// Closing the socket is the point: a client dropped from
|
||||
// the fan-out but left connected would carry on believing
|
||||
// it was subscribed, silently blind to everything after.
|
||||
(subscriber.disconnect)();
|
||||
false
|
||||
}
|
||||
Err(TrySendError::Disconnected(_)) => false,
|
||||
@@ -192,11 +215,8 @@ impl PendingBrowses {
|
||||
pending.iter().map(|(name, _)| name.clone()).collect()
|
||||
}
|
||||
|
||||
fn settled(&self, username: &str) {
|
||||
self.forget(username);
|
||||
}
|
||||
|
||||
/// Stop waiting on a peer whose request never reached the wire.
|
||||
/// Stop waiting on a peer — because it answered, or because its request
|
||||
/// never reached the wire.
|
||||
pub fn forget(&self, username: &str) {
|
||||
if let Ok(mut pending) = self.0.lock() {
|
||||
pending.retain(|(name, _)| name != username);
|
||||
@@ -272,7 +292,11 @@ fn tick(
|
||||
/// Both sources matter: the drained events carry states that came and went
|
||||
/// between two ticks (a small file served straight out of the queue), and the
|
||||
/// live table carries progress.
|
||||
fn publish_uploads(session: &dyn SessionApi, hub: &Hub, state: &mut DrainState) {
|
||||
fn publish_uploads(
|
||||
session: &dyn SessionApi,
|
||||
hub: &Hub,
|
||||
state: &mut DrainState,
|
||||
) {
|
||||
let sampled = session
|
||||
.take_upload_events()
|
||||
.iter()
|
||||
@@ -285,12 +309,25 @@ fn publish_uploads(session: &dyn SessionApi, hub: &Hub, state: &mut DrainState)
|
||||
if state.uploads.get(&key) == Some(&upload) {
|
||||
continue;
|
||||
}
|
||||
let settled = matches!(
|
||||
upload.status,
|
||||
UploadStatusDto::Completed
|
||||
| UploadStatusDto::Cancelled
|
||||
| UploadStatusDto::Failed { .. }
|
||||
);
|
||||
hub.publish(
|
||||
Event::Upload,
|
||||
serde_json::to_value(&upload).unwrap_or(serde_json::Value::Null),
|
||||
);
|
||||
// An upload that has finished cannot change again, so remembering it
|
||||
// would grow this table by one entry per file for the life of the
|
||||
// daemon.
|
||||
if settled {
|
||||
state.uploads.remove(&key);
|
||||
} else {
|
||||
state.uploads.insert(key, upload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_browses(
|
||||
@@ -302,7 +339,7 @@ fn publish_browses(
|
||||
let Some(directories) = session.take_browse_result(&username) else {
|
||||
continue;
|
||||
};
|
||||
browses.settled(&username);
|
||||
browses.forget(&username);
|
||||
hub.publish(
|
||||
Event::Browse,
|
||||
serde_json::to_value(BrowseEvent {
|
||||
@@ -358,8 +395,8 @@ mod tests {
|
||||
// The whole reason the hub exists: two clients must both see an event
|
||||
// that only exists once in the library's buffer.
|
||||
let hub = Hub::new();
|
||||
let (_, first) = hub.subscribe();
|
||||
let (_, second) = hub.subscribe();
|
||||
let (_, first) = hub.subscribe(|| {});
|
||||
let (_, second) = hub.subscribe(|| {});
|
||||
|
||||
hub.publish(Event::Room, json!({ "event": "left", "room": "lobby" }));
|
||||
|
||||
@@ -370,7 +407,7 @@ mod tests {
|
||||
#[test]
|
||||
fn unsubscribing_stops_delivery() {
|
||||
let hub = Hub::new();
|
||||
let (id, receiver) = hub.subscribe();
|
||||
let (id, receiver) = hub.subscribe(|| {});
|
||||
hub.unsubscribe(id);
|
||||
hub.publish(Event::Upload, json!({}));
|
||||
assert!(drain(&receiver).is_empty());
|
||||
@@ -380,8 +417,8 @@ mod tests {
|
||||
#[test]
|
||||
fn a_client_that_stops_reading_is_dropped_and_the_others_keep_up() {
|
||||
let hub = Hub::new();
|
||||
let (_, slow) = hub.subscribe();
|
||||
let (_, healthy) = hub.subscribe();
|
||||
let (_, slow) = hub.subscribe(|| {});
|
||||
let (_, healthy) = hub.subscribe(|| {});
|
||||
|
||||
// Overrun the slow client's queue while the healthy one keeps reading,
|
||||
// which is exactly the asymmetry the bound exists to survive.
|
||||
@@ -408,7 +445,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_dropped_receiver_removes_its_subscriber() {
|
||||
let hub = Hub::new();
|
||||
let (_, receiver) = hub.subscribe();
|
||||
let (_, receiver) = hub.subscribe(|| {});
|
||||
drop(receiver);
|
||||
hub.publish(Event::Message, json!({}));
|
||||
assert_eq!(hub.subscribers(), 0);
|
||||
@@ -427,7 +464,10 @@ mod tests {
|
||||
}
|
||||
let history = hub.history();
|
||||
assert_eq!(history.len(), HISTORY_LIMIT);
|
||||
assert_eq!(history[0].at, 5, "the oldest messages are the ones dropped");
|
||||
assert_eq!(
|
||||
history[0].at, 5,
|
||||
"the oldest messages are the ones dropped"
|
||||
);
|
||||
assert_eq!(history[HISTORY_LIMIT - 1].at, HISTORY_LIMIT as i64 + 4);
|
||||
}
|
||||
|
||||
@@ -460,7 +500,7 @@ mod tests {
|
||||
pending.expect("bob");
|
||||
pending.expect("bob");
|
||||
assert_eq!(pending.snapshot(), ["bob"]);
|
||||
pending.settled("bob");
|
||||
pending.forget("bob");
|
||||
assert!(pending.snapshot().is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ pub fn run(
|
||||
let downloads = Arc::new(Mutex::new(Vec::new()));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
|
||||
restore(ctx, &store, session.client.as_ref(), &hub, &downloads);
|
||||
let rooms = restore(ctx, &store, session.client.as_ref(), &hub, &downloads);
|
||||
|
||||
let daemon = Arc::new(Daemon {
|
||||
session: Arc::clone(&session.client),
|
||||
@@ -202,7 +202,8 @@ pub fn run(
|
||||
token,
|
||||
started: Instant::now(),
|
||||
stop: Arc::clone(&stop),
|
||||
rooms: Mutex::new(store.load_rooms()),
|
||||
open: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
||||
rooms: Mutex::new(rooms),
|
||||
});
|
||||
|
||||
let drainer = {
|
||||
@@ -263,7 +264,7 @@ fn restore(
|
||||
session: &dyn SessionApi,
|
||||
hub: &Hub,
|
||||
downloads: &Mutex<Vec<PendingDownload>>,
|
||||
) {
|
||||
) -> Vec<String> {
|
||||
hub.restore(
|
||||
store
|
||||
.load_messages()
|
||||
@@ -277,8 +278,9 @@ fn restore(
|
||||
.collect(),
|
||||
);
|
||||
|
||||
for room in store.load_rooms() {
|
||||
if let Err(e) = session.join_room(&room) {
|
||||
let rooms = store.load_rooms();
|
||||
for room in &rooms {
|
||||
if let Err(e) = session.join_room(room) {
|
||||
ctx.out.warn(&format!("cannot rejoin {room}: {e}"));
|
||||
}
|
||||
}
|
||||
@@ -289,7 +291,7 @@ fn restore(
|
||||
.filter(|download| !download.completed)
|
||||
.collect();
|
||||
if unfinished.is_empty() {
|
||||
return;
|
||||
return rooms;
|
||||
}
|
||||
ctx.out
|
||||
.status(&format!("resuming {} transfers", unfinished.len()));
|
||||
@@ -314,6 +316,7 @@ fn restore(
|
||||
.warn(&format!("cannot resume {}: {e}", download.filename)),
|
||||
}
|
||||
}
|
||||
rooms
|
||||
}
|
||||
|
||||
/// Write daemon-owned state back. Best-effort: a state file that cannot be
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
//! Regenerate with `UPDATE_OPENRPC=1 cargo test -p soulseek-rs openrpc`.
|
||||
|
||||
use super::proto::{
|
||||
Ack, AuthParams, AuthResult, BrowseEvent, DaemonStatus, DownloadStartParams,
|
||||
DownloadStatusEvent, Downloads, DownloadStarted, Event, IntervalSeconds,
|
||||
MessageParams, Members, Messages, Method, QueryParams, RoomEventDto,
|
||||
RoomRef, SayParams, SearchResults, Seconds, SessionLossEvent, SharesStatus,
|
||||
SlotsParams, TransferRef, UploadInfoDto, Uploads, UserMessageDto, UserRef,
|
||||
UserResult, DirectoriesParams,
|
||||
Ack, AuthParams, AuthResult, BrowseEvent, DaemonStatus, DirectoriesParams,
|
||||
DownloadStartParams, DownloadStarted, DownloadStatusEvent, Downloads,
|
||||
Event, IntervalSeconds, Members, MessageParams, Messages, Method,
|
||||
QueryParams, RoomEventDto, RoomRef, SayParams, SearchResults, Seconds,
|
||||
SessionLossEvent, SharesStatus, SlotsParams, TransferRef, UploadInfoDto,
|
||||
Uploads, UserMessageDto, UserRef, UserResult,
|
||||
};
|
||||
use schemars::{JsonSchema, SchemaGenerator, generate::SchemaSettings};
|
||||
use serde_json::{Value, json};
|
||||
@@ -117,15 +117,29 @@ fn result_schema(method: Method, generator: &mut SchemaGenerator) -> Value {
|
||||
/// What a method does, for the generated documentation.
|
||||
fn summary(method: Method) -> &'static str {
|
||||
match method {
|
||||
Method::Auth => "Authenticate. Must be the first request on a connection.",
|
||||
Method::DaemonStatus => "Who the daemon is logged in as and what its session is doing.",
|
||||
Method::Auth => {
|
||||
"Authenticate. Must be the first request on a connection."
|
||||
}
|
||||
Method::DaemonStatus => {
|
||||
"Who the daemon is logged in as and what its session is doing."
|
||||
}
|
||||
Method::DaemonStop => "Ask the daemon to shut down.",
|
||||
Method::RpcDiscover => "This document.",
|
||||
Method::SearchStart => "Put a search on the wire and return at once; results accumulate.",
|
||||
Method::SearchResults => "Everything that has answered a search so far.",
|
||||
Method::SearchWishlist => "Send a query as a wishlist search (server code 103).",
|
||||
Method::SearchWishlistInterval => "How long the server wants between wishlist searches.",
|
||||
Method::DownloadStart => "Queue a transfer. Progress arrives as event.download_status.",
|
||||
Method::SearchStart => {
|
||||
"Put a search on the wire and return at once; results accumulate."
|
||||
}
|
||||
Method::SearchResults => {
|
||||
"Everything that has answered a search so far."
|
||||
}
|
||||
Method::SearchWishlist => {
|
||||
"Send a query as a wishlist search (server code 103)."
|
||||
}
|
||||
Method::SearchWishlistInterval => {
|
||||
"How long the server wants between wishlist searches."
|
||||
}
|
||||
Method::DownloadStart => {
|
||||
"Queue a transfer. Progress arrives as event.download_status."
|
||||
}
|
||||
Method::DownloadList => "Every transfer the daemon knows about.",
|
||||
Method::DownloadPause => "Pause a transfer in progress.",
|
||||
Method::DownloadResume => "Resume a paused transfer.",
|
||||
@@ -134,17 +148,29 @@ fn summary(method: Method) -> &'static str {
|
||||
Method::UploadList => "Uploads being served, active first.",
|
||||
Method::UploadCancel => "Stop serving an upload in progress.",
|
||||
Method::UploadSlots => "How many uploads may run at once.",
|
||||
Method::PrivilegesCheck => "Ask the server for this account's privilege time.",
|
||||
Method::PrivilegesOwn => "Privilege seconds left, or null if the server has not answered.",
|
||||
Method::RoomListRequest => "Ask the server for the public room list; it arrives as event.room.",
|
||||
Method::PrivilegesCheck => {
|
||||
"Ask the server for this account's privilege time."
|
||||
}
|
||||
Method::PrivilegesOwn => {
|
||||
"Privilege seconds left, or null if the server has not answered."
|
||||
}
|
||||
Method::RoomListRequest => {
|
||||
"Ask the server for the public room list; it arrives as event.room."
|
||||
}
|
||||
Method::RoomJoin => "Join a chat room.",
|
||||
Method::RoomLeave => "Leave a chat room.",
|
||||
Method::RoomSay => "Post a message to a room.",
|
||||
Method::RoomMembers => "Who is currently in a room.",
|
||||
Method::MessageSend => "Send a private message.",
|
||||
Method::MessageHistory => "Private messages the daemon collected, including while nobody was attached.",
|
||||
Method::BrowseUser => "Ask a peer for its share listing; it arrives as event.browse.",
|
||||
Method::UserRequest => "Ask the server about a user; poll user.info for the answer.",
|
||||
Method::MessageHistory => {
|
||||
"Private messages the daemon collected, including while nobody was attached."
|
||||
}
|
||||
Method::BrowseUser => {
|
||||
"Ask a peer for its share listing; it arrives as event.browse."
|
||||
}
|
||||
Method::UserRequest => {
|
||||
"Ask the server about a user; poll user.info for the answer."
|
||||
}
|
||||
Method::UserInfoOf => "What the server has said about a user so far.",
|
||||
Method::SharesStatusOf => "What the share index currently holds.",
|
||||
Method::SharesSet => "Replace the shared folders and re-index.",
|
||||
@@ -171,8 +197,59 @@ fn event_summary(event: Event) -> &'static str {
|
||||
Event::Upload => "An upload changed state.",
|
||||
Event::DownloadStatus => "A transfer moved.",
|
||||
Event::Browse => "A peer answered a browse request.",
|
||||
Event::SessionLoss => "The server session ended; nothing seen after this means anything.",
|
||||
Event::SessionLoss => {
|
||||
"The server session ended; nothing seen after this means anything."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a params object schema into OpenRPC content descriptors — one per
|
||||
/// field, which is what `by-name` means.
|
||||
///
|
||||
/// A single descriptor holding the whole struct would tell a generator to send
|
||||
/// `{"params": {"params": {…}}}`, and every generated call would come back
|
||||
/// `-32602`. The daemon deserializes the params object directly into the
|
||||
/// struct, so each of its fields is a parameter in its own right.
|
||||
fn descriptors(
|
||||
params: &Value,
|
||||
definitions: &serde_json::Map<String, Value>,
|
||||
) -> Vec<Value> {
|
||||
let Some(schema) = resolve(params, definitions) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let required: Vec<&str> = schema
|
||||
.get("required")
|
||||
.and_then(Value::as_array)
|
||||
.map(|names| names.iter().filter_map(Value::as_str).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
schema
|
||||
.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.map(|properties| {
|
||||
properties
|
||||
.iter()
|
||||
.map(|(name, property)| {
|
||||
json!({
|
||||
"name": name,
|
||||
"required": required.contains(&name.as_str()),
|
||||
"schema": property,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Follow a `$ref` into the generated definitions.
|
||||
fn resolve<'a>(
|
||||
schema: &'a Value,
|
||||
definitions: &'a serde_json::Map<String, Value>,
|
||||
) -> Option<&'a Value> {
|
||||
let Some(reference) = schema.get("$ref").and_then(Value::as_str) else {
|
||||
return schema.get("properties").map(|_| schema);
|
||||
};
|
||||
definitions.get(reference.strip_prefix(DEFINITIONS_PATH)?)
|
||||
}
|
||||
|
||||
/// Build the whole document.
|
||||
@@ -182,56 +259,62 @@ fn document() -> Value {
|
||||
});
|
||||
let mut generator = SchemaGenerator::new(settings);
|
||||
|
||||
let methods: Vec<Value> = Method::ALL
|
||||
// Schemas first, definitions second: a params schema is a `$ref` until the
|
||||
// generator has been asked for everything, and expanding it into
|
||||
// descriptors needs the definition it points at.
|
||||
let shapes: Vec<(Method, Value, Value)> = Method::ALL
|
||||
.iter()
|
||||
.map(|&method| {
|
||||
let params = params_schema(method, &mut generator);
|
||||
let result = result_schema(method, &mut generator);
|
||||
(
|
||||
method,
|
||||
params_schema(method, &mut generator),
|
||||
result_schema(method, &mut generator),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let event_shapes: Vec<(Event, Value)> = Event::ALL
|
||||
.iter()
|
||||
.map(|&event| (event, event_schema(event, &mut generator)))
|
||||
.collect();
|
||||
|
||||
let definitions: serde_json::Map<String, Value> =
|
||||
generator.take_definitions(true).into_iter().collect();
|
||||
|
||||
let methods = shapes.into_iter().map(|(method, params, result)| {
|
||||
json!({
|
||||
"name": method.wire_name(),
|
||||
"summary": summary(method),
|
||||
"paramStructure": "by-name",
|
||||
"params": if params == json!({ "type": "null" }) {
|
||||
json!([])
|
||||
} else {
|
||||
json!([{
|
||||
"name": "params",
|
||||
"required": true,
|
||||
"schema": params,
|
||||
}])
|
||||
},
|
||||
"params": descriptors(¶ms, &definitions),
|
||||
"result": {
|
||||
"name": "result",
|
||||
"schema": result,
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
});
|
||||
|
||||
// OpenRPC has no first-class notion of a server-pushed notification, so
|
||||
// the events are published as methods flagged with an extension field.
|
||||
// A generator still emits their payload types; the delivery semantics are
|
||||
// prose in docs/daemon-protocol.md.
|
||||
let events: Vec<Value> = Event::ALL
|
||||
.iter()
|
||||
.map(|&event| {
|
||||
let events = event_shapes.into_iter().map(|(event, payload)| {
|
||||
json!({
|
||||
"name": event.wire_name(),
|
||||
"summary": event_summary(event),
|
||||
"x-notification": true,
|
||||
"paramStructure": "by-name",
|
||||
// Unlike a method, an event's `params` object *is* the payload,
|
||||
// and several payloads are tagged enums with no single property
|
||||
// map to split. One descriptor carrying the whole schema is
|
||||
// what a generator needs to emit the type.
|
||||
"params": [{
|
||||
"name": "params",
|
||||
"name": "payload",
|
||||
"required": true,
|
||||
"schema": event_schema(event, &mut generator),
|
||||
"schema": payload,
|
||||
}],
|
||||
"result": { "name": "result", "schema": { "type": "null" } },
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let definitions: serde_json::Map<String, Value> =
|
||||
generator.take_definitions(true).into_iter().collect();
|
||||
});
|
||||
|
||||
json!({
|
||||
"openrpc": "1.3.2",
|
||||
@@ -239,11 +322,11 @@ fn document() -> Value {
|
||||
"title": "soulseek-rs daemon",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"description": "Control a running soulseek-rs daemon. Newline-delimited \
|
||||
JSON-RPC 2.0 over a Unix socket (local, authenticated by socket permissions) or \
|
||||
TCP (remote, authenticated by the token in <state-dir>/daemon.token). `auth` must \
|
||||
be the first request on a connection. Errors carry the CLI exit status in \
|
||||
`error.data.exit`. Entries marked `x-notification` are pushed by the daemon and \
|
||||
are never requested.",
|
||||
JSON-RPC 2.0 over a Unix socket (local, authenticated by socket permissions) or \
|
||||
TCP (remote, authenticated by the token in <state-dir>/daemon.token). `auth` must \
|
||||
be the first request on a connection. Errors carry the CLI exit status in \
|
||||
`error.data.exit`. Entries marked `x-notification` are pushed by the daemon and \
|
||||
are never requested.",
|
||||
"license": { "name": "MIT" },
|
||||
},
|
||||
"methods": methods.into_iter().chain(events).collect::<Vec<_>>(),
|
||||
@@ -306,6 +389,68 @@ fn every_method_and_event_is_published() {
|
||||
assert_eq!(published.len(), Method::ALL.len() + Event::ALL.len());
|
||||
}
|
||||
|
||||
/// The published parameters must be the keys the daemon actually reads.
|
||||
///
|
||||
/// This is the shape a generated client sends. Publishing the params struct as
|
||||
/// one descriptor named `params` type-checks and generates cleanly, and every
|
||||
/// call it produces fails `-32602`, because the client then sends
|
||||
/// `{"params": {"params": {…}}}` where the daemon expects the inner object.
|
||||
#[test]
|
||||
fn published_parameters_are_the_keys_the_daemon_reads() {
|
||||
use super::proto::{QueryParams, RoomRef, TransferRef};
|
||||
|
||||
let document = document();
|
||||
let published = |name: &str| -> Vec<String> {
|
||||
document["methods"]
|
||||
.as_array()
|
||||
.expect("methods is an array")
|
||||
.iter()
|
||||
.find(|method| method["name"] == name)
|
||||
.unwrap_or_else(|| panic!("{name} is published"))["params"]
|
||||
.as_array()
|
||||
.expect("params is an array")
|
||||
.iter()
|
||||
.map(|p| p["name"].as_str().expect("a name").to_string())
|
||||
.collect()
|
||||
};
|
||||
let keys = |value: &Value| -> Vec<String> {
|
||||
value
|
||||
.as_object()
|
||||
.expect("params serialize as an object")
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
published("room.join"),
|
||||
keys(
|
||||
&serde_json::to_value(RoomRef {
|
||||
room: "lobby".into()
|
||||
})
|
||||
.unwrap()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
published("search.results"),
|
||||
keys(&serde_json::to_value(QueryParams { query: "x".into() }).unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
published("download.pause"),
|
||||
keys(
|
||||
&serde_json::to_value(TransferRef {
|
||||
username: "bob".into(),
|
||||
filename: "a.mp3".into()
|
||||
})
|
||||
.unwrap()
|
||||
)
|
||||
);
|
||||
assert!(
|
||||
published("daemon.status").is_empty(),
|
||||
"a method that takes nothing publishes no parameters"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wire_name_round_trips_through_its_enum() {
|
||||
for method in Method::ALL {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,612 @@
|
||||
//! The data types that cross the wire.
|
||||
//!
|
||||
//! Library types never travel as themselves. Each has an explicit DTO and a
|
||||
//! mechanical conversion, so the published shape is versioned independently of
|
||||
//! `soulseek-rs-lib` — a refactor there cannot silently change what a
|
||||
//! third-party script receives, and the compiler points here when one would.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use soulseek_rs::types::{
|
||||
Download, DownloadMetadata, UserPresence, UserStats, UserStatus,
|
||||
};
|
||||
use soulseek_rs::{
|
||||
DownloadStatus, File, RoomEvent, RoomInfo, SearchResult, SessionLoss,
|
||||
SharedDirectory, UploadInfo, UploadStatus, UserInfo, UserMessage,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct AuthParams {
|
||||
/// Required on TCP. Omitted on a Unix socket, where the socket's own
|
||||
/// permissions already prove the caller is this user.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub protocol: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct AuthResult {
|
||||
pub protocol: u32,
|
||||
pub daemon_version: String,
|
||||
/// The Soulseek account the daemon is logged in as.
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct FileDto {
|
||||
pub name: String,
|
||||
pub size: u64,
|
||||
/// Soulseek attribute codes (0 = bitrate, 1 = duration in seconds).
|
||||
pub attribs: HashMap<u32, u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct SearchResultDto {
|
||||
pub token: u32,
|
||||
pub username: String,
|
||||
pub slots: u8,
|
||||
pub speed: u32,
|
||||
pub files: Vec<FileDto>,
|
||||
}
|
||||
|
||||
impl From<&SearchResult> for SearchResultDto {
|
||||
fn from(result: &SearchResult) -> Self {
|
||||
Self {
|
||||
token: result.token,
|
||||
username: result.username.clone(),
|
||||
slots: result.slots,
|
||||
speed: result.speed,
|
||||
files: result
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| FileDto {
|
||||
name: file.name.clone(),
|
||||
size: file.size,
|
||||
attribs: file.attribs.clone(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SearchResultDto> for SearchResult {
|
||||
fn from(dto: SearchResultDto) -> Self {
|
||||
Self {
|
||||
token: dto.token,
|
||||
slots: dto.slots,
|
||||
speed: dto.speed,
|
||||
files: dto
|
||||
.files
|
||||
.into_iter()
|
||||
.map(|file| File {
|
||||
username: dto.username.clone(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
attribs: file.attribs,
|
||||
})
|
||||
.collect(),
|
||||
username: dto.username,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
#[serde(tag = "state", rename_all = "snake_case")]
|
||||
pub enum DownloadStatusDto {
|
||||
Queued,
|
||||
InProgress {
|
||||
bytes_downloaded: u64,
|
||||
total_bytes: u64,
|
||||
speed_bytes_per_sec: f64,
|
||||
},
|
||||
Paused {
|
||||
bytes_downloaded: u64,
|
||||
total_bytes: u64,
|
||||
},
|
||||
Completed,
|
||||
Failed {
|
||||
reason: Option<String>,
|
||||
},
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
impl From<&DownloadStatus> for DownloadStatusDto {
|
||||
fn from(status: &DownloadStatus) -> Self {
|
||||
match status {
|
||||
DownloadStatus::Queued => Self::Queued,
|
||||
DownloadStatus::InProgress {
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
speed_bytes_per_sec,
|
||||
} => Self::InProgress {
|
||||
bytes_downloaded: *bytes_downloaded,
|
||||
total_bytes: *total_bytes,
|
||||
speed_bytes_per_sec: *speed_bytes_per_sec,
|
||||
},
|
||||
DownloadStatus::Paused {
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
} => Self::Paused {
|
||||
bytes_downloaded: *bytes_downloaded,
|
||||
total_bytes: *total_bytes,
|
||||
},
|
||||
DownloadStatus::Completed => Self::Completed,
|
||||
DownloadStatus::Failed(reason) => Self::Failed {
|
||||
reason: reason.clone(),
|
||||
},
|
||||
DownloadStatus::TimedOut => Self::TimedOut,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DownloadStatusDto> for DownloadStatus {
|
||||
fn from(dto: DownloadStatusDto) -> Self {
|
||||
match dto {
|
||||
DownloadStatusDto::Queued => Self::Queued,
|
||||
DownloadStatusDto::InProgress {
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
speed_bytes_per_sec,
|
||||
} => Self::InProgress {
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
speed_bytes_per_sec,
|
||||
},
|
||||
DownloadStatusDto::Paused {
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
} => Self::Paused {
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
},
|
||||
DownloadStatusDto::Completed => Self::Completed,
|
||||
DownloadStatusDto::Failed { reason } => Self::Failed(reason),
|
||||
DownloadStatusDto::TimedOut => Self::TimedOut,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct DownloadMetadataDto {
|
||||
#[serde(default)]
|
||||
pub bitrate: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub length_seconds: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub peer_upload_speed: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub peer_free_slots: Option<u8>,
|
||||
}
|
||||
|
||||
impl From<&DownloadMetadata> for DownloadMetadataDto {
|
||||
fn from(metadata: &DownloadMetadata) -> Self {
|
||||
Self {
|
||||
bitrate: metadata.bitrate,
|
||||
length_seconds: metadata.length_seconds,
|
||||
peer_upload_speed: metadata.peer_upload_speed,
|
||||
peer_free_slots: metadata.peer_free_slots,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DownloadMetadataDto> for DownloadMetadata {
|
||||
fn from(dto: DownloadMetadataDto) -> Self {
|
||||
Self {
|
||||
bitrate: dto.bitrate,
|
||||
length_seconds: dto.length_seconds,
|
||||
peer_upload_speed: dto.peer_upload_speed,
|
||||
peer_free_slots: dto.peer_free_slots,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct DownloadDto {
|
||||
pub username: String,
|
||||
pub filename: String,
|
||||
pub token: u32,
|
||||
pub size: u64,
|
||||
pub download_directory: String,
|
||||
pub status: DownloadStatusDto,
|
||||
#[serde(default)]
|
||||
pub queue_position: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub metadata: DownloadMetadataDto,
|
||||
}
|
||||
|
||||
impl From<&Download> for DownloadDto {
|
||||
fn from(download: &Download) -> Self {
|
||||
Self {
|
||||
username: download.username.clone(),
|
||||
filename: download.filename.clone(),
|
||||
token: download.token,
|
||||
size: download.size,
|
||||
download_directory: download.download_directory.clone(),
|
||||
status: DownloadStatusDto::from(&download.status),
|
||||
queue_position: download.queue_position,
|
||||
metadata: DownloadMetadataDto::from(&download.metadata),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
#[serde(tag = "state", rename_all = "snake_case")]
|
||||
pub enum UploadStatusDto {
|
||||
/// Waiting for a slot, at this 1-based place in the queue.
|
||||
Queued {
|
||||
place: u32,
|
||||
},
|
||||
InProgress,
|
||||
Completed,
|
||||
Cancelled,
|
||||
Failed {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<&UploadStatus> for UploadStatusDto {
|
||||
fn from(status: &UploadStatus) -> Self {
|
||||
match status {
|
||||
UploadStatus::Queued(place) => Self::Queued { place: *place },
|
||||
UploadStatus::InProgress => Self::InProgress,
|
||||
UploadStatus::Completed => Self::Completed,
|
||||
UploadStatus::Cancelled => Self::Cancelled,
|
||||
UploadStatus::Failed(reason) => Self::Failed {
|
||||
reason: reason.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UploadStatusDto> for UploadStatus {
|
||||
fn from(dto: UploadStatusDto) -> Self {
|
||||
match dto {
|
||||
UploadStatusDto::Queued { place } => Self::Queued(place),
|
||||
UploadStatusDto::InProgress => Self::InProgress,
|
||||
UploadStatusDto::Completed => Self::Completed,
|
||||
UploadStatusDto::Cancelled => Self::Cancelled,
|
||||
UploadStatusDto::Failed { reason } => Self::Failed(reason),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct UploadInfoDto {
|
||||
pub username: String,
|
||||
pub filename: String,
|
||||
pub size: u64,
|
||||
pub bytes_sent: u64,
|
||||
pub status: UploadStatusDto,
|
||||
pub speed_bytes_per_sec: f64,
|
||||
}
|
||||
|
||||
impl From<&UploadInfo> for UploadInfoDto {
|
||||
fn from(upload: &UploadInfo) -> Self {
|
||||
Self {
|
||||
username: upload.username.clone(),
|
||||
filename: upload.filename.clone(),
|
||||
size: upload.size,
|
||||
bytes_sent: upload.bytes_sent,
|
||||
status: UploadStatusDto::from(&upload.status),
|
||||
speed_bytes_per_sec: upload.speed_bytes_per_sec,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UploadInfoDto> for UploadInfo {
|
||||
fn from(dto: UploadInfoDto) -> Self {
|
||||
Self {
|
||||
username: dto.username,
|
||||
filename: dto.filename,
|
||||
size: dto.size,
|
||||
bytes_sent: dto.bytes_sent,
|
||||
status: dto.status.into(),
|
||||
speed_bytes_per_sec: dto.speed_bytes_per_sec,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct RoomInfoDto {
|
||||
pub name: String,
|
||||
pub user_count: u32,
|
||||
}
|
||||
|
||||
impl From<&RoomInfo> for RoomInfoDto {
|
||||
fn from(room: &RoomInfo) -> Self {
|
||||
Self {
|
||||
name: room.name.clone(),
|
||||
user_count: room.user_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RoomInfoDto> for RoomInfo {
|
||||
fn from(dto: RoomInfoDto) -> Self {
|
||||
Self {
|
||||
name: dto.name,
|
||||
user_count: dto.user_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
#[serde(tag = "event", rename_all = "snake_case")]
|
||||
pub enum RoomEventDto {
|
||||
List {
|
||||
rooms: Vec<RoomInfoDto>,
|
||||
},
|
||||
Joined {
|
||||
room: String,
|
||||
users: Vec<String>,
|
||||
},
|
||||
Left {
|
||||
room: String,
|
||||
},
|
||||
Message {
|
||||
room: String,
|
||||
username: String,
|
||||
message: String,
|
||||
},
|
||||
UserJoined {
|
||||
room: String,
|
||||
username: String,
|
||||
},
|
||||
UserLeft {
|
||||
room: String,
|
||||
username: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<&RoomEvent> for RoomEventDto {
|
||||
fn from(event: &RoomEvent) -> Self {
|
||||
match event {
|
||||
RoomEvent::List(rooms) => Self::List {
|
||||
rooms: rooms.iter().map(RoomInfoDto::from).collect(),
|
||||
},
|
||||
RoomEvent::Joined { room, users } => Self::Joined {
|
||||
room: room.clone(),
|
||||
users: users.clone(),
|
||||
},
|
||||
RoomEvent::Left { room } => Self::Left { room: room.clone() },
|
||||
RoomEvent::Message {
|
||||
room,
|
||||
username,
|
||||
message,
|
||||
} => Self::Message {
|
||||
room: room.clone(),
|
||||
username: username.clone(),
|
||||
message: message.clone(),
|
||||
},
|
||||
RoomEvent::UserJoined { room, username } => Self::UserJoined {
|
||||
room: room.clone(),
|
||||
username: username.clone(),
|
||||
},
|
||||
RoomEvent::UserLeft { room, username } => Self::UserLeft {
|
||||
room: room.clone(),
|
||||
username: username.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RoomEventDto> for RoomEvent {
|
||||
fn from(dto: RoomEventDto) -> Self {
|
||||
match dto {
|
||||
RoomEventDto::List { rooms } => {
|
||||
Self::List(rooms.into_iter().map(RoomInfo::from).collect())
|
||||
}
|
||||
RoomEventDto::Joined { room, users } => {
|
||||
Self::Joined { room, users }
|
||||
}
|
||||
RoomEventDto::Left { room } => Self::Left { room },
|
||||
RoomEventDto::Message {
|
||||
room,
|
||||
username,
|
||||
message,
|
||||
} => Self::Message {
|
||||
room,
|
||||
username,
|
||||
message,
|
||||
},
|
||||
RoomEventDto::UserJoined { room, username } => {
|
||||
Self::UserJoined { room, username }
|
||||
}
|
||||
RoomEventDto::UserLeft { room, username } => {
|
||||
Self::UserLeft { room, username }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct UserPresenceDto {
|
||||
/// `online`, `away`, or `offline`.
|
||||
pub status: String,
|
||||
pub privileged: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct UserStatsDto {
|
||||
pub average_speed: u32,
|
||||
pub shared_files: u32,
|
||||
pub shared_folders: u32,
|
||||
}
|
||||
|
||||
/// Each half is `None` until its reply lands, so a consumer can tell "the
|
||||
/// server said offline" from "the server has not answered yet".
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct UserInfoDto {
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
pub presence: Option<UserPresenceDto>,
|
||||
#[serde(default)]
|
||||
pub stats: Option<UserStatsDto>,
|
||||
}
|
||||
|
||||
impl From<&UserInfo> for UserInfoDto {
|
||||
fn from(info: &UserInfo) -> Self {
|
||||
Self {
|
||||
username: info.username.clone(),
|
||||
presence: info.presence.map(|presence| UserPresenceDto {
|
||||
status: presence.status.to_string(),
|
||||
privileged: presence.privileged,
|
||||
}),
|
||||
stats: info.stats.map(|stats| UserStatsDto {
|
||||
average_speed: stats.average_speed,
|
||||
shared_files: stats.shared_files,
|
||||
shared_folders: stats.shared_folders,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserInfoDto> for UserInfo {
|
||||
fn from(dto: UserInfoDto) -> Self {
|
||||
let mut info = Self::pending(dto.username);
|
||||
info.presence = dto.presence.map(|presence| UserPresence {
|
||||
status: match presence.status.as_str() {
|
||||
"online" => UserStatus::Online,
|
||||
"away" => UserStatus::Away,
|
||||
_ => UserStatus::Offline,
|
||||
},
|
||||
privileged: presence.privileged,
|
||||
});
|
||||
info.stats = dto.stats.map(|stats| UserStats {
|
||||
average_speed: stats.average_speed,
|
||||
shared_files: stats.shared_files,
|
||||
shared_folders: stats.shared_folders,
|
||||
});
|
||||
info
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct SharedDirectoryDto {
|
||||
pub name: String,
|
||||
/// Basename and size; join with the directory to get a downloadable path.
|
||||
pub files: Vec<(String, u64)>,
|
||||
}
|
||||
|
||||
impl From<&SharedDirectory> for SharedDirectoryDto {
|
||||
fn from(directory: &SharedDirectory) -> Self {
|
||||
Self {
|
||||
name: directory.name.clone(),
|
||||
files: directory.files.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SharedDirectoryDto> for SharedDirectory {
|
||||
fn from(dto: SharedDirectoryDto) -> Self {
|
||||
Self {
|
||||
name: dto.name,
|
||||
files: dto.files,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct UserMessageDto {
|
||||
pub id: u32,
|
||||
pub timestamp: u32,
|
||||
pub username: String,
|
||||
pub message: String,
|
||||
pub new_message: bool,
|
||||
}
|
||||
|
||||
impl From<&UserMessage> for UserMessageDto {
|
||||
fn from(message: &UserMessage) -> Self {
|
||||
Self {
|
||||
id: message.id(),
|
||||
timestamp: message.timestamp(),
|
||||
username: message.username().to_string(),
|
||||
message: message.message().to_string(),
|
||||
new_message: message.is_new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserMessageDto> for UserMessage {
|
||||
fn from(dto: UserMessageDto) -> Self {
|
||||
Self::new(
|
||||
dto.id,
|
||||
dto.timestamp,
|
||||
dto.username,
|
||||
dto.message,
|
||||
dto.new_message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionLossDto {
|
||||
Displaced,
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
impl From<SessionLoss> for SessionLossDto {
|
||||
fn from(loss: SessionLoss) -> Self {
|
||||
match loss {
|
||||
SessionLoss::Displaced => Self::Displaced,
|
||||
SessionLoss::Disconnected => Self::Disconnected,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SessionLossDto> for SessionLoss {
|
||||
fn from(dto: SessionLossDto) -> Self {
|
||||
match dto {
|
||||
SessionLossDto::Displaced => Self::Displaced,
|
||||
SessionLossDto::Disconnected => Self::Disconnected,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the daemon is and what its session is doing, from `daemon.status`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct DaemonStatus {
|
||||
pub username: String,
|
||||
pub server: String,
|
||||
pub daemon_version: String,
|
||||
pub protocol: u32,
|
||||
#[serde(default)]
|
||||
pub listen_port: Option<u16>,
|
||||
pub shared_folders: u32,
|
||||
pub shared_files: u32,
|
||||
pub download_dir: String,
|
||||
#[serde(default)]
|
||||
pub session_loss: Option<SessionLossDto>,
|
||||
/// Control connections currently attached.
|
||||
pub clients: usize,
|
||||
pub uptime_secs: u64,
|
||||
}
|
||||
|
||||
/// What the share index holds, from `shares.status` / `shares.reindex`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct SharesStatus {
|
||||
pub folders: u32,
|
||||
pub files: u32,
|
||||
pub directories: Vec<String>,
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! JSON-RPC 2.0 framing: what a request, a reply, and an error look like on
|
||||
//! the wire, independent of the methods they carry.
|
||||
|
||||
use crate::output::Exit;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A request from a client. `id` is absent for a notification, which by
|
||||
/// JSON-RPC rule gets no reply.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Request {
|
||||
#[serde(default = "jsonrpc_version")]
|
||||
pub jsonrpc: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<serde_json::Value>,
|
||||
pub method: String,
|
||||
#[serde(default)]
|
||||
pub params: serde_json::Value,
|
||||
}
|
||||
|
||||
/// A reply. Exactly one of `result`/`error` is set; `id` echoes the request,
|
||||
/// and is absent on a daemon-initiated notification.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Response {
|
||||
#[serde(default = "jsonrpc_version")]
|
||||
pub jsonrpc: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub method: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub params: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<RpcError>,
|
||||
}
|
||||
|
||||
fn jsonrpc_version() -> String {
|
||||
"2.0".to_string()
|
||||
}
|
||||
|
||||
/// A failure, carrying the exit code the same command would have produced
|
||||
/// locally so a remote run exits exactly as a local one does.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct RpcError {
|
||||
pub code: i32,
|
||||
pub message: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<ErrorData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ErrorData {
|
||||
/// The `Exit` discriminant this failure maps to.
|
||||
pub exit: u8,
|
||||
}
|
||||
|
||||
/// Application errors all share one code; the exit status in `data` is what a
|
||||
/// caller branches on.
|
||||
pub const CODE_APPLICATION: i32 = -32000;
|
||||
pub const CODE_PARSE: i32 = -32700;
|
||||
pub const CODE_INVALID_REQUEST: i32 = -32600;
|
||||
pub const CODE_METHOD_NOT_FOUND: i32 = -32601;
|
||||
pub const CODE_INVALID_PARAMS: i32 = -32602;
|
||||
|
||||
impl RpcError {
|
||||
#[must_use]
|
||||
pub fn new(code: i32, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// An application failure that should reproduce `exit` on the client.
|
||||
#[must_use]
|
||||
pub fn application(exit: Exit, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: CODE_APPLICATION,
|
||||
message: message.into(),
|
||||
data: Some(ErrorData { exit: exit.code() }),
|
||||
}
|
||||
}
|
||||
|
||||
/// The exit status this error asks the client to produce, defaulting to a
|
||||
/// generic failure when the daemon did not say.
|
||||
#[must_use]
|
||||
pub fn exit(&self) -> Exit {
|
||||
match self.data.as_ref().map(|data| data.exit) {
|
||||
Some(0) => Exit::Ok,
|
||||
Some(2) => Exit::Usage,
|
||||
Some(3) => Exit::Connection,
|
||||
Some(4) => Exit::NoResults,
|
||||
Some(5) => Exit::Timeout,
|
||||
Some(6) => Exit::Transfer,
|
||||
Some(7) => Exit::SessionLost,
|
||||
_ => Exit::Failure,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Response {
|
||||
#[must_use]
|
||||
pub fn result(
|
||||
id: Option<serde_json::Value>,
|
||||
value: serde_json::Value,
|
||||
) -> Self {
|
||||
Self {
|
||||
jsonrpc: jsonrpc_version(),
|
||||
id,
|
||||
method: None,
|
||||
params: None,
|
||||
result: Some(value),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A failed reply.
|
||||
///
|
||||
/// An error always carries an `id` member, explicitly `null` when the
|
||||
/// request was too malformed to have one. JSON-RPC requires it, and
|
||||
/// without it a client following the "no id means a notification" rule
|
||||
/// would drop the error and wait out its timeout for a reply that is
|
||||
/// already there.
|
||||
#[must_use]
|
||||
pub fn failure(id: Option<serde_json::Value>, error: RpcError) -> Self {
|
||||
Self {
|
||||
jsonrpc: jsonrpc_version(),
|
||||
id: Some(id.unwrap_or(serde_json::Value::Null)),
|
||||
method: None,
|
||||
params: None,
|
||||
result: None,
|
||||
error: Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// A daemon-initiated event: no id, so no reply is expected.
|
||||
#[must_use]
|
||||
pub fn notification(method: &str, params: serde_json::Value) -> Self {
|
||||
Self {
|
||||
jsonrpc: jsonrpc_version(),
|
||||
id: None,
|
||||
method: Some(method.to_string()),
|
||||
params: Some(params),
|
||||
result: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! What each method takes and returns, and what each pushed event carries.
|
||||
|
||||
use super::dto::{
|
||||
DownloadDto, DownloadMetadataDto, DownloadStatusDto, SearchResultDto,
|
||||
SessionLossDto, SharedDirectoryDto, UploadInfoDto, UserInfoDto,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A void result. JSON-RPC allows a null result, but an object leaves room to
|
||||
/// add a field later without changing the shape a client already parses.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct Ack {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
impl Ack {
|
||||
pub const OK: Self = Self { ok: true };
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct QueryParams {
|
||||
pub query: String,
|
||||
}
|
||||
|
||||
/// Names one transfer, which is how both directions are addressed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct TransferRef {
|
||||
pub username: String,
|
||||
pub filename: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct RoomRef {
|
||||
pub room: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct UserRef {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct SayParams {
|
||||
pub room: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct MessageParams {
|
||||
pub username: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct DownloadStartParams {
|
||||
pub username: String,
|
||||
pub filename: String,
|
||||
#[serde(default)]
|
||||
pub size: u64,
|
||||
#[serde(default)]
|
||||
pub metadata: DownloadMetadataDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct SlotsParams {
|
||||
pub slots: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct DirectoriesParams {
|
||||
pub directories: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct SearchResults {
|
||||
pub results: Vec<SearchResultDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct Downloads {
|
||||
pub downloads: Vec<DownloadDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct DownloadStarted {
|
||||
pub download: DownloadDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct Uploads {
|
||||
pub uploads: Vec<UploadInfoDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct Members {
|
||||
pub users: Vec<String>,
|
||||
}
|
||||
|
||||
/// One line of chat history. Unlike a live [`UserMessageDto`] this carries a
|
||||
/// direction, because a conversation the daemon collected includes what this
|
||||
/// account sent as well as what it received.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct ChatMessageDto {
|
||||
/// The other party, whichever way the message went.
|
||||
pub peer: String,
|
||||
pub outgoing: bool,
|
||||
pub text: String,
|
||||
/// Unix seconds.
|
||||
pub at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct Messages {
|
||||
pub messages: Vec<ChatMessageDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct UserResult {
|
||||
#[serde(default)]
|
||||
pub user: Option<UserInfoDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct Seconds {
|
||||
#[serde(default)]
|
||||
pub seconds: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct IntervalSeconds {
|
||||
pub seconds: u64,
|
||||
}
|
||||
|
||||
/// A peer answered a browse request. Carried whole rather than as a "go ask
|
||||
/// for it" nudge, because the daemon's own copy is consumed when it drains.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct BrowseEvent {
|
||||
pub username: String,
|
||||
pub directories: Vec<SharedDirectoryDto>,
|
||||
}
|
||||
|
||||
/// One step in a transfer this client started.
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct DownloadStatusEvent {
|
||||
pub username: String,
|
||||
pub filename: String,
|
||||
pub status: DownloadStatusDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||
pub struct SessionLossEvent {
|
||||
pub loss: SessionLossDto,
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
//! The wire contract: JSON-RPC 2.0 framing and the data types that cross it.
|
||||
//!
|
||||
//! Library types never travel as themselves. Every payload is a DTO defined
|
||||
//! here with an explicit conversion, so the wire format is versioned
|
||||
//! independently of the library: a refactor inside `soulseek-rs-lib` cannot
|
||||
//! silently change what a third-party script receives, and the compiler points
|
||||
//! at this file when one would.
|
||||
//!
|
||||
//! The schemas published in `docs/openrpc.json` are derived from these types
|
||||
//! (see the tests at the bottom), which is what keeps the advertised contract
|
||||
//! and the running daemon in agreement.
|
||||
|
||||
/// Bumped when a change would break a client written against the old shape.
|
||||
/// Sent in the `auth` reply so a mismatch is caught at connect time rather
|
||||
/// than as a confusing failure three calls later.
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
/// The OpenRPC document, served by `rpc.discover` so a generator can point at
|
||||
/// a running daemon instead of needing this repository.
|
||||
pub const OPENRPC: &str = include_str!("../../../../docs/openrpc.json");
|
||||
|
||||
mod dto;
|
||||
mod envelope;
|
||||
mod method;
|
||||
|
||||
pub use dto::*;
|
||||
pub use envelope::*;
|
||||
pub use method::*;
|
||||
|
||||
/// Declare a wire vocabulary once: the enum, the complete list, and the
|
||||
/// name mapping in both directions.
|
||||
///
|
||||
/// Everything that consumes one of these matches on the enum, so the match is
|
||||
/// exhaustive and adding a line here is a compile error everywhere the new
|
||||
/// member still has to be handled — the dispatcher, the published schema, the
|
||||
/// remote client. A `&[&str]` table checked by a test would only fail after
|
||||
/// the fact, and only if someone remembered to write the test.
|
||||
macro_rules! wire_vocabulary {
|
||||
($(#[$meta:meta])* $name:ident { $($variant:ident => $wire:literal),* $(,)? }) => {
|
||||
$(#[$meta])*
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum $name {
|
||||
$($variant),*
|
||||
}
|
||||
|
||||
impl $name {
|
||||
/// Every member, in published order.
|
||||
pub const ALL: &'static [Self] = &[$(Self::$variant),*];
|
||||
|
||||
#[must_use]
|
||||
pub const fn wire_name(self) -> &'static str {
|
||||
match self {
|
||||
$(Self::$variant => $wire),*
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_wire(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
$($wire => Some(Self::$variant),)*
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.wire_name())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
wire_vocabulary! {
|
||||
/// Every method the daemon answers.
|
||||
Method {
|
||||
Auth => "auth",
|
||||
DaemonStatus => "daemon.status",
|
||||
DaemonStop => "daemon.stop",
|
||||
RpcDiscover => "rpc.discover",
|
||||
SearchStart => "search.start",
|
||||
SearchResults => "search.results",
|
||||
SearchWishlist => "search.wishlist",
|
||||
SearchWishlistInterval => "search.wishlist_interval",
|
||||
DownloadStart => "download.start",
|
||||
DownloadList => "download.list",
|
||||
DownloadPause => "download.pause",
|
||||
DownloadResume => "download.resume",
|
||||
DownloadRemove => "download.remove",
|
||||
DownloadRemoveQueued => "download.remove_queued",
|
||||
UploadList => "upload.list",
|
||||
UploadCancel => "upload.cancel",
|
||||
UploadSlots => "upload.slots",
|
||||
PrivilegesCheck => "privileges.check",
|
||||
PrivilegesOwn => "privileges.own",
|
||||
RoomListRequest => "room.list_request",
|
||||
RoomJoin => "room.join",
|
||||
RoomLeave => "room.leave",
|
||||
RoomSay => "room.say",
|
||||
RoomMembers => "room.members",
|
||||
MessageSend => "message.send",
|
||||
MessageHistory => "message.history",
|
||||
BrowseUser => "browse.user",
|
||||
UserRequest => "user.request",
|
||||
UserInfoOf => "user.info",
|
||||
SharesStatusOf => "shares.status",
|
||||
SharesSet => "shares.set",
|
||||
SharesReindex => "shares.reindex",
|
||||
}
|
||||
}
|
||||
|
||||
wire_vocabulary! {
|
||||
/// Events the daemon pushes without being asked.
|
||||
///
|
||||
/// These exist because the library's `take_*` accessors are destructive:
|
||||
/// with two clients attached, whichever drained first would steal the
|
||||
/// others' events. The daemon is the single drainer and fans out.
|
||||
Event {
|
||||
Room => "event.room",
|
||||
Message => "event.message",
|
||||
Upload => "event.upload",
|
||||
DownloadStatus => "event.download_status",
|
||||
Browse => "event.browse",
|
||||
SessionLoss => "event.session_loss",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::output::Exit;
|
||||
use soulseek_rs::types::{UserPresence, UserStats, UserStatus};
|
||||
use soulseek_rs::{
|
||||
DownloadStatus, File, RoomEvent, RoomInfo, SearchResult,
|
||||
SharedDirectory, UploadStatus, UserInfo, UserMessage,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn a_search_result_survives_the_round_trip() {
|
||||
let mut attribs = HashMap::new();
|
||||
attribs.insert(0, 320);
|
||||
attribs.insert(1, 210);
|
||||
let original = SearchResult {
|
||||
token: 7,
|
||||
slots: 2,
|
||||
speed: 900,
|
||||
username: "bob".into(),
|
||||
files: vec![File {
|
||||
username: "bob".into(),
|
||||
name: "@@x\\a.mp3".into(),
|
||||
size: 4096,
|
||||
attribs,
|
||||
}],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&SearchResultDto::from(&original))
|
||||
.expect("a DTO serializes");
|
||||
let back: SearchResult = serde_json::from_str::<SearchResultDto>(&json)
|
||||
.unwrap()
|
||||
.into();
|
||||
|
||||
assert_eq!(back.token, original.token);
|
||||
assert_eq!(back.username, original.username);
|
||||
assert_eq!(back.slots, original.slots);
|
||||
assert_eq!(back.speed, original.speed);
|
||||
assert_eq!(back.files.len(), 1);
|
||||
assert_eq!(back.files[0].name, "@@x\\a.mp3");
|
||||
assert_eq!(back.files[0].size, 4096);
|
||||
assert_eq!(back.files[0].attribs.get(&0), Some(&320));
|
||||
// The per-file username is carried by the parent result, so it has to
|
||||
// be restored rather than lost.
|
||||
assert_eq!(back.files[0].username, "bob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_download_status_survives_the_round_trip() {
|
||||
let statuses = [
|
||||
DownloadStatus::Queued,
|
||||
DownloadStatus::InProgress {
|
||||
bytes_downloaded: 10,
|
||||
total_bytes: 100,
|
||||
speed_bytes_per_sec: 1.5,
|
||||
},
|
||||
DownloadStatus::Paused {
|
||||
bytes_downloaded: 10,
|
||||
total_bytes: 100,
|
||||
},
|
||||
DownloadStatus::Completed,
|
||||
DownloadStatus::Failed(Some("gone".into())),
|
||||
DownloadStatus::Failed(None),
|
||||
DownloadStatus::TimedOut,
|
||||
];
|
||||
for status in &statuses {
|
||||
let dto = DownloadStatusDto::from(status);
|
||||
let json = serde_json::to_string(&dto).unwrap();
|
||||
let back: DownloadStatusDto = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(dto, back, "{status:?} must survive the wire");
|
||||
assert_eq!(
|
||||
DownloadStatusDto::from(&DownloadStatus::from(back)),
|
||||
dto,
|
||||
"{status:?} must convert back to the same library value"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_upload_status_survives_the_round_trip() {
|
||||
let statuses = [
|
||||
UploadStatus::Queued(3),
|
||||
UploadStatus::InProgress,
|
||||
UploadStatus::Completed,
|
||||
UploadStatus::Cancelled,
|
||||
UploadStatus::Failed("nope".into()),
|
||||
];
|
||||
for status in &statuses {
|
||||
let dto = UploadStatusDto::from(status);
|
||||
let json = serde_json::to_string(&dto).unwrap();
|
||||
let back: UploadStatus =
|
||||
serde_json::from_str::<UploadStatusDto>(&json)
|
||||
.unwrap()
|
||||
.into();
|
||||
assert_eq!(&back, status);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_room_event_survives_the_round_trip() {
|
||||
let events = [
|
||||
RoomEvent::List(vec![RoomInfo {
|
||||
name: "lobby".into(),
|
||||
user_count: 42,
|
||||
}]),
|
||||
RoomEvent::Joined {
|
||||
room: "lobby".into(),
|
||||
users: vec!["bob".into()],
|
||||
},
|
||||
RoomEvent::Left {
|
||||
room: "lobby".into(),
|
||||
},
|
||||
RoomEvent::Message {
|
||||
room: "lobby".into(),
|
||||
username: "bob".into(),
|
||||
message: "hi".into(),
|
||||
},
|
||||
RoomEvent::UserJoined {
|
||||
room: "lobby".into(),
|
||||
username: "sue".into(),
|
||||
},
|
||||
RoomEvent::UserLeft {
|
||||
room: "lobby".into(),
|
||||
username: "sue".into(),
|
||||
},
|
||||
];
|
||||
for event in &events {
|
||||
let json =
|
||||
serde_json::to_string(&RoomEventDto::from(event)).unwrap();
|
||||
let back: RoomEvent =
|
||||
serde_json::from_str::<RoomEventDto>(&json).unwrap().into();
|
||||
assert_eq!(&back, event);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_partly_answered_user_stays_partly_answered() {
|
||||
// "the server has not said" must not arrive as "offline, shares
|
||||
// nothing": that is the difference between waiting and giving up.
|
||||
let pending = UserInfo::pending("bob".into());
|
||||
let json = serde_json::to_string(&UserInfoDto::from(&pending)).unwrap();
|
||||
let back: UserInfo =
|
||||
serde_json::from_str::<UserInfoDto>(&json).unwrap().into();
|
||||
assert_eq!(back.username, "bob");
|
||||
assert!(back.presence.is_none());
|
||||
assert!(back.stats.is_none());
|
||||
assert!(!back.is_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_complete_user_survives_the_round_trip() {
|
||||
let mut info = UserInfo::pending("bob".into());
|
||||
info.presence = Some(UserPresence {
|
||||
status: UserStatus::Away,
|
||||
privileged: true,
|
||||
});
|
||||
info.stats = Some(UserStats {
|
||||
average_speed: 500,
|
||||
shared_files: 10,
|
||||
shared_folders: 2,
|
||||
});
|
||||
|
||||
let json = serde_json::to_string(&UserInfoDto::from(&info)).unwrap();
|
||||
let back: UserInfo =
|
||||
serde_json::from_str::<UserInfoDto>(&json).unwrap().into();
|
||||
assert_eq!(back, info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_private_message_survives_the_round_trip() {
|
||||
let message =
|
||||
UserMessage::new(9, 1234, "bob".into(), "hi".into(), true);
|
||||
let json =
|
||||
serde_json::to_string(&UserMessageDto::from(&message)).unwrap();
|
||||
let back: UserMessage = serde_json::from_str::<UserMessageDto>(&json)
|
||||
.unwrap()
|
||||
.into();
|
||||
assert_eq!(back.id(), 9);
|
||||
assert_eq!(back.timestamp(), 1234);
|
||||
assert_eq!(back.username(), "bob");
|
||||
assert_eq!(back.message(), "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_listing_survives_the_round_trip() {
|
||||
let directory = SharedDirectory {
|
||||
name: "@@x\\Music".into(),
|
||||
files: vec![("a.mp3".into(), 4096)],
|
||||
};
|
||||
let json = serde_json::to_string(&SharedDirectoryDto::from(&directory))
|
||||
.unwrap();
|
||||
let back: SharedDirectory =
|
||||
serde_json::from_str::<SharedDirectoryDto>(&json)
|
||||
.unwrap()
|
||||
.into();
|
||||
assert_eq!(back.name, directory.name);
|
||||
assert_eq!(back.files, directory.files);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_error_carries_the_exit_code_a_local_run_would_have_produced() {
|
||||
for exit in [
|
||||
Exit::Usage,
|
||||
Exit::Connection,
|
||||
Exit::NoResults,
|
||||
Exit::Timeout,
|
||||
Exit::Transfer,
|
||||
Exit::SessionLost,
|
||||
] {
|
||||
let error = RpcError::application(exit, "nope");
|
||||
let json = serde_json::to_string(&error).unwrap();
|
||||
let back: RpcError = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.exit(), exit);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_error_without_an_exit_code_is_a_generic_failure() {
|
||||
let error = RpcError::new(CODE_METHOD_NOT_FOUND, "no such method");
|
||||
assert_eq!(error.exit(), Exit::Failure);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_parses_without_optional_fields() {
|
||||
let request: Request = serde_json::from_str(
|
||||
r#"{"jsonrpc":"2.0","method":"daemon.status"}"#,
|
||||
)
|
||||
.expect("params and id are optional");
|
||||
assert_eq!(request.method, "daemon.status");
|
||||
assert!(request.id.is_none());
|
||||
assert!(request.params.is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_error_always_carries_an_id_even_when_there_was_none() {
|
||||
// JSON-RPC requires it, and without it a client following the
|
||||
// documented "no id means a notification" rule drops the error and
|
||||
// waits out its timeout for a reply that already arrived.
|
||||
let json = serde_json::to_string(&Response::failure(
|
||||
None,
|
||||
RpcError::new(CODE_PARSE, "unparseable"),
|
||||
))
|
||||
.expect("a response is plain data");
|
||||
assert!(json.contains("\"id\":null"), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_notification_carries_no_id_so_no_reply_is_expected() {
|
||||
let notification =
|
||||
Response::notification("event.room", serde_json::json!({"a": 1}));
|
||||
let json = serde_json::to_string(¬ification).unwrap();
|
||||
assert!(!json.contains("\"id\""), "{json}");
|
||||
assert!(json.contains("\"method\":\"event.room\""), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_response_serializes_exactly_one_of_result_and_error() {
|
||||
let ok = serde_json::to_string(&Response::result(
|
||||
Some(serde_json::json!(1)),
|
||||
serde_json::json!({}),
|
||||
))
|
||||
.unwrap();
|
||||
assert!(ok.contains("\"result\""));
|
||||
assert!(!ok.contains("\"error\""));
|
||||
|
||||
let failed = serde_json::to_string(&Response::failure(
|
||||
Some(serde_json::json!(1)),
|
||||
RpcError::application(Exit::NoResults, "nothing"),
|
||||
))
|
||||
.unwrap();
|
||||
assert!(failed.contains("\"error\""));
|
||||
assert!(!failed.contains("\"result\""));
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,10 @@
|
||||
|
||||
use super::hub::{Hub, PendingBrowses, PendingDownload};
|
||||
use super::proto::{
|
||||
Ack, AuthParams, AuthResult, CODE_APPLICATION, CODE_INVALID_PARAMS,
|
||||
ChatMessageDto,
|
||||
CODE_INVALID_REQUEST, CODE_METHOD_NOT_FOUND, CODE_PARSE, DaemonStatus,
|
||||
DirectoriesParams, DownloadDto, DownloadStartParams, DownloadStarted,
|
||||
Downloads, IntervalSeconds, MessageParams, Members, Messages, Method,
|
||||
OPENRPC, PROTOCOL_VERSION, QueryParams, Request, Response, RoomRef,
|
||||
Ack, AuthResult, CODE_APPLICATION, CODE_INVALID_PARAMS, ChatMessageDto,
|
||||
DaemonStatus, DirectoriesParams, DownloadDto, DownloadStartParams,
|
||||
DownloadStarted, Downloads, IntervalSeconds, Members, MessageParams,
|
||||
Messages, Method, OPENRPC, PROTOCOL_VERSION, QueryParams, RoomRef,
|
||||
RpcError, SayParams, SearchResultDto, SearchResults, Seconds, SharesStatus,
|
||||
SlotsParams, TransferRef, UploadInfoDto, Uploads, UserInfoDto, UserRef,
|
||||
UserResult,
|
||||
@@ -21,21 +19,10 @@ use crate::api::SessionApi;
|
||||
use crate::output::Exit;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{Receiver, SyncSender, sync_channel};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How long a failed authentication costs the caller. Not a serious defence —
|
||||
/// the token is 256 bits — but it keeps a wrong-token loop off the CPU.
|
||||
const AUTH_FAILURE_DELAY: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Replies waiting to go out on one connection. Bounded, so a client that has
|
||||
/// stopped reading cannot make the daemon buffer without limit.
|
||||
const WRITE_QUEUE_DEPTH: usize = 1024;
|
||||
|
||||
/// Everything a connection needs to answer a request.
|
||||
pub struct Daemon {
|
||||
pub session: Arc<dyn SessionApi>,
|
||||
@@ -49,6 +36,9 @@ pub struct Daemon {
|
||||
pub token: String,
|
||||
pub started: Instant,
|
||||
pub stop: Arc<AtomicBool>,
|
||||
/// Connections currently being served, capped so an unauthenticated peer
|
||||
/// cannot spawn threads without bound.
|
||||
pub open: Arc<std::sync::atomic::AtomicUsize>,
|
||||
/// Rooms this session has joined, so a restart can rejoin them.
|
||||
pub rooms: Mutex<Vec<String>>,
|
||||
}
|
||||
@@ -59,17 +49,27 @@ impl Daemon {
|
||||
/// The match is exhaustive over [`Method`], so a new method cannot be added
|
||||
/// to the protocol without being implemented here.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn call(&self, method: Method, params: Value) -> Result<Value, RpcError> {
|
||||
pub fn call(
|
||||
&self,
|
||||
method: Method,
|
||||
params: Value,
|
||||
) -> Result<Value, RpcError> {
|
||||
match method {
|
||||
// Handled during the handshake; reaching here means a second auth.
|
||||
Method::Auth => ok(Ack::OK),
|
||||
// Handled during the handshake; reaching here means a second
|
||||
// `auth` on an established connection, which is answered with the
|
||||
// shape the contract publishes rather than a bare acknowledgement.
|
||||
Method::Auth => ok(self.identify()),
|
||||
Method::DaemonStatus => ok(self.status()),
|
||||
Method::DaemonStop => {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
ok(Ack::OK)
|
||||
}
|
||||
Method::RpcDiscover => serde_json::from_str(OPENRPC)
|
||||
.map_err(|e| internal(format!("the contract is unreadable: {e}"))),
|
||||
Method::RpcDiscover => serde_json::from_str(OPENRPC).map_err(|e| {
|
||||
RpcError::new(
|
||||
CODE_APPLICATION,
|
||||
format!("the contract is unreadable: {e}"),
|
||||
)
|
||||
}),
|
||||
|
||||
Method::SearchStart => {
|
||||
let query: QueryParams = parse(params)?;
|
||||
@@ -78,7 +78,12 @@ impl Daemon {
|
||||
// what keeps one client's search from blocking the daemon.
|
||||
self.session
|
||||
.search_with_cancel(&query.query, Duration::ZERO, None)
|
||||
.map_err(|e| failed(Exit::Connection, format!("search failed: {e}")))?;
|
||||
.map_err(|e| {
|
||||
RpcError::application(
|
||||
Exit::Connection,
|
||||
format!("search failed: {e}"),
|
||||
)
|
||||
})?;
|
||||
ok(Ack::OK)
|
||||
}
|
||||
Method::SearchResults => {
|
||||
@@ -94,9 +99,9 @@ impl Daemon {
|
||||
}
|
||||
Method::SearchWishlist => {
|
||||
let query: QueryParams = parse(params)?;
|
||||
self.session
|
||||
.start_wishlist_search(&query.query)
|
||||
.map_err(|e| failed(Exit::Connection, e.to_string()))?;
|
||||
self.session.start_wishlist_search(&query.query).map_err(
|
||||
|e| RpcError::application(Exit::Connection, e.to_string()),
|
||||
)?;
|
||||
ok(Ack::OK)
|
||||
}
|
||||
Method::SearchWishlistInterval => ok(IntervalSeconds {
|
||||
@@ -123,17 +128,19 @@ impl Daemon {
|
||||
Method::DownloadResume => {
|
||||
let transfer: TransferRef = parse(params)?;
|
||||
ok(Ack {
|
||||
ok: self
|
||||
.session
|
||||
.resume_download(&transfer.username, &transfer.filename),
|
||||
ok: self.session.resume_download(
|
||||
&transfer.username,
|
||||
&transfer.filename,
|
||||
),
|
||||
})
|
||||
}
|
||||
Method::DownloadRemove => {
|
||||
let transfer: TransferRef = parse(params)?;
|
||||
ok(Ack {
|
||||
ok: self
|
||||
.session
|
||||
.remove_download(&transfer.username, &transfer.filename),
|
||||
ok: self.session.remove_download(
|
||||
&transfer.username,
|
||||
&transfer.filename,
|
||||
),
|
||||
})
|
||||
}
|
||||
Method::DownloadRemoveQueued => {
|
||||
@@ -169,9 +176,9 @@ impl Daemon {
|
||||
}
|
||||
|
||||
Method::PrivilegesCheck => {
|
||||
self.session
|
||||
.check_privileges()
|
||||
.map_err(|e| failed(Exit::Connection, e.to_string()))?;
|
||||
self.session.check_privileges().map_err(|e| {
|
||||
RpcError::application(Exit::Connection, e.to_string())
|
||||
})?;
|
||||
ok(Ack::OK)
|
||||
}
|
||||
Method::PrivilegesOwn => ok(Seconds {
|
||||
@@ -179,32 +186,32 @@ impl Daemon {
|
||||
}),
|
||||
|
||||
Method::RoomListRequest => {
|
||||
self.session
|
||||
.request_room_list()
|
||||
.map_err(|e| failed(Exit::Connection, e.to_string()))?;
|
||||
self.session.request_room_list().map_err(|e| {
|
||||
RpcError::application(Exit::Connection, e.to_string())
|
||||
})?;
|
||||
ok(Ack::OK)
|
||||
}
|
||||
Method::RoomJoin => {
|
||||
let room: RoomRef = parse(params)?;
|
||||
self.session
|
||||
.join_room(&room.room)
|
||||
.map_err(|e| failed(Exit::Connection, e.to_string()))?;
|
||||
self.session.join_room(&room.room).map_err(|e| {
|
||||
RpcError::application(Exit::Connection, e.to_string())
|
||||
})?;
|
||||
self.joined(&room.room, true);
|
||||
ok(Ack::OK)
|
||||
}
|
||||
Method::RoomLeave => {
|
||||
let room: RoomRef = parse(params)?;
|
||||
self.session
|
||||
.leave_room(&room.room)
|
||||
.map_err(|e| failed(Exit::Connection, e.to_string()))?;
|
||||
self.session.leave_room(&room.room).map_err(|e| {
|
||||
RpcError::application(Exit::Connection, e.to_string())
|
||||
})?;
|
||||
self.joined(&room.room, false);
|
||||
ok(Ack::OK)
|
||||
}
|
||||
Method::RoomSay => {
|
||||
let say: SayParams = parse(params)?;
|
||||
self.session
|
||||
.say_in_room(&say.room, &say.message)
|
||||
.map_err(|e| failed(Exit::Connection, e.to_string()))?;
|
||||
self.session.say_in_room(&say.room, &say.message).map_err(
|
||||
|e| RpcError::application(Exit::Connection, e.to_string()),
|
||||
)?;
|
||||
ok(Ack::OK)
|
||||
}
|
||||
Method::RoomMembers => {
|
||||
@@ -218,7 +225,9 @@ impl Daemon {
|
||||
let message: MessageParams = parse(params)?;
|
||||
self.session
|
||||
.send_private_message(&message.username, &message.message)
|
||||
.map_err(|e| failed(Exit::Connection, e.to_string()))?;
|
||||
.map_err(|e| {
|
||||
RpcError::application(Exit::Connection, e.to_string())
|
||||
})?;
|
||||
// Record our own half of the conversation: the drainer only
|
||||
// ever sees incoming messages, so without this a client that
|
||||
// attaches later reads a one-sided chat.
|
||||
@@ -241,15 +250,18 @@ impl Daemon {
|
||||
self.browses.expect(&user.username);
|
||||
if let Err(e) = self.session.browse_user(&user.username) {
|
||||
self.browses.forget(&user.username);
|
||||
return Err(failed(Exit::Connection, e.to_string()));
|
||||
return Err(RpcError::application(
|
||||
Exit::Connection,
|
||||
e.to_string(),
|
||||
));
|
||||
}
|
||||
ok(Ack::OK)
|
||||
}
|
||||
Method::UserRequest => {
|
||||
let user: UserRef = parse(params)?;
|
||||
self.session
|
||||
.request_user_info(&user.username)
|
||||
.map_err(|e| failed(Exit::Connection, e.to_string()))?;
|
||||
self.session.request_user_info(&user.username).map_err(
|
||||
|e| RpcError::application(Exit::Connection, e.to_string()),
|
||||
)?;
|
||||
ok(Ack::OK)
|
||||
}
|
||||
Method::UserInfoOf => {
|
||||
@@ -268,13 +280,17 @@ impl Daemon {
|
||||
let directories: DirectoriesParams = parse(params)?;
|
||||
self.session
|
||||
.set_shared_directories(directories.directories)
|
||||
.map_err(|e| failed(Exit::Usage, e.to_string()))?;
|
||||
.map_err(|e| {
|
||||
RpcError::application(Exit::Usage, e.to_string())
|
||||
})?;
|
||||
ok(self.shares())
|
||||
}
|
||||
Method::SharesReindex => {
|
||||
self.session
|
||||
.set_shared_directories(self.session.shared_directories())
|
||||
.map_err(|e| failed(Exit::Usage, e.to_string()))?;
|
||||
.map_err(|e| {
|
||||
RpcError::application(Exit::Usage, e.to_string())
|
||||
})?;
|
||||
ok(self.shares())
|
||||
}
|
||||
}
|
||||
@@ -303,9 +319,11 @@ impl Daemon {
|
||||
&self,
|
||||
params: DownloadStartParams,
|
||||
) -> Result<Value, RpcError> {
|
||||
let directory = params
|
||||
.download_directory
|
||||
.unwrap_or_else(|| self.download_dir.clone());
|
||||
// The daemon's own directory, always. Letting a caller name a path
|
||||
// would hand any authenticated client an arbitrary write on this
|
||||
// host's filesystem, and a remote one cannot see that filesystem to
|
||||
// choose sensibly anyway.
|
||||
let directory = self.download_dir.clone();
|
||||
let (download, updates) = self
|
||||
.session
|
||||
.download_with_metadata(
|
||||
@@ -315,7 +333,12 @@ impl Daemon {
|
||||
directory,
|
||||
params.metadata.into(),
|
||||
)
|
||||
.map_err(|e| failed(Exit::Transfer, format!("cannot start: {e}")))?;
|
||||
.map_err(|e| {
|
||||
RpcError::application(
|
||||
Exit::Transfer,
|
||||
format!("cannot start: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Ok(mut pending) = self.downloads.lock() {
|
||||
pending.push(PendingDownload {
|
||||
@@ -338,6 +361,14 @@ impl Daemon {
|
||||
}
|
||||
}
|
||||
|
||||
fn identify(&self) -> AuthResult {
|
||||
AuthResult {
|
||||
protocol: PROTOCOL_VERSION,
|
||||
daemon_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
username: self.session.username(),
|
||||
}
|
||||
}
|
||||
|
||||
fn status(&self) -> DaemonStatus {
|
||||
let (folders, files) = self.session.shared_counts();
|
||||
DaemonStatus {
|
||||
@@ -357,8 +388,12 @@ impl Daemon {
|
||||
}
|
||||
|
||||
fn ok<T: serde::Serialize>(value: T) -> Result<Value, RpcError> {
|
||||
serde_json::to_value(value)
|
||||
.map_err(|e| internal(format!("cannot serialize the reply: {e}")))
|
||||
serde_json::to_value(value).map_err(|e| {
|
||||
RpcError::new(
|
||||
CODE_APPLICATION,
|
||||
format!("cannot serialize the reply: {e}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse<T: DeserializeOwned>(params: Value) -> Result<T, RpcError> {
|
||||
@@ -366,14 +401,6 @@ fn parse<T: DeserializeOwned>(params: Value) -> Result<T, RpcError> {
|
||||
.map_err(|e| RpcError::new(CODE_INVALID_PARAMS, e.to_string()))
|
||||
}
|
||||
|
||||
fn failed(exit: Exit, message: impl Into<String>) -> RpcError {
|
||||
RpcError::application(exit, message)
|
||||
}
|
||||
|
||||
fn internal(message: impl Into<String>) -> RpcError {
|
||||
RpcError::new(CODE_APPLICATION, message)
|
||||
}
|
||||
|
||||
/// Wall-clock seconds, to stamp a message the way the server stamps the ones
|
||||
/// it delivers. A clock before the epoch reads as zero rather than panicking.
|
||||
fn now_seconds() -> i64 {
|
||||
@@ -382,336 +409,27 @@ fn now_seconds() -> i64 {
|
||||
.map_or(0, |since| since.as_secs() as i64)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transports
|
||||
// ---------------------------------------------------------------------------
|
||||
mod transport;
|
||||
|
||||
/// Where control connections arrive.
|
||||
///
|
||||
/// The Unix socket is the local path — its file permissions are the
|
||||
/// authentication, the same bargain Docker and ssh-agent make. TCP is opt-in
|
||||
/// and always needs the token.
|
||||
pub enum Listener {
|
||||
#[cfg(unix)]
|
||||
Unix(std::os::unix::net::UnixListener),
|
||||
Tcp(TcpListener),
|
||||
}
|
||||
|
||||
pub enum Stream {
|
||||
#[cfg(unix)]
|
||||
Unix(std::os::unix::net::UnixStream),
|
||||
Tcp(TcpStream),
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
fn try_clone(&self) -> std::io::Result<Self> {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
Self::Unix(stream) => stream.try_clone().map(Self::Unix),
|
||||
Self::Tcp(stream) => stream.try_clone().map(Self::Tcp),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the transport itself proves who is calling.
|
||||
const fn trusted_by_transport(&self) -> bool {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
Self::Unix(_) => true,
|
||||
Self::Tcp(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::io::Read for Stream {
|
||||
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
Self::Unix(stream) => stream.read(buffer),
|
||||
Self::Tcp(stream) => stream.read(buffer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for Stream {
|
||||
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
Self::Unix(stream) => stream.write(buffer),
|
||||
Self::Tcp(stream) => stream.write(buffer),
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
Self::Unix(stream) => stream.flush(),
|
||||
Self::Tcp(stream) => stream.flush(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Listener {
|
||||
/// Accept forever, handing each connection to its own thread.
|
||||
pub fn serve(self, daemon: Arc<Daemon>) {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
Self::Unix(listener) => {
|
||||
for stream in listener.incoming() {
|
||||
match stream {
|
||||
Ok(stream) => spawn_connection(
|
||||
Stream::Unix(stream),
|
||||
Arc::clone(&daemon),
|
||||
),
|
||||
Err(e) => {
|
||||
soulseek_rs::warn!("control accept failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::Tcp(listener) => {
|
||||
for stream in listener.incoming() {
|
||||
match stream {
|
||||
Ok(stream) => spawn_connection(
|
||||
Stream::Tcp(stream),
|
||||
Arc::clone(&daemon),
|
||||
),
|
||||
Err(e) => {
|
||||
soulseek_rs::warn!("control accept failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_connection(stream: Stream, daemon: Arc<Daemon>) {
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = handle(stream, daemon) {
|
||||
soulseek_rs::debug!("control connection ended: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Serve one connection: authenticate, then answer requests until it closes.
|
||||
fn handle(stream: Stream, daemon: Arc<Daemon>) -> std::io::Result<()> {
|
||||
let reader = BufReader::new(stream.try_clone()?);
|
||||
let trusted = stream.trusted_by_transport();
|
||||
|
||||
let (out, outbox) = sync_channel::<Response>(WRITE_QUEUE_DEPTH);
|
||||
let writer = std::thread::spawn(move || write_loop(stream, &outbox));
|
||||
|
||||
let mut lines = reader.lines();
|
||||
let authenticated = authenticate(&mut lines, &daemon, trusted, &out);
|
||||
if !authenticated {
|
||||
drop(out);
|
||||
let _ = writer.join();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Only now does this connection get to see events: an unauthenticated
|
||||
// socket must learn nothing at all.
|
||||
let (subscription, events) = daemon.hub.subscribe();
|
||||
let pump = {
|
||||
let out = out.clone();
|
||||
std::thread::spawn(move || {
|
||||
for event in events {
|
||||
if out.send(event).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// Requests are answered on this thread, in order. A method slow enough to
|
||||
// matter (re-indexing a large share) delays only the connection that asked
|
||||
// for it, which is the right person to wait — and every other client has a
|
||||
// connection of its own. Spawning per request would instead churn a thread
|
||||
// for each of the handful of polls a TUI makes every frame.
|
||||
for line in lines {
|
||||
let line = line?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(response) = answer(&line, &daemon)
|
||||
&& out.send(response).is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
daemon.hub.unsubscribe(subscription);
|
||||
drop(out);
|
||||
let _ = pump.join();
|
||||
let _ = writer.join();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The first request has to be `auth`, and has to be good.
|
||||
fn authenticate(
|
||||
lines: &mut std::io::Lines<BufReader<Stream>>,
|
||||
daemon: &Daemon,
|
||||
trusted: bool,
|
||||
out: &SyncSender<Response>,
|
||||
) -> bool {
|
||||
let Some(Ok(line)) = lines.next() else {
|
||||
return false;
|
||||
};
|
||||
let request: Request = match serde_json::from_str(&line) {
|
||||
Ok(request) => request,
|
||||
Err(e) => {
|
||||
let _ = out.send(Response::failure(
|
||||
None,
|
||||
RpcError::new(CODE_PARSE, e.to_string()),
|
||||
));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if Method::from_wire(&request.method) != Some(Method::Auth) {
|
||||
let _ = out.send(Response::failure(
|
||||
request.id,
|
||||
RpcError::new(
|
||||
CODE_INVALID_REQUEST,
|
||||
"the first request on a connection must be `auth`",
|
||||
),
|
||||
));
|
||||
return false;
|
||||
}
|
||||
|
||||
let params: AuthParams =
|
||||
serde_json::from_value(request.params).unwrap_or_default();
|
||||
if !accepts(daemon, trusted, params.token.as_deref()) {
|
||||
std::thread::sleep(AUTH_FAILURE_DELAY);
|
||||
let _ = out.send(Response::failure(
|
||||
request.id,
|
||||
RpcError::application(Exit::Connection, "authentication failed"),
|
||||
));
|
||||
return false;
|
||||
}
|
||||
|
||||
let result = serde_json::to_value(AuthResult {
|
||||
protocol: PROTOCOL_VERSION,
|
||||
daemon_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
username: daemon.session.username(),
|
||||
})
|
||||
.unwrap_or(Value::Null);
|
||||
let _ = out.send(Response::result(request.id, result));
|
||||
true
|
||||
}
|
||||
|
||||
/// A Unix socket has already proved the caller is this user, so a token is
|
||||
/// optional there and mandatory everywhere else. A token that *is* offered is
|
||||
/// always checked, so a wrong one never passes.
|
||||
fn accepts(daemon: &Daemon, trusted: bool, token: Option<&str>) -> bool {
|
||||
match token {
|
||||
Some(token) => constant_time_eq(token, &daemon.token),
|
||||
None => trusted,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare without leaking the position of the first difference through timing.
|
||||
fn constant_time_eq(left: &str, right: &str) -> bool {
|
||||
if left.len() != right.len() {
|
||||
return false;
|
||||
}
|
||||
left.bytes()
|
||||
.zip(right.bytes())
|
||||
.fold(0u8, |difference, (a, b)| difference | (a ^ b))
|
||||
== 0
|
||||
}
|
||||
|
||||
/// Turn one request line into the reply it deserves, or `None` for a
|
||||
/// notification, which by JSON-RPC rule is answered with silence.
|
||||
fn answer(line: &str, daemon: &Daemon) -> Option<Response> {
|
||||
let request: Request = match serde_json::from_str(line) {
|
||||
Ok(request) => request,
|
||||
Err(e) => {
|
||||
return Some(Response::failure(
|
||||
None,
|
||||
RpcError::new(CODE_PARSE, e.to_string()),
|
||||
));
|
||||
}
|
||||
};
|
||||
let id = request.id.clone();
|
||||
let Some(method) = Method::from_wire(&request.method) else {
|
||||
return id.is_some().then(|| {
|
||||
Response::failure(
|
||||
request.id,
|
||||
RpcError::new(
|
||||
CODE_METHOD_NOT_FOUND,
|
||||
format!("no such method: {}", request.method),
|
||||
),
|
||||
)
|
||||
});
|
||||
};
|
||||
|
||||
let outcome = daemon.call(method, request.params);
|
||||
id.map(|id| match outcome {
|
||||
Ok(result) => Response::result(Some(id), result),
|
||||
Err(error) => Response::failure(Some(id), error),
|
||||
})
|
||||
}
|
||||
|
||||
fn write_loop(stream: Stream, outbox: &Receiver<Response>) {
|
||||
let mut stream = stream;
|
||||
for response in outbox {
|
||||
let Ok(line) = serde_json::to_string(&response) else {
|
||||
continue;
|
||||
};
|
||||
if writeln!(stream, "{line}").is_err() || stream.flush().is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind the TCP control port, refusing to come up rather than silently
|
||||
/// listening somewhere unintended.
|
||||
pub fn bind_tcp(address: &str) -> std::io::Result<Listener> {
|
||||
TcpListener::bind(address).map(Listener::Tcp)
|
||||
}
|
||||
|
||||
/// Bind the Unix control socket with permissions that make it the local
|
||||
/// authentication: only this user can open it.
|
||||
#[cfg(unix)]
|
||||
pub fn bind_unix(path: &std::path::Path) -> std::io::Result<Listener> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
// A socket file outlives the process that made it, so a previous crash
|
||||
// would otherwise make the daemon unstartable.
|
||||
if path.exists() {
|
||||
remove_stale_socket(path)?;
|
||||
}
|
||||
let listener = std::os::unix::net::UnixListener::bind(path)?;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
|
||||
Ok(Listener::Unix(listener))
|
||||
}
|
||||
|
||||
/// Remove a socket file left by a dead daemon — but never one a live daemon is
|
||||
/// still serving, which would silently steal its clients.
|
||||
#[cfg(unix)]
|
||||
fn remove_stale_socket(path: &std::path::Path) -> std::io::Result<()> {
|
||||
match std::os::unix::net::UnixStream::connect(path) {
|
||||
Ok(_) => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::AddrInUse,
|
||||
format!("a daemon is already listening on {}", path.display()),
|
||||
)),
|
||||
Err(_) => std::fs::remove_file(path),
|
||||
}
|
||||
}
|
||||
pub use transport::bind_unix;
|
||||
pub use transport::{Listener, bind_tcp};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::proto::{CODE_METHOD_NOT_FOUND, CODE_PARSE};
|
||||
use super::transport::{accepts, answer, constant_time_eq};
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::sync::mpsc::Receiver;
|
||||
|
||||
#[test]
|
||||
fn a_unix_socket_needs_no_token_but_tcp_does() {
|
||||
let daemon = daemon_with_token("secret");
|
||||
assert!(accepts(&daemon, true, None), "socket permissions are the auth");
|
||||
assert!(
|
||||
accepts(&daemon, true, None),
|
||||
"socket permissions are the auth"
|
||||
);
|
||||
assert!(!accepts(&daemon, false, None), "TCP must demand a token");
|
||||
}
|
||||
|
||||
@@ -784,7 +502,8 @@ mod tests {
|
||||
assert!(
|
||||
document["methods"]
|
||||
.as_array()
|
||||
.is_some_and(|methods| methods.len() == Method::ALL.len()
|
||||
.is_some_and(|methods| methods.len()
|
||||
== Method::ALL.len()
|
||||
+ super::super::proto::Event::ALL.len()),
|
||||
"discover must describe every method and event"
|
||||
);
|
||||
@@ -815,6 +534,7 @@ mod tests {
|
||||
token: token.to_string(),
|
||||
started: Instant::now(),
|
||||
stop: Arc::new(AtomicBool::new(false)),
|
||||
open: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
||||
rooms: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
@@ -859,7 +579,10 @@ mod tests {
|
||||
) -> Option<Vec<soulseek_rs::SearchResult>> {
|
||||
None
|
||||
}
|
||||
fn start_wishlist_search(&self, _query: &str) -> soulseek_rs::Result<()> {
|
||||
fn start_wishlist_search(
|
||||
&self,
|
||||
_query: &str,
|
||||
) -> soulseek_rs::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn wishlist_interval(&self) -> Duration {
|
||||
@@ -972,7 +695,10 @@ mod tests {
|
||||
) -> Option<Vec<soulseek_rs::SharedDirectory>> {
|
||||
None
|
||||
}
|
||||
fn request_user_info(&self, _username: &str) -> soulseek_rs::Result<()> {
|
||||
fn request_user_info(
|
||||
&self,
|
||||
_username: &str,
|
||||
) -> soulseek_rs::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn user_info(&self, _username: &str) -> Option<soulseek_rs::UserInfo> {
|
||||
@@ -0,0 +1,429 @@
|
||||
//! Getting bytes to and from a control client: who may connect, how a request
|
||||
//! is framed, and how replies and pushed events share one socket.
|
||||
//!
|
||||
//! One thread per connection, which is noise next to the thousand-odd peer
|
||||
//! threads the client already runs. Each connection owns a writer thread fed by
|
||||
//! a channel, so request replies and events interleave without a lock and
|
||||
//! neither can block the other.
|
||||
|
||||
use super::super::proto::{
|
||||
AuthParams, CODE_INVALID_REQUEST, CODE_METHOD_NOT_FOUND, CODE_PARSE,
|
||||
Method, Request, Response, RpcError,
|
||||
};
|
||||
use super::Daemon;
|
||||
use crate::output::Exit;
|
||||
use serde_json::Value;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::mpsc::{Receiver, SyncSender, sync_channel};
|
||||
use std::time::Duration;
|
||||
|
||||
/// How long a failed authentication costs the caller. Not a serious defence —
|
||||
/// the token is 256 bits — but it keeps a wrong-token loop off the CPU.
|
||||
const AUTH_FAILURE_DELAY: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Replies waiting to go out on one connection. Bounded, so a client that has
|
||||
/// stopped reading cannot make the daemon buffer without limit.
|
||||
const WRITE_QUEUE_DEPTH: usize = 1024;
|
||||
|
||||
/// The longest request line the daemon will read. Generous next to the largest
|
||||
/// real request (a `shares.set` naming every folder) and small enough that an
|
||||
/// unauthenticated peer cannot make the daemon allocate without bound.
|
||||
const MAX_LINE: u64 = 1 << 20;
|
||||
|
||||
/// How long an unauthenticated connection may stay silent. Applies only until
|
||||
/// `auth` succeeds — an established client is expected to idle, holding its
|
||||
/// connection open for events without sending anything.
|
||||
const AUTH_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How many control connections may be open at once. Far above any real use
|
||||
/// (a TUI, a few scripts, an agent), and low enough that an unauthenticated
|
||||
/// peer cannot exhaust the daemon's threads.
|
||||
const MAX_CONNECTIONS: usize = 64;
|
||||
|
||||
/// Where control connections arrive.
|
||||
///
|
||||
/// The Unix socket is the local path — its file permissions are the
|
||||
/// authentication, the same bargain Docker and ssh-agent make. TCP is opt-in
|
||||
/// and always needs the token.
|
||||
pub enum Listener {
|
||||
#[cfg(unix)]
|
||||
Unix(std::os::unix::net::UnixListener),
|
||||
Tcp(TcpListener),
|
||||
}
|
||||
|
||||
pub enum Stream {
|
||||
#[cfg(unix)]
|
||||
Unix(std::os::unix::net::UnixStream),
|
||||
Tcp(TcpStream),
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
fn try_clone(&self) -> std::io::Result<Self> {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
Self::Unix(stream) => stream.try_clone().map(Self::Unix),
|
||||
Self::Tcp(stream) => stream.try_clone().map(Self::Tcp),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_read_timeout(&self, timeout: Option<Duration>) {
|
||||
let _ = match self {
|
||||
#[cfg(unix)]
|
||||
Self::Unix(stream) => stream.set_read_timeout(timeout),
|
||||
Self::Tcp(stream) => stream.set_read_timeout(timeout),
|
||||
};
|
||||
}
|
||||
|
||||
/// End the connection from another thread. This is what makes "a client
|
||||
/// that stops reading is disconnected" true rather than aspirational: the
|
||||
/// hub can only reach a stalled client by closing its socket.
|
||||
pub(super) fn shutdown(&self) {
|
||||
let _ = match self {
|
||||
#[cfg(unix)]
|
||||
Self::Unix(stream) => stream.shutdown(std::net::Shutdown::Both),
|
||||
Self::Tcp(stream) => stream.shutdown(std::net::Shutdown::Both),
|
||||
};
|
||||
}
|
||||
|
||||
/// Whether the transport itself proves who is calling.
|
||||
const fn trusted_by_transport(&self) -> bool {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
Self::Unix(_) => true,
|
||||
Self::Tcp(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::io::Read for Stream {
|
||||
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
Self::Unix(stream) => stream.read(buffer),
|
||||
Self::Tcp(stream) => stream.read(buffer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for Stream {
|
||||
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
Self::Unix(stream) => stream.write(buffer),
|
||||
Self::Tcp(stream) => stream.write(buffer),
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
Self::Unix(stream) => stream.flush(),
|
||||
Self::Tcp(stream) => stream.flush(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Listener {
|
||||
/// Accept forever, handing each connection to its own thread.
|
||||
pub fn serve(self, daemon: Arc<Daemon>) {
|
||||
loop {
|
||||
match self.accept() {
|
||||
Ok(stream) => spawn_connection(stream, Arc::clone(&daemon)),
|
||||
Err(e) => soulseek_rs::warn!("control accept failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn accept(&self) -> std::io::Result<Stream> {
|
||||
match self {
|
||||
#[cfg(unix)]
|
||||
Self::Unix(listener) => {
|
||||
listener.accept().map(|(stream, _)| Stream::Unix(stream))
|
||||
}
|
||||
Self::Tcp(listener) => {
|
||||
listener.accept().map(|(stream, _)| Stream::Tcp(stream))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_connection(stream: Stream, daemon: Arc<Daemon>) {
|
||||
let open = daemon.open.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if open > MAX_CONNECTIONS {
|
||||
daemon.open.fetch_sub(1, Ordering::Relaxed);
|
||||
soulseek_rs::warn!(
|
||||
"refusing a control connection: {MAX_CONNECTIONS} already open"
|
||||
);
|
||||
stream.shutdown();
|
||||
return;
|
||||
}
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = handle(stream, daemon.as_ref()) {
|
||||
soulseek_rs::debug!("control connection ended: {e}");
|
||||
}
|
||||
daemon.open.fetch_sub(1, Ordering::Relaxed);
|
||||
});
|
||||
}
|
||||
|
||||
/// Serve one connection: authenticate, then answer requests until it closes.
|
||||
fn handle(stream: Stream, daemon: &Daemon) -> std::io::Result<()> {
|
||||
let mut reader = BufReader::new(stream.try_clone()?);
|
||||
let trusted = stream.trusted_by_transport();
|
||||
let closer = stream.try_clone()?;
|
||||
|
||||
// A peer that connects and says nothing must not hold a thread forever.
|
||||
stream.set_read_timeout(Some(AUTH_TIMEOUT));
|
||||
|
||||
let (out, outbox) = sync_channel::<Response>(WRITE_QUEUE_DEPTH);
|
||||
let writer = std::thread::spawn(move || write_loop(stream, &outbox));
|
||||
|
||||
if !authenticate(&mut reader, daemon, trusted, &out) {
|
||||
closer.shutdown();
|
||||
drop(out);
|
||||
let _ = writer.join();
|
||||
return Ok(());
|
||||
}
|
||||
// An established client is expected to idle, holding the connection open
|
||||
// for events without sending anything.
|
||||
closer.set_read_timeout(None);
|
||||
|
||||
// Only now does this connection get to see events: an unauthenticated
|
||||
// socket must learn nothing at all.
|
||||
let (subscription, events) = {
|
||||
let closer = closer.try_clone()?;
|
||||
daemon.hub.subscribe(move || closer.shutdown())
|
||||
};
|
||||
let pump = {
|
||||
let out = out.clone();
|
||||
std::thread::spawn(move || {
|
||||
for event in events {
|
||||
if out.send(event).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// Requests are answered on this thread, in order. A method slow enough to
|
||||
// matter (re-indexing a large share) delays only the connection that asked
|
||||
// for it, which is the right person to wait — and every other client has a
|
||||
// connection of its own. Spawning per request would instead churn a thread
|
||||
// for each of the handful of polls a TUI makes every frame.
|
||||
//
|
||||
// Errors break rather than propagate: returning here would skip the
|
||||
// unsubscribe below and leak a hub subscriber plus two threads for the
|
||||
// life of the daemon.
|
||||
loop {
|
||||
match read_line(&mut reader) {
|
||||
Ok(Some(line)) => {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(response) = answer(&line, daemon)
|
||||
&& out.send(response).is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
soulseek_rs::debug!("control connection ended: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
daemon.hub.unsubscribe(subscription);
|
||||
closer.shutdown();
|
||||
drop(out);
|
||||
let _ = pump.join();
|
||||
let _ = writer.join();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read one newline-delimited request, refusing a line long enough to be an
|
||||
/// attack rather than a request. `Ok(None)` means the peer hung up.
|
||||
fn read_line(
|
||||
reader: &mut BufReader<Stream>,
|
||||
) -> std::io::Result<Option<String>> {
|
||||
let mut line = String::new();
|
||||
let read = reader.by_ref().take(MAX_LINE).read_line(&mut line)?;
|
||||
if read == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
if read as u64 == MAX_LINE && !line.ends_with('\n') {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"request line exceeds the maximum size",
|
||||
));
|
||||
}
|
||||
Ok(Some(line))
|
||||
}
|
||||
|
||||
/// The first request has to be `auth`, and has to be good.
|
||||
fn authenticate(
|
||||
reader: &mut BufReader<Stream>,
|
||||
daemon: &Daemon,
|
||||
trusted: bool,
|
||||
out: &SyncSender<Response>,
|
||||
) -> bool {
|
||||
let Ok(Some(line)) = read_line(reader) else {
|
||||
return false;
|
||||
};
|
||||
let request: Request = match serde_json::from_str(&line) {
|
||||
Ok(request) => request,
|
||||
Err(e) => {
|
||||
let _ = out.send(Response::failure(
|
||||
None,
|
||||
RpcError::new(CODE_PARSE, e.to_string()),
|
||||
));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if Method::from_wire(&request.method) != Some(Method::Auth) {
|
||||
let _ = out.send(Response::failure(
|
||||
request.id,
|
||||
RpcError::new(
|
||||
CODE_INVALID_REQUEST,
|
||||
"the first request on a connection must be `auth`",
|
||||
),
|
||||
));
|
||||
return false;
|
||||
}
|
||||
|
||||
let params: AuthParams =
|
||||
serde_json::from_value(request.params).unwrap_or_default();
|
||||
if !accepts(daemon, trusted, params.token.as_deref()) {
|
||||
std::thread::sleep(AUTH_FAILURE_DELAY);
|
||||
let _ = out.send(Response::failure(
|
||||
request.id,
|
||||
RpcError::application(Exit::Connection, "authentication failed"),
|
||||
));
|
||||
return false;
|
||||
}
|
||||
|
||||
let result = serde_json::to_value(daemon.identify()).unwrap_or(Value::Null);
|
||||
let _ = out.send(Response::result(request.id, result));
|
||||
true
|
||||
}
|
||||
|
||||
/// A Unix socket has already proved the caller is this user, so a token is
|
||||
/// optional there and mandatory everywhere else. A token that *is* offered is
|
||||
/// always checked, so a wrong one never passes.
|
||||
pub(super) fn accepts(
|
||||
daemon: &Daemon,
|
||||
trusted: bool,
|
||||
token: Option<&str>,
|
||||
) -> bool {
|
||||
match token {
|
||||
Some(token) => constant_time_eq(token, &daemon.token),
|
||||
None => trusted,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare without leaking the position of the first difference through timing.
|
||||
pub(super) fn constant_time_eq(left: &str, right: &str) -> bool {
|
||||
if left.len() != right.len() {
|
||||
return false;
|
||||
}
|
||||
left.bytes()
|
||||
.zip(right.bytes())
|
||||
.fold(0u8, |difference, (a, b)| difference | (a ^ b))
|
||||
== 0
|
||||
}
|
||||
|
||||
/// Turn one request line into the reply it deserves, or `None` for a
|
||||
/// notification, which by JSON-RPC rule is answered with silence.
|
||||
pub(super) fn answer(line: &str, daemon: &Daemon) -> Option<Response> {
|
||||
let request: Request = match serde_json::from_str(line) {
|
||||
Ok(request) => request,
|
||||
Err(e) => {
|
||||
return Some(Response::failure(
|
||||
None,
|
||||
RpcError::new(CODE_PARSE, e.to_string()),
|
||||
));
|
||||
}
|
||||
};
|
||||
let id = request.id.clone();
|
||||
let Some(method) = Method::from_wire(&request.method) else {
|
||||
return id.is_some().then(|| {
|
||||
Response::failure(
|
||||
request.id,
|
||||
RpcError::new(
|
||||
CODE_METHOD_NOT_FOUND,
|
||||
format!("no such method: {}", request.method),
|
||||
),
|
||||
)
|
||||
});
|
||||
};
|
||||
|
||||
let outcome = daemon.call(method, request.params);
|
||||
id.map(|id| match outcome {
|
||||
Ok(result) => Response::result(Some(id), result),
|
||||
Err(error) => Response::failure(Some(id), error),
|
||||
})
|
||||
}
|
||||
|
||||
fn write_loop(stream: Stream, outbox: &Receiver<Response>) {
|
||||
let mut stream = stream;
|
||||
for response in outbox {
|
||||
let Ok(line) = serde_json::to_string(&response) else {
|
||||
continue;
|
||||
};
|
||||
if writeln!(stream, "{line}").is_err() || stream.flush().is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind the TCP control port, refusing to come up rather than silently
|
||||
/// listening somewhere unintended.
|
||||
pub fn bind_tcp(address: &str) -> std::io::Result<Listener> {
|
||||
TcpListener::bind(address).map(Listener::Tcp)
|
||||
}
|
||||
|
||||
/// Bind the Unix control socket with permissions that make it the local
|
||||
/// authentication: only this user can open it.
|
||||
///
|
||||
/// Bound under a temporary name and moved into place, because `bind` creates
|
||||
/// the socket with the process umask — for the moment between `bind` and
|
||||
/// `set_permissions` the real path would be connectable by anyone on the
|
||||
/// machine, and on this transport connecting *is* authenticating. The rename
|
||||
/// is atomic, so the published path never exists with the wrong mode.
|
||||
#[cfg(unix)]
|
||||
pub fn bind_unix(path: &std::path::Path) -> std::io::Result<Listener> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
// A socket file outlives the process that made it, so a previous crash
|
||||
// would otherwise make the daemon unstartable.
|
||||
if path.exists() {
|
||||
remove_stale_socket(path)?;
|
||||
}
|
||||
|
||||
let staging = path.with_extension("binding");
|
||||
let _ = std::fs::remove_file(&staging);
|
||||
let listener = std::os::unix::net::UnixListener::bind(&staging)?;
|
||||
std::fs::set_permissions(&staging, std::fs::Permissions::from_mode(0o600))?;
|
||||
std::fs::rename(&staging, path)?;
|
||||
Ok(Listener::Unix(listener))
|
||||
}
|
||||
|
||||
/// Remove a socket file left by a dead daemon — but never one a live daemon is
|
||||
/// still serving, which would silently steal its clients.
|
||||
#[cfg(unix)]
|
||||
fn remove_stale_socket(path: &std::path::Path) -> std::io::Result<()> {
|
||||
match std::os::unix::net::UnixStream::connect(path) {
|
||||
Ok(_) => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::AddrInUse,
|
||||
format!("a daemon is already listening on {}", path.display()),
|
||||
)),
|
||||
Err(_) => std::fs::remove_file(path),
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,14 @@ use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
/// How many undrained events of one kind a client may accumulate.
|
||||
///
|
||||
/// Every attached client is sent everything, including events for work another
|
||||
/// client asked for. A command that only drains room events would otherwise
|
||||
/// grow its private-message buffer for as long as it runs. Dropping the oldest
|
||||
/// is right for a buffer nobody is reading: the newest state is the useful one.
|
||||
const BUFFER_LIMIT: usize = 4096;
|
||||
|
||||
/// Everything a pushed event can land in, behind one lock.
|
||||
///
|
||||
/// One lock rather than several: it is taken for the length of a `Vec::push`
|
||||
@@ -49,21 +57,24 @@ impl Mirror {
|
||||
match event {
|
||||
Event::Room => {
|
||||
if let Ok(room) = parse::<RoomEventDto>(event, params) {
|
||||
buffers.rooms.push(room.into());
|
||||
push(&mut buffers.rooms, room.into());
|
||||
}
|
||||
}
|
||||
Event::Message => {
|
||||
if let Ok(message) = parse::<UserMessageDto>(event, params) {
|
||||
buffers.messages.push(message.into());
|
||||
push(&mut buffers.messages, message.into());
|
||||
}
|
||||
}
|
||||
Event::Upload => {
|
||||
if let Ok(upload) = parse::<UploadInfoDto>(event, params) {
|
||||
buffers.uploads.push(upload.into());
|
||||
push(&mut buffers.uploads, upload.into());
|
||||
}
|
||||
}
|
||||
Event::Browse => {
|
||||
if let Ok(browse) = parse::<BrowseEvent>(event, params) {
|
||||
if buffers.browses.len() >= BUFFER_LIMIT {
|
||||
buffers.browses.clear();
|
||||
}
|
||||
buffers.browses.insert(
|
||||
browse.username,
|
||||
browse
|
||||
@@ -112,6 +123,18 @@ impl Mirror {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop tracking every transfer, so anything blocked on one is told the
|
||||
/// connection is gone.
|
||||
///
|
||||
/// Dropping the senders is the signal: a caller waiting on its receiver
|
||||
/// sees a disconnect immediately instead of sitting out its full transfer
|
||||
/// timeout and then reporting a misleading "gave up".
|
||||
pub fn abandon_downloads(&self) {
|
||||
if let Ok(mut buffers) = self.0.lock() {
|
||||
buffers.downloads.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Register where a transfer's updates should go before asking for it, so
|
||||
/// an update that lands immediately is not dropped on the floor.
|
||||
pub fn expect_download(
|
||||
@@ -156,10 +179,20 @@ impl Mirror {
|
||||
}
|
||||
|
||||
fn take<T: Default>(&self, drain: impl FnOnce(&mut Buffers) -> T) -> T {
|
||||
self.0.lock().map_or_else(|_| T::default(), |mut b| drain(&mut b))
|
||||
self.0
|
||||
.lock()
|
||||
.map_or_else(|_| T::default(), |mut b| drain(&mut b))
|
||||
}
|
||||
}
|
||||
|
||||
/// Append, dropping the oldest once the buffer is at its limit.
|
||||
fn push<T>(buffer: &mut Vec<T>, item: T) {
|
||||
if buffer.len() >= BUFFER_LIMIT {
|
||||
buffer.remove(0);
|
||||
}
|
||||
buffer.push(item);
|
||||
}
|
||||
|
||||
fn parse<T: serde::de::DeserializeOwned>(
|
||||
event: Event,
|
||||
params: serde_json::Value,
|
||||
@@ -178,10 +211,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_room_event_is_delivered_once_and_then_gone() {
|
||||
let mirror = Mirror::default();
|
||||
mirror.apply(
|
||||
Event::Room,
|
||||
json!({ "event": "left", "room": "lobby" }),
|
||||
);
|
||||
mirror.apply(Event::Room, json!({ "event": "left", "room": "lobby" }));
|
||||
|
||||
assert_eq!(
|
||||
mirror.take_rooms(),
|
||||
@@ -286,6 +316,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_undrained_buffer_stops_growing_at_its_limit() {
|
||||
// A client is sent every event, including ones for work it never asked
|
||||
// for; a command that drains only rooms must not grow without bound.
|
||||
let mirror = Mirror::default();
|
||||
for _ in 0..(BUFFER_LIMIT + 50) {
|
||||
mirror.apply(
|
||||
Event::Room,
|
||||
json!({ "event": "left", "room": "lobby" }),
|
||||
);
|
||||
}
|
||||
assert_eq!(mirror.take_rooms().len(), BUFFER_LIMIT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abandoning_transfers_wakes_whoever_is_waiting_on_them() {
|
||||
let mirror = Mirror::default();
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
mirror.expect_download("bob", "a.mp3", sender);
|
||||
|
||||
mirror.abandon_downloads();
|
||||
|
||||
assert!(
|
||||
matches!(receiver.recv(), Err(std::sync::mpsc::RecvError)),
|
||||
"a caller blocked on a transfer must be told the daemon is gone, \
|
||||
not left to time out"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_event_is_ignored_rather_than_fatal() {
|
||||
// A newer daemon may send a shape this client does not know. Dropping
|
||||
|
||||
@@ -170,7 +170,11 @@ impl RemoteSession {
|
||||
std::thread::spawn(move || {
|
||||
read_loop(read, &pending, &mirror);
|
||||
hung_up.store(true, Ordering::Relaxed);
|
||||
// Everything waiting on this connection has to be told, not
|
||||
// left to discover it by timing out: calls in flight, and
|
||||
// transfers whose progress only ever arrived as events.
|
||||
pending.fail_all();
|
||||
mirror.abandon_downloads();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -185,7 +189,10 @@ impl RemoteSession {
|
||||
};
|
||||
|
||||
let token = if endpoint.self_authenticating() {
|
||||
token
|
||||
// Offering a token here can only hurt: the socket's permissions
|
||||
// have already answered the question, and a stale one on disk
|
||||
// would turn a connection that should succeed into a refusal.
|
||||
None
|
||||
} else {
|
||||
Some(token.ok_or_else(|| {
|
||||
network(
|
||||
@@ -250,7 +257,8 @@ impl RemoteSession {
|
||||
{
|
||||
let mut outgoing =
|
||||
self.outgoing.lock().map_err(|_| SoulseekRs::LockPoisoned)?;
|
||||
if writeln!(outgoing, "{line}").is_err() || outgoing.flush().is_err()
|
||||
if writeln!(outgoing, "{line}").is_err()
|
||||
|| outgoing.flush().is_err()
|
||||
{
|
||||
self.pending.abandon(id);
|
||||
return Err(SoulseekRs::ConnectionClosed);
|
||||
@@ -328,33 +336,27 @@ impl RemoteSession {
|
||||
username: username.clone(),
|
||||
filename: filename.clone(),
|
||||
size,
|
||||
// The daemon writes to its own filesystem, which a remote
|
||||
// caller cannot see and so does not get to choose.
|
||||
download_directory: None,
|
||||
metadata: (&metadata).into(),
|
||||
},
|
||||
)
|
||||
.inspect_err(|_| self.mirror.forget_download(&username, &filename))?;
|
||||
.inspect_err(|_| {
|
||||
self.mirror.forget_download(&username, &filename);
|
||||
})?;
|
||||
|
||||
Ok((rebuild(started.download, sender, metadata), receiver))
|
||||
Ok((rebuild(started.download, sender), receiver))
|
||||
}
|
||||
|
||||
/// One parameterless request, for the control commands that only need to
|
||||
/// ask a daemon something once.
|
||||
///
|
||||
pub fn ask_for<R: DeserializeOwned>(&self, method: Method) -> Result<R> {
|
||||
self.request(method, ())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Turn the daemon's view of a transfer back into the library's, with the
|
||||
/// caller's own channel spliced in.
|
||||
fn rebuild(
|
||||
dto: DownloadDto,
|
||||
sender: Sender<DownloadStatus>,
|
||||
metadata: DownloadMetadata,
|
||||
) -> Download {
|
||||
fn rebuild(dto: DownloadDto, sender: Sender<DownloadStatus>) -> Download {
|
||||
let metadata = dto.metadata.into();
|
||||
Download {
|
||||
username: dto.username,
|
||||
filename: dto.filename,
|
||||
@@ -381,8 +383,9 @@ fn open(endpoint: &Endpoint) -> Result<Halves> {
|
||||
network(format!("cannot reach the daemon at {address}: {e}"))
|
||||
})?;
|
||||
let read = stream.try_clone().map_err(SoulseekRs::NetworkError)?;
|
||||
let closer =
|
||||
Closer::Tcp(stream.try_clone().map_err(SoulseekRs::NetworkError)?);
|
||||
let closer = Closer::Tcp(
|
||||
stream.try_clone().map_err(SoulseekRs::NetworkError)?,
|
||||
);
|
||||
Ok((Box::new(read), Box::new(stream), closer))
|
||||
}
|
||||
Endpoint::Socket(path) => connect_socket(path),
|
||||
@@ -391,8 +394,12 @@ fn open(endpoint: &Endpoint) -> Result<Halves> {
|
||||
|
||||
#[cfg(unix)]
|
||||
fn connect_socket(path: &Path) -> Result<Halves> {
|
||||
let stream = std::os::unix::net::UnixStream::connect(path).map_err(|e| {
|
||||
network(format!("cannot reach the daemon at {}: {e}", path.display()))
|
||||
let stream =
|
||||
std::os::unix::net::UnixStream::connect(path).map_err(|e| {
|
||||
network(format!(
|
||||
"cannot reach the daemon at {}: {e}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
let read = stream.try_clone().map_err(SoulseekRs::NetworkError)?;
|
||||
let closer =
|
||||
@@ -474,14 +481,6 @@ impl SessionApi for RemoteSession {
|
||||
})
|
||||
}
|
||||
|
||||
fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<SearchResult>> {
|
||||
self.search_with_cancel(query, timeout, None)
|
||||
}
|
||||
|
||||
fn search_with_cancel(
|
||||
&self,
|
||||
query: &str,
|
||||
@@ -577,8 +576,7 @@ impl SessionApi for RemoteSession {
|
||||
// transfers too, and the ones this client started already have
|
||||
// a live channel from `download`.
|
||||
let (sender, _) = channel();
|
||||
let metadata = dto.metadata.clone().into();
|
||||
rebuild(dto, sender, metadata)
|
||||
rebuild(dto, sender)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
+37
-28
@@ -7,13 +7,13 @@ use crate::cli::{
|
||||
};
|
||||
use crate::commands::Ctx;
|
||||
use crate::output::{CliError, CliResult, Exit, Out};
|
||||
use crate::ui::launch_main_tui;
|
||||
use soulseek_rs::{ClientSettings, ClientVersion, PeerAddress};
|
||||
use std::io::{BufRead, IsTerminal};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::{env, io};
|
||||
use crate::ui::launch_main_tui;
|
||||
|
||||
pub fn run(mut cli: Cli, out: &Out) -> CliResult {
|
||||
let config_path = config_path(&cli);
|
||||
@@ -25,6 +25,15 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
|
||||
let resolved = crate::persist::config::resolve(&cli, &file_config);
|
||||
|
||||
let Some(command) = cli.command.take() else {
|
||||
// Without a terminal the TUI would panic on its first draw. A script
|
||||
// that reached this point wanted a subcommand, so say that instead of
|
||||
// crashing.
|
||||
if !std::io::stdout().is_terminal() {
|
||||
return Err(CliError::usage(
|
||||
"the interactive interface needs a terminal; run a subcommand \
|
||||
for scripted use (see --help)",
|
||||
));
|
||||
}
|
||||
// A running daemon already holds a session; the TUI attaches to it
|
||||
// rather than logging in a second time, which the server would answer
|
||||
// by cutting the daemon off.
|
||||
@@ -62,7 +71,9 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
|
||||
return crate::commands::settings::config_get(out, &store, key);
|
||||
}
|
||||
Commands::Config(ConfigCommand::Set { ref key, ref value }) => {
|
||||
return crate::commands::settings::config_set(out, &store, key, value);
|
||||
return crate::commands::settings::config_set(
|
||||
out, &store, key, value,
|
||||
);
|
||||
}
|
||||
Commands::Shares(SharesCommand::List) => {
|
||||
return crate::commands::settings::shares_list(
|
||||
@@ -72,10 +83,14 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
|
||||
);
|
||||
}
|
||||
Commands::Shares(SharesCommand::Add { ref directory }) => {
|
||||
return crate::commands::settings::shares_add(out, &store, directory);
|
||||
return crate::commands::settings::shares_add(
|
||||
out, &store, directory,
|
||||
);
|
||||
}
|
||||
Commands::Shares(SharesCommand::Remove { ref directory }) => {
|
||||
return crate::commands::settings::shares_remove(out, &store, directory);
|
||||
return crate::commands::settings::shares_remove(
|
||||
out, &store, directory,
|
||||
);
|
||||
}
|
||||
Commands::Wish(WishCommand::Add { ref query }) => {
|
||||
return crate::commands::wish::add(out, &store, query);
|
||||
@@ -97,9 +112,11 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
|
||||
let token = cli.daemon_token.clone();
|
||||
return match command {
|
||||
DaemonCommand::Token => crate::commands::daemon::token(out),
|
||||
DaemonCommand::Status => {
|
||||
crate::commands::daemon::status(out, endpoint.as_ref(), token)
|
||||
}
|
||||
DaemonCommand::Status => crate::commands::daemon::status(
|
||||
out,
|
||||
endpoint.as_ref(),
|
||||
token,
|
||||
),
|
||||
DaemonCommand::Stop => {
|
||||
crate::commands::daemon::stop(out, endpoint.as_ref(), token)
|
||||
}
|
||||
@@ -125,7 +142,9 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
|
||||
Commands::Serve(ref args) => {
|
||||
let sweeper = args
|
||||
.wishlist
|
||||
.then(|| crate::commands::wish::Sweeper::new(store.config.wishes()))
|
||||
.then(|| {
|
||||
crate::commands::wish::Sweeper::new(store.config.wishes())
|
||||
})
|
||||
.flatten();
|
||||
if args.wishlist && sweeper.is_none() {
|
||||
out.warn(
|
||||
@@ -145,7 +164,9 @@ fn config_path(cli: &Cli) -> Option<PathBuf> {
|
||||
if cli.no_config {
|
||||
return None;
|
||||
}
|
||||
cli.config.clone().or_else(crate::persist::paths::config_file)
|
||||
cli.config
|
||||
.clone()
|
||||
.or_else(crate::persist::paths::config_file)
|
||||
}
|
||||
|
||||
/// Assemble everything a one-shot command needs, demanding the credentials
|
||||
@@ -338,15 +359,6 @@ fn run_default_tui(
|
||||
terminal::{Clear, ClearType},
|
||||
};
|
||||
|
||||
// Without a terminal the TUI would panic on its first draw. A script that
|
||||
// reached this point wanted a subcommand, so say that instead of crashing.
|
||||
if !std::io::stdout().is_terminal() {
|
||||
return Err(CliError::usage(
|
||||
"the interactive interface needs a terminal; run a subcommand for \
|
||||
scripted use (see --help)",
|
||||
));
|
||||
}
|
||||
|
||||
let out = Out::new(false, false);
|
||||
let (server_host, server_port) =
|
||||
parse_server_address(&resolved.server).map_err(CliError::usage)?;
|
||||
@@ -418,8 +430,8 @@ fn run_default_tui(
|
||||
.listen_port()
|
||||
.map(crate::port_mapping::PortMapper::spawn);
|
||||
|
||||
let store =
|
||||
crate::persist::paths::state_dir().map(crate::persist::state::StateStore::new);
|
||||
let store = crate::persist::paths::state_dir()
|
||||
.map(crate::persist::state::StateStore::new);
|
||||
|
||||
launch_main_tui(
|
||||
terminal,
|
||||
@@ -448,19 +460,16 @@ fn run_attached_tui(
|
||||
terminal::{Clear, ClearType},
|
||||
};
|
||||
|
||||
if !std::io::stdout().is_terminal() {
|
||||
return Err(CliError::usage(
|
||||
"the interactive interface needs a terminal; run a subcommand for \
|
||||
scripted use (see --help)",
|
||||
));
|
||||
}
|
||||
|
||||
let token = cli
|
||||
.daemon_token
|
||||
.clone()
|
||||
.or_else(crate::daemon::stored_token);
|
||||
let session = crate::remote::RemoteSession::connect(endpoint, token)
|
||||
.map_err(|e| CliError::connection(format!("cannot use the daemon at {endpoint}: {e}")))?;
|
||||
.map_err(|e| {
|
||||
CliError::connection(format!(
|
||||
"cannot use the daemon at {endpoint}: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
soulseek_rs::utils::logger::enable_buffering();
|
||||
let _ =
|
||||
|
||||
@@ -551,10 +551,8 @@ impl DaemonFixture {
|
||||
static COUNTER: std::sync::atomic::AtomicU32 =
|
||||
std::sync::atomic::AtomicU32::new(0);
|
||||
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let state = std::env::temp_dir().join(format!(
|
||||
"soulseek-e2e-daemon-{}-{n}",
|
||||
std::process::id()
|
||||
));
|
||||
let state = std::env::temp_dir()
|
||||
.join(format!("soulseek-e2e-daemon-{}-{n}", std::process::id()));
|
||||
std::fs::create_dir_all(&state).expect("daemon state directory");
|
||||
|
||||
let mut args = vec![
|
||||
@@ -568,9 +566,10 @@ impl DaemonFixture {
|
||||
];
|
||||
// A listener of its own unless the test asked for something specific:
|
||||
// transfers arrive on a connection the uploader opens back to us.
|
||||
if !session_flags.iter().any(|flag| {
|
||||
flag == "--listener-port" || flag == "--no-listener"
|
||||
}) {
|
||||
if !session_flags
|
||||
.iter()
|
||||
.any(|flag| flag == "--listener-port" || flag == "--no-listener")
|
||||
{
|
||||
args.push("--listener-port".to_string());
|
||||
args.push(free_port().expect("listener port").to_string());
|
||||
}
|
||||
@@ -631,9 +630,7 @@ impl DaemonFixture {
|
||||
"daemon",
|
||||
"status",
|
||||
]);
|
||||
command
|
||||
.output()
|
||||
.is_ok_and(|output| output.status.success())
|
||||
command.output().is_ok_and(|output| output.status.success())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,9 +660,7 @@ impl TestServer {
|
||||
child: None,
|
||||
db: None,
|
||||
_gate: gate,
|
||||
daemons: std::sync::Mutex::new(
|
||||
std::collections::HashMap::new(),
|
||||
),
|
||||
daemons: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user