diff --git a/runtime/src/app_window.rs b/runtime/src/app_window.rs index 3fa86892..b8630e69 100644 --- a/runtime/src/app_window.rs +++ b/runtime/src/app_window.rs @@ -23,7 +23,8 @@ use crate::api_token::APIToken; use crate::clipboard::shutdown_clipboard; use crate::dotnet::{cleanup_dotnet_server, start_dotnet_server, stop_dotnet_server}; use crate::environment::{ - is_prod, is_dev, is_flatpak, CONFIG_DIRECTORY, DATA_DIRECTORY, FLATPAK_LIBRARY_DIRECTORY, + is_prod, is_dev, is_flatpak, is_managed_installation, CONFIG_DIRECTORY, DATA_DIRECTORY, + FLATPAK_LIBRARY_DIRECTORY, }; use crate::log::switch_to_file_logging; use crate::pdfium::PDFIUM_LIB_PATH; @@ -514,8 +515,7 @@ pub async fn change_location_to(url: &str) { /// Checks for updates. pub async fn check_for_update(_token: APIToken) -> Json { - if !self_update_allowed(is_dev(), is_flatpak()) { - let reason = if is_flatpak() { "Flatpak installations are updated externally" } else { "the app is running in development mode" }; + if let Some(reason) = self_update_blocked_reason(is_dev(), is_flatpak(), is_managed_installation()) { warn!(Source = "Updater"; "Skipping update check because {reason}."); return Json(CheckUpdateResponse { update_is_available: false, @@ -600,8 +600,7 @@ pub struct CheckUpdateResponse { /// Installs the update. pub async fn install_update(_token: APIToken) { - if !self_update_allowed(is_dev(), is_flatpak()) { - let reason = if is_flatpak() { "Flatpak installations are updated externally" } else { "the app is running in development mode" }; + if let Some(reason) = self_update_blocked_reason(is_dev(), is_flatpak(), is_managed_installation()) { warn!(Source = "Updater"; "Skipping update installation because {reason}."); return; } @@ -660,8 +659,21 @@ pub async fn install_update(_token: APIToken) { } } -fn self_update_allowed(development: bool, flatpak: bool) -> bool { - !development && !flatpak +/// Returns why this installation cannot update itself, or `None` when it can. +fn self_update_blocked_reason(development: bool, flatpak: bool, managed_installation: bool) -> Option<&'static str> { + if flatpak { + return Some("Flatpak installations are updated externally"); + } + + if managed_installation { + return Some("this installation is managed by an IT department"); + } + + if development { + return Some("the app is running in development mode"); + } + + None } /// Response for application exit requests. @@ -895,18 +907,24 @@ mod tests { #[test] fn self_update_is_disabled_in_development() { - assert!(!self_update_allowed(true, false)); + assert!(self_update_blocked_reason(true, false, false).is_some()); } #[test] fn self_update_is_disabled_for_flatpak() { - assert!(!self_update_allowed(false, true)); + assert!(self_update_blocked_reason(false, true, false).is_some()); + } + + #[test] + fn self_update_is_disabled_for_managed_installations() { + assert!(self_update_blocked_reason(false, false, true).is_some()); } #[test] fn self_update_is_enabled_for_normal_production_installations() { - assert!(self_update_allowed(false, false)); + assert!(self_update_blocked_reason(false, false, false).is_none()); } + #[test] fn pdfium_library_directory_prefers_resources_libraries() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/runtime/src/environment.rs b/runtime/src/environment.rs index 6f10b1c9..505ffa52 100644 --- a/runtime/src/environment.rs +++ b/runtime/src/environment.rs @@ -23,6 +23,12 @@ const ENTERPRISE_REGISTRY_KEY_PATH: &str = r"Software\github\MindWork AI Studio\ const ENTERPRISE_POLICY_SECRET_FILE_NAME: &str = "config_encryption_secret.yaml"; const EXTERNAL_HTTP_CUSTOM_ROOT_CERTIFICATE_POLICY_FILE_NAME: &str = "external_http_custom_root_certificates.yaml"; +/// Marker file an IT department may place next to the executable to declare this installation +/// as centrally managed. It is not used on macOS, because any additional file inside the app +/// bundle would break its code signature. +#[cfg(any(target_os = "windows", target_os = "linux", test))] +const MANAGED_INSTALLATION_MARKER_FILE_NAME: &str = "managed-installation"; + pub const DOTNET_ENV_CUSTOM_ROOT_CERTIFICATE_POLICY_CONFIGURED: &str = "AI_STUDIO_EXTERNAL_HTTP_CUSTOM_ROOT_CERTIFICATES_POLICY_CONFIGURED"; pub const DOTNET_ENV_CUSTOM_ROOT_CERTIFICATES_ENABLED: &str = "AI_STUDIO_EXTERNAL_HTTP_CUSTOM_ROOT_CERTIFICATES_ENABLED"; pub const DOTNET_ENV_CUSTOM_ROOT_CERTIFICATE_BUNDLE_PATH: &str = "AI_STUDIO_EXTERNAL_HTTP_CUSTOM_ROOT_CERTIFICATE_BUNDLE_PATH"; @@ -47,6 +53,9 @@ pub static CONFIG_DIRECTORY: OnceLock = OnceLock::new(); /// The user language cached once per runtime process. static USER_LANGUAGE: OnceLock = OnceLock::new(); +/// The installation kind cached once per runtime process. +static INSTALLATION_KIND: OnceLock = OnceLock::new(); + /// Returns the config directory. pub async fn get_config_directory(_token: APIToken) -> String { match CONFIG_DIRECTORY.get() { @@ -71,11 +80,23 @@ pub async fn read_user_name(_token: APIToken) -> String { }) } +/// Tells whether this installation is able to update itself. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub enum InstallationKind { + /// An installation the current user owns and which the app may update itself. + User, + + /// An installation deployed and maintained by an IT department. The app never updates itself + /// here; the IT department distributes new versions instead. + Managed, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct RuntimeInfo { pub working_directory: String, pub executable_path: String, pub linux_package_type: String, + pub installation_kind: InstallationKind, } pub async fn get_runtime_info(_token: APIToken) -> Json { @@ -87,6 +108,7 @@ pub async fn get_runtime_info(_token: APIToken) -> Json { .map(|path| path.to_string_lossy().into_owned()) .unwrap_or_default(), linux_package_type: detect_linux_package_type().to_string(), + installation_kind: installation_kind(), }) } @@ -129,6 +151,206 @@ fn env_var_has_value(key: &str) -> bool { env::var(key).is_ok_and(|value| !value.trim().is_empty()) } +/// Returns true when this installation is maintained by an IT department and therefore must not +/// update itself. +pub(crate) fn is_managed_installation() -> bool { + installation_kind() == InstallationKind::Managed +} + +/// Returns the kind of this installation, cached for the lifetime of the process. +/// +/// A managed installation was deployed by an IT department, for example, system-wide into +/// `C:\Program Files`. The Tauri updater cannot replace such an installation: on Windows it runs +/// the NSIS setup with its per-user defaults and creates a second installation below the local +/// app data directory instead of updating the existing one. +/// +/// Whenever the kind cannot be determined, we report a user installation. Wrongly reporting a +/// managed installation would cut regular users off from every future update, including security +/// updates, which is far worse than a second installation. +pub(crate) fn installation_kind() -> InstallationKind { + *INSTALLATION_KIND.get_or_init(|| { + let kind = detect_installation_kind(); + info!(Source = "Updater"; "Detected a {kind:?} installation of AI Studio."); + kind + }) +} + +#[cfg(target_os = "windows")] +fn detect_installation_kind() -> InstallationKind { + let executable_path = match env::current_exe() { + Ok(path) => path, + Err(e) => { + warn!(Source = "Updater"; "Cannot read the current executable path: {e}. Assuming a user installation."); + return InstallationKind::User; + } + }; + + if has_managed_installation_marker(&executable_path) { + return InstallationKind::Managed; + } + + windows_installation_kind_from_paths(&executable_path, dirs::data_local_dir().as_deref()) +} + +#[cfg(target_os = "macos")] +fn detect_installation_kind() -> InstallationKind { + let executable_path = match env::current_exe() { + Ok(path) => path, + Err(e) => { + warn!(Source = "Updater"; "Cannot read the current executable path: {e}. Assuming a user installation."); + return InstallationKind::User; + } + }; + + // The updater replaces the entire app bundle, so it needs to write into the directory that + // contains the bundle. On a device managed through an MDM solution like Jamf, the bundle sits + // in a location the user cannot write to. We deliberately do not look for a marker file here: + // any additional file inside the bundle would break its code signature. + match macos_app_bundle_directory(&executable_path) { + Some(bundle_directory) => update_target_installation_kind(&bundle_directory), + None => InstallationKind::User, + } +} + +#[cfg(target_os = "linux")] +fn detect_installation_kind() -> InstallationKind { + // Flatpak installations are always updated from outside the app: + if is_flatpak() { + return InstallationKind::Managed; + } + + let executable_path = match env::current_exe() { + Ok(path) => path, + Err(e) => { + warn!(Source = "Updater"; "Cannot read the current executable path: {e}. Assuming a user installation."); + return InstallationKind::User; + } + }; + + if has_managed_installation_marker(&executable_path) { + return InstallationKind::Managed; + } + + // For AppImages, the updater replaces the AppImage file itself. Everything else is replaced + // in place as well. A deployment into a system-wide location such as /opt is therefore not + // updatable by the app: + let update_target = env::var("APPIMAGE") + .map(PathBuf::from) + .unwrap_or(executable_path); + + update_target_installation_kind(&update_target) +} + +/// Decides the Windows installation kind by comparing the executable path against the local app +/// data directory. The NSIS updater always targets the per-user location below that directory. +/// An executable outside of it, for example, in `C:\Program Files`, was deployed by someone else, +/// and an update would create a second installation next to it instead of replacing it. +#[cfg(any(target_os = "windows", test))] +fn windows_installation_kind_from_paths(executable_path: &Path, local_app_data_directory: Option<&Path>) -> InstallationKind { + let Some(local_app_data_directory) = local_app_data_directory else { + warn!(Source = "Updater"; "Cannot read the local app data directory. Assuming a user installation."); + return InstallationKind::User; + }; + + // Both paths must be compared in the same form. Canonicalization resolves junctions, symbolic + // links, and 8.3 short names such as PROGRA~1, but it also prepends the \\?\ verbatim prefix on + // Windows. Applying it to only one of the two paths would make even a regular per-user + // installation look like it sits outside the local app data directory. Therefore, we either use + // both canonicalized paths or neither of them: + let (executable_path, local_app_data_directory) = match (fs::canonicalize(executable_path), fs::canonicalize(local_app_data_directory)) { + (Ok(canonical_executable_path), Ok(canonical_local_app_data_directory)) => (canonical_executable_path, canonical_local_app_data_directory), + _ => (executable_path.to_path_buf(), local_app_data_directory.to_path_buf()), + }; + + if path_starts_with_ignoring_case(&executable_path, &local_app_data_directory) { + InstallationKind::User + } else { + InstallationKind::Managed + } +} + +/// Compares the path components case-insensitively, because Windows paths are not case-sensitive. +/// A plain string prefix check is not enough either: it would treat `C:\Users\Alice-Backup` as +/// being below `C:\Users\Alice`. +#[cfg(any(target_os = "windows", test))] +fn path_starts_with_ignoring_case(path: &Path, prefix: &Path) -> bool { + let mut path_components = path.components(); + for prefix_component in prefix.components() { + let Some(path_component) = path_components.next() else { + return false; + }; + + let path_text = path_component.as_os_str().to_string_lossy(); + let prefix_text = prefix_component.as_os_str().to_string_lossy(); + if !path_text.eq_ignore_ascii_case(&prefix_text) { + return false; + } + } + + true +} + +/// Derives the app bundle root from the executable path, e.g. +/// `/Applications/MindWork AI Studio.app/Contents/MacOS/MindWork AI Studio` becomes +/// `/Applications/MindWork AI Studio.app`. +#[cfg(any(target_os = "macos", test))] +fn macos_app_bundle_directory(executable_path: &Path) -> Option { + let macos_directory = executable_path.parent()?; + if macos_directory.file_name()? != "MacOS" { + return None; + } + + let contents_directory = macos_directory.parent()?; + if contents_directory.file_name()? != "Contents" { + return None; + } + + let bundle_directory = contents_directory.parent()?; + if !bundle_directory.extension().is_some_and(|extension| extension.eq_ignore_ascii_case("app")) { + return None; + } + + Some(bundle_directory.to_path_buf()) +} + +/// Decides the installation kind by testing whether the current user may replace the given update +/// target. The updater writes the replacement into the directory that contains the target, so that +/// is the directory we test. +#[cfg(any(target_os = "macos", target_os = "linux", test))] +fn update_target_installation_kind(update_target: &Path) -> InstallationKind { + let Some(directory) = update_target.parent() else { + return InstallationKind::User; + }; + + directory_installation_kind(directory) +} + +/// Tests whether the current user may write into the given directory by actually creating a +/// temporary file there. Permission bits alone are not reliable: ACLs, read-only mounts, and +/// managed-device restrictions do not show up in them. +#[cfg(any(target_os = "macos", target_os = "linux", test))] +fn directory_installation_kind(directory: &Path) -> InstallationKind { + match tempfile::Builder::new().prefix(".ai-studio-write-test").tempfile_in(directory) { + Ok(_) => InstallationKind::User, + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => InstallationKind::Managed, + Err(e) => { + warn!(Source = "Updater"; "Cannot test whether '{}' is writable: {e}. Assuming a user installation.", directory.display()); + InstallationKind::User + } + } +} + +/// Returns whether an IT department declared this installation as centrally managed by placing a +/// marker file next to the executable. This covers deployments the path check cannot recognize, +/// for example, when an organization rolls out the regular per-user installer through Intune. +#[cfg(any(target_os = "windows", target_os = "linux", test))] +fn has_managed_installation_marker(executable_path: &Path) -> bool { + match executable_path.parent() { + Some(directory) => directory.join(MANAGED_INSTALLATION_MARKER_FILE_NAME).is_file(), + None => false, + } +} + /// Returns true if the application is running in development mode. pub fn is_dev() -> bool { cfg!(debug_assertions) @@ -1055,17 +1277,20 @@ fn normalize_enterprise_config_id(value: &str) -> Option { #[cfg(test)] mod tests { use super::{ - enterprise_environment_key_name, enterprise_policy_file_slot_suffix, + directory_installation_kind, enterprise_environment_key_name, + enterprise_policy_file_slot_suffix, has_managed_installation_marker, load_external_http_custom_root_certificate_policy_from_directories, linux_policy_directories_from_xdg, load_policy_values_from_directories, - normalize_locale_tag, parse_enterprise_source_values, - select_effective_enterprise_config_source, select_effective_enterprise_secret_source, - EnterpriseConfig, EnterpriseSourceData, EnterpriseSourceValue, EnterpriseSourceValues, - ExternalHttpCustomRootCertificatePolicy, + macos_app_bundle_directory, normalize_locale_tag, parse_enterprise_source_values, + path_starts_with_ignoring_case, select_effective_enterprise_config_source, + select_effective_enterprise_secret_source, update_target_installation_kind, + windows_installation_kind_from_paths, EnterpriseConfig, EnterpriseSourceData, + EnterpriseSourceValue, EnterpriseSourceValues, ExternalHttpCustomRootCertificatePolicy, + InstallationKind, MANAGED_INSTALLATION_MARKER_FILE_NAME, }; use std::collections::HashMap; use std::fs; - use std::path::PathBuf; + use std::path::{Path, PathBuf}; use tempfile::tempdir; const TEST_ID_A: &str = "9072B77D-CA81-40DA-BE6A-861DA525EF7B"; @@ -1454,6 +1679,193 @@ mod tests { ); } + /// Builds a path from its components using the separator of the current platform. Windows + /// paths written with backslashes would be a single component on Unix, so the tests below + /// could not exercise the component comparison there. + fn path_of(components: &[&str]) -> PathBuf { + components.iter().collect() + } + + #[test] + fn windows_per_user_installations_may_update_themselves() { + let local_app_data = path_of(&["/", "Users", "Alice", "AppData", "Local"]); + let executable = path_of(&["/", "Users", "Alice", "AppData", "Local", "MindWork AI Studio", "MindWork AI Studio.exe"]); + + assert_eq!( + windows_installation_kind_from_paths(&executable, Some(&local_app_data)), + InstallationKind::User + ); + } + + #[test] + fn windows_system_wide_installations_are_managed() { + let local_app_data = path_of(&["/", "Users", "Alice", "AppData", "Local"]); + + for install_directory in [ + vec!["/", "Program Files", "MindWork AI Studio"], + vec!["/", "Program Files (x86)", "MindWork AI Studio"], + vec!["/", "Apps", "MindWork AI Studio"], + vec!["/", "Users", "Alice", "AppData", "Roaming", "MindWork AI Studio"], + ] { + let mut components = install_directory.clone(); + components.push("MindWork AI Studio.exe"); + let executable = path_of(&components); + + assert_eq!( + windows_installation_kind_from_paths(&executable, Some(&local_app_data)), + InstallationKind::Managed, + "expected '{}' to be a managed installation", + executable.display() + ); + } + } + + #[test] + fn windows_installation_kind_ignores_case_but_respects_component_boundaries() { + let local_app_data = path_of(&["/", "Users", "Alice", "AppData", "Local"]); + + // Windows paths are not case-sensitive: + let differently_cased = path_of(&["/", "users", "alice", "appdata", "local", "MindWork AI Studio", "MindWork AI Studio.exe"]); + assert_eq!( + windows_installation_kind_from_paths(&differently_cased, Some(&local_app_data)), + InstallationKind::User + ); + + // A plain string prefix check would wrongly accept this one: + let sibling_directory = path_of(&["/", "Users", "Alice", "AppData", "LocalBackup", "MindWork AI Studio", "MindWork AI Studio.exe"]); + assert_eq!( + windows_installation_kind_from_paths(&sibling_directory, Some(&local_app_data)), + InstallationKind::Managed + ); + } + + #[test] + fn windows_installation_kind_falls_back_to_user_without_local_app_data() { + let executable = path_of(&["/", "Program Files", "MindWork AI Studio", "MindWork AI Studio.exe"]); + + assert_eq!( + windows_installation_kind_from_paths(&executable, None), + InstallationKind::User + ); + } + + #[test] + fn windows_installation_kind_does_not_mix_canonical_and_raw_paths() { + // The local app data directory exists and can be canonicalized, while the executable below + // it does not. Canonicalizing only one of the two would compare different path forms, for + // example '/private/var/...' against '/var/...' or '\\?\C:\...' against 'C:\...', and would + // report a managed installation for a perfectly regular per-user installation: + let local_app_data = tempdir().unwrap(); + let executable = local_app_data + .path() + .join("MindWork AI Studio") + .join("MindWork AI Studio.exe"); + + assert_eq!( + windows_installation_kind_from_paths(&executable, Some(local_app_data.path())), + InstallationKind::User + ); + } + + #[test] + fn path_starts_with_ignoring_case_compares_whole_components() { + assert!(path_starts_with_ignoring_case( + Path::new("/Applications/Some App.app/Contents"), + Path::new("/applications/some app.app") + )); + + assert!(!path_starts_with_ignoring_case( + Path::new("/Applications"), + Path::new("/Applications/Some App.app") + )); + + assert!(!path_starts_with_ignoring_case( + Path::new("/Applications-Backup/Some App.app"), + Path::new("/Applications") + )); + } + + #[test] + fn macos_app_bundle_directory_resolves_the_bundle_root() { + assert_eq!( + macos_app_bundle_directory(Path::new( + "/Applications/MindWork AI Studio.app/Contents/MacOS/MindWork AI Studio" + )), + Some(PathBuf::from("/Applications/MindWork AI Studio.app")) + ); + } + + #[test] + fn macos_app_bundle_directory_rejects_paths_outside_a_bundle() { + assert_eq!( + macos_app_bundle_directory(Path::new("/usr/local/bin/mindwork-ai-studio")), + None + ); + + assert_eq!( + macos_app_bundle_directory(Path::new( + "/Applications/MindWork AI Studio/Contents/MacOS/MindWork AI Studio" + )), + None + ); + } + + #[test] + fn writable_directories_are_user_installations() { + let directory = tempdir().unwrap(); + assert_eq!( + directory_installation_kind(directory.path()), + InstallationKind::User + ); + + let update_target = directory.path().join("MindWork AI Studio.AppImage"); + assert_eq!( + update_target_installation_kind(&update_target), + InstallationKind::User + ); + } + + #[cfg(unix)] + #[test] + fn read_only_directories_are_managed_installations() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempdir().unwrap(); + let read_only_directory = directory.path().join("read-only"); + fs::create_dir(&read_only_directory).unwrap(); + fs::set_permissions(&read_only_directory, fs::Permissions::from_mode(0o500)).unwrap(); + + // Permissions do not apply to root, so the assertion below would fail there. In that case, + // we skip the test instead of asserting something the environment cannot provide: + let running_as_root = fs::write(read_only_directory.join("root-probe"), "").is_ok(); + if !running_as_root { + assert_eq!( + directory_installation_kind(&read_only_directory), + InstallationKind::Managed + ); + } + + // Restore the permissions so that the temporary directory can be cleaned up: + fs::set_permissions(&read_only_directory, fs::Permissions::from_mode(0o700)).unwrap(); + } + + #[test] + fn the_marker_file_declares_a_managed_installation() { + let directory = tempdir().unwrap(); + let executable = directory.path().join("MindWork AI Studio"); + fs::write(&executable, "").unwrap(); + + assert!(!has_managed_installation_marker(&executable)); + + fs::write( + directory.path().join(MANAGED_INSTALLATION_MARKER_FILE_NAME), + "", + ) + .unwrap(); + + assert!(has_managed_installation_marker(&executable)); + } + #[test] fn load_policy_values_from_directories_uses_first_directory_wins() { let directory_a = tempdir().unwrap();