From 45adfc66a2e2671af0565b8665e58a033b23a571 Mon Sep 17 00:00:00 2001 From: guenhter Date: Sat, 22 Jun 2024 12:50:30 +0200 Subject: [PATCH] Support netdrive reconnect --- .github/workflows/release.yml | 1 + README.md | 2 +- install-schedule.bat | 8 +- package.wxs | 5 +- reconnect-netdrive.bat | 47 +++ src/main.rs | 692 +++++++++++++++++++++++++++++----- uninstall-schedule.bat | 2 + 7 files changed, 650 insertions(+), 107 deletions(-) create mode 100644 reconnect-netdrive.bat diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec59668..a22168d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,3 +24,4 @@ jobs: uses: actions/upload-artifact@v4 with: path: SimpleFolderSyncer.msi + diff --git a/README.md b/README.md index 8dfe0c6..cffb58a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Simple Folder Syncer -Currently workin progress. Updates to the Docs will follow... +The ## Prerequisites diff --git a/install-schedule.bat b/install-schedule.bat index 52ebccf..237e796 100644 --- a/install-schedule.bat +++ b/install-schedule.bat @@ -1,9 +1,13 @@ @echo off set "BatchFilePath='%programfiles%\Simple Folder Syncer\simple-folder-syncer.exe'" +set "ReconnectDrivePath='%programfiles%\Simple Folder Syncer\reconnect-netdrive.bat'" set "TaskName=Simple Folder Syncer" set "TaskDescription=Run my batch file daily" -:: https://learn.microsoft.com/de-de/windows/win32/taskschd/schtasks -schtasks /create /f /tn "%TaskName%" /tr "%BatchFilePath%" /sc hourly +@REM https://learn.microsoft.com/de-de/windows/win32/taskschd/schtasks +schtasks /create /f /tn "%TaskName%" /tr "%BatchFilePath%" /sc hourly /mo 1 + +@REM This is only a workaround until the actual SSHFS drive reconnecting is implemented. +schtasks /create /f /tn "Reconnect SSHFS drive" /tr "%ReconnectDrivePath%" /sc minute /mo 30 diff --git a/package.wxs b/package.wxs index e953069..aeb377d 100644 --- a/package.wxs +++ b/package.wxs @@ -13,13 +13,14 @@ + - - + + diff --git a/reconnect-netdrive.bat b/reconnect-netdrive.bat new file mode 100644 index 0000000..204a871 --- /dev/null +++ b/reconnect-netdrive.bat @@ -0,0 +1,47 @@ +@echo off + +setlocal enabledelayedexpansion + +for /F "tokens=*" %%l in ('net use ^| findstr /i "\\sshfs"') do set net_use_line=%%l + +for %%a in (%net_use_line%) do ( + echo %%a|find "\\sshfs" >nul + + if errorlevel 1 ( + set previous=%%a + ) else ( + set drive=!previous! + ) +) + +IF [!drive!] == [] ( + exit /B 0 +) + +for %%a in (%net_use_line%) do ( + if "%%a"=="%drive%" ( + set "nextToken=1" + ) else if defined nextToken ( + set "remote=%%a" + set "nextToken=" + ) +) + +IF [!remote!] == [] ( + exit /B 0 +) + +echo Drive: !drive! +echo Remote: !remote! + + +if not exist "!drive!" ( + echo Reconnect drive now... + net use !drive! !remote! +) else ( + echo Nothing to do. Drive is already connected. +) + +endlocal + +exit /B 0 diff --git a/src/main.rs b/src/main.rs index 09951d4..b43db52 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,123 +1,611 @@ -use std::fs::File; -use std::io; -use std::os::windows::fs::OpenOptionsExt; -use std::os::windows::io::AsRawHandle; -use std::os::windows::io::FromRawHandle; -use std::path::Path; -use std::ptr; -use windows_sys::Wdk::Foundation::OBJECT_ATTRIBUTES; -use windows_sys::Wdk::Storage::FileSystem::{NtCreateFile, FILE_OPEN, FILE_OPEN_REPARSE_POINT}; -use windows_sys::Win32::Foundation::{ - RtlNtStatusToDosError, ERROR_DELETE_PENDING, STATUS_DELETE_PENDING, STATUS_PENDING, - UNICODE_STRING, +#![cfg_attr(not(test), windows_subsystem = "windows")] + +use std::{ + fs::{self, File}, + io::Write, + os::windows::{fs::MetadataExt, process::CommandExt}, + path::{self, Path, PathBuf}, }; -use windows_sys::Win32::Storage::FileSystem::{ - FileDispositionInfoEx, SetFileInformationByHandle, DELETE, FILE_DISPOSITION_FLAG_DELETE, - FILE_DISPOSITION_FLAG_IGNORE_READONLY_ATTRIBUTE, FILE_DISPOSITION_FLAG_POSIX_SEMANTICS, - FILE_DISPOSITION_INFO_EX, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, - FILE_LIST_DIRECTORY, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, SYNCHRONIZE, + +use serde::{Deserialize, Serialize}; +use windows_sys::Win32::{ + Storage::FileSystem::FILE_ATTRIBUTE_HIDDEN, System::Threading::CREATE_NO_WINDOW, }; -use windows_sys::Win32::System::Kernel::OBJ_DONT_REPARSE; -use windows_sys::Win32::System::IO::{IO_STATUS_BLOCK, IO_STATUS_BLOCK_0}; -fn utf16(s: &str) -> Vec { - s.encode_utf16().collect() +const DEFAULT_CONFIG_FILE_NAME: &str = "backup_config.yaml"; + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +struct Configuration { + source: String, + target: String, + create_last_backup_result_file: bool, + ignore_root_source_hidden_entries: bool, + additional_ignores: Vec, } -fn main() { - let path = r"Z:\latest\.xxx"; - let subpath = "y"; - let filename = "some.txt"; - - let dir = open_link(path.as_ref(), DELETE | FILE_LIST_DIRECTORY).unwrap(); - let subdir = open_link_no_reparse( - &dir, - &utf16(subpath), - SYNCHRONIZE | DELETE | FILE_LIST_DIRECTORY, - ) - .unwrap(); - let f = open_link_no_reparse(&subdir, &utf16(filename), SYNCHRONIZE | DELETE).unwrap(); - f.posix_delete().unwrap(); +fn main() -> anyhow::Result<()> { + let home_dir = find_config_path().unwrap(); + run_backup(&home_dir)?; + + Ok(()) } -trait FileExt { - fn posix_delete(&self) -> io::Result<()>; +fn run_backup(config_path: &Path) -> anyhow::Result<()> { + let config = read_config(config_path)?; + + let output = run_backup_with_config(config.clone())?; + + if config.create_last_backup_result_file { + let target = path::absolute(PathBuf::from(config.target))?; + write_backup_result(&target, &output)?; + } + + Ok(()) } -impl FileExt for File { - fn posix_delete(&self) -> io::Result<()> { - unsafe { - let handle = self.as_raw_handle() as _; - let info = FILE_DISPOSITION_INFO_EX { - Flags: FILE_DISPOSITION_FLAG_DELETE - | FILE_DISPOSITION_FLAG_POSIX_SEMANTICS - | FILE_DISPOSITION_FLAG_IGNORE_READONLY_ATTRIBUTE, - }; - let result = SetFileInformationByHandle( - handle, - FileDispositionInfoEx, - std::ptr::from_ref(&info).cast(), - std::mem::size_of::() as u32, - ); - if result == 0 { - Err(std::io::Error::last_os_error()) - } else { - Ok(()) + +fn run_backup_with_config(config: Configuration) -> anyhow::Result { + let source = path::absolute(config.source)?; + let target = path::absolute(config.target)?; + + let additional_ignores: Vec = config + .additional_ignores + .into_iter() + .map(path::absolute) + .collect::, _>>()?; + + let all_ignore_paths = collect_ignore_paths( + &source, + config.ignore_root_source_hidden_entries, + additional_ignores, + )?; + let mut exclude_args = build_robocopy_exclude_arguments(&all_ignore_paths)?; + + let mut args = vec![ + source.display().to_string(), + target.display().to_string(), + "/mir".to_string(), + "/z".to_string(), + "/r:1".to_string(), + "/w:1".to_string(), + "/sl".to_string(), + r"/unilog:C:\temp\backup_robocopy.log".to_string(), + ]; + args.append(&mut exclude_args); + + let output = std::process::Command::new("robocopy") + .args(args) + .creation_flags(CREATE_NO_WINDOW) + .output()?; + + remove_ignored_files_and_folders_in_target(&source, &target, all_ignore_paths)?; + + let full_output = format!( + "=== STDOUT ===\n{}\n=== STDERR ===\n{}", + String::from_utf8(output.stdout)?, + String::from_utf8(output.stderr)? + ); + + Ok(full_output) +} + +fn find_config_path() -> Option { + match home::home_dir() { + Some(path) => Some(path.join(DEFAULT_CONFIG_FILE_NAME)), + None => None, + } +} + +fn read_config(config_path: &Path) -> anyhow::Result { + let config_file = fs::File::open(config_path)?; + let config = serde_yaml::from_reader(config_file)?; + Ok(config) +} + +fn write_backup_result(backup_target: &Path, content: &str) -> anyhow::Result<()> { + let mut file = File::create(backup_target.join("last-backup-result.txt"))?; + file.write_all(content.as_bytes())?; + + Ok(()) +} + +fn collect_ignore_paths( + source: &Path, + ignore_root_source_hidden_entries: bool, + additional_ignores: Vec, +) -> anyhow::Result> { + let hidden_entries: Vec = if ignore_root_source_hidden_entries { + list_dir_entries(source)? + .into_iter() + .filter(|e| is_hidden(&e.path()).unwrap_or(false)) + .map(|e| e.path()) + .collect() + } else { + vec![] + }; + + let mut all_entries = hidden_entries; + let mut additional_ignores = additional_ignores; + all_entries.append(&mut additional_ignores); + + Ok(all_entries) +} + +fn build_robocopy_exclude_arguments(ignore_paths: &Vec) -> anyhow::Result> { + let mut args = vec![]; + + let ignore_file_paths: Vec = ignore_paths + .iter() + .filter(|e| e.is_file()) + .map(|e| e.display().to_string()) + .collect(); + let ignore_folder_paths: Vec = ignore_paths + .iter() + .filter(|e| e.is_dir()) + .map(|e| e.display().to_string()) + .collect(); + + if !ignore_file_paths.is_empty() { + args.push("/XF".to_string()); + args.append(&mut ignore_file_paths.clone()); + } + + if !ignore_folder_paths.is_empty() { + args.push("/XD".to_string()); + args.append(&mut ignore_folder_paths.clone()); + } + + Ok(args) +} + +fn list_dir_entries(dir: &Path) -> anyhow::Result> { + let paths = fs::read_dir(dir)? + .into_iter() + .filter_map(|e| e.ok()) + .collect(); + Ok(paths) +} + +fn is_hidden(dir_entry: &Path) -> std::io::Result { + let metadata = fs::metadata(dir_entry)?; + let attributes = metadata.file_attributes(); + + Ok(attributes & FILE_ATTRIBUTE_HIDDEN > 0) +} + +fn remove_ignored_files_and_folders_in_target( + source: &Path, + target: &Path, + paths_to_delete: Vec, +) -> anyhow::Result<()> { + let source = path::absolute(source)?; + let target = path::absolute(target)?; + + let paths_to_delete: Vec = replace_root_path(&source, &target, &paths_to_delete)? + .into_iter() + .map(|e| path::absolute(e)) + .collect::, _>>()?; + + // This check is actually unnecessary because logically it cannot happen due to `replace_root_path` + // but I anyway make it as a final safety net, in case something changes in the future, so this is + // the last gate which must be passed. + if paths_to_delete.iter().any(|e| !e.starts_with(&target)) { + return Err(anyhow::Error::msg( + "Some paths to delete are not prefixed with the target path", + )); + } + + let folders_to_delete = paths_to_delete.iter().filter(|e| e.is_dir()); + let files_to_delete = paths_to_delete.iter().filter(|e| e.is_file()); + + // Perform the actual deletion + for entry in folders_to_delete { + println!(" -- Removing {}", entry.display()); + + match remove_dir_all_alternative(entry) { + Ok(_) => {} + Err(e) => { + println!("Error deleting folder {}", entry.display()); + return Err(e.into()); } } } + for entry in files_to_delete { + fs::remove_file(entry)?; + } + + Ok(()) } -fn open_link(path: &Path, access_mode: u32) -> io::Result { - File::options() - .access_mode(access_mode) - .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) - .open(path) +fn replace_root_path( + current_root: &Path, + new_root: &Path, + paths: &Vec, +) -> anyhow::Result> { + paths + .iter() + .map(|path| { + let relative_path = path.strip_prefix(current_root)?; + Ok(new_root.join(relative_path)) + }) + .collect() } -fn open_link_no_reparse(parent: &File, name: &[u16], access: u32) -> io::Result { - unsafe { - let mut handle = 0; - let mut io_status = IO_STATUS_BLOCK { - Anonymous: IO_STATUS_BLOCK_0 { - Status: STATUS_PENDING, +// https://github.com/rust-lang/rust/issues/126576 +// https://github.com/winfsp/winfsp/issues/561 +fn remove_dir_all_alternative(path: &Path) -> anyhow::Result<()> { + for entry in fs::read_dir(path)? { + let path = entry?.path(); + + if path.is_dir() { + remove_dir_all_alternative(&path)?; + } else { + fs::remove_file(path)?; + } + } + + fs::remove_dir(path)?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::ffi::CString; + use std::fs; + + use anyhow::Ok; + use assertor::{assert_that, BooleanAssertion, EqualityAssertion, VecAssertion}; + use tempfile::tempdir; + use walkdir::WalkDir; + use windows_sys::Win32::Storage::FileSystem::SetFileAttributesA; + + // Note this useful idiom: importing names from outer (for mod tests) scope. + use super::*; + + #[test] + fn test_run_without_special_parameters() { + let temp_dir = tempdir().expect("Failed to create a temp dir"); + let config_path = temp_dir.path().join("config.yaml"); + let source_dir_path: PathBuf = temp_dir.path().join("source"); + let target_dir_path: PathBuf = temp_dir.path().join("target"); + + store_config( + &config_path, + &Configuration { + source: source_dir_path.display().to_string(), + target: target_dir_path.display().to_string(), + create_last_backup_result_file: false, + ignore_root_source_hidden_entries: false, + additional_ignores: vec![], }, - Information: 0, - }; - let mut name_str = UNICODE_STRING { - Length: (name.len() * 2) as u16, - MaximumLength: (name.len() * 2) as u16, - Buffer: name.as_ptr().cast_mut(), + ) + .unwrap(); + prepare_test_folder(&source_dir_path).unwrap(); + + // Actual call under test + { + run_backup(&config_path).unwrap(); + } + + // Assertions + { + let target_dir_hierarchy = list_files_and_folders(&target_dir_path).unwrap(); + let expected_dir_hierarchy = vec![ + "file1.txt".to_string(), + "hidden-file1.txt".to_string(), + "some-folder".to_string(), + "some-folder\\file2.txt".to_string(), + "some-folder\\hidden-file2.txt".to_string(), + "some-hidden-folder".to_string(), + "some-hidden-folder\\file3.txt".to_string(), + ]; + assert_that!(target_dir_hierarchy).contains_exactly(expected_dir_hierarchy); + } + } + + #[test] + fn test_run_with_hidden_files_in_root_ignored() { + let temp_dir = tempdir().expect("Failed to create a temp dir"); + let config_path = temp_dir.path().join("config.yaml"); + let source_dir_path: PathBuf = temp_dir.path().join("source"); + let target_dir_path: PathBuf = temp_dir.path().join("target"); + + store_config( + &config_path, + &Configuration { + source: source_dir_path.display().to_string(), + target: target_dir_path.display().to_string(), + create_last_backup_result_file: false, + ignore_root_source_hidden_entries: true, + additional_ignores: vec![], + }, + ) + .unwrap(); + prepare_test_folder(&source_dir_path).unwrap(); + + // Actual call under test + { + run_backup(&config_path).unwrap(); + } + + // Assertions + { + let target_dir_hierarchy = list_files_and_folders(&target_dir_path).unwrap(); + let expected_dir_hierarchy = vec![ + "file1.txt".to_string(), + "some-folder".to_string(), + "some-folder\\file2.txt".to_string(), + "some-folder\\hidden-file2.txt".to_string(), + ]; + assert_that!(target_dir_hierarchy).contains_exactly(expected_dir_hierarchy); + } + } + + #[test] + fn test_create_ignore_paths() { + let temp_dir = tempdir().expect("Failed to create a temp dir"); + let source_dir_path: PathBuf = temp_dir.path().join("source"); + + prepare_test_folder(&source_dir_path).unwrap(); + + let ignore_paths = collect_ignore_paths( + &source_dir_path, + true, + vec![source_dir_path.join("foobar.txt")], + ) + .unwrap(); + + assert_that!(ignore_paths).contains_exactly(vec![ + source_dir_path.join("hidden-file1.txt"), + source_dir_path.join("some-hidden-folder"), + source_dir_path.join("foobar.txt"), + ]); + } + + #[test] + fn test_create_ignore_paths_nothing_ignored() { + let ignore_paths = collect_ignore_paths(Path::new("/tmp/foo"), false, vec![]).unwrap(); + + assert_that!(ignore_paths).is_empty(); + } + + #[test] + fn test_backup_result_file_written_to_target() { + let temp_dir = tempdir().expect("Failed to create a temp dir"); + let config_path = temp_dir.path().join("config.yaml"); + let source_dir_path = temp_dir.path().join("source"); + let target_dir_path = temp_dir.path().join("target"); + + store_config( + &config_path, + &Configuration { + source: source_dir_path.display().to_string(), + target: target_dir_path.display().to_string(), + create_last_backup_result_file: true, + ignore_root_source_hidden_entries: false, + additional_ignores: vec![], + }, + ) + .unwrap(); + prepare_test_folder(&source_dir_path).unwrap(); + + // Actual call under test + { + run_backup(&config_path).unwrap(); + } + + // Assertions + { + let backup_result_file = target_dir_path.join("last-backup-result.txt"); + assert_that!(backup_result_file.exists()).is_true(); + } + } + + #[test] + fn test_ignored_files_or_folders_get_deleted_on_target() { + let temp_dir = tempdir().expect("Failed to create a temp dir"); + let source_dir_path = temp_dir.path().join("source"); + let target_dir_path = temp_dir.path().join("target"); + + let config = Configuration { + source: source_dir_path.display().to_string(), + target: target_dir_path.display().to_string(), + create_last_backup_result_file: false, + ignore_root_source_hidden_entries: false, + additional_ignores: vec![], }; - let object = OBJECT_ATTRIBUTES { - Length: std::mem::size_of::() as u32, - ObjectName: &mut name_str, - RootDirectory: parent.as_raw_handle() as _, - Attributes: OBJ_DONT_REPARSE as _, - SecurityDescriptor: ptr::null(), - SecurityQualityOfService: ptr::null(), + prepare_test_folder(&source_dir_path).unwrap(); + + // Actual call under test + { + run_backup_with_config(config.clone()).unwrap(); + + // Run again, but this time ignore hidden files + let config = Configuration { + ignore_root_source_hidden_entries: true, + ..config + }; + run_backup_with_config(config).unwrap(); + } + + // Assertions + { + let target_dir_hierarchy = list_files_and_folders(&target_dir_path).unwrap(); + let source_dir_hierarchy = list_files_and_folders(&source_dir_path).unwrap(); + let expected_target_dir_hierarchy = vec![ + "file1.txt".to_string(), + "some-folder".to_string(), + "some-folder\\file2.txt".to_string(), + "some-folder\\hidden-file2.txt".to_string(), + ]; + let expected_source_dir_hierarchy = vec![ + "file1.txt".to_string(), + "hidden-file1.txt".to_string(), + "some-folder".to_string(), + "some-folder\\file2.txt".to_string(), + "some-folder\\hidden-file2.txt".to_string(), + "some-hidden-folder".to_string(), + "some-hidden-folder\\file3.txt".to_string(), + ]; + assert_that!(target_dir_hierarchy).contains_exactly(expected_target_dir_hierarchy); + assert_that!(source_dir_hierarchy).contains_exactly(expected_source_dir_hierarchy); + } + } + + #[test] + fn test_read_config() { + let temp_dir = tempdir().expect("Failed to create a temp dir"); + let config_path = temp_dir.path().join("config.yaml"); + + let config = Configuration { + source: r"C:\temp\source\".to_string(), + target: r"C:\temp\target".to_string(), + create_last_backup_result_file: true, + ignore_root_source_hidden_entries: true, + additional_ignores: vec![r"C:/temp/source/some-folder/".to_string()], }; - let status = NtCreateFile( - &mut handle, - access, - &object, - &mut io_status, - ptr::null_mut(), - 0, - FILE_SHARE_DELETE | FILE_SHARE_READ | FILE_SHARE_WRITE, - FILE_OPEN, - FILE_OPEN_REPARSE_POINT, - ptr::null_mut(), - 0, - ); - eprintln!("NtCreateFile: {status:#X}"); - if status >= 0 { - Ok(File::from_raw_handle(handle as _)) - } else if status == STATUS_DELETE_PENDING { - Err(io::Error::from_raw_os_error(ERROR_DELETE_PENDING as i32)) - } else { - Err(io::Error::from_raw_os_error( - RtlNtStatusToDosError(status) as _ - )) + + store_config(&config_path, &config).unwrap(); + + let read_config = read_config(&config_path).unwrap(); + + assert_that!(read_config).is_equal_to(config); + } + + #[test] + fn test_remove_all_files_and_folders_in_target() { + let temp_dir = tempdir().expect("Failed to create a temp dir"); + let temp_dir = temp_dir.path(); + + fs::File::create(temp_dir.join("f1.txt")).unwrap(); + fs::File::create(temp_dir.join("f2.txt")).unwrap(); + fs::create_dir_all(temp_dir.join("stay")).unwrap(); + fs::File::create(temp_dir.join("stay").join("f3.txt")).unwrap(); + + fs::create_dir_all(temp_dir.join("tbr").join("foo").join("bar")).unwrap(); + fs::File::create(temp_dir.join("tbr").join("f4.txt")).unwrap(); + fs::File::create(temp_dir.join("tbr").join("foo").join("f5.txt")).unwrap(); + fs::File::create(temp_dir.join("tbr").join("foo").join("bar").join("f6.txt")).unwrap(); + + let paths_to_remove = vec![temp_dir.join("f2.txt"), temp_dir.join("tbr")]; + + // Function under test + { + remove_ignored_files_and_folders_in_target(&temp_dir, &temp_dir, paths_to_remove) + .unwrap(); } + + // Assertions + { + let actual_dir_hierarchy = list_files_and_folders(&temp_dir).unwrap(); + let expected_dir_hierarchy = vec![ + "f1.txt".to_string(), + "stay".to_string(), + "stay\\f3.txt".to_string(), + ]; + assert_that!(actual_dir_hierarchy).contains_exactly(expected_dir_hierarchy); + } + } + + #[test] + fn test_replace_root_path() { + let current_root = Path::new("/tmp/foo"); + let paths_to_replace = vec![ + PathBuf::from("/tmp/foo/file1.txt"), + PathBuf::from("/tmp/foo/subdir/file2.txt"), + ]; + let new_root = Path::new("/foo/bar"); + + let updated_paths = replace_root_path(current_root, new_root, &paths_to_replace).unwrap(); + + assert_that!(updated_paths.len()).is_equal_to(2); + assert_that!(updated_paths[0]).is_equal_to(PathBuf::from("/foo/bar/file1.txt")); + assert_that!(updated_paths[1]).is_equal_to(PathBuf::from("/foo/bar/subdir/file2.txt")); + } + + fn store_config(config_path: &PathBuf, test_config: &Configuration) -> anyhow::Result<()> { + let config_file = fs::File::create(config_path)?; + serde_yaml::to_writer(config_file, test_config)?; + Ok(()) + } + + fn list_files_and_folders(dir: &Path) -> anyhow::Result> { + println!("== List {}", dir.display()); + + WalkDir::new(dir) + .into_iter() + .filter_map(|e| e.ok()) + .map(|e| e.path().to_path_buf()) + .filter(|e| *e != dir) + .map(|path| { + let relative_path = path.strip_prefix(dir)?; + Ok(relative_path.display().to_string()) + }) + .collect() + } + + fn prepare_test_folder(temp_dir: &Path) -> anyhow::Result<()> { + // | + // - some-folder + // - file2.txt + // - hidden-file2.txt + // - some-hidden-folder + // - file3.txt + // - file1.txt + // - hidden-file1.txt + + fs::create_dir_all(temp_dir)?; + + let vis_folder = temp_dir.join("some-folder"); + let hidden_folder = temp_dir.join("some-hidden-folder"); + + create_folder(&vis_folder, false).unwrap(); + create_folder(&hidden_folder, true).unwrap(); + + create_file(&temp_dir.join("file1.txt"), false).unwrap(); + create_file(&temp_dir.join("hidden-file1.txt"), true).unwrap(); + + create_file(&vis_folder.join("file2.txt"), false).unwrap(); + create_file(&vis_folder.join("hidden-file2.txt"), true).unwrap(); + + create_file(&hidden_folder.join("file3.txt"), true).unwrap(); + + Ok(()) + } + + fn create_folder(path: &Path, hidden: bool) -> anyhow::Result<()> { + fs::create_dir_all(path)?; + + if hidden { + add_file_attributes(path, FILE_ATTRIBUTE_HIDDEN)?; + } + + Ok(()) + } + + fn create_file(path: &Path, hidden: bool) -> anyhow::Result<()> { + fs::File::create(path)?; + + if hidden { + add_file_attributes(path, FILE_ATTRIBUTE_HIDDEN)?; + } + + Ok(()) + } + + fn add_file_attributes(path: &Path, new_attributes: u32) -> anyhow::Result<()> { + let metadata = fs::metadata(path)?; + let existing_attributes = metadata.file_attributes(); + + let c_str = CString::new(path.display().to_string())?; + + unsafe { + SetFileAttributesA( + c_str.as_ptr() as *const u8, + existing_attributes | new_attributes, + ); + } + + Ok(()) } } diff --git a/uninstall-schedule.bat b/uninstall-schedule.bat index ba4b1a5..18353f6 100644 --- a/uninstall-schedule.bat +++ b/uninstall-schedule.bat @@ -3,3 +3,5 @@ set "TaskName=Simple Folder Syncer" schtasks /delete /tn "%TaskName%" /f + +schtasks /delete /tn "Reconnect SSHFS drive" /f