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
+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(())
}
}
}