feat(daemon): make the download folder a live setting in both modes

The full-surface audit found the settings story half-true: `config set
download_dir` and the TUI's settings form edited a file a running daemon
never rereads, while the attached TUI displayed the laptop's path and
discarded the edit entirely.

- `download.set_dir`: a session-wide move, published in the contract;
  the per-transfer destination stays refused. `daemon.status` reports
  it and the next transfer lands there.
- `config set download_dir` and `config set shared_dirs` push to a
  live daemon the way `shares add` does, through one shared helper;
  the socket probe is paid only by the keys a daemon owns.
- The attached TUI seeds its settings form from the daemon's status,
  pushes both folders over the socket as typed, reports a partial
  refusal as exactly that, and creates no client-side directories. It
  reads the daemon token from the resolved config — with the token only
  in config.toml it could not attach over TCP — and settings
  persistence honours --config instead of hard-coding the default path.
- `search.wishlist` records a start time, so an attached window
  renders it as running rather than instantly finished.

The rest is the coverage the audit demanded: CLI-level token auth
(wrong, missing, and valid over TCP), `daemon status|stop` with
nothing running, --sort, --limit, --pick first, --password-cmd,
--log-file, --upload-slots, the config-named download folder in both
modes, and headless settings-form tests attached and local. Every
guard was checked by disabling what it protects. Both e2e modes run
green: 94 CLI tests each way, 16 protocol, 285 unit, 34 lib.
This commit is contained in:
Michel de Graaf
2026-07-28 10:44:06 +02:00
parent 35f5cd4f22
commit cd56fb3b39
16 changed files with 917 additions and 89 deletions
+1 -1
View File
@@ -221,7 +221,7 @@ pub fn run(
hub: Arc::clone(&hub),
browses: Arc::clone(&browses),
downloads: Arc::clone(&downloads),
download_dir: ctx.download_dir.clone(),
download_dir: Mutex::new(ctx.download_dir.clone()),
server: ctx.settings.server_address.to_string(),
token: token.clone(),
started: Instant::now(),
+10 -5
View File
@@ -10,11 +10,11 @@
use super::proto::{
Ack, AuthParams, AuthResult, BrowseEvent, DaemonStatus, DirectoriesParams,
DownloadStartParams, DownloadStarted, DownloadStatusEvent, Downloads,
Event, IntervalSeconds, Members, MessageParams, Messages, Method,
QueryParams, RoomEventDto, RoomRef, SayParams, SearchResults, Searches,
Seconds, SessionLossEvent, SharesStatus, SlotsParams, TransferRef,
UploadInfoDto, Uploads, UserMessageDto, UserRef, UserResult,
DirectoryParams, DownloadStartParams, DownloadStarted, DownloadStatusEvent,
Downloads, Event, IntervalSeconds, Members, MessageParams, Messages,
Method, QueryParams, RoomEventDto, RoomRef, SayParams, SearchResults,
Searches, Seconds, SessionLossEvent, SharesStatus, SlotsParams,
TransferRef, UploadInfoDto, Uploads, UserMessageDto, UserRef, UserResult,
};
use schemars::{JsonSchema, SchemaGenerator, generate::SchemaSettings};
use serde_json::{Value, json};
@@ -49,6 +49,7 @@ fn params_schema(method: Method, generator: &mut SchemaGenerator) -> Value {
| Method::DownloadRemove
| Method::DownloadRemoveQueued
| Method::UploadCancel => schema_of::<TransferRef>(generator),
Method::DownloadSetDir => schema_of::<DirectoryParams>(generator),
Method::UploadSlots => schema_of::<SlotsParams>(generator),
Method::RoomJoin | Method::RoomLeave | Method::RoomMembers => {
schema_of::<RoomRef>(generator)
@@ -104,6 +105,7 @@ fn result_schema(method: Method, generator: &mut SchemaGenerator) -> Value {
| Method::DownloadResume
| Method::DownloadRemove
| Method::DownloadRemoveQueued
| Method::DownloadSetDir
| Method::UploadCancel
| Method::UploadSlots
| Method::PrivilegesCheck
@@ -155,6 +157,9 @@ fn summary(method: Method) -> &'static str {
Method::DownloadResume => "Resume a paused transfer.",
Method::DownloadRemove => "Forget a transfer entirely.",
Method::DownloadRemoveQueued => "Drop a transfer that has not started.",
Method::DownloadSetDir => {
"Land future transfers in a different folder on the daemon's host."
}
Method::UploadList => "Uploads being served, active first.",
Method::UploadCancel => "Stop serving an upload in progress.",
Method::UploadSlots => "How many uploads may run at once.",
+8
View File
@@ -81,6 +81,14 @@ pub struct DirectoriesParams {
pub directories: Vec<String>,
}
/// Where the daemon should land transfers from now on, on *its* filesystem.
/// The same trust as `shares.set`: an authenticated caller is the owner.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct DirectoryParams {
pub directory: String,
}
/// Every search this session has run, newest last. Shared by all clients, so
/// a second one attaching sees what the first has been looking for.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
+1
View File
@@ -90,6 +90,7 @@ wire_vocabulary! {
DownloadResume => "download.resume",
DownloadRemove => "download.remove",
DownloadRemoveQueued => "download.remove_queued",
DownloadSetDir => "download.set_dir",
UploadList => "upload.list",
UploadCancel => "upload.cancel",
UploadSlots => "upload.slots",
+58 -15
View File
@@ -8,12 +8,12 @@
use super::hub::{Hub, PendingBrowses, PendingDownload};
use super::proto::{
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, SearchSummary,
Searches, Seconds, SharesStatus, SlotsParams, TransferRef, UploadInfoDto,
Uploads, UserInfoDto, UserRef, UserResult,
DaemonStatus, DirectoriesParams, DirectoryParams, DownloadDto,
DownloadStartParams, DownloadStarted, Downloads, IntervalSeconds, Members,
MessageParams, Messages, Method, OPENRPC, PROTOCOL_VERSION, QueryParams,
RoomRef, RpcError, SayParams, SearchResultDto, SearchResults,
SearchSummary, Searches, Seconds, SharesStatus, SlotsParams, TransferRef,
UploadInfoDto, Uploads, UserInfoDto, UserRef, UserResult,
};
use crate::api::SessionApi;
use crate::output::Exit;
@@ -29,9 +29,9 @@ pub struct Daemon {
pub hub: Arc<Hub>,
pub browses: Arc<PendingBrowses>,
pub downloads: Arc<Mutex<Vec<PendingDownload>>>,
/// Where transfers land, on *this* machine. A remote caller cannot see
/// this filesystem, so it does not get to choose.
pub download_dir: String,
/// Where transfers land, on *this* machine. A caller may point it at a
/// different folder with `download.set_dir`, but never per download.
pub download_dir: Mutex<String>,
pub server: String,
pub token: String,
pub started: Instant,
@@ -126,6 +126,9 @@ impl Daemon {
self.session.start_wishlist_search(&query.query).map_err(
|e| RpcError::application(Exit::Connection, e.to_string()),
)?;
// Without a start time every other window would render this
// search as finished the moment it goes out.
self.record_search_start(&query.query);
ok(Ack::OK)
}
Method::SearchWishlistInterval => ok(IntervalSeconds {
@@ -133,6 +136,11 @@ impl Daemon {
}),
Method::DownloadStart => self.start_download(parse(params)?),
Method::DownloadSetDir => {
let dir: DirectoryParams = parse(params)?;
self.set_download_dir(dir.directory)?;
ok(Ack::OK)
}
Method::DownloadList => ok(Downloads {
downloads: self
.session
@@ -344,10 +352,10 @@ impl Daemon {
params: DownloadStartParams,
) -> Result<Value, RpcError> {
// 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();
// per download would scatter transfers in ways `daemon.status` cannot
// report; changing the folder is a deliberate session-wide act,
// `download.set_dir`.
let directory = self.download_dir();
let (download, updates) = self
.session
.download_with_metadata(
@@ -376,6 +384,35 @@ impl Daemon {
})
}
fn download_dir(&self) -> String {
self.download_dir
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
/// Point future transfers at `directory`, creating it on *this* host.
/// The same trust boundary as `shares.set`: authenticated means owner.
fn set_download_dir(&self, directory: String) -> Result<(), RpcError> {
if directory.trim().is_empty() {
return Err(RpcError::new(
CODE_INVALID_PARAMS,
"the download directory cannot be empty".to_string(),
));
}
std::fs::create_dir_all(&directory).map_err(|e| {
RpcError::application(
Exit::Usage,
format!("cannot create {directory}: {e}"),
)
})?;
*self
.download_dir
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = directory;
Ok(())
}
fn shares(&self) -> SharesStatus {
let (folders, files) = self.session.shared_counts();
SharesStatus {
@@ -427,7 +464,7 @@ impl Daemon {
listen_port: self.session.listen_port(),
shared_folders: folders,
shared_files: files,
download_dir: self.download_dir.clone(),
download_dir: self.download_dir(),
session_loss: self.session.session_loss().map(Into::into),
clients: self.hub.subscribers(),
uptime_secs: self.started.elapsed().as_secs(),
@@ -577,7 +614,7 @@ mod tests {
hub: Arc::new(Hub::new()),
browses: Arc::new(PendingBrowses::default()),
downloads: Arc::new(Mutex::new(Vec::new())),
download_dir: "/tmp".to_string(),
download_dir: Mutex::new("/tmp".to_string()),
server: "server.invalid:2416".to_string(),
token: token.to_string(),
started: Instant::now(),
@@ -771,5 +808,11 @@ mod tests {
) -> soulseek_rs::Result<()> {
Ok(())
}
fn set_download_directory(
&self,
_directory: String,
) -> soulseek_rs::Result<()> {
Ok(())
}
}
}