Skip to content

Commit 3d9b23a

Browse files
committed
fix(wizard): preserve usb source folder selections
1 parent e663770 commit 3d9b23a

1 file changed

Lines changed: 161 additions & 15 deletions

File tree

src/wizard.rs

Lines changed: 161 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use std::env;
22
use std::fs;
3+
use std::path::Component;
34
use std::path::{Path, PathBuf};
45
use std::process::Command;
56
use std::time::{SystemTime, UNIX_EPOCH};
@@ -9,7 +10,7 @@ use eframe::egui;
910
use rfd::FileDialog;
1011
use serde_json::Value;
1112

12-
use crate::config::{AppConfig, AppPaths, JobConfig, default_config_template, load_config, rel_path_string};
13+
use crate::config::{AppConfig, AppPaths, JobConfig, default_config_template, load_config};
1314
use crate::platform;
1415

1516
const WIZARD_FLAG: &str = "--wizard";
@@ -280,7 +281,7 @@ impl WizardApp {
280281

281282
fn validate_and_save(&mut self) -> Result<()> {
282283
append_wizard_log(&self.paths, "Validating wizard configuration");
283-
let config = self.normalized_config_for_save();
284+
let config = self.normalized_config_for_save()?;
284285
let serialized = serde_json::to_string_pretty(&config)
285286
.context("failed to serialize config.json")?;
286287

@@ -309,19 +310,21 @@ impl WizardApp {
309310
Ok(())
310311
}
311312

312-
fn normalized_config_for_save(&self) -> AppConfig {
313+
fn normalized_config_for_save(&self) -> Result<AppConfig> {
313314
let mut config = self.config.clone();
314315
normalize_optional_string(&mut config.drive.letter);
315316
normalize_optional_string(&mut config.drive.path);
316317
normalize_optional_string(&mut config.cache.root);
318+
let drive_root = drive_root_from_config(&config);
317319

318320
for job in &mut config.jobs {
319321
job.name = job.name.trim().to_string();
320-
job.source = job.source.trim().to_string();
322+
job.source = normalize_job_source_text(&job.source, drive_root.as_deref())
323+
.with_context(|| format!("job '{}' source is invalid", job.name.trim()))?;
321324
job.target = job.target.trim().to_string();
322325
}
323326

324-
config
327+
Ok(config)
325328
}
326329

327330
fn current_drive_root(&self) -> Option<PathBuf> {
@@ -372,16 +375,13 @@ impl WizardApp {
372375

373376
let picker = FileDialog::new().set_directory(&root);
374377
if let Some(folder) = picker.pick_folder() {
375-
match folder.strip_prefix(&root) {
376-
Ok(relative) => match rel_path_string(relative) {
377-
Ok(value) => self.config.jobs[index].source = value,
378-
Err(error) => self.status = format!("Source browse failed: {error}"),
379-
},
380-
Err(_) => {
381-
self.status = format!(
382-
"The selected folder must stay inside the configured drive root {}",
383-
root.display()
384-
);
378+
match normalize_job_source_from_path(&folder, &root) {
379+
Ok(value) => {
380+
self.config.jobs[index].source = value;
381+
self.status = format!("Selected {}", folder.display());
382+
}
383+
Err(error) => {
384+
self.status = format!("Source browse failed: {error}");
385385
}
386386
}
387387
}
@@ -487,6 +487,131 @@ impl WizardApp {
487487
}
488488
}
489489

490+
fn drive_root_from_config(config: &AppConfig) -> Option<PathBuf> {
491+
if let Some(path) = config
492+
.drive
493+
.path
494+
.as_deref()
495+
.map(str::trim)
496+
.filter(|value| !value.is_empty())
497+
{
498+
return Some(PathBuf::from(path));
499+
}
500+
501+
#[cfg(target_os = "windows")]
502+
{
503+
let letter = config
504+
.drive
505+
.letter
506+
.as_deref()
507+
.map(str::trim)
508+
.filter(|value| !value.is_empty())?;
509+
return Some(PathBuf::from(format!("{}:\\", letter.trim_end_matches(':'))));
510+
}
511+
512+
#[allow(unreachable_code)]
513+
None
514+
}
515+
516+
fn normalize_job_source_from_path(path: &Path, root: &Path) -> Result<String> {
517+
let relative = strip_root_prefix_normalized(path, root).ok_or_else(|| {
518+
anyhow::anyhow!(
519+
"the selected folder must stay inside the configured drive root {}",
520+
root.display()
521+
)
522+
})?;
523+
native_relative_path_string(&relative)
524+
}
525+
526+
fn normalize_job_source_text(value: &str, root: Option<&Path>) -> Result<String> {
527+
let trimmed = value.trim();
528+
anyhow::ensure!(!trimmed.is_empty(), "source path must not be empty");
529+
530+
let candidate = PathBuf::from(trimmed);
531+
if candidate.is_absolute() {
532+
let root = root.ok_or_else(|| {
533+
anyhow::anyhow!("set the USB root first before using an absolute source path")
534+
})?;
535+
return normalize_job_source_from_path(&candidate, root);
536+
}
537+
538+
let mut normalized = PathBuf::new();
539+
for part in trimmed.split(['/', '\\']).filter(|part| !part.is_empty()) {
540+
match part {
541+
"." => {}
542+
".." => anyhow::bail!("source path must stay inside the USB root"),
543+
_ => normalized.push(part),
544+
}
545+
}
546+
547+
anyhow::ensure!(
548+
!normalized.as_os_str().is_empty(),
549+
"source path must not collapse to an empty value"
550+
);
551+
native_relative_path_string(&normalized)
552+
}
553+
554+
fn native_relative_path_string(path: &Path) -> Result<String> {
555+
let mut parts = Vec::new();
556+
for component in path.components() {
557+
match component {
558+
Component::Normal(part) => parts.push(part.to_string_lossy().to_string()),
559+
_ => anyhow::bail!("path contains a non-normal component: {}", path.display()),
560+
}
561+
}
562+
563+
anyhow::ensure!(!parts.is_empty(), "path must not be empty");
564+
Ok(parts.join(std::path::MAIN_SEPARATOR_STR))
565+
}
566+
567+
fn strip_root_prefix_normalized(path: &Path, root: &Path) -> Option<PathBuf> {
568+
let path_parts = normalized_path_parts(path)?;
569+
let root_parts = normalized_path_parts(root)?;
570+
if path_parts.len() < root_parts.len() {
571+
return None;
572+
}
573+
if !path_parts
574+
.iter()
575+
.zip(root_parts.iter())
576+
.all(|(path_part, root_part)| path_part_matches(path_part, root_part))
577+
{
578+
return None;
579+
}
580+
581+
Some(
582+
path_parts[root_parts.len()..]
583+
.iter()
584+
.fold(PathBuf::new(), |mut path, part| {
585+
path.push(part);
586+
path
587+
}),
588+
)
589+
}
590+
591+
fn normalized_path_parts(path: &Path) -> Option<Vec<String>> {
592+
let mut parts = Vec::new();
593+
for component in path.components() {
594+
match component {
595+
Component::Prefix(prefix) => parts.push(prefix.as_os_str().to_string_lossy().to_string()),
596+
Component::RootDir => {}
597+
Component::CurDir => {}
598+
Component::Normal(part) => parts.push(part.to_string_lossy().to_string()),
599+
Component::ParentDir => return None,
600+
}
601+
}
602+
Some(parts)
603+
}
604+
605+
#[cfg(target_os = "windows")]
606+
fn path_part_matches(path_part: &str, root_part: &str) -> bool {
607+
path_part.eq_ignore_ascii_case(root_part)
608+
}
609+
610+
#[cfg(not(target_os = "windows"))]
611+
fn path_part_matches(path_part: &str, root_part: &str) -> bool {
612+
path_part == root_part
613+
}
614+
490615
impl eframe::App for WizardApp {
491616
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
492617
self.clear_startup_signal_if_needed();
@@ -1434,4 +1559,25 @@ mod tests {
14341559

14351560
assert!(first_missing_target_prompt(&config).is_none());
14361561
}
1562+
1563+
#[test]
1564+
fn normalize_job_source_text_accepts_nested_relative_windows_path() {
1565+
let value =
1566+
normalize_job_source_text(r"Projects\Folder_Alpha", Some(Path::new(r"S:\"))).unwrap();
1567+
assert_eq!(value, r"Projects\Folder_Alpha");
1568+
}
1569+
1570+
#[test]
1571+
fn normalize_job_source_text_converts_absolute_path_under_root() {
1572+
let value =
1573+
normalize_job_source_text(r"S:\Projects\Folder_Alpha", Some(Path::new(r"S:\")))
1574+
.unwrap();
1575+
assert_eq!(value, r"Projects\Folder_Alpha");
1576+
}
1577+
1578+
#[test]
1579+
fn normalize_job_source_from_path_rejects_folder_outside_root() {
1580+
let result = normalize_job_source_from_path(Path::new(r"T:\Other\Folder"), Path::new(r"S:\"));
1581+
assert!(result.is_err());
1582+
}
14371583
}

0 commit comments

Comments
 (0)