diff --git a/CHANGELOG.md b/CHANGELOG.md index 75d818e..1c2c717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ Changes to prior versions can be found on the [GitHub release page](https://gith ## Unreleased -No changes yet. +### Added +* Update mechanism `InstallerUpdateMechanism`, which downloads, verifies and runs the EXE or MSI installer matching the current installation. Self-updates are only performed if the registry value `HKLM\SOFTWARE\Skymatic GmbH\Cryptomator\InstallType` is `EXE` or `MSI`. ## [1.6.2](https://github.com/cryptomator/integrations-win/releases/1.6.2) - 2026-09-29 diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 0ebecda..af5a6fb 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -3,12 +3,14 @@ import org.cryptomator.integrations.quickaccess.QuickAccessService; import org.cryptomator.integrations.revealpath.RevealPathService; import org.cryptomator.integrations.uiappearance.UiAppearanceProvider; +import org.cryptomator.integrations.update.UpdateMechanism; import org.cryptomator.windows.autostart.WindowsAutoStart; import org.cryptomator.windows.keychain.WindowsHelloKeychainAccess; import org.cryptomator.windows.keychain.WindowsProtectedKeychainAccess; import org.cryptomator.windows.quickaccess.ExplorerQuickAccessService; import org.cryptomator.windows.revealpath.ExplorerRevealPathService; import org.cryptomator.windows.uiappearance.WinUiAppearanceProvider; +import org.cryptomator.windows.update.InstallerUpdateMechanism; module org.cryptomator.integrations.win { requires org.cryptomator.integrations.api; @@ -24,5 +26,6 @@ provides UiAppearanceProvider with WinUiAppearanceProvider; provides RevealPathService with ExplorerRevealPathService; provides QuickAccessService with ExplorerQuickAccessService; + provides UpdateMechanism with InstallerUpdateMechanism; } \ No newline at end of file diff --git a/src/main/java/org/cryptomator/windows/common/RegistryKey.java b/src/main/java/org/cryptomator/windows/common/RegistryKey.java index 42fe644..dfc5495 100644 --- a/src/main/java/org/cryptomator/windows/common/RegistryKey.java +++ b/src/main/java/org/cryptomator/windows/common/RegistryKey.java @@ -47,8 +47,23 @@ public class RegistryKey implements AutoCloseable { * @throws RegistryValueException if winreg.h:RegGetValueW returns a result != ERROR_SUCCESS */ public String getStringValue(String name, boolean isExpandable) throws RegistryValueException { + return getStringValue(null, name, isExpandable); + } + + /** + * Gets a REG_SZ or REG_EXPAND_SZ value of a subkey of this registry key, only requiring read access to the subkey. + *

+ * The size of the data is restricted to at most {@value MAX_DATA_SIZE}. If the the value exceeds the size, a runtime exception is thrown. + * + * @param subkey name/path of the subkey containing the value or {@code null} to read the value of this key + * @param name name of the value + * @param isExpandable flag indicating if the value is of type REG_EXPAND_SZ + * @return the data of the value + * @throws RegistryValueException if winreg.h:RegGetValueW returns a result != ERROR_SUCCESS + */ + public String getStringValue(String subkey, String name, boolean isExpandable) throws RegistryValueException { try (var arena = Arena.ofConfined()) { - var data = getValue(arena, name, isExpandable ? RRF_RT_REG_EXPAND_SZ() : RRF_RT_REG_SZ()); + var data = getValue(arena, subkey, name, isExpandable ? RRF_RT_REG_EXPAND_SZ() : RRF_RT_REG_SZ()); return data.getString(0, StandardCharsets.UTF_16LE); } } @@ -62,12 +77,13 @@ public String getStringValue(String name, boolean isExpandable) throws RegistryV */ public int getDwordValue(String name) throws RegistryValueException { try (var arena = Arena.ofConfined()) { - var data = getValue(arena, name, RRF_RT_REG_DWORD()); + var data = getValue(arena, null, name, RRF_RT_REG_DWORD()); return data.get(ValueLayout.JAVA_INT, 0); } } - private MemorySegment getValue(Arena arena, String name, int dwFlags) throws RegistryValueException { + private MemorySegment getValue(Arena arena, String subkey, String name, int dwFlags) throws RegistryValueException { + var lpSubKey = subkey == null ? NULL : arena.allocateFrom(subkey, StandardCharsets.UTF_16LE); var lpValueName = arena.allocateFrom(name, StandardCharsets.UTF_16LE); var lpDataSize = arena.allocateFrom(ValueLayout.JAVA_INT, 0); @@ -82,14 +98,14 @@ private MemorySegment getValue(Arena arena, String name, int dwFlags) throws Reg lpData = arena.allocate(bufferSize); lpDataSize.set(ValueLayout.JAVA_INT, 0, bufferSize); - result = Winreg_h.RegGetValueW(handle, NULL, lpValueName, dwFlags, NULL, lpData, lpDataSize); + result = Winreg_h.RegGetValueW(handle, lpSubKey, lpValueName, dwFlags, NULL, lpData, lpDataSize); } while (result == ERROR_MORE_DATA()); if (result == ERROR_SUCCESS()) { return lpData; } else { - throw new RegistryValueException("winreg_h:RegGetValue", path, name, result); + throw new RegistryValueException("winreg_h:RegGetValue", subkey == null ? path : path + "\\" + subkey, name, result); } } diff --git a/src/main/java/org/cryptomator/windows/update/InstallerUpdateMechanism.java b/src/main/java/org/cryptomator/windows/update/InstallerUpdateMechanism.java new file mode 100644 index 0000000..7caa83f --- /dev/null +++ b/src/main/java/org/cryptomator/windows/update/InstallerUpdateMechanism.java @@ -0,0 +1,172 @@ +package org.cryptomator.windows.update; + +import org.cryptomator.integrations.common.LocalizedDisplayName; +import org.cryptomator.integrations.common.OperatingSystem; +import org.cryptomator.integrations.update.DownloadUpdateInfo; +import org.cryptomator.integrations.update.DownloadUpdateMechanism; +import org.cryptomator.integrations.update.UpdateFailedException; +import org.cryptomator.integrations.update.UpdateMechanism; +import org.cryptomator.integrations.update.UpdateStep; +import org.cryptomator.windows.common.Localization; +import org.cryptomator.windows.common.RegistryKey; +import org.cryptomator.windows.common.RegistryValueException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InterruptedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.function.Supplier; + +import static org.cryptomator.windows.capi.common.Windows_h.ERROR_FILE_NOT_FOUND; + +/** + * Updates Cryptomator by downloading and running the same kind of installer (EXE bundle or MSI) that was used to install it, unless it is managed by a package manager. + */ +@OperatingSystem(OperatingSystem.Value.WINDOWS) +@LocalizedDisplayName(bundle = "WinIntegrationsBundle", key = "org.cryptomator.windows.update.installer.displayName") +public class InstallerUpdateMechanism extends DownloadUpdateMechanism { + + private static final Logger LOG = LoggerFactory.getLogger(InstallerUpdateMechanism.class); + // written by the Cryptomator installer. Package managers (winget, Chocolatey, ...) set the INSTALLTYPE installer property to a different value, opting out of self-updates. + private static final String INSTALL_TYPE_REG_KEY = "SOFTWARE\\Skymatic GmbH\\Cryptomator"; + private static final String INSTALL_TYPE_REG_VALUE = "InstallType"; + private static final String EXPECTED_SIGNER_SUBJECT_PATTERN = "CN=Skymatic GmbH,*"; + private static final Path SYSTEM32 = Path.of(Optional.ofNullable(System.getenv("SystemRoot")).orElse("C:\\Windows"), "System32"); + + private final Supplier installType; + + public InstallerUpdateMechanism() { + this(InstallerUpdateMechanism::readInstallType); + } + + // visible for testing + InstallerUpdateMechanism(Supplier installType) { + this.installType = installType; + } + + @Override + protected DownloadUpdateInfo checkForUpdate(String currentVersion, LatestVersionResponse response) { + String arch = switch (System.getProperty("os.arch")) { + case "aarch64", "arm64" -> "arm64"; + default -> "x64"; + }; + // stick to the installer type used for the current installation, so that the entry in "Apps & Features" stays accurate + String installType = this.installType.get(); + String extension = switch (installType == null ? "" : installType.toUpperCase(Locale.ROOT)) { + case "EXE" -> ".exe"; + case "MSI" -> ".msi"; + default -> null; + }; + if (extension == null) { + LOG.info("Install type is {}, not updating.", installType); + return null; + } + String suffix = "-" + arch + extension; + var updateVersion = response.latestVersion().winVersion(); + var asset = response.assets().stream().filter(a -> a.name().endsWith(suffix)).findAny().orElse(null); + + if (updateVersion != null && asset != null && UpdateMechanism.isUpdateAvailable(updateVersion, currentVersion)) { + return new DownloadUpdateInfo(this, updateVersion, asset); + } else { + return null; + } + } + + @Override + public UpdateStep secondStep(Path workDir, Path assetPath, DownloadUpdateInfo updateInfo) { + return UpdateStep.of(Localization.get().getString("org.cryptomator.windows.update.installer.verifying"), () -> this.verify(workDir, assetPath)); + } + + private UpdateStep verify(Path workDir, Path assetPath) throws IOException { + // Verify the Authenticode signature of the downloaded installer. The script must not contain double quotes, as it is passed as a command line argument. + var script = """ + $s = Get-AuthenticodeSignature -LiteralPath $env:INSTALLER_PATH; \ + Write-Output $s.Status, $s.SignerCertificate.Subject; \ + if ($s.Status -ne 'Valid' -or $s.SignerCertificate.Subject -notlike $env:EXPECTED_SIGNER) { exit 1 }"""; + var processBuilder = new ProcessBuilder(List.of(SYSTEM32.resolve("WindowsPowerShell\\v1.0\\powershell.exe").toString(), "-NoProfile", "-NonInteractive", "-Command", script)); + processBuilder.directory(workDir.toFile()); + processBuilder.redirectErrorStream(true); + processBuilder.environment().remove("PSModulePath"); // inherited PowerShell 7 module paths prevent Windows PowerShell from loading its own modules + processBuilder.environment().put("INSTALLER_PATH", assetPath.toString()); + processBuilder.environment().put("EXPECTED_SIGNER", EXPECTED_SIGNER_SUBJECT_PATTERN); + Process p = processBuilder.start(); + try { + p.getOutputStream().close(); + var output = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).strip(); + if (p.waitFor() != 0) { + LOG.error("Checking signature of {} failed, exit code: {}, output: {}", assetPath, p.exitValue(), output); + throw new UpdateFailedException("Invalid Signature."); + } + LOG.debug("Verified installer {}: {}", assetPath, output); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new InterruptedIOException("Signature verification interrupted"); + } + return UpdateStep.of(Localization.get().getString("org.cryptomator.windows.update.installer.restarting"), () -> this.restart(workDir, assetPath)); + } + + private UpdateStep restart(Path workDir, Path assetPath) throws IOException { + String selfPath = ProcessHandle.current().info().command().orElse(""); + if (!selfPath.toLowerCase(Locale.ROOT).endsWith(".exe")) { + throw new UpdateFailedException("Cannot determine Cryptomator executable, current path: " + selfPath); + } + Path executable = Path.of(selfPath); + String installerType = assetPath.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".msi") ? "msi" : "exe"; + LOG.info("Restarting to apply update in {} now...", workDir); + // System tools are called by full path, as PATH may contain look-alikes (e.g. GNU find). `timeout` fails if stdin is redirected, hence `ping` is used to sleep. + // INSTALLDIR is only supported by the MSI, the bundle always installs to its default location. + String script = """ + @echo off + set "SYS32=%SystemRoot%\\System32" + :waitForExit + "%SYS32%\\tasklist.exe" /NH /FI "PID eq %CRYPTOMATOR_PID%" 2>nul | "%SYS32%\\find.exe" " %CRYPTOMATOR_PID% " >nul + if not errorlevel 1 ( + "%SYS32%\\PING.EXE" -n 2 127.0.0.1 >nul + goto waitForExit + ) + echo Running %INSTALLER_TYPE% installer %INSTALLER_PATH% + if /i "%INSTALLER_TYPE%"=="msi" ( + start "" /wait "%SYS32%\\msiexec.exe" /i "%INSTALLER_PATH%" /passive /norestart INSTALLDIR="%CRYPTOMATOR_INSTALL_DIR%" /l*v "%~dp0installer.log" + ) else ( + start "" /wait "%INSTALLER_PATH%" /passive /norestart /log "%~dp0installer.log" + ) + echo Installer exited with code %ERRORLEVEL% + start "" "%CRYPTOMATOR_EXE%" + """.replace("\n", "\r\n"); + Path scriptPath = workDir.resolve("install.cmd"); + Files.writeString(scriptPath, script, StandardCharsets.US_ASCII, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW); + var processBuilder = new ProcessBuilder(List.of(SYSTEM32.resolve("cmd.exe").toString(), "/c", scriptPath.toString())); + processBuilder.directory(workDir.toFile()); + processBuilder.redirectErrorStream(true); + processBuilder.redirectOutput(workDir.resolve("install.log").toFile()); + processBuilder.environment().put("CRYPTOMATOR_PID", String.valueOf(ProcessHandle.current().pid())); + processBuilder.environment().put("CRYPTOMATOR_EXE", executable.toString()); + processBuilder.environment().put("CRYPTOMATOR_INSTALL_DIR", executable.getParent().toString()); + processBuilder.environment().put("INSTALLER_TYPE", installerType); + processBuilder.environment().put("INSTALLER_PATH", assetPath.toString()); + processBuilder.start(); + + return UpdateStep.EXIT; + } + + private static String readInstallType() { + try { + return RegistryKey.HKEY_LOCAL_MACHINE.getStringValue(INSTALL_TYPE_REG_KEY, INSTALL_TYPE_REG_VALUE, false); + } catch (RegistryValueException e) { + if (e.getSystemErrorCode() == ERROR_FILE_NOT_FOUND()) { + LOG.debug("Install type not found in registry."); + } else { + LOG.warn("Failed to read install type from registry.", e); + } + return null; + } + } + +} diff --git a/src/main/resources/META-INF/services/org.cryptomator.integrations.update.UpdateMechanism b/src/main/resources/META-INF/services/org.cryptomator.integrations.update.UpdateMechanism new file mode 100644 index 0000000..45a2a5a --- /dev/null +++ b/src/main/resources/META-INF/services/org.cryptomator.integrations.update.UpdateMechanism @@ -0,0 +1 @@ +org.cryptomator.windows.update.InstallerUpdateMechanism diff --git a/src/main/resources/WinIntegrationsBundle.properties b/src/main/resources/WinIntegrationsBundle.properties index 47f8ba5..a7b6682 100644 --- a/src/main/resources/WinIntegrationsBundle.properties +++ b/src/main/resources/WinIntegrationsBundle.properties @@ -1,2 +1,5 @@ org.cryptomator.windows.keychain.displayName=Windows Data Protection -org.cryptomator.windows.keychain.displayWindowsHelloName=Windows Hello \ No newline at end of file +org.cryptomator.windows.keychain.displayWindowsHelloName=Windows Hello +org.cryptomator.windows.update.installer.displayName=Download installer +org.cryptomator.windows.update.installer.verifying=Verifying... +org.cryptomator.windows.update.installer.restarting=Restarting... \ No newline at end of file diff --git a/src/test/java/org/cryptomator/windows/common/WindowsRegistryIT.java b/src/test/java/org/cryptomator/windows/common/WindowsRegistryIT.java index 75f86a3..a1280dd 100644 --- a/src/test/java/org/cryptomator/windows/common/WindowsRegistryIT.java +++ b/src/test/java/org/cryptomator/windows/common/WindowsRegistryIT.java @@ -47,6 +47,22 @@ public void testDeleteIgnoreNotExisting() throws WindowsException { } } + @Test + @DisplayName("Read value of HKLM subkey without write access succeeds") + @Order(1) + public void testGetStringValueOfSubkey() throws RegistryValueException { + var buildNumber = RegistryKey.HKEY_LOCAL_MACHINE.getStringValue("SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion", "CurrentBuildNumber", false); + Assertions.assertTrue(buildNumber.matches("\\d+")); + } + + @Test + @DisplayName("Read value of not existing subkey fails") + @Order(1) + public void testGetStringValueOfNotExistingSubkey() { + var winException = Assertions.assertThrows(RegistryValueException.class, () -> RegistryKey.HKEY_LOCAL_MACHINE.getStringValue("i\\do\\not\\exist", "foo", false)); + Assertions.assertEquals(ERROR_FILE_NOT_FOUND(), winException.getSystemErrorCode()); + } + @Test @DisplayName("Create and no commit leads to rollback") @Order(1) diff --git a/src/test/java/org/cryptomator/windows/update/InstallerUpdateMechanismTest.java b/src/test/java/org/cryptomator/windows/update/InstallerUpdateMechanismTest.java new file mode 100644 index 0000000..5c997aa --- /dev/null +++ b/src/test/java/org/cryptomator/windows/update/InstallerUpdateMechanismTest.java @@ -0,0 +1,73 @@ +package org.cryptomator.windows.update; + +import org.cryptomator.integrations.update.DownloadUpdateMechanism.Asset; +import org.cryptomator.integrations.update.DownloadUpdateMechanism.LatestVersion; +import org.cryptomator.integrations.update.DownloadUpdateMechanism.LatestVersionResponse; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.List; + +public class InstallerUpdateMechanismTest { + + private static final Asset DMG = new Asset("Cryptomator-1.19.3-x64.dmg", "sha256:00", 1, "https://example.com/Cryptomator-1.19.3-x64.dmg"); + private static final Asset EXE = new Asset("Cryptomator-1.19.3-x64.exe", "sha256:00", 1, "https://example.com/Cryptomator-1.19.3-x64.exe"); + private static final Asset MSI = new Asset("Cryptomator-1.19.3-x64.msi", "sha256:00", 1, "https://example.com/Cryptomator-1.19.3-x64.msi"); + private static final LatestVersionResponse RESPONSE = new LatestVersionResponse(new LatestVersion("1.19.4", "1.19.3", "1.19.2"), List.of(DMG, EXE, MSI)); + + @BeforeAll + public static void setup() { + Assumptions.assumeTrue(System.getProperty("os.arch").equals("amd64"), "Test assets only exist for x64"); + } + + @ParameterizedTest + @CsvSource({"EXE, Cryptomator-1.19.3-x64.exe", "MSI, Cryptomator-1.19.3-x64.msi", "msi, Cryptomator-1.19.3-x64.msi"}) + public void testAssetMatchesInstallType(String installType, String expectedAsset) { + var mechanism = new InstallerUpdateMechanism(() -> installType); + + var updateInfo = mechanism.checkForUpdate("1.19.2", RESPONSE); + + Assertions.assertNotNull(updateInfo); + Assertions.assertEquals("1.19.3", updateInfo.version()); + Assertions.assertEquals(expectedAsset, updateInfo.asset().name()); + Assertions.assertSame(mechanism, updateInfo.updateMechanism()); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"winget", "chocolatey", "managed"}) + public void testNoUpdateIfNotSelfManaged(String installType) { + var mechanism = new InstallerUpdateMechanism(() -> installType); + + var updateInfo = mechanism.checkForUpdate("1.19.2", RESPONSE); + + Assertions.assertNull(updateInfo); + } + + @ParameterizedTest + @CsvSource({"1.19.3", "1.20.0"}) + public void testNoUpdateIfUpToDate(String currentVersion) { + var mechanism = new InstallerUpdateMechanism(() -> "MSI"); + + var updateInfo = mechanism.checkForUpdate(currentVersion, RESPONSE); + + Assertions.assertNull(updateInfo); + } + + @Test + public void testNoUpdateIfAssetMissing() { + var mechanism = new InstallerUpdateMechanism(() -> "MSI"); + var response = new LatestVersionResponse(RESPONSE.latestVersion(), List.of(DMG, EXE)); + + var updateInfo = mechanism.checkForUpdate("1.19.2", response); + + Assertions.assertNull(updateInfo); + } + +}