diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 63526f95e..418338dfe 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -919,6 +919,12 @@ jobs: name: rustdesk-unsigned-windows-x86_64 path: ./windows-x86_64/ + - name: Download Artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: rustdesk-unsigned-windows-aarch64 + path: ./windows-aarch64/ + - name: Download Artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -927,7 +933,7 @@ jobs: - name: Combine unsigned app run: | - tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-x86 + tar czf rustdesk-${{ env.VERSION }}-unsigned.tar.gz *.dmg windows-x86_64 windows-aarch64 windows-x86 - name: Publish unsigned app uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 diff --git a/libs/portable/Cargo.toml b/libs/portable/Cargo.toml index aacfdcf9b..602781b67 100644 --- a/libs/portable/Cargo.toml +++ b/libs/portable/Cargo.toml @@ -12,7 +12,7 @@ build = "build.rs" brotli = "3.4" dirs = "5.0" md5 = "0.7" -winapi = { version = "0.3", features = ["winbase"] } +winapi = { version = "0.3", features = ["winbase", "libloaderapi"] } [target.'cfg(target_os = "windows")'.dependencies] windows = { version = "0.61", features = [ diff --git a/libs/portable/generate.py b/libs/portable/generate.py index d5468a5dc..c7f0259e5 100755 --- a/libs/portable/generate.py +++ b/libs/portable/generate.py @@ -15,15 +15,27 @@ encoding = 'utf-8' # output: {path: (compressed_data, file_md5)} -def generate_md5_table(folder: str, level) -> dict: +def normalize(path: str) -> str: + path = path.replace('\\', '/') + while path.startswith('./'): + path = path[2:] + return path.lower() + + +def generate_md5_table(folder: str, level, exclude: str = None) -> dict: res: dict = dict() - curdir = os.curdir + skip = normalize(exclude) if exclude else None + # os.curdir is the literal ".", so restoring it left us inside `folder`. + curdir = os.getcwd() os.chdir(folder) for root, _, files in os.walk('.'): # remove ./ for f in files: md5_generator = md5() full_path = os.path.join(root, f) + if skip and normalize(full_path) == skip: + print(f"Excluding {full_path}...") + continue print(f"Processing {full_path}...") f = open(full_path, "rb") content = f.read() @@ -37,7 +49,10 @@ def generate_md5_table(folder: str, level) -> dict: def write_package_metadata(md5_table: dict, output_folder: str, exe: str): - output_path = os.path.join(output_folder, "data.bin") + write_blob(md5_table, os.path.join(output_folder, "data.bin"), exe) + + +def write_blob(md5_table: dict, output_path: str, exe: str): with open(output_path, "wb") as f: f.write("rustdesk".encode(encoding=encoding)) for path in md5_table.keys(): @@ -92,6 +107,14 @@ if __name__ == '__main__': help="the target used by cargo") parser.add_option("-l", "--level", dest="level", type="int", help="compression level, default is 11, highest", default=11) + parser.add_option("--package", dest="package", + help="write the per-customer blob to this path instead of " + "data.bin, and skip the cargo build. Injected into the " + "template's RDPKG resource so customizing needs no rebuild") + parser.add_option("--exclude-exe", dest="exclude_exe", action="store_true", + default=False, + help="omit the executable from the blob, for a template whose " + "executable ships in the package instead") (options, args) = parser.parse_args() folder = options.folder or './rustdesk' output_folder = os.path.abspath(options.output_folder or './') @@ -107,7 +130,11 @@ if __name__ == '__main__': exe = '.' + exe[len(os.path.abspath(folder)):] print("Executable path: " + exe) print("Compression level: " + str(options.level)) - md5_table = generate_md5_table(folder, options.level) - write_package_metadata(md5_table, output_folder, exe) - write_app_metadata(output_folder) - build_portable(output_folder, options.target) + md5_table = generate_md5_table( + folder, options.level, exe if options.exclude_exe else None) + if options.package: + write_blob(md5_table, os.path.abspath(options.package), exe) + else: + write_package_metadata(md5_table, output_folder, exe) + write_app_metadata(output_folder) + build_portable(output_folder, options.target) diff --git a/libs/portable/src/bin_reader.rs b/libs/portable/src/bin_reader.rs index 9effbc589..0adc9575e 100644 --- a/libs/portable/src/bin_reader.rs +++ b/libs/portable/src/bin_reader.rs @@ -1,15 +1,24 @@ use std::{ + collections::HashSet, fs::{self}, io::{Cursor, Read}, path::Path, }; +// The generic payload, shared by every customer and compiled in once per release. #[cfg(windows)] const BIN_DATA: &[u8] = include_bytes!("../data.bin"); #[cfg(not(windows))] const BIN_DATA: &[u8] = &[]; + +// The per-customer payload, injected into the RCDATA resource after the template +// has been built, so that customizing a client needs no recompilation. +#[cfg(windows)] +const PACKAGE_RESOURCE_NAME: &str = "RDPKG"; + // 4bytes const LENGTH: usize = 4; +const IDENTIFIER: &[u8] = b"rustdesk"; const IDENTIFIER_LENGTH: usize = 8; const MD5_LENGTH: usize = 32; const BUF_SIZE: usize = 4096; @@ -28,11 +37,149 @@ pub(crate) struct BinaryReader { impl Default for BinaryReader { fn default() -> Self { - let (files, exe) = BinaryReader::read(); + let (files, exe) = merge(read_embedded(), read_package()); Self { files, exe } } } +// Folds the per-customer package into the generic payload. +fn merge( + embedded: (Vec, String), + package: (Vec, String), +) -> (Vec, String) { + let (mut files, generic_exe) = embedded; + let (package_files, package_exe) = package; + + let exe = if package_exe.is_empty() { + generic_exe.clone() + } else { + package_exe + }; + + // The generic payload ships the executable under its stock name, the package + // decides the final one. Rename on extraction so the process is always + // `.exe`, which the app itself relies on to find its own sessions. + if !generic_exe.is_empty() && normalize_path(&exe) != normalize_path(&generic_exe) { + let generic_key = normalize_path(&generic_exe); + for file in files.iter_mut() { + if normalize_path(&file.path) == generic_key { + file.path = exe.clone(); + } + } + } + + // Per-customer entries replace the generic ones they shadow. + if !package_files.is_empty() { + let overridden: HashSet = package_files + .iter() + .map(|file| normalize_path(&file.path)) + .collect(); + files.retain(|file| !overridden.contains(&normalize_path(&file.path))); + files.extend(package_files); + } + + (files, exe) +} + +fn normalize_path(path: &str) -> String { + path.replace('\\', "/") + .trim_start_matches("./") + .to_lowercase() +} + +fn read_u32(blob: &[u8], at: usize) -> Option { + let bytes = blob.get(at..at + LENGTH)?; + Some(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) +} + +// Returns the files and the executable to launch, or None if the blob is absent or malformed. +fn parse(blob: &'static [u8]) -> Option<(Vec, String)> { + let mut base = 0usize; + let mut parsed = Vec::new(); + if blob.get(base..base + IDENTIFIER_LENGTH)? != IDENTIFIER { + return None; + } + base += IDENTIFIER_LENGTH; + loop { + if blob.get(base..base + IDENTIFIER_LENGTH)? == IDENTIFIER { + base += IDENTIFIER_LENGTH; + break; + } + let path_length = read_u32(blob, base)? as usize; + base += LENGTH; + let path = String::from_utf8_lossy(blob.get(base..base + path_length)?).to_string(); + base += path_length; + let file_length = read_u32(blob, base)? as usize; + base += LENGTH; + let raw = blob.get(base..base + file_length)?; + base += file_length; + let md5_code = blob.get(base..base + MD5_LENGTH)?; + base += MD5_LENGTH; + parsed.push(BinaryData { + md5_code, + raw, + path, + }); + } + let executable = String::from_utf8_lossy(blob.get(base..)?).to_string(); + Some((parsed, executable)) +} + +#[cfg(windows)] +fn read_embedded() -> (Vec, String) { + parse(BIN_DATA).expect("bin file is not valid!") +} + +#[cfg(not(windows))] +fn read_embedded() -> (Vec, String) { + parse(BIN_DATA).unwrap_or_default() +} + +#[cfg(windows)] +fn read_package() -> (Vec, String) { + read_resource(PACKAGE_RESOURCE_NAME) + .and_then(parse) + .unwrap_or_default() +} + +#[cfg(not(windows))] +fn read_package() -> (Vec, String) { + Default::default() +} + +// Reads an RCDATA resource out of the running image. Resources live in the mapped +// image for the lifetime of the process, so the slice is genuinely 'static and no +// copy is needed. +#[cfg(windows)] +fn read_resource(name: &str) -> Option<&'static [u8]> { + use std::ptr::null_mut; + use winapi::um::libloaderapi::{FindResourceW, LoadResource, LockResource, SizeofResource}; + + // MAKEINTRESOURCEW(10), avoids depending on the winuser feature for RT_RCDATA. + const RT_RCDATA: *const u16 = 10 as _; + + let name: Vec = name.encode_utf16().chain(std::iter::once(0)).collect(); + unsafe { + let info = FindResourceW(null_mut(), name.as_ptr(), RT_RCDATA); + if info.is_null() { + return None; + } + let size = SizeofResource(null_mut(), info) as usize; + if size == 0 { + return None; + } + let handle = LoadResource(null_mut(), info); + if handle.is_null() { + return None; + } + let data = LockResource(handle) as *const u8; + if data.is_null() { + return None; + } + Some(std::slice::from_raw_parts(data, size)) + } +} + impl BinaryData { fn decompress(&self) -> Vec { let cursor = Cursor::new(self.raw); @@ -68,59 +215,6 @@ impl BinaryData { } impl BinaryReader { - fn read() -> (Vec, String) { - let mut base: usize = 0; - let mut parsed = vec![]; - assert!(BIN_DATA.len() > IDENTIFIER_LENGTH, "bin data invalid!"); - let mut iden = String::from_utf8_lossy(&BIN_DATA[base..base + IDENTIFIER_LENGTH]); - if iden != "rustdesk" { - panic!("bin file is not valid!"); - } - base += IDENTIFIER_LENGTH; - loop { - iden = String::from_utf8_lossy(&BIN_DATA[base..base + IDENTIFIER_LENGTH]); - if iden == "rustdesk" { - base += IDENTIFIER_LENGTH; - break; - } - // start reading - let mut offset = 0; - let path_length = u32::from_be_bytes([ - BIN_DATA[base + offset], - BIN_DATA[base + offset + 1], - BIN_DATA[base + offset + 2], - BIN_DATA[base + offset + 3], - ]) as usize; - offset += LENGTH; - let path = - String::from_utf8_lossy(&BIN_DATA[base + offset..base + offset + path_length]) - .to_string(); - offset += path_length; - // file sz - let file_length = u32::from_be_bytes([ - BIN_DATA[base + offset], - BIN_DATA[base + offset + 1], - BIN_DATA[base + offset + 2], - BIN_DATA[base + offset + 3], - ]) as usize; - offset += LENGTH; - let raw = &BIN_DATA[base + offset..base + offset + file_length]; - offset += file_length; - // md5 - let md5 = &BIN_DATA[base + offset..base + offset + MD5_LENGTH]; - offset += MD5_LENGTH; - parsed.push(BinaryData { - md5_code: md5, - raw: raw, - path: path, - }); - base += offset; - } - // executable - let executable = String::from_utf8_lossy(&BIN_DATA[base..]).to_string(); - (parsed, executable) - } - #[cfg(linux)] pub fn configure_permission(&self, prefix: &Path) { use std::os::unix::prelude::PermissionsExt; @@ -137,3 +231,132 @@ impl BinaryReader { } } } + +#[cfg(test)] +mod tests { + use super::*; + + // Builds a blob in the same layout generate.py writes, so these tests pin the + // cross-language format contract as well as the merge rules. + fn blob(files: &[(&str, &[u8])], exe: &str) -> &'static [u8] { + let mut out = Vec::new(); + out.extend_from_slice(IDENTIFIER); + for (path, data) in files { + out.extend_from_slice(&(path.len() as u32).to_be_bytes()); + out.extend_from_slice(path.as_bytes()); + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(data); + out.extend_from_slice(&[b'a'; MD5_LENGTH]); + } + out.extend_from_slice(IDENTIFIER); + out.extend_from_slice(exe.as_bytes()); + Box::leak(out.into_boxed_slice()) + } + + fn entry<'a>(files: &'a [BinaryData], path: &str) -> Option<&'a BinaryData> { + files + .iter() + .find(|file| normalize_path(&file.path) == normalize_path(path)) + } + + #[test] + fn parses_the_generate_py_layout() { + let (files, exe) = parse(blob( + &[("./rustdesk.exe", b"app"), ("./custom.txt", b"cfg")], + "./rustdesk.exe", + )) + .unwrap(); + assert_eq!(exe, "./rustdesk.exe"); + assert_eq!(files.len(), 2); + assert_eq!(entry(&files, "./custom.txt").unwrap().raw, b"cfg"); + } + + #[test] + fn rejects_malformed_blobs() { + assert!(parse(b"".as_slice()).is_none()); + assert!(parse(b"notrustd".as_slice()).is_none()); + // Truncated mid-record rather than panicking on a slice out of range. + assert!(parse(b"rustdesk\x00\x00\x00\x40partial".as_slice()).is_none()); + } + + #[test] + fn without_a_package_the_stock_payload_is_untouched() { + let embedded = parse(blob(&[("./rustdesk.exe", b"app")], "./rustdesk.exe")).unwrap(); + let (files, exe) = merge(embedded, Default::default()); + assert_eq!(exe, "./rustdesk.exe"); + assert!(entry(&files, "./rustdesk.exe").is_some()); + } + + #[test] + fn renames_the_stock_executable_to_the_package_name() { + // x86: the big executable stays in the generic payload and only gets renamed. + let embedded = parse(blob( + &[("./rustdesk.exe", b"app"), ("./sciter.dll", b"dll")], + "./rustdesk.exe", + )) + .unwrap(); + let package = parse(blob(&[("./custom.txt", b"cfg")], "./acme.exe")).unwrap(); + + let (files, exe) = merge(embedded, package); + + assert_eq!(exe, "./acme.exe"); + assert!(entry(&files, "./acme.exe").is_some()); + assert!(entry(&files, "./rustdesk.exe").is_none()); + // Untouched neighbours survive. + assert_eq!(entry(&files, "./sciter.dll").unwrap().raw, b"dll"); + assert_eq!(entry(&files, "./custom.txt").unwrap().raw, b"cfg"); + } + + #[test] + fn package_entries_win_over_the_generic_payload() { + // x64: the customized executable and icons ship in the package instead. + let embedded = parse(blob( + &[ + ("./data/flutter_assets/assets/icon.ico", b"stock-icon"), + ("./librustdesk.dll", b"core"), + ], + "./rustdesk.exe", + )) + .unwrap(); + let package = parse(blob( + &[ + ("./acme.exe", b"branded"), + ("./data/flutter_assets/assets/icon.ico", b"acme-icon"), + ], + "./acme.exe", + )) + .unwrap(); + + let (files, exe) = merge(embedded, package); + + assert_eq!(exe, "./acme.exe"); + assert_eq!( + entry(&files, "./data/flutter_assets/assets/icon.ico") + .unwrap() + .raw, + b"acme-icon" + ); + assert_eq!( + files + .iter() + .filter(|f| normalize_path(&f.path) + == "data/flutter_assets/assets/icon.ico") + .count(), + 1 + ); + assert_eq!(entry(&files, "./librustdesk.dll").unwrap().raw, b"core"); + } + + #[test] + fn matches_paths_across_separator_styles() { + // generate.py emits backslashes when it runs on Windows. + let embedded = parse(blob(&[(".\\rustdesk.exe", b"app")], ".\\rustdesk.exe")).unwrap(); + let package = parse(blob(&[("./custom.txt", b"cfg")], "./acme.exe")).unwrap(); + + let (files, exe) = merge(embedded, package); + + assert_eq!(exe, "./acme.exe"); + assert!(entry(&files, "./acme.exe").is_some()); + assert!(entry(&files, ".\\rustdesk.exe").is_none()); + } +} diff --git a/libs/portable/src/main.rs b/libs/portable/src/main.rs index b7ff44ec5..f3a1ff3fa 100644 --- a/libs/portable/src/main.rs +++ b/libs/portable/src/main.rs @@ -22,6 +22,18 @@ const APPNAME_RUNTIME_ENV_KEY: &str = "RUSTDESK_APPNAME"; #[cfg(windows)] const SET_FOREGROUND_WINDOW_ENV_KEY: &str = "SET_FOREGROUND_WINDOW"; +// The extraction directory follows whatever executable the payload asks for, so a +// custom client gets its own directory instead of sharing RustDesk's. Falls back to +// APP_PREFIX when no package is injected, which keeps stock builds unchanged. +fn app_dir_name(exe: &str) -> String { + Path::new(&exe.replace('\\', "/")) + .file_stem() + .and_then(|stem| stem.to_str()) + .map(|stem| stem.trim().to_lowercase()) + .filter(|stem| !stem.is_empty()) + .unwrap_or_else(|| APP_PREFIX.to_owned()) +} + fn is_timestamp_matches(dir: &Path, ts: &mut u64) -> bool { let Ok(app_metadata) = std::str::from_utf8(APP_METADATA) else { return true; @@ -71,7 +83,7 @@ fn setup( } else { // home dir if let Some(dir) = dirs::data_local_dir() { - dir.join(APP_PREFIX) + dir.join(app_dir_name(&reader.exe)) } else { eprintln!("not found data local dir"); return None;