feat: multiple shared directories
Shares::scan_many merges several roots into one index, suffixing duplicate root names so virtual paths cannot collide, and skipping unreadable roots instead of failing. ClientSettings now takes shared_directories: Vec<String>; config gains a shared_dirs list (combined with the single shared_dir and --shared-dir, deduped), with an explicitly empty value still disabling sharing and the share-what-you-download default unchanged.
This commit is contained in:
@@ -23,27 +23,25 @@ impl Client {
|
||||
|
||||
let listen_sender = sender.clone();
|
||||
|
||||
// Scan the shared directory once into the read-only index, and report
|
||||
// the real folder/file counts to the server on login.
|
||||
let shares = match self.shared_directory.as_deref() {
|
||||
Some(dir) if !dir.trim().is_empty() => {
|
||||
match Shares::scan(std::path::Path::new(dir)) {
|
||||
Ok(scanned) => {
|
||||
info!(
|
||||
"Sharing {} files in {} folders from {}",
|
||||
scanned.file_count(),
|
||||
scanned.folder_count(),
|
||||
dir
|
||||
);
|
||||
Arc::new(scanned)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to scan shared directory {}: {}", dir, e);
|
||||
Arc::new(Shares::empty())
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => Arc::new(Shares::empty()),
|
||||
// Scan the shared directories once into the read-only index, and
|
||||
// report the real folder/file counts to the server on login.
|
||||
let roots: Vec<std::path::PathBuf> = self
|
||||
.shared_directories
|
||||
.iter()
|
||||
.filter(|dir| !dir.trim().is_empty())
|
||||
.map(std::path::PathBuf::from)
|
||||
.collect();
|
||||
let shares = if roots.is_empty() {
|
||||
Arc::new(Shares::empty())
|
||||
} else {
|
||||
let scanned = Shares::scan_many(&roots);
|
||||
info!(
|
||||
"Sharing {} files in {} folders from {} directories",
|
||||
scanned.file_count(),
|
||||
scanned.folder_count(),
|
||||
roots.len()
|
||||
);
|
||||
Arc::new(scanned)
|
||||
};
|
||||
let shared_folder_count = shares.folder_count();
|
||||
let shared_file_count = shares.file_count();
|
||||
|
||||
@@ -97,9 +97,9 @@ pub struct ClientSettings {
|
||||
pub server_address: PeerAddress,
|
||||
pub enable_listen: bool,
|
||||
pub listen_port: u16,
|
||||
/// Directory whose files are shared with (uploaded to) other peers.
|
||||
/// `None` means nothing is shared.
|
||||
pub shared_directory: Option<String>,
|
||||
/// Directories whose files are shared with (uploaded to) other peers.
|
||||
/// Empty means nothing is shared.
|
||||
pub shared_directories: Vec<String>,
|
||||
}
|
||||
|
||||
impl ClientSettings {
|
||||
@@ -126,7 +126,7 @@ impl Default for ClientSettings {
|
||||
),
|
||||
enable_listen: true,
|
||||
listen_port: DEFAULT_LISTEN_PORT,
|
||||
shared_directory: None,
|
||||
shared_directories: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -617,7 +617,7 @@ pub struct Client {
|
||||
address: PeerAddress,
|
||||
username: String,
|
||||
password: String,
|
||||
shared_directory: Option<String>,
|
||||
shared_directories: Vec<String>,
|
||||
server_handle: Option<ActorHandle<ServerMessage>>,
|
||||
context: Arc<RwLock<ClientContext>>,
|
||||
}
|
||||
@@ -639,16 +639,16 @@ impl Client {
|
||||
address: settings.server_address,
|
||||
username: settings.username,
|
||||
password: settings.password,
|
||||
shared_directory: settings.shared_directory,
|
||||
shared_directories: settings.shared_directories,
|
||||
context: Arc::new(RwLock::new(ClientContext::new())),
|
||||
server_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The directory whose files are shared with other peers, if configured.
|
||||
/// The directories whose files are shared with other peers.
|
||||
#[must_use]
|
||||
pub fn shared_directory(&self) -> Option<&str> {
|
||||
self.shared_directory.as_deref()
|
||||
pub fn shared_directories(&self) -> &[String] {
|
||||
&self.shared_directories
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,55 +44,55 @@ impl Shares {
|
||||
// Fail fast if the root is unreadable; deeper failures are skipped.
|
||||
let _ = std::fs::read_dir(root)?;
|
||||
|
||||
let root_name = root.file_name().map_or_else(
|
||||
|| "shared".to_string(),
|
||||
|n| n.to_string_lossy().into_owned(),
|
||||
);
|
||||
|
||||
let mut files = Vec::new();
|
||||
let mut folders_with_files: HashSet<PathBuf> = HashSet::new();
|
||||
let mut stack = vec![root.to_path_buf()];
|
||||
let mut folder_count = 0;
|
||||
scan_root(root, &root_display_name(root), &mut files, &mut folder_count);
|
||||
Ok(Self::from_files(files, folder_count))
|
||||
}
|
||||
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
/// Scan several roots into one merged index. Unreadable roots are
|
||||
/// skipped (with a log line) instead of failing the whole scan, and
|
||||
/// roots that share a directory name get a ` (2)`, ` (3)`… suffix so
|
||||
/// their virtual paths cannot collide.
|
||||
#[must_use]
|
||||
pub fn scan_many(roots: &[PathBuf]) -> Self {
|
||||
let mut files = Vec::new();
|
||||
let mut folder_count = 0;
|
||||
let mut name_uses: HashMap<String, u32> = HashMap::new();
|
||||
|
||||
for root in roots {
|
||||
if std::fs::read_dir(root).is_err() {
|
||||
crate::warn!(
|
||||
"Skipping unreadable shared directory {}",
|
||||
root.display()
|
||||
);
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let Ok(meta) = entry.metadata() else {
|
||||
continue;
|
||||
};
|
||||
// `entry.metadata()` does not traverse the symlink itself.
|
||||
if meta.file_type().is_symlink() {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
if meta.is_dir() {
|
||||
stack.push(path);
|
||||
} else if meta.is_file() {
|
||||
let virtual_path =
|
||||
virtual_path_for(&root_name, root, &path);
|
||||
files.push(SharedFile {
|
||||
virtual_path,
|
||||
real_path: path,
|
||||
size: meta.len(),
|
||||
attributes: Vec::new(),
|
||||
});
|
||||
folders_with_files.insert(dir.clone());
|
||||
}
|
||||
}
|
||||
let base = root_display_name(root);
|
||||
let uses = name_uses.entry(base.clone()).or_insert(0);
|
||||
*uses += 1;
|
||||
let name = if *uses == 1 {
|
||||
base
|
||||
} else {
|
||||
format!("{base} ({uses})")
|
||||
};
|
||||
scan_root(root, &name, &mut files, &mut folder_count);
|
||||
}
|
||||
|
||||
Self::from_files(files, folder_count)
|
||||
}
|
||||
|
||||
fn from_files(files: Vec<SharedFile>, folder_count: usize) -> Self {
|
||||
let by_virtual = files
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| (f.virtual_path.clone(), i))
|
||||
.collect();
|
||||
|
||||
Ok(Self {
|
||||
Self {
|
||||
files,
|
||||
by_virtual,
|
||||
folder_count: folders_with_files.len(),
|
||||
})
|
||||
folder_count,
|
||||
}
|
||||
}
|
||||
|
||||
/// Files whose virtual path contains *every* whitespace-separated term of
|
||||
@@ -159,6 +159,54 @@ impl Shares {
|
||||
}
|
||||
}
|
||||
|
||||
fn root_display_name(root: &Path) -> String {
|
||||
root.file_name().map_or_else(
|
||||
|| "shared".to_string(),
|
||||
|n| n.to_string_lossy().into_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Recursively walk `root`, appending its files (under the virtual root name
|
||||
/// `root_name`) to `files` and counting folders that contain files.
|
||||
fn scan_root(
|
||||
root: &Path,
|
||||
root_name: &str,
|
||||
files: &mut Vec<SharedFile>,
|
||||
folder_count: &mut usize,
|
||||
) {
|
||||
let mut folders_with_files: HashSet<PathBuf> = HashSet::new();
|
||||
let mut stack = vec![root.to_path_buf()];
|
||||
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let Ok(meta) = entry.metadata() else {
|
||||
continue;
|
||||
};
|
||||
// `entry.metadata()` does not traverse the symlink itself.
|
||||
if meta.file_type().is_symlink() {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
if meta.is_dir() {
|
||||
stack.push(path);
|
||||
} else if meta.is_file() {
|
||||
let virtual_path = virtual_path_for(root_name, root, &path);
|
||||
files.push(SharedFile {
|
||||
virtual_path,
|
||||
real_path: path,
|
||||
size: meta.len(),
|
||||
attributes: Vec::new(),
|
||||
});
|
||||
folders_with_files.insert(dir.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
*folder_count += folders_with_files.len();
|
||||
}
|
||||
|
||||
/// Build the peer-facing virtual path for `path` under `root`: the root's own
|
||||
/// name followed by the backslash-separated components relative to it.
|
||||
fn virtual_path_for(root_name: &str, root: &Path, path: &Path) -> String {
|
||||
@@ -226,6 +274,57 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_many_merges_roots() {
|
||||
let root_a = temp_tree();
|
||||
let root_b = temp_tree();
|
||||
let shares =
|
||||
Shares::scan_many(&[root_a.clone(), root_b.clone()]);
|
||||
assert_eq!(shares.file_count(), 6);
|
||||
assert_eq!(shares.folder_count(), 4);
|
||||
// Files from both roots are reachable under their root's name.
|
||||
let name_a = root_a.file_name().unwrap().to_string_lossy();
|
||||
let name_b = root_b.file_name().unwrap().to_string_lossy();
|
||||
assert!(shares.get(&format!("{name_a}\\top.mp3")).is_some());
|
||||
assert!(shares.get(&format!("{name_b}\\top.mp3")).is_some());
|
||||
let _ = std::fs::remove_dir_all(root_a);
|
||||
let _ = std::fs::remove_dir_all(root_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_many_disambiguates_equal_root_names() {
|
||||
// Two different directories both named "music".
|
||||
let parent_a = temp_tree();
|
||||
let parent_b = temp_tree();
|
||||
let music_a = parent_a.join("music");
|
||||
let music_b = parent_b.join("music");
|
||||
std::fs::create_dir_all(&music_a).unwrap();
|
||||
std::fs::create_dir_all(&music_b).unwrap();
|
||||
std::fs::write(music_a.join("a.mp3"), b"a").unwrap();
|
||||
std::fs::write(music_b.join("b.mp3"), b"b").unwrap();
|
||||
|
||||
let shares =
|
||||
Shares::scan_many(&[music_a.clone(), music_b.clone()]);
|
||||
assert!(shares.get("music\\a.mp3").is_some());
|
||||
assert!(
|
||||
shares.get("music (2)\\b.mp3").is_some(),
|
||||
"second root with the same name gets a numbered suffix"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(parent_a);
|
||||
let _ = std::fs::remove_dir_all(parent_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_many_skips_unreadable_roots() {
|
||||
let root = temp_tree();
|
||||
let shares = Shares::scan_many(&[
|
||||
std::path::PathBuf::from("/does/not/exist"),
|
||||
root.clone(),
|
||||
]);
|
||||
assert_eq!(shares.file_count(), 3);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_shares_have_no_files_or_folders() {
|
||||
let shares = Shares::empty();
|
||||
|
||||
@@ -96,7 +96,7 @@ impl TestServer {
|
||||
server_address: PeerAddress::new(self.host.clone(), self.port),
|
||||
enable_listen: false,
|
||||
listen_port: 0,
|
||||
shared_directory: None,
|
||||
shared_directories: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1164,7 +1164,7 @@ fn two_real_clients_search_and_download() {
|
||||
|
||||
let sharer_port = free_port().expect("sharer port");
|
||||
let mut sharer = Client::with_settings(ClientSettings {
|
||||
shared_directory: Some(share_dir.display().to_string()),
|
||||
shared_directories: vec![share_dir.display().to_string()],
|
||||
..server.listening_settings("e2e_sharer", "pw", sharer_port)
|
||||
});
|
||||
sharer.connect().expect("sharer connect");
|
||||
@@ -1260,7 +1260,7 @@ fn browse_a_peers_shared_files() {
|
||||
|
||||
let sharer_port = free_port().expect("sharer port");
|
||||
let mut sharer = Client::with_settings(ClientSettings {
|
||||
shared_directory: Some(share_dir.display().to_string()),
|
||||
shared_directories: vec![share_dir.display().to_string()],
|
||||
..server.listening_settings("e2e_browsee", "pw", sharer_port)
|
||||
});
|
||||
sharer.connect().expect("sharer connect");
|
||||
@@ -1316,7 +1316,7 @@ fn browse_a_firewalled_peer_via_broker() {
|
||||
// Sharer does NOT listen (firewalled): the browser's direct dial will fail,
|
||||
// forcing the server-brokered connect-back.
|
||||
let mut sharer = Client::with_settings(ClientSettings {
|
||||
shared_directory: Some(share_dir.display().to_string()),
|
||||
shared_directories: vec![share_dir.display().to_string()],
|
||||
..server.settings("e2e_fw_sharer", "pw")
|
||||
});
|
||||
sharer.connect().expect("sharer connect");
|
||||
|
||||
Reference in New Issue
Block a user