Files
craft-libs/crates/update/src/detect.rs
T
Dylan M. Taylor fcd3441993 fix(update): drop the dirs crate and move to zip 9
dirs pulls in option-ext (MPL-2.0), which the apps' cargo-deny licence policy rejects, and zip 8.6 carries RUSTSEC-2026-0367, -0368 and -0369. The per-user folders now come from the environment.
2026-10-25 11:56:58 -04:00

778 lines
36 KiB
Rust

//! Finding Craft Apps that are already installed, however they got there.
//!
//! - macOS: `<App>.app` in `/Applications` or `~/Applications`, read from its `Info.plist`.
//! - Windows: the Windows Installer's uninstall entries (`Learning Machines LLC`), and portable
//! copies the launcher unpacked.
//! - Linux: AppImages in `~/Applications` (where the launcher and AppImageLauncher put them),
//! `~/AppImages` (Gear Lever) and `~/.local/bin`, Debian/RPM packages, Flatpaks, and tarball
//! installs.
//! - FreeBSD: tarball installs and `/usr/local/bin/<app>`.
use std::path::{Path, PathBuf};
use crate::{App, InstallSource, Installed, version};
use crate::manifest::Manifest;
/// One look at the system, shared by every app's detection (package lists are read once).
#[derive(Default)]
pub struct Snapshot {
#[allow(dead_code)]
packages: Vec<crate::packages::Package>,
}
impl Snapshot {
pub fn take() -> Self {
#[cfg(target_os = "linux")]
{
Self { packages: linux::packages() }
}
#[cfg(not(target_os = "linux"))]
{
Self::default()
}
}
}
/// What's installed of `app`, best source first.
pub fn detect(app: &App, manifest: &Manifest, snapshot: &Snapshot) -> Option<Installed> {
let _ = snapshot;
if let Some(found) = from_manifest(app, manifest) {
return Some(found);
}
#[cfg(target_os = "macos")]
return macos::detect(app);
#[cfg(windows)]
return windows::detect(app);
#[cfg(target_os = "linux")]
return linux::detect(app, snapshot);
#[cfg(target_os = "freebsd")]
return freebsd::detect(app);
#[allow(unreachable_code)]
None
}
fn from_manifest(app: &App, manifest: &Manifest) -> Option<Installed> {
let rec = manifest.apps.get(app.id)?;
if !rec.path.exists() {
return None;
}
// On macOS the bundle itself is the truth (the app may have updated itself).
#[cfg(target_os = "macos")]
if rec.kind == crate::PackageKind::Dmg {
return macos::read_bundle(app, &rec.path);
}
Some(Installed {
version: version::parse(&rec.version),
source: InstallSource::Launcher,
location: rec.path.display().to_string(),
package: None,
})
}
/// Whether a file name is an AppImage of the app with asset stems `stems`, and the version its
/// name gives. Matches only the names a release AppImage ends up with: the release's own
/// (`photocraft-0.6.0-linux-x86_64.AppImage`) and the bare app name Gear Lever renames it to
/// (`photocraft.appimage`, version unknown). The extension's case doesn't matter.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) fn appimage_version(name: &str, stems: &[&str]) -> Option<Option<String>> {
let split = name.len().checked_sub(".appimage".len())?;
let (base, ext) = (name.get(..split)?, name.get(split..)?);
if !ext.eq_ignore_ascii_case(".appimage") {
return None;
}
if stems.iter().any(|s| base.eq_ignore_ascii_case(s)) {
return Some(None);
}
let parsed = crate::platform::parse_asset_name(&format!("{base}.AppImage"), stems)?;
(parsed.kind == crate::PackageKind::AppImage && parsed.token.starts_with("linux-")).then_some(Some(parsed.version))
}
/// Who our Windows Installer packages name as their publisher.
#[cfg_attr(not(windows), allow(dead_code))]
const PUBLISHER: &str = "Learning Machines LLC";
/// Whether an uninstall entry's `DisplayName` and `Publisher` are `app`'s, under any name its
/// installers have registered.
#[cfg_attr(not(windows), allow(dead_code))]
fn is_installer_entry(app: &App, display_name: &str, publisher: &str) -> bool {
publisher == PUBLISHER && app.names().any(|n| n == display_name)
}
/// Where a Windows install of `app` may keep its exe, in the order to try: the entry's
/// `InstallLocation`, then `<App>` under each Program Files root for each name the app has had,
/// and in each folder `<stem>.exe` for each stem, current first. Doesn't touch the disk.
#[cfg_attr(not(windows), allow(dead_code))]
fn exe_candidates(app: &App, location: Option<&Path>, program_files: &[PathBuf]) -> Vec<PathBuf> {
let in_program_files = program_files.iter().flat_map(|root| app.names().map(move |n| root.join(n)));
let dirs = location.map(Path::to_path_buf).into_iter().chain(in_program_files);
dirs.flat_map(|d| app.stems().map(move |s| d.join(format!("{s}.exe")))).collect()
}
/// Version from `<app> --version` output. Binaries print the machine id (`photocraft 0.7.0`),
/// the display name (`SoundCraft 0.5.0`), or a former id (`printcraft 0.2.1` for PdfCraft).
#[cfg_attr(not(any(target_os = "linux", target_os = "freebsd")), allow(dead_code))]
fn parse_cli_version(line: &str, app: &App) -> Option<version::Version> {
let line = line.trim();
for prefix in app.stems().chain(std::iter::once(app.name)) {
if let Some(rest) = strip_cli_prefix(line, prefix)
&& let Some(tok) = rest.split_whitespace().next()
&& let Some(v) = version::parse(tok)
{
return Some(v);
}
}
version::parse_package(line)
}
fn strip_cli_prefix<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
let (head, rest) = line.split_at_checked(prefix.len())?;
if !head.eq_ignore_ascii_case(prefix) {
return None;
}
match rest.chars().next() {
None => Some(rest),
Some(c) if c.is_ascii_whitespace() => Some(rest),
_ => None,
}
}
#[cfg(target_os = "macos")]
pub mod macos {
use std::path::{Path, PathBuf};
use super::{App, InstallSource, Installed, version};
/// Where a `.app` might be: the system and the user's Applications folders.
pub fn candidates(app: &App) -> Vec<PathBuf> {
let bundle = format!("{}.app", app.name);
let mut out = vec![Path::new("/Applications").join(&bundle)];
if let Some(home) = crate::folders::home_dir() {
out.push(home.join("Applications").join(&bundle));
}
out
}
pub fn detect(app: &App) -> Option<Installed> {
candidates(app).iter().find_map(|p| read_bundle(app, p))
}
/// Reads a bundle's `Info.plist`; `None` unless it's really this app.
pub fn read_bundle(app: &App, path: &Path) -> Option<Installed> {
let plist = plist::Value::from_file(path.join("Contents/Info.plist")).ok()?;
let dict = plist.as_dictionary()?;
if dict.get("CFBundleIdentifier")?.as_string()? != app.bundle_id() {
return None;
}
// `<App>Version` carries the full version including a pre-release suffix;
// CFBundleShortVersionString only has the numbers.
let full_key = format!("{}Version", app.name);
let text =
[full_key.as_str(), "CFBundleShortVersionString", "CFBundleVersion"].iter().find_map(|k| dict.get(k).and_then(plist::Value::as_string));
Some(Installed { version: text.and_then(version::parse), source: InstallSource::MacApp, location: path.display().to_string(), package: None })
}
}
#[cfg(windows)]
pub mod windows {
use std::path::PathBuf;
use winreg::RegKey;
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_32KEY, KEY_WOW64_64KEY};
use super::{App, InstallSource, Installed, exe_candidates, is_installer_entry, version};
const UNINSTALL: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
/// Reads the ProductVersion string from a program's VERSIONINFO, in the first language that
/// has one, without running the program or any other process.
pub fn executable_version(path: &std::path::Path) -> Option<crate::version::Version> {
let path = dunce::canonicalize(path).ok()?;
let info = winsafe::HVERSIONINFO::GetFileVersionInfo(path.to_str()?).ok()?;
let listed = info.langs_and_cps().map(<[_]>::to_vec).unwrap_or_default();
let product = string_tables(&listed).into_iter().find_map(|table| info.str_val(table, "ProductVersion").ok())?;
version::parse(product.trim())
}
/// The string tables to look in: the languages the file lists, then U.S. English in UTF-16,
/// Windows-1252 and no code page, as .NET's `FileVersionInfo` also tries when a file lists none
/// or its listed one has no strings.
pub(crate) fn string_tables(listed: &[(winsafe::LANGID, winsafe::co::CP)]) -> Vec<(winsafe::LANGID, winsafe::co::CP)> {
use winsafe::{LANGID, co};
let us = LANGID::new(co::LANG::ENGLISH, co::SUBLANG::ENGLISH_US);
listed.iter().copied().chain([(us, co::CP::UTF16), (us, co::CP::WINDOWS_1252), (us, co::CP::ACP)]).collect()
}
/// An MSI install found in the registry.
pub struct MsiInstall {
pub product_code: String,
pub version: Option<crate::version::Version>,
pub exe: Option<PathBuf>,
}
/// Every Windows Installer entry for `app`, newest first.
pub fn msi_installs(app: &App) -> Vec<MsiInstall> {
let mut out = Vec::new();
for (hive, flags) in [(HKEY_LOCAL_MACHINE, KEY_WOW64_64KEY), (HKEY_LOCAL_MACHINE, KEY_WOW64_32KEY), (HKEY_CURRENT_USER, 0)] {
let Ok(root) = RegKey::predef(hive).open_subkey_with_flags(UNINSTALL, KEY_READ | flags) else { continue };
for code in root.enum_keys().flatten() {
let Ok(key) = root.open_subkey_with_flags(&code, KEY_READ | flags) else { continue };
let name: String = key.get_value("DisplayName").unwrap_or_default();
let publisher: String = key.get_value("Publisher").unwrap_or_default();
if !is_installer_entry(app, &name, &publisher) || out.iter().any(|m: &MsiInstall| m.product_code == code) {
continue;
}
let version = key.get_value::<String, _>("DisplayVersion").ok().and_then(|v| version::parse(&v));
let location: String = key.get_value("InstallLocation").unwrap_or_default();
let exe = exe_path(app, (!location.is_empty()).then(|| PathBuf::from(location)).or_else(|| install_dir(app)));
out.push(MsiInstall { product_code: code, version, exe });
}
}
out.sort_by(|a, b| match (&b.version, &a.version) {
(Some(x), Some(y)) => version::compare(x, y),
(Some(_), None) => std::cmp::Ordering::Greater,
(None, Some(_)) => std::cmp::Ordering::Less,
(None, None) => std::cmp::Ordering::Equal,
});
out
}
/// The folder the app's MSI remembers for its next upgrade, which is where it went when the
/// launcher chose the folder.
fn install_dir(app: &App) -> Option<PathBuf> {
[KEY_WOW64_64KEY, KEY_WOW64_32KEY].into_iter().find_map(|flags| {
let key = RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey_with_flags(format!(r"SOFTWARE\{}", app.name), KEY_READ | flags).ok()?;
key.get_value::<String, _>("InstallDir").ok().filter(|d| !d.is_empty()).map(PathBuf::from)
})
}
/// The app's exe: from `App Paths`, the install folder, or Program Files.
fn exe_path(app: &App, location: Option<PathBuf>) -> Option<PathBuf> {
for stem in app.stems() {
let app_paths = format!(r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{stem}.exe");
for flags in [KEY_WOW64_64KEY, KEY_WOW64_32KEY] {
if let Ok(key) = RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey_with_flags(&app_paths, KEY_READ | flags)
&& let Ok(path) = key.get_value::<String, _>("")
{
let p = PathBuf::from(path.trim_matches('"'));
if p.exists() {
return Some(p);
}
}
}
}
let mut roots = Vec::new();
for var in ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"] {
if let Some(pf) = std::env::var_os(var) {
roots.push(PathBuf::from(pf));
}
}
exe_candidates(app, location.as_deref(), &roots).into_iter().find(|p| p.exists())
}
pub fn detect(app: &App) -> Option<Installed> {
let m = msi_installs(app).into_iter().next()?;
Some(Installed {
version: m.version,
source: InstallSource::Msi,
location: m.exe.map_or_else(|| app.name.to_string(), |p| p.display().to_string()),
package: Some(m.product_code),
})
}
}
#[cfg(target_os = "linux")]
pub mod linux {
use std::path::{Path, PathBuf};
use std::time::Duration;
use super::{App, InstallSource, Installed, Snapshot, appimage_version, parse_cli_version, version};
use crate::packages::{self, Package};
use crate::process::capture;
/// Installed Craft App packages from every package manager on this system (dpkg, rpm,
/// pacman, Flatpak). Read once per detection pass.
pub fn packages() -> Vec<Package> {
let ours = |name: &str| {
crate::suite::SUITE.iter().any(|a| {
[InstallSource::Deb, InstallSource::Rpm, InstallSource::Pacman, InstallSource::Flatpak]
.iter()
.any(|m| crate::packages::candidate_names(a, *m).iter().any(|n| n == name))
})
};
let t = Duration::from_secs(10);
let mut out = Vec::new();
let mut add = |manager, list: Vec<(String, String)>| {
out.extend(list.into_iter().filter(|(n, _)| ours(n)).map(|(name, version)| Package { manager, name, version }));
};
if let Some(list) = capture("dpkg-query", &["-W", "-f", "${Package}|${db:Status-Status}|${Version}\n"], t) {
add(InstallSource::Deb, packages::parse_dpkg(&list));
}
if let Some(list) = capture("rpm", &["-qa", "--qf", "%{NAME}|%{VERSION}\n"], t) {
add(InstallSource::Rpm, packages::parse_rpm(&list));
}
// Only run `pacman` where its database exists: elsewhere `pacman` on PATH may be an unrelated program
// (the Pac-Man game in /usr/games on Debian-family systems).
if has_pacman_db()
&& let Some(list) = capture("pacman", &["-Q"], t)
{
add(InstallSource::Pacman, packages::parse_pacman(&list));
}
let arch = format!("--arch={}", std::env::consts::ARCH);
if let Some(list) = crate::process::capture_flatpak(&["list", "--app", &arch, "--columns=application,version"], t) {
let mut flatpaks = packages::parse_flatpak_list(&list);
// Bundles built from a release tarball often list no version: ask Flatpak directly.
for (id, version) in flatpaks.iter_mut().filter(|(id, v)| v.is_empty() && id.starts_with("ai.storyteller.")) {
if let Some(v) = crate::process::capture_flatpak(&["info", &arch, id], t).as_deref().and_then(packages::parse_flatpak_info) {
*version = v;
}
}
add(InstallSource::Flatpak, flatpaks);
}
out
}
/// The local package database of Arch's pacman.
pub(crate) const PACMAN_DB: &str = "/var/lib/pacman/local";
/// True when `db` is a directory, i.e. this system really uses pacman.
pub(crate) fn pacman_db_present(db: &Path) -> bool {
db.is_dir()
}
/// True when the system's default pacman database directory exists.
pub(crate) fn has_pacman_db() -> bool {
pacman_db_present(Path::new(PACMAN_DB))
}
/// Where a package put the app's binary: on `PATH`, else `/usr/bin/<id>`.
fn binary_on_path(id: &str) -> String {
std::env::var_os("PATH")
.and_then(|path| std::env::split_paths(&path).map(|d| d.join(id)).find(|p| p.is_file()))
.unwrap_or_else(|| PathBuf::from("/usr/bin").join(id))
.display()
.to_string()
}
/// `~/Applications`, where the launcher puts AppImages (the AppImageLauncher convention).
pub fn appimage_dir() -> Option<PathBuf> {
crate::folders::home_dir().map(|h| h.join("Applications"))
}
/// Every folder searched for AppImages, the launcher's own first: `~/Applications`,
/// `~/AppImages` (Gear Lever's default) and `~/.local/bin`.
pub fn appimage_dirs() -> Vec<PathBuf> {
let Some(home) = crate::folders::home_dir() else { return Vec::new() };
vec![home.join("Applications"), home.join("AppImages"), home.join(".local").join("bin")]
}
/// AppImages of `app` in `dirs`, newest first (unknown versions last; earlier folders win
/// ties).
pub fn appimages_in(app: &App, dirs: &[PathBuf]) -> Vec<(PathBuf, Option<version::Version>)> {
let stems: Vec<&str> = app.stems().collect();
let mut out: Vec<(PathBuf, Option<version::Version>)> = Vec::new();
for dir in dirs {
let Ok(entries) = std::fs::read_dir(dir) else { continue };
let mut found: Vec<(PathBuf, Option<version::Version>)> = entries
.flatten()
.filter_map(|e| {
let name = e.file_name().into_string().ok()?;
let version = appimage_version(&name, &stems)?;
let path = e.path();
path.is_file().then(|| (path, version.as_deref().and_then(version::parse)))
})
.collect();
found.sort_by(|a, b| a.0.cmp(&b.0));
out.extend(found);
}
// Stable sort: equal versions keep the folder order above.
out.sort_by(|a, b| match (&b.1, &a.1) {
(Some(x), Some(y)) => version::compare(x, y),
(Some(_), None) => std::cmp::Ordering::Greater,
(None, Some(_)) => std::cmp::Ordering::Less,
(None, None) => std::cmp::Ordering::Equal,
});
out
}
/// AppImages of `app` in any of the usual folders, newest first.
pub fn appimages(app: &App) -> Vec<(PathBuf, Option<version::Version>)> {
appimages_in(app, &appimage_dirs())
}
pub fn detect(app: &App, snapshot: &Snapshot) -> Option<Installed> {
if let Some((path, version)) = appimages(app).into_iter().next() {
return Some(Installed { version, source: InstallSource::AppImage, location: path.display().to_string(), package: None });
}
let order = [InstallSource::Deb, InstallSource::Rpm, InstallSource::Pacman, InstallSource::Flatpak];
if let Some(p) = packages::find(app, &snapshot.packages, &order) {
let location = if p.manager == InstallSource::Flatpak { app.bundle_id() } else { binary_on_path(app.id) };
return Some(Installed { version: packages::version_of(p), source: p.manager, location, package: Some(p.name.clone()) });
}
detect_system_install(app)
}
/// When the package managers don't report the app (a query timed out or was locked, an
/// unlisted package name, or a sandbox that can't see them) but its binary is there. It is
/// reported as a system binary: the launcher can open it but leaves updating and removing it
/// to whatever installed it, since it can't tell which package manager that was.
fn detect_system_install(app: &App) -> Option<Installed> {
let bin = find_binary(app.id)?;
Some(Installed { version: binary_version(&bin, app), source: InstallSource::Binary, location: bin.display().to_string(), package: None })
}
fn find_binary(id: &str) -> Option<PathBuf> {
let on_path = std::env::var_os("PATH").and_then(|path| std::env::split_paths(&path).map(|d| d.join(id)).find(|p| p.is_file()));
let usual = ["/usr/bin", "/usr/local/bin"].into_iter().map(PathBuf::from).chain(crate::folders::home_dir().map(|h| h.join(".local/bin")));
on_path.or_else(|| usual.map(|d| d.join(id)).find(|p| p.is_file()))
}
pub(crate) fn binary_version(bin: &Path, app: &App) -> Option<version::Version> {
let out = crate::process::capture(bin.to_str()?, &["--version"], Duration::from_secs(5))?;
parse_cli_version(out.trim(), app)
}
}
#[cfg(target_os = "freebsd")]
pub mod freebsd {
use std::path::Path;
use std::time::Duration;
use super::{App, InstallSource, Installed, parse_cli_version, version};
pub fn detect(app: &App) -> Option<Installed> {
let bin = Path::new("/usr/local/bin").join(app.id);
if !bin.exists() {
return None;
}
Some(Installed { version: binary_version(&bin, app), source: InstallSource::Binary, location: bin.display().to_string(), package: None })
}
/// `<app> --version` prints `<app> 0.5.0 (sha, date)`.
fn binary_version(bin: &Path, app: &App) -> Option<version::Version> {
let out = crate::process::capture(bin.to_str()?, &["--version"], Duration::from_secs(5))?;
parse_cli_version(out.trim(), app)
}
}
#[cfg(all(test, target_os = "linux"))]
mod linux_tests {
use super::*;
#[test]
fn ours_filter_matches_rpm_packages() {
let ours = |name: &str| {
crate::suite::SUITE.iter().any(|a| {
[InstallSource::Deb, InstallSource::Rpm, InstallSource::Pacman, InstallSource::Flatpak]
.iter()
.any(|m| crate::packages::candidate_names(a, *m).iter().any(|n| n == name))
})
};
assert!(ours("photocraft"));
assert!(ours("ai.storyteller.photocraft"));
assert!(!ours("unrelated-package"));
}
#[test]
fn pacman_is_only_queried_where_its_database_exists() {
let root = tempfile::tempdir().unwrap();
assert!(!linux::pacman_db_present(&root.path().join("local")));
std::fs::create_dir(root.path().join("local")).unwrap();
assert!(linux::pacman_db_present(&root.path().join("local")));
}
/// `linux::binary_version` on a script the test just wrote. Another test thread that forks
/// while the script is still open for writing holds that descriptor until it execs, and running
/// the script in that window fails with ETXTBSY ("Text file busy"), so a miss is retried.
fn binary_version_of_new_script(bin: &Path, app: &App) -> Option<version::Version> {
for _ in 0..20 {
if let Some(v) = linux::binary_version(bin, app) {
return Some(v);
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
None
}
#[test]
fn reads_binary_version_from_cli() {
let photo = App::by_id("photocraft").unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let bin = temp_dir.path().join("photocraft");
std::fs::write(&bin, "#!/bin/sh\necho 'photocraft 0.6.0 (0c72d9542, 2026-10-10)'\n").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
let ver = binary_version_of_new_script(&bin, photo);
assert_eq!(ver.unwrap().to_string(), "0.6.0");
}
#[test]
fn reads_binary_version_when_the_cli_uses_the_display_name_or_a_former_id() {
let temp_dir = tempfile::tempdir().unwrap();
use std::os::unix::fs::PermissionsExt;
let write = |name: &str, script: &str| {
let bin = temp_dir.path().join(name);
std::fs::write(&bin, script).unwrap();
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
bin
};
let sound = write("soundcraft", "#!/bin/sh\necho 'SoundCraft 0.5.0'\n");
assert_eq!(binary_version_of_new_script(&sound, App::by_id("soundcraft").unwrap()).unwrap().to_string(), "0.5.0");
let pdf = write("pdfcraft", "#!/bin/sh\necho 'printcraft 0.2.1'\n");
assert_eq!(binary_version_of_new_script(&pdf, App::by_id("pdfcraft").unwrap()).unwrap().to_string(), "0.2.1");
}
}
#[cfg(test)]
mod tests {
use crate::SUITE;
use super::*;
/// A file without VERSIONINFO, or one that isn't there, has no version.
#[cfg(windows)]
#[test]
fn a_file_without_version_info_has_no_version() {
let dir = tempfile::tempdir().unwrap();
let text = dir.path().join("photocraft.exe");
std::fs::write(&text, "not a program").unwrap();
assert_eq!(windows::executable_version(&text), None);
assert_eq!(windows::executable_version(&dir.path().join("missing.exe")), None);
}
/// Without a listed language, the version is still looked for in the string tables .NET's
/// `FileVersionInfo` falls back to (040904B0, 040904E4, 04090000), after any listed one.
#[cfg(windows)]
#[test]
fn the_version_is_looked_for_in_the_usual_string_tables_too() {
use winsafe::{LANGID, co};
let key = |(lang, cp): (LANGID, co::CP)| format!("{:04x}{:04x}", u16::from(lang), u16::from(cp));
let keys = |listed: &[(LANGID, co::CP)]| windows::string_tables(listed).into_iter().map(key).collect::<Vec<_>>();
assert_eq!(keys(&[]), ["040904b0", "040904e4", "04090000"]);
let german = (LANGID::new(co::LANG::GERMAN, co::SUBLANG::GERMAN), co::CP::UTF16);
assert_eq!(keys(&[german]), ["040704b0", "040904b0", "040904e4", "04090000"]);
}
/// A program whose VERSIONINFO has a ProductVersion in the U.S. English UTF-16 table but no
/// `\VarFileInfo\Translation` entry still has a version.
#[cfg(windows)]
#[test]
fn a_version_without_a_translation_entry_is_found() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("photocraft.exe");
std::fs::write(&exe, resource_only_program("1.2.3")).unwrap();
assert_eq!(windows::executable_version(&exe), version::parse("1.2.3"));
}
/// A VERSIONINFO block node: length, value length, type, the key, then the value and the
/// children, each aligned to 4 bytes.
#[cfg(windows)]
fn version_node(key: &str, value: &[u8], value_length: u16, text: bool, children: &[Vec<u8>]) -> Vec<u8> {
let align = |bytes: &mut Vec<u8>| bytes.resize(bytes.len().next_multiple_of(4), 0);
let mut node = vec![0, 0];
node.extend(value_length.to_le_bytes());
node.extend(u16::from(text).to_le_bytes());
node.extend(key.encode_utf16().chain([0]).flat_map(u16::to_le_bytes));
align(&mut node);
node.extend(value);
for child in children {
align(&mut node);
node.extend(child);
}
let length = u16::try_from(node.len()).unwrap();
node[..2].copy_from_slice(&length.to_le_bytes());
node
}
/// A 64-bit program file holding only a version resource (`ProductVersion` = `product`, in
/// the 040904B0 string table) and no `\VarFileInfo\Translation` entry.
#[cfg(windows)]
fn resource_only_program(product: &str) -> Vec<u8> {
let put = |bytes: &mut Vec<u8>, at: usize, value: &[u8]| bytes[at..at + value.len()].copy_from_slice(value);
let text: Vec<u8> = product.encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect();
let string = version_node("ProductVersion", &text, u16::try_from(product.len() + 1).unwrap(), true, &[]);
let table = version_node("040904b0", &[], 0, true, &[string]);
let strings = version_node("StringFileInfo", &[], 0, true, &[table]);
let mut fixed = vec![0u8; 52];
put(&mut fixed, 0, &0xFEEF_04BDu32.to_le_bytes());
put(&mut fixed, 4, &0x0001_0000u32.to_le_bytes());
let info = version_node("VS_VERSION_INFO", &fixed, 52, false, &[strings]);
// The resource section: type 16 (RT_VERSION) > id 1 > language 0x0409 > the data.
const RVA: u32 = 0x1000;
let mut rsrc = vec![0u8; 0x58];
for (dir, id, next) in [(0x00, 16u32, 0x8000_0018u32), (0x18, 1, 0x8000_0030), (0x30, 0x0409, 0x48)] {
put(&mut rsrc, dir + 14, &1u16.to_le_bytes());
put(&mut rsrc, dir + 16, &id.to_le_bytes());
put(&mut rsrc, dir + 20, &next.to_le_bytes());
}
put(&mut rsrc, 0x48, &(RVA + 0x58).to_le_bytes());
put(&mut rsrc, 0x4C, &u32::try_from(info.len()).unwrap().to_le_bytes());
rsrc.extend(&info);
let raw = u32::try_from(rsrc.len().next_multiple_of(0x200)).unwrap();
let rsrc_len = u32::try_from(rsrc.len()).unwrap();
let mut file = vec![0u8; 0x200];
put(&mut file, 0, b"MZ");
put(&mut file, 0x3C, &0x40u32.to_le_bytes());
put(&mut file, 0x40, b"PE\0\0");
// COFF header: x64, one section, a 240-byte optional header, an executable DLL image.
put(&mut file, 0x44, &0x8664u16.to_le_bytes());
put(&mut file, 0x46, &1u16.to_le_bytes());
put(&mut file, 0x54, &240u16.to_le_bytes());
put(&mut file, 0x56, &0x2022u16.to_le_bytes());
// PE32+ optional header.
let opt = 0x58;
put(&mut file, opt, &0x20Bu16.to_le_bytes());
put(&mut file, opt + 8, &raw.to_le_bytes());
put(&mut file, opt + 24, &0x1_4000_0000u64.to_le_bytes());
put(&mut file, opt + 32, &0x1000u32.to_le_bytes());
put(&mut file, opt + 36, &0x200u32.to_le_bytes());
put(&mut file, opt + 40, &6u16.to_le_bytes());
put(&mut file, opt + 48, &6u16.to_le_bytes());
put(&mut file, opt + 56, &(RVA + rsrc_len.next_multiple_of(0x1000)).to_le_bytes());
put(&mut file, opt + 60, &0x200u32.to_le_bytes());
put(&mut file, opt + 68, &2u16.to_le_bytes());
put(&mut file, opt + 72, &0x10_0000u64.to_le_bytes());
put(&mut file, opt + 80, &0x1000u64.to_le_bytes());
put(&mut file, opt + 88, &0x10_0000u64.to_le_bytes());
put(&mut file, opt + 96, &0x1000u64.to_le_bytes());
put(&mut file, opt + 108, &16u32.to_le_bytes());
put(&mut file, opt + 112 + 2 * 8, &RVA.to_le_bytes());
put(&mut file, opt + 112 + 2 * 8 + 4, &rsrc_len.to_le_bytes());
// The .rsrc section header.
let section = opt + 240;
put(&mut file, section, b".rsrc\0\0\0");
put(&mut file, section + 8, &rsrc_len.to_le_bytes());
put(&mut file, section + 12, &RVA.to_le_bytes());
put(&mut file, section + 16, &raw.to_le_bytes());
put(&mut file, section + 20, &0x200u32.to_le_bytes());
put(&mut file, section + 36, &0x4000_0040u32.to_le_bytes());
rsrc.resize(usize::try_from(raw).unwrap(), 0);
file.extend(rsrc);
file
}
#[test]
fn recognises_appimage_names_conservatively() {
let pdf = ["pdfcraft", "printcraft"];
let v = |n: &str| appimage_version(n, &pdf);
assert_eq!(v("pdfcraft-0.3.0-linux-x86_64.AppImage"), Some(Some("0.3.0".into())));
assert_eq!(v("printcraft-0.2.1-linux-aarch64.appimage"), Some(Some("0.2.1".into())));
assert_eq!(v("pdfcraft-0.3.0-rc.1-linux-x86_64.AppImage"), Some(Some("0.3.0-rc.1".into())));
// Gear Lever's rename, and a plain rename by hand.
assert_eq!(v("pdfcraft.appimage"), Some(None));
assert_eq!(v("PdfCraft.AppImage"), Some(None));
for other in [
"pdfcraft-0.3.0-linux-x86_64.AppImage.zsync",
"pdfcraft-0.3.0-linux-x86_64.deb",
"pdfcraft-0.3.0-windows-x64.AppImage",
"pdfcraft-cli-0.3.0-linux-x86_64.AppImage",
"photocraft-0.6.0-linux-x86_64.AppImage",
"mypdfcraft.AppImage",
"pdfcraft-latest.AppImage",
".appimage",
"x",
] {
assert_eq!(v(other), None, "{other}");
}
}
#[test]
fn parse_cli_version_accepts_id_display_name_and_former_id() {
let photo = App::by_id("photocraft").unwrap();
let sound = App::by_id("soundcraft").unwrap();
let pdf = App::by_id("pdfcraft").unwrap();
assert_eq!(parse_cli_version("photocraft 0.7.0 (dev build)", photo).unwrap().to_string(), "0.7.0");
assert_eq!(parse_cli_version("SoundCraft 0.5.0", sound).unwrap().to_string(), "0.5.0");
assert_eq!(parse_cli_version("printcraft 0.2.1", pdf).unwrap().to_string(), "0.2.1");
assert_eq!(parse_cli_version("PdfCraft 0.6.0", pdf).unwrap().to_string(), "0.6.0");
assert!(strip_cli_prefix("photocraft-cli 1.0.0", "photocraft").is_none());
}
#[cfg(target_os = "linux")]
#[test]
fn finds_appimages_in_every_folder_newest_first() {
let app = App::by_id("photocraft").unwrap();
let root = tempfile::tempdir().unwrap();
let (apps, gear, bin) = (root.path().join("Applications"), root.path().join("AppImages"), root.path().join("bin"));
for d in [&apps, &gear, &bin] {
std::fs::create_dir_all(d).unwrap();
}
std::fs::write(apps.join("photocraft-0.5.0-linux-x86_64.AppImage"), b"").unwrap();
std::fs::write(apps.join("wordcraft-0.3.0-linux-x86_64.AppImage"), b"").unwrap();
std::fs::write(gear.join("photocraft.appimage"), b"").unwrap();
std::fs::write(bin.join("photocraft-0.6.0-linux-x86_64.AppImage"), b"").unwrap();
std::fs::create_dir(bin.join("photocraft.AppImage")).unwrap();
let found = linux::appimages_in(app, &[apps.clone(), gear.clone(), bin.clone(), root.path().join("missing")]);
let names: Vec<_> = found.iter().map(|(p, v)| (p.clone(), v.as_ref().map(ToString::to_string))).collect();
assert_eq!(
names,
[
(bin.join("photocraft-0.6.0-linux-x86_64.AppImage"), Some("0.6.0".to_string())),
(apps.join("photocraft-0.5.0-linux-x86_64.AppImage"), Some("0.5.0".to_string())),
(gear.join("photocraft.appimage"), None),
]
);
}
const LEARNING_MACHINES: &str = "Learning Machines LLC";
fn roots() -> Vec<PathBuf> {
vec![PathBuf::from(r"C:\Program Files"), PathBuf::from(r"C:\Program Files (x86)")]
}
#[test]
fn pdfcraft_matches_its_former_printcraft_installer_name() {
let pdf = App::by_id("pdfcraft").unwrap();
assert!(is_installer_entry(pdf, "PrintCraft", LEARNING_MACHINES), "a PrintCraft entry from {LEARNING_MACHINES} should be PdfCraft's");
}
#[test]
fn pdfcraft_still_matches_its_own_installer_name() {
let pdf = App::by_id("pdfcraft").unwrap();
assert!(is_installer_entry(pdf, "PdfCraft", LEARNING_MACHINES));
}
#[test]
fn printcraft_entries_match_nothing_else() {
let pdf = App::by_id("pdfcraft").unwrap();
assert!(!is_installer_entry(pdf, "PrintCraft", "Some Other Publisher"));
assert!(!is_installer_entry(pdf, "PhotoCraft", LEARNING_MACHINES));
for a in SUITE.iter().filter(|a| a.id != "pdfcraft") {
assert!(!is_installer_entry(a, "PrintCraft", LEARNING_MACHINES), "PrintCraft matched {}", a.name);
}
}
#[test]
fn pdfcraft_exe_is_sought_under_every_name_it_has_shipped_as() {
let pdf = App::by_id("pdfcraft").unwrap();
let location = PathBuf::from(r"D:\Apps\PrintCraft");
let roots = roots();
let mut want = vec![location.join("pdfcraft.exe"), location.join("printcraft.exe")];
for root in &roots {
for dir in ["PdfCraft", "PrintCraft"] {
want.push(root.join(dir).join("pdfcraft.exe"));
want.push(root.join(dir).join("printcraft.exe"));
}
}
assert_eq!(exe_candidates(pdf, Some(&location), &roots), want);
}
#[test]
fn apps_without_former_names_match_and_find_their_exe_as_before() {
let roots = roots();
let location = PathBuf::from(r"D:\Apps\Somewhere");
for a in SUITE.iter().filter(|a| a.former_ids.is_empty() && a.former_names.is_empty()) {
assert!(is_installer_entry(a, a.name, LEARNING_MACHINES), "{}", a.name);
assert!(!is_installer_entry(a, a.name, "Some Other Publisher"), "{}", a.name);
assert!(!is_installer_entry(a, &a.name.to_lowercase(), LEARNING_MACHINES), "{}", a.name);
let exe = format!("{}.exe", a.id);
let in_program_files: Vec<PathBuf> = roots.iter().map(|r| r.join(a.name).join(&exe)).collect();
assert_eq!(exe_candidates(a, None, &roots), in_program_files);
let with_location: Vec<PathBuf> = std::iter::once(location.join(&exe)).chain(in_program_files).collect();
assert_eq!(exe_candidates(a, Some(&location), &roots), with_location);
}
}
}