Files
rustdesk/src/updater.rs
RustDesk b50fde6910 add the base crate and repoint the moved modules at it (#16107)
* add the base crate and repoint the moved modules at it

`libs/base` (crate `base`) takes the parts of hbb_common that only this app
uses: `fs`, `platform`, `keyboard`, `message.proto`, and 145 of the 177
`config::keys` constants. hbb_common keeps what the server names, and the 32
keys it reads itself are re-exported from `base::config::keys` so call sites
still see the full set through one path.

Sources move verbatim. The only edits inside them are `crate::` prefixes that
now have to say `hbb_common::`; `keyboard.rs` and `platform/windows.rs` are
byte-identical. The crate stays on edition 2018, the edition the moved code was
written under. `log`, `lazy_static` and `anyhow` become direct dependencies so
the bare paths in that code resolve exactly as before, and its winapi features
are spelled out rather than left to feature unification.

Two call sites outside Rust and Cargo had to follow the move: the Android
protobuf source dir, which still pointed at hbb_common/protos for message.proto,
and the three AGENTS.md entries that named hbb_common for options, protos and
file transfer.

`scrap`'s `drm` feature now forwards to `base/wayland_probe`. Left pointing at
hbb_common it would still have compiled, silently dropping the Wayland
socket-probe fallback, so that forward is verified by a build with and without
the feature.

`config::keys` carries a test asserting its names stay disjoint from the ones
hbb_common kept: the glob re-export and the local constants share a namespace,
and Rust prefers the local item silently, so a name added to both sides would
otherwise let client and server disagree with no diagnostic.

Verified: macOS and Linux, debug and release, `--all-targets`; the 177 key
constants diffed name-for-name and value-for-value; the generated protobuf types
compared before and after; every `#[cfg]` gate on a moved import checked against
its original; and every file that was `rustfmt`-clean before this change still
is, compared against master file by file. Windows is checked by inspection only
-- it cannot be compiled here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab

* one `use` per crate, and write the rule down

`fs.rs` came out of the move with two ungated `use hbb_common::` statements,
because the original single `use crate::{...}` had to give up `message_proto`
to the new crate and the rest was left in a second block. Fold it back into one.

A scan of the whole tree for the same shape finds nothing else: every other file
with more than one top-level `use base::` or `use hbb_common::` is split by a
`#[cfg]` that does not cover the whole block, or by `pub use` next to `use`.
Those are the cases that cannot merge, so AGENTS.md now states both the rule and
the exemption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:46:42 +08:00

694 lines
27 KiB
Rust

use crate::{common::do_check_software_update, hbbs_http::create_http_client_with_url_strict};
use hbb_common::{bail, config, log, ResultType};
use base::config::keys;
use std::{
io::Write,
path::{Component, Path, PathBuf},
sync::{
atomic::{AtomicUsize, Ordering},
mpsc::{channel, Receiver, Sender},
Mutex,
},
time::{Duration, Instant},
};
#[cfg(target_os = "macos")]
use std::os::{
fd::AsRawFd,
unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt},
};
#[cfg(target_os = "macos")]
struct MacUpdateLock {
_file: std::fs::File,
}
#[cfg(target_os = "macos")]
fn acquire_mac_update_lock() -> ResultType<MacUpdateLock> {
let path = std::path::PathBuf::from("/var/run/rustdesk-update.lock");
let handle = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.custom_flags(hbb_common::libc::O_NOFOLLOW | hbb_common::libc::O_CLOEXEC)
.open(&path)?;
let metadata = handle.metadata()?;
if !metadata.file_type().is_file() || metadata.uid() != 0 {
bail!("[root-update] update lock is not a root-owned regular file");
}
handle.set_permissions(std::fs::Permissions::from_mode(0o600))?;
// Keep the descriptor open through update preparation and detached-script
// launch. O_CLOEXEC means this lock does not cover the detached bundle
// swap; flock is released when this guard is dropped or the process exits.
let lock_result = unsafe {
hbb_common::libc::flock(
handle.as_raw_fd(),
hbb_common::libc::LOCK_EX | hbb_common::libc::LOCK_NB,
)
};
if lock_result != 0 {
let err = std::io::Error::last_os_error();
if err.kind() == std::io::ErrorKind::WouldBlock {
bail!("[root-update] another update is already running");
}
return Err(err.into());
}
Ok(MacUpdateLock { _file: handle })
}
enum UpdateMsg {
CheckUpdate,
Exit,
}
lazy_static::lazy_static! {
static ref TX_MSG : Mutex<Sender<UpdateMsg>> = Mutex::new(start_auto_update_check());
}
static CONTROLLING_SESSION_COUNT: AtomicUsize = AtomicUsize::new(0);
/// Initial wait after startup before the first update check (30 seconds).
pub const INITIAL_CHECK_DELAY: Duration = Duration::from_secs(30);
/// One full day — default interval between update checks.
pub const DUR_ONE_DAY: Duration = Duration::from_secs(60 * 60 * 24);
/// Minimum interval between consecutive update checks (10 minutes).
pub const MIN_INTERVAL: Duration = Duration::from_secs(60 * 10);
/// Retry interval when an update check fails or a session is active (30 minutes).
pub const RETRY_INTERVAL: Duration = Duration::from_secs(60 * 30);
pub fn update_controlling_session_count(count: usize) {
CONTROLLING_SESSION_COUNT.store(count, Ordering::SeqCst);
}
#[allow(dead_code)]
pub fn start_auto_update() {
let _sender = TX_MSG.lock().unwrap();
}
#[allow(dead_code)]
pub fn manually_check_update() -> ResultType<()> {
let sender = TX_MSG.lock().unwrap();
sender.send(UpdateMsg::CheckUpdate)?;
Ok(())
}
#[allow(dead_code)]
pub fn stop_auto_update() {
let sender = TX_MSG.lock().unwrap();
sender.send(UpdateMsg::Exit).unwrap_or_default();
}
#[inline]
/// Returns true when there are no active incoming or outgoing connections.
/// Used to avoid updating while a remote session is in progress.
pub fn has_no_active_conns() -> bool {
let conns = crate::Connection::alive_conns();
conns.is_empty() && has_no_controlling_conns()
}
#[cfg(any(not(target_os = "windows"), feature = "flutter"))]
fn has_no_controlling_conns() -> bool {
CONTROLLING_SESSION_COUNT.load(Ordering::SeqCst) == 0
}
#[cfg(not(any(not(target_os = "windows"), feature = "flutter")))]
fn has_no_controlling_conns() -> bool {
let app_exe = format!("{}.exe", crate::get_app_name().to_lowercase());
for arg in [
"--connect",
"--play",
"--file-transfer",
"--view-camera",
"--port-forward",
"--rdp",
] {
if !crate::platform::get_pids_of_process_with_first_arg(&app_exe, arg).is_empty() {
return false;
}
}
true
}
fn start_auto_update_check() -> Sender<UpdateMsg> {
let (tx, rx) = channel();
std::thread::spawn(move || start_auto_update_check_(rx));
return tx;
}
fn start_auto_update_check_(rx_msg: Receiver<UpdateMsg>) {
std::thread::sleep(INITIAL_CHECK_DELAY);
if let Err(e) = check_update(false) {
log::error!("Error checking for updates: {}", e);
}
let mut last_check_time = Instant::now();
let mut check_interval = DUR_ONE_DAY;
loop {
let recv_res = rx_msg.recv_timeout(check_interval);
match &recv_res {
Ok(UpdateMsg::CheckUpdate) | Err(_) => {
if last_check_time.elapsed() < MIN_INTERVAL {
// log::debug!("Update check skipped due to minimum interval.");
continue;
}
// Don't check update if there are alive connections.
if !has_no_active_conns() {
check_interval = RETRY_INTERVAL;
continue;
}
if let Err(e) = check_update(matches!(recv_res, Ok(UpdateMsg::CheckUpdate))) {
log::error!("Error checking for updates: {}", e);
check_interval = RETRY_INTERVAL;
} else {
last_check_time = Instant::now();
check_interval = DUR_ONE_DAY;
}
}
Ok(UpdateMsg::Exit) => break,
}
}
}
fn check_update(manually: bool) -> ResultType<()> {
// On macOS, auto-update is handled by check_update_as_root() in the service process.
// The shared check_update() path is only used for manual update checks from the GUI.
#[cfg(target_os = "macos")]
if !manually {
return Ok(());
}
#[cfg(target_os = "windows")]
let update_msi = crate::platform::is_msi_installed()? && !crate::is_custom_client();
if !(manually || config::Config::get_bool_option(keys::OPTION_ALLOW_AUTO_UPDATE)) {
return Ok(());
}
if do_check_software_update().is_err() {
// ignore
return Ok(());
}
let update_url = crate::common::SOFTWARE_UPDATE_URL.lock().unwrap().clone();
if update_url.is_empty() {
log::debug!("No update available.");
} else {
let download_url = update_url.replace("tag", "download");
let version = download_url.split('/').last().unwrap_or_default();
#[cfg(target_os = "windows")]
let download_url = if cfg!(feature = "flutter") {
let Some(arch) = crate::platform::windows::release_arch_suffix() else {
bail!(
"Unsupported Windows release architecture: {}",
std::env::consts::ARCH
);
};
format!(
"{}/rustdesk-{}-{}.{}",
download_url,
version,
arch,
if update_msi { "msi" } else { "exe" }
)
} else {
format!("{}/rustdesk-{}-x86-sciter.exe", download_url, version)
};
log::debug!("New version available: {}", &version);
let client = create_http_client_with_url_strict(&download_url)?;
let Some(file_path) = get_download_file_from_url(&download_url) else {
bail!("Failed to get the file path from the URL: {}", download_url);
};
let mut is_file_exists = false;
if file_path.exists() {
// Check if the file size is the same as the server file size
// If the file size is the same, we don't need to download it again.
let file_size = std::fs::metadata(&file_path)?.len();
let response = client.head(&download_url).send()?;
if !response.status().is_success() {
bail!("Failed to get the file size: {}", response.status());
}
let total_size = response
.headers()
.get(reqwest::header::CONTENT_LENGTH)
.and_then(|ct_len| ct_len.to_str().ok())
.and_then(|ct_len| ct_len.parse::<u64>().ok());
let Some(total_size) = total_size else {
bail!("Failed to get content length");
};
if file_size == total_size {
is_file_exists = true;
} else {
std::fs::remove_file(&file_path)?;
}
}
if !is_file_exists {
let response = client.get(&download_url).send()?;
if !response.status().is_success() {
bail!(
"Failed to download the new version file: {}",
response.status()
);
}
let file_data = response.bytes()?;
let mut file = std::fs::File::create(&file_path)?;
file.write_all(&file_data)?;
}
// We have checked if the `conns` is empty before, but we need to check again.
// No need to care about the downloaded file here, because it's rare case that the `conns` are empty
// before the download, but not empty after the download.
if has_no_active_conns() {
#[cfg(target_os = "windows")]
update_new_version(update_msi, &version, &file_path);
}
}
Ok(())
}
#[cfg(target_os = "windows")]
fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) {
log::debug!(
"New version is downloaded, update begin, update msi: {update_msi}, version: {version}, file: {:?}",
file_path.to_str()
);
if let Some(p) = file_path.to_str() {
if let Some(session_id) = crate::platform::get_current_process_session_id() {
if update_msi {
match crate::platform::update_me_msi(p, true) {
Ok(_) => {
log::debug!("New version \"{}\" updated.", version);
}
Err(e) => {
log::error!(
"Failed to install the new msi version \"{}\": {}",
version,
e
);
std::fs::remove_file(&file_path).ok();
}
}
} else {
let custom_client_staging_dir = if crate::is_custom_client() {
let custom_client_staging_dir =
crate::platform::get_custom_client_staging_dir();
if let Err(e) = crate::platform::handle_custom_client_staging_dir_before_update(
&custom_client_staging_dir,
) {
log::error!(
"Failed to handle custom client staging dir before update: {}",
e
);
std::fs::remove_file(&file_path).ok();
return;
}
Some(custom_client_staging_dir)
} else {
// Clean up any residual staging directory from previous custom client
let staging_dir = crate::platform::get_custom_client_staging_dir();
hbb_common::allow_err!(crate::platform::remove_custom_client_staging_dir(
&staging_dir
));
None
};
let update_launched = match crate::platform::launch_privileged_process(
session_id,
&format!("{} --update", p),
) {
Ok(h) => {
if h.is_null() {
log::error!("Failed to update to the new version: {}", version);
false
} else {
log::debug!("New version \"{}\" is launched.", version);
true
}
}
Err(e) => {
log::error!("Failed to run the new version: {}", e);
false
}
};
if !update_launched {
if let Some(dir) = custom_client_staging_dir {
hbb_common::allow_err!(crate::platform::remove_custom_client_staging_dir(
&dir
));
}
std::fs::remove_file(&file_path).ok();
}
}
} else {
log::error!(
"Failed to get the current process session id, Error {}",
std::io::Error::last_os_error()
);
std::fs::remove_file(&file_path).ok();
}
} else {
// unreachable!()
log::error!(
"Failed to convert the file path to string: {}",
file_path.display()
);
}
}
pub fn get_update_download_file_from_url(url: &str) -> Option<PathBuf> {
let parsed = url::Url::parse(url).ok()?;
// Check the raw prefix before Url normalizes default ports.
if !url.starts_with("https://github.com/")
|| parsed.scheme() != "https"
|| parsed.host_str() != Some("github.com")
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.port().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
return None;
}
let mut segments = parsed.path_segments()?;
let owner = segments.next()?;
let repo = segments.next()?;
let releases = segments.next()?;
let download = segments.next()?;
let tag = segments.next()?;
let filename = segments.next()?;
if owner != "rustdesk"
|| repo != "rustdesk"
|| releases != "releases"
|| download != "download"
|| tag.is_empty()
|| segments.next().is_some()
|| !is_plain_update_filename(filename)
{
return None;
}
Some(std::env::temp_dir().join(filename))
}
fn is_plain_update_filename(filename: &str) -> bool {
if filename.is_empty()
|| filename.contains('/')
|| filename.contains('\\')
|| filename.contains(':')
{
return false;
}
let mut components = Path::new(filename).components();
matches!(
components.next(),
Some(Component::Normal(name)) if name.to_str() == Some(filename)
) && components.next().is_none()
}
pub fn get_download_file_from_url(url: &str) -> Option<PathBuf> {
get_update_download_file_from_url(url)
}
/// Queries all active connections (remote, file-transfer, port-forward, camera, terminal)
/// from every logged-in user's --server process via IPC.
/// The root service cannot read connection state directly since connections
/// live in user --server processes. Handles fast user switching by querying
/// all GUI users, including the login-window server at UID 0. Falls back to
/// false (assumes sessions active) on any IPC error to avoid updating during
/// an unknown session state.
#[cfg(target_os = "macos")]
pub fn has_no_active_conns_ipc() -> bool {
let rt = match hbb_common::tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(_) => return false,
};
rt.block_on(async {
// Use the same GUI-domain-filtered UID set as the update script.
// Shell-only SSH/TTY users are excluded, while an empty GUI set maps
// to UID 0 so the LoginWindow server is queried rather than assumed idle.
let uids = crate::platform::get_logged_in_uids();
// Check each user's server — fail closed if any has active connections
for uid in uids {
if let Ok(mut conn) = crate::ipc::connect_for_uid(1000, uid, "").await {
if conn.send(&crate::ipc::Data::HasNoActiveConns(None)).await.is_ok() {
match conn.next_timeout(1000).await {
Ok(Some(crate::ipc::Data::HasNoActiveConns(Some(true)))) => {
// Explicit no active connections — safe to continue
}
Ok(Some(crate::ipc::Data::HasNoActiveConns(Some(false)))) => {
return false; // Explicit active connections
}
_ => {
return false; // Timeout/error/unexpected — fail closed
}
}
} else {
return false; // Send failed — fail closed
}
} else {
return false; // Connection failed — fail closed
}
}
true // All users explicitly confirmed no active connections
})
}
#[cfg(target_os = "macos")]
fn wait_for_failed_update_retry() {
const FAILURE_MARKER: &str = "/var/root/.rustdeskupdate_failed";
let marker = std::path::Path::new(FAILURE_MARKER);
if !marker.exists() {
return;
}
// The updater script records failure immediately before launchd restarts
// the old daemon. Preserve the retry deadline across that restart instead
// of consuming the marker and retrying the same broken release in 30 sec.
let remaining = std::fs::metadata(marker)
.and_then(|metadata| metadata.modified())
.ok()
.and_then(|modified| {
std::time::SystemTime::now()
.duration_since(modified)
.ok()
})
.map(|elapsed| RETRY_INTERVAL.saturating_sub(elapsed))
.unwrap_or(RETRY_INTERVAL);
if !remaining.is_zero() {
log::info!(
"[root-update] Previous update failed; retrying in {} seconds.",
remaining.as_secs()
);
std::thread::sleep(remaining);
}
match std::fs::remove_file(marker) {
Ok(()) => log::info!("[root-update] Previous update retry interval elapsed."),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => log::warn!("[root-update] Failed to clear failure marker: {}", err),
}
}
/// Starts the background silent auto-update scheduler for macOS.
/// Called from `start_os_service()` which runs as root via LaunchDaemon.
#[cfg(target_os = "macos")]
pub fn start_auto_update_macos() {
let spawn_result = std::thread::Builder::new()
.name("rustdesk-auto-update".to_owned())
.spawn(|| {
log::info!("[root-update] Auto-update scheduler thread started.");
std::thread::sleep(INITIAL_CHECK_DELAY);
wait_for_failed_update_retry();
let mut interval = DUR_ONE_DAY;
loop {
log::info!("[root-update] Running scheduled update check...");
let no_active_conns = has_no_active_conns_ipc();
if !no_active_conns {
log::info!("[root-update] Active session in progress, retrying in 10 min.");
interval = MIN_INTERVAL;
} else {
match check_update_as_root() {
Ok(update_started) => {
if update_started {
// The replacement script is detached and may fail
// after this process returns. Always retry at the
// failure interval until the new daemon replaces us.
interval = RETRY_INTERVAL;
} else {
interval = DUR_ONE_DAY;
}
}
Err(e) => {
log::error!("[root-update] Update check failed: {}", e);
interval = RETRY_INTERVAL;
}
}
}
std::thread::sleep(interval);
}
});
if let Err(err) = spawn_result {
log::error!("[root-update] Failed to start scheduler thread: {}", err);
}
}
#[cfg(target_os = "macos")]
pub fn check_update_as_root() -> ResultType<bool> {
let _update_lock = acquire_mac_update_lock()?;
// Allow-auto-update setting
if !config::Config::get_bool_option(keys::OPTION_ALLOW_AUTO_UPDATE) {
log::info!("[root-update] Auto update is disabled, skipping.");
return Ok(false);
}
if crate::is_custom_client() {
log::info!("[root-update] Custom client detected, skipping stock update.");
return Ok(false);
}
// Clean up only old temp dirs from previous failed updates. The detached
// installer keeps using its update directory after this process exits and
// releases the advisory lock, so a newly-started daemon must not remove a
// directory that still belongs to the active transaction.
if let Ok(entries) = std::fs::read_dir("/tmp") {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with(".rustdeskupdate-root-")
|| name_str.starts_with(".rustdeskdownload-")
{
let path = entry.path();
let Ok(metadata) = std::fs::symlink_metadata(&path) else {
continue;
};
let mode = metadata.mode() & 0o7777;
let is_stale = metadata
.modified()
.ok()
.and_then(|modified| std::time::SystemTime::now().duration_since(modified).ok())
.is_some_and(|age| age >= RETRY_INTERVAL);
if metadata.file_type().is_dir() && metadata.uid() == 0 && mode == 0o700 && is_stale
{
if let Err(err) = std::fs::remove_dir_all(&path) {
log::warn!(
"[root-update] Failed to remove stale temp dir {}: {}",
path.display(),
err
);
}
}
}
}
}
if let Err(e) = do_check_software_update() {
bail!("[root-update] Failed to check for software update: {}", e);
}
let update_url = crate::common::SOFTWARE_UPDATE_URL.lock().unwrap().clone();
if update_url.is_empty() {
log::info!("[root-update] No update available.");
return Ok(false);
}
let download_url = update_url.replace("tag", "download");
let version = download_url.split('/').last().unwrap_or_default().to_string();
let arch = if std::env::consts::ARCH == "aarch64" { "aarch64" } else { "x86_64" };
let dmg_url = format!("{}/rustdesk-{}-{}.dmg", download_url, version, arch);
log::info!("[root-update] New version: {}, downloading from {}", version, dmg_url);
// Validate URL against GitHub release allowlist before downloading as root
let Some(file_path_validated) = get_update_download_file_from_url(&dmg_url) else {
bail!("[root-update] URL failed allowlist check: {}", dmg_url);
};
drop(file_path_validated);
let client = create_http_client_with_url_strict(&dmg_url)?;
// Use mktemp so a local user cannot pre-create a predictable path and
// permanently deny updates for a reused service PID.
let private_tmp_output = std::process::Command::new("/usr/bin/mktemp")
.args(["-d", "/tmp/.rustdeskdownload-XXXXXX"])
.output()?;
if !private_tmp_output.status.success() {
bail!(
"[root-update] Failed to create private download directory: {}",
String::from_utf8_lossy(&private_tmp_output.stderr).trim()
);
}
let private_tmp = String::from_utf8(private_tmp_output.stdout)
.map_err(|err| hbb_common::anyhow::anyhow!("[root-update] mktemp output error: {}", err))?
.trim()
.to_owned();
if private_tmp.is_empty() {
bail!("[root-update] mktemp returned an empty download directory");
}
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&private_tmp, std::fs::Permissions::from_mode(0o700))?;
}
let filename = dmg_url.split('/').last().unwrap_or("rustdesk.dmg");
let file_path = std::path::PathBuf::from(format!("{}/{}", private_tmp, filename));
let tmp_path = file_path.to_string_lossy().to_string();
// Download
let mut response = client.get(&dmg_url).send()?;
if !response.status().is_success() {
let _ = std::fs::remove_dir_all(&private_tmp);
bail!("[root-update] Failed to download: {}", response.status());
}
// Create file exclusively (O_EXCL) and stream response directly into it
{
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&file_path)
.map_err(|e| { let _ = std::fs::remove_dir_all(&private_tmp); e })?;
std::io::copy(&mut response, &mut file)
.map_err(|e| { let _ = std::fs::remove_dir_all(&private_tmp); e })?;
}
log::info!("[root-update] Downloaded to {}", tmp_path);
// Recheck active sessions before installing — download can take minutes
if !has_no_active_conns_ipc() {
if let Err(e) = std::fs::remove_dir_all(&private_tmp) {
log::warn!("[root-update] Failed to remove temp dir {}: {}", private_tmp, e);
}
bail!("[root-update] Active session started during download, deferring update.");
}
// Install silently as root
let result = crate::platform::update_from_dmg_as_root(&tmp_path, &version);
// Clean up download directory
if let Err(e) = std::fs::remove_dir_all(&private_tmp) {
log::warn!("[root-update] Failed to remove temp dir {}: {}", private_tmp, e);
}
result.map(|_| true)
}
#[cfg(test)]
mod tests {
use super::get_download_file_from_url;
#[test]
fn update_download_file_accepts_expected_github_asset_urls() {
let file = get_download_file_from_url(
"https://github.com/rustdesk/rustdesk/releases/download/1.4.0/rustdesk-1.4.0-x86_64.dmg",
)
.expect("valid GitHub release asset URL");
assert_eq!(
file.file_name().and_then(|name| name.to_str()),
Some("rustdesk-1.4.0-x86_64.dmg")
);
}
#[test]
fn update_download_file_rejects_untrusted_or_malformed_urls() {
for url in [
"http://github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe",
"https://example.com/rustdesk.exe",
"https://github.com/other/project/releases/download/1/rustdesk.exe",
"https://github.com/rustdesk/rustdesk/releases/download/1/",
"https://github.com/rustdesk/rustdesk/releases/download/1/nested/rustdesk.exe",
"https://github.com/rustdesk/rustdesk/releases/download/1/C:rustdesk.exe",
"https://user@github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe",
"https://github.com:443/rustdesk/rustdesk/releases/download/1/rustdesk.exe",
"https://github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe?download=1",
"https://github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe#download",
"not a url",
] {
assert!(get_download_file_from_url(url).is_none(), "{url}");
}
}
}