diff --git a/src/deadline/client/cli/_deadline_web_url.py b/src/deadline/client/cli/_deadline_web_url.py index 919b94803..811334231 100644 --- a/src/deadline/client/cli/_deadline_web_url.py +++ b/src/deadline/client/cli/_deadline_web_url.py @@ -1,5 +1,6 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +import configparser import os import re import sys @@ -215,13 +216,81 @@ def install_deadline_web_url_handler(all_users: bool) -> None: MimeType=x-scheme-handler/{DEADLINE_URL_SCHEME_NAME} """ - mimeapps_file_content = f"""[Default Applications] -x-scheme-handler/{DEADLINE_URL_SCHEME_NAME}={DEADLINE_URL_SCHEME_NAME}.desktop; -""" with open(desktop_file_path, "w") as desktop_file: desktop_file.write(desktop_file_content) - with open(mimeapps_list_file_path, "w") as mimeapps_list_file: - mimeapps_list_file.write(mimeapps_file_content) + + # Read/parse any existing mimeapps.list and add/update ONLY the deadline + # handler entry, preserving all other default-application associations. + # Opening in "w" mode would truncate the file and destroy unrelated + # associations (browser, PDF, mailto, etc.), causing permanent data loss. + # interpolation=None avoids treating "%" in values specially, and + # optionxform=str preserves the case of mime-type/scheme keys. + # strict=False tolerates duplicate keys/sections (last value wins), + # which real-world mimeapps.list files written by other desktop tools + # have historically contained; the default strict=True would turn an + # otherwise-usable file into a hard install failure. + mimeapps = configparser.ConfigParser(interpolation=None, strict=False) + mimeapps.optionxform = str # type: ignore[assignment,method-assign] + + if os.path.isfile(mimeapps_list_file_path): + # A pre-existing mimeapps.list that is not valid INI can still make + # configparser raise even with strict=False (MissingSectionHeaderError + # for a key before any section header, UnicodeDecodeError for a + # non-text file, ...). Surface these as a DeadlineOperationError for + # consistency with the rest of this function rather than crashing the + # CLI with a raw traceback. + try: + mimeapps.read(mimeapps_list_file_path) + except (configparser.Error, UnicodeDecodeError) as e: + raise DeadlineOperationError( + f"Failed to install the handler for {DEADLINE_URL_SCHEME_NAME} URLs: " + f"could not parse existing {mimeapps_list_file_path}:\n{e}" + ) from e + + if not mimeapps.has_section("Default Applications"): + mimeapps.add_section("Default Applications") + mimeapps.set( + "Default Applications", + f"x-scheme-handler/{DEADLINE_URL_SCHEME_NAME}", + f"{DEADLINE_URL_SCHEME_NAME}.desktop;", + ) + + # Write atomically: rendering to a temp file in the same directory and + # os.replace()-ing it into place means the original mimeapps.list is only + # replaced once the new content is fully and successfully written. Opening + # the destination directly in "w" mode would truncate it before write() + # runs, so a crash/kill/disk-full mid-write would leave it empty or partial + # -- losing exactly the unrelated associations this change protects. + import stat + import tempfile + + # tempfile.mkstemp() creates the temp file with mode 0600, and os.replace() + # keeps that mode on the final file. That would silently drop the + # permissions of a pre-existing mimeapps.list, and for the all_users case + # (/usr/share/applications/mimeapps.list, written as root) would leave a + # system-wide file that other users' desktop environments cannot read. + # Preserve the existing file's mode, or default to 0644 for a new file. + if os.path.isfile(mimeapps_list_file_path): + mimeapps_list_file_mode = stat.S_IMODE(os.stat(mimeapps_list_file_path).st_mode) + else: + mimeapps_list_file_mode = 0o644 + + dir_name = os.path.dirname(mimeapps_list_file_path) + fd, tmp_path = tempfile.mkstemp(dir=dir_name, prefix=".mimeapps.list.", suffix=".tmp") + try: + with os.fdopen(fd, "w") as tmp_file: + mimeapps.write(tmp_file, space_around_delimiters=False) + os.chmod(tmp_path, mimeapps_list_file_mode) + os.replace(tmp_path, mimeapps_list_file_path) + except BaseException: + try: + os.unlink(tmp_path) + except OSError: + # Best-effort temp file cleanup; the original exception below is + # the actual failure to surface, and a leftover temp file is + # harmless compared to masking it. + pass + raise try: subprocess.run(["update-desktop-database", entry_dir], check=True) diff --git a/test/unit/deadline_client/cli/test_cli_handle_web_url.py b/test/unit/deadline_client/cli/test_cli_handle_web_url.py index 7902cca35..a9d186836 100644 --- a/test/unit/deadline_client/cli/test_cli_handle_web_url.py +++ b/test/unit/deadline_client/cli/test_cli_handle_web_url.py @@ -6,6 +6,7 @@ import os import shutil +import stat import subprocess import sys from pathlib import Path @@ -1006,6 +1007,320 @@ def test_linux_install_generates_valid_desktop_file(fresh_deadline_config, tmp_p ) +def test_linux_install_preserves_existing_mimeapps_entries(fresh_deadline_config, tmp_path): + """ + Regression test: installing the web URL handler on Linux must NOT wipe out + unrelated default-application associations already present in mimeapps.list. + + Previously the install opened mimeapps.list in "w" mode, truncating the whole + file and destroying every other association (browser, PDF, mailto, etc.). + """ + entry_dir = tmp_path / "applications" + entry_dir.mkdir() + config_dir = tmp_path / "config" + config_dir.mkdir() + + mimeapps_path = config_dir / "mimeapps.list" + # Pre-populate with unrelated default-application associations. + mimeapps_path.write_text( + "[Default Applications]\n" + "text/html=firefox.desktop\n" + "x-scheme-handler/http=firefox.desktop\n" + "x-scheme-handler/mailto=thunderbird.desktop\n" + "application/pdf=okular.desktop\n" + ) + + with ( + patch.object(sys, "platform", "linux"), + patch.object(sys, "argv", ["/usr/bin/deadline"]), + patch.object(shutil, "which", return_value="/usr/bin/deadline"), + patch.object( + os.path, + "expanduser", + side_effect=lambda p: p.replace("~/.local/share", str(tmp_path)).replace( + "~/.config", str(config_dir) + ), + ), + patch.object(subprocess, "run"), + patch.object(os, "makedirs"), + ): + from deadline.client.cli._deadline_web_url import install_deadline_web_url_handler + + install_deadline_web_url_handler(all_users=False) + + contents = mimeapps_path.read_text() + + # The pre-existing associations must survive. + assert "text/html=firefox.desktop" in contents + assert "x-scheme-handler/http=firefox.desktop" in contents + assert "x-scheme-handler/mailto=thunderbird.desktop" in contents + assert "application/pdf=okular.desktop" in contents + + # And the deadline handler must be added. + assert "x-scheme-handler/deadline=deadline.desktop" in contents + + +def test_linux_install_preserves_mimeapps_when_write_fails(fresh_deadline_config, tmp_path): + """ + Regression test: the mimeapps.list rewrite must be atomic. If writing the new + content fails partway through (crash, disk-full, exception inside write()), + the original file must be left intact rather than truncated/emptied -- losing + the unrelated associations this change exists to protect. + """ + entry_dir = tmp_path / "applications" + entry_dir.mkdir() + config_dir = tmp_path / "config" + config_dir.mkdir() + + mimeapps_path = config_dir / "mimeapps.list" + original = ( + "[Default Applications]\n" + "text/html=firefox.desktop\n" + "x-scheme-handler/http=firefox.desktop\n" + "application/pdf=okular.desktop\n" + ) + mimeapps_path.write_text(original) + + with ( + patch.object(sys, "platform", "linux"), + patch.object(sys, "argv", ["/usr/bin/deadline"]), + patch.object(shutil, "which", return_value="/usr/bin/deadline"), + patch.object( + os.path, + "expanduser", + side_effect=lambda p: p.replace("~/.local/share", str(tmp_path)).replace( + "~/.config", str(config_dir) + ), + ), + patch.object(subprocess, "run"), + patch.object(os, "makedirs"), + # Simulate a failure while rendering the new content to the temp file. + patch( + "configparser.ConfigParser.write", + side_effect=OSError("No space left on device"), + ), + ): + from deadline.client.cli._deadline_web_url import install_deadline_web_url_handler + + with pytest.raises(OSError, match="No space left on device"): + install_deadline_web_url_handler(all_users=False) + + # The original file must be untouched, and no temp files left behind. + assert mimeapps_path.read_text() == original + leftover = [p.name for p in config_dir.iterdir() if p.name != "mimeapps.list"] + assert leftover == [], f"temp files not cleaned up: {leftover}" + + +@pytest.mark.skipif( + sys.platform == "win32", reason="POSIX file modes are not meaningful on Windows" +) +def test_linux_install_preserves_mimeapps_permissions(fresh_deadline_config, tmp_path): + """ + Regression test: the atomic temp-file rewrite must not change the permissions + of an existing mimeapps.list. tempfile.mkstemp() creates the temp file with + mode 0600 and os.replace() would keep that mode -- e.g. turning a + world-readable system-wide /usr/share/applications/mimeapps.list into a + root-only file that other users' desktop environments cannot read. + """ + entry_dir = tmp_path / "applications" + entry_dir.mkdir() + config_dir = tmp_path / "config" + config_dir.mkdir() + + mimeapps_path = config_dir / "mimeapps.list" + mimeapps_path.write_text("[Default Applications]\ntext/html=firefox.desktop\n") + mimeapps_path.chmod(0o644) + + with ( + patch.object(sys, "platform", "linux"), + patch.object(sys, "argv", ["/usr/bin/deadline"]), + patch.object(shutil, "which", return_value="/usr/bin/deadline"), + patch.object( + os.path, + "expanduser", + side_effect=lambda p: p.replace("~/.local/share", str(tmp_path)).replace( + "~/.config", str(config_dir) + ), + ), + patch.object(subprocess, "run"), + patch.object(os, "makedirs"), + ): + from deadline.client.cli._deadline_web_url import install_deadline_web_url_handler + + install_deadline_web_url_handler(all_users=False) + + assert stat.S_IMODE(mimeapps_path.stat().st_mode) == 0o644 + # Sanity check that the rewrite happened. + assert "x-scheme-handler/deadline=deadline.desktop" in mimeapps_path.read_text() + + +@pytest.mark.skipif( + sys.platform == "win32", reason="POSIX file modes are not meaningful on Windows" +) +def test_linux_install_creates_mimeapps_with_default_permissions(fresh_deadline_config, tmp_path): + """ + When mimeapps.list does not exist yet, the new file must get a standard + world-readable config file mode (0644), not mkstemp's private 0600. + """ + entry_dir = tmp_path / "applications" + entry_dir.mkdir() + config_dir = tmp_path / "config" + config_dir.mkdir() + + mimeapps_path = config_dir / "mimeapps.list" + assert not mimeapps_path.exists() + + with ( + patch.object(sys, "platform", "linux"), + patch.object(sys, "argv", ["/usr/bin/deadline"]), + patch.object(shutil, "which", return_value="/usr/bin/deadline"), + patch.object( + os.path, + "expanduser", + side_effect=lambda p: p.replace("~/.local/share", str(tmp_path)).replace( + "~/.config", str(config_dir) + ), + ), + patch.object(subprocess, "run"), + patch.object(os, "makedirs"), + ): + from deadline.client.cli._deadline_web_url import install_deadline_web_url_handler + + install_deadline_web_url_handler(all_users=False) + + assert stat.S_IMODE(mimeapps_path.stat().st_mode) == 0o644 + + +def test_linux_install_creates_mimeapps_when_missing(fresh_deadline_config, tmp_path): + """ + Tests that when mimeapps.list does not exist yet, installing creates it + with the deadline handler entry. + """ + entry_dir = tmp_path / "applications" + entry_dir.mkdir() + config_dir = tmp_path / "config" + config_dir.mkdir() + + mimeapps_path = config_dir / "mimeapps.list" + assert not mimeapps_path.exists() + + with ( + patch.object(sys, "platform", "linux"), + patch.object(sys, "argv", ["/usr/bin/deadline"]), + patch.object(shutil, "which", return_value="/usr/bin/deadline"), + patch.object( + os.path, + "expanduser", + side_effect=lambda p: p.replace("~/.local/share", str(tmp_path)).replace( + "~/.config", str(config_dir) + ), + ), + patch.object(subprocess, "run"), + patch.object(os, "makedirs"), + ): + from deadline.client.cli._deadline_web_url import install_deadline_web_url_handler + + install_deadline_web_url_handler(all_users=False) + + contents = mimeapps_path.read_text() + assert "[Default Applications]" in contents + assert "x-scheme-handler/deadline=deadline.desktop" in contents + + +def test_linux_install_raises_on_malformed_mimeapps(fresh_deadline_config, tmp_path): + """ + A pre-existing mimeapps.list that is not valid INI (here, a stray key before + any section header) must surface as a DeadlineOperationError rather than + crashing the CLI with a raw configparser traceback. + """ + entry_dir = tmp_path / "applications" + entry_dir.mkdir() + config_dir = tmp_path / "config" + config_dir.mkdir() + + mimeapps_path = config_dir / "mimeapps.list" + # Not valid INI: a key/value pair before any [section] header. + mimeapps_path.write_text("this-is=not-valid-ini\n") + + with ( + patch.object(sys, "platform", "linux"), + patch.object(sys, "argv", ["/usr/bin/deadline"]), + patch.object(shutil, "which", return_value="/usr/bin/deadline"), + patch.object( + os.path, + "expanduser", + side_effect=lambda p: p.replace("~/.local/share", str(tmp_path)).replace( + "~/.config", str(config_dir) + ), + ), + patch.object(subprocess, "run"), + patch.object(os, "makedirs"), + ): + from deadline.client.cli._deadline_web_url import install_deadline_web_url_handler + from deadline.client.exceptions import DeadlineOperationError + + with pytest.raises(DeadlineOperationError, match="could not parse existing"): + install_deadline_web_url_handler(all_users=False) + + +def test_linux_install_tolerates_duplicate_mimeapps_entries(fresh_deadline_config, tmp_path): + """ + Regression test: real-world mimeapps.list files written by other desktop + tools have historically contained duplicate keys/sections. The default + configparser strict=True would raise DuplicateOptionError/DuplicateSectionError + on these, turning a previously always-succeeding install into a hard failure. + + With strict=False the install must succeed (last value wins) while still + preserving unrelated associations and adding the deadline handler. + """ + entry_dir = tmp_path / "applications" + entry_dir.mkdir() + config_dir = tmp_path / "config" + config_dir.mkdir() + + mimeapps_path = config_dir / "mimeapps.list" + # Duplicate key (x-scheme-handler/http appears twice) AND a duplicate section + # ([Default Applications] appears twice) — both illegal under strict=True. + mimeapps_path.write_text( + "[Default Applications]\n" + "x-scheme-handler/http=firefox.desktop\n" + "x-scheme-handler/http=chrome.desktop\n" + "application/pdf=okular.desktop\n" + "[Default Applications]\n" + "x-scheme-handler/mailto=thunderbird.desktop\n" + ) + + with ( + patch.object(sys, "platform", "linux"), + patch.object(sys, "argv", ["/usr/bin/deadline"]), + patch.object(shutil, "which", return_value="/usr/bin/deadline"), + patch.object( + os.path, + "expanduser", + side_effect=lambda p: p.replace("~/.local/share", str(tmp_path)).replace( + "~/.config", str(config_dir) + ), + ), + patch.object(subprocess, "run"), + patch.object(os, "makedirs"), + ): + from deadline.client.cli._deadline_web_url import install_deadline_web_url_handler + + # Must NOT raise despite the duplicate key/section. + install_deadline_web_url_handler(all_users=False) + + contents = mimeapps_path.read_text() + + # Unrelated associations survive; duplicate key resolves last-value-wins. + assert "x-scheme-handler/http=chrome.desktop" in contents + assert "x-scheme-handler/http=firefox.desktop" not in contents + assert "application/pdf=okular.desktop" in contents + assert "x-scheme-handler/mailto=thunderbird.desktop" in contents + + # And the deadline handler is added. + assert "x-scheme-handler/deadline=deadline.desktop" in contents + + def test_linux_install_resolves_bare_command_via_shutil_which(fresh_deadline_config, tmp_path): """ Tests that on Linux, when sys.argv[0] is a bare command name (e.g. 'deadline'),