From 1a731bcfbf2273c364028b4f4888525badd4b901 Mon Sep 17 00:00:00 2001 From: Alex Sears Date: Tue, 27 Oct 2026 20:42:04 -0400 Subject: [PATCH] feat(research): package Linux Dev independently of production RPM policy --- apps/maple-research/README.md | 5 +- .../docs/research-dev-desktop.md | 61 ++++++++- .../frontend/src-tauri/build.rs | 11 +- .../src-tauri/desktop-dev-profile.json | 4 + .../frontend/src-tauri/src/onnxruntime.rs | 10 +- .../src-tauri/tauri.desktop-dev.conf.json | 3 +- docs/repository-workflows.md | 9 ++ flake.nix | 12 ++ scripts/ci/_common.sh | 23 ++-- scripts/ci/research-dev-desktop.sh | 60 ++++++++- scripts/ci/research-dev-profile.py | 119 +++++++++++++++++- scripts/ci/test_linux_rpm_packaging.py | 59 ++++++++- scripts/ci/test_research_dev_profile.py | 115 +++++++++++++++++ 13 files changed, 462 insertions(+), 29 deletions(-) diff --git a/apps/maple-research/README.md b/apps/maple-research/README.md index ef0587aee..c20332a04 100644 --- a/apps/maple-research/README.md +++ b/apps/maple-research/README.md @@ -77,8 +77,9 @@ just desktop-dev # Tauri desktop and native features pinned ONNX Runtime and applies a local Tauri configuration overlay when one is present. -For a separately installed macOS package targeting the standalone Auth Dev site, -use the [Research Dev desktop package](docs/research-dev-desktop.md). It has its +For a separately installed desktop package targeting the standalone Auth Dev site, +use the [Research Dev desktop package](docs/research-dev-desktop.md), built with +`nix run --no-update-lock-file .#research-dev-desktop` on Linux or macOS. It has its own bundle identity, callback scheme, storage, and disabled updater; the ordinary Local workflow and iOS Dev TestFlight profile remain separate. diff --git a/apps/maple-research/docs/research-dev-desktop.md b/apps/maple-research/docs/research-dev-desktop.md index 6c3a9f35d..845ccc5b4 100644 --- a/apps/maple-research/docs/research-dev-desktop.md +++ b/apps/maple-research/docs/research-dev-desktop.md @@ -1,8 +1,9 @@ # Research Dev desktop package -This manual macOS package rehearses Research's native OAuth handoff through the -standalone Auth Dev site. It is separate from the ordinary `just desktop-dev` -Local workflow and from the existing iOS Dev TestFlight build. +These manual macOS and Linux packages use hosted Dev and rehearse Research's +native OAuth handoff through the standalone Auth Dev site. They install beside +production Maple. They are separate from the ordinary `just desktop-dev` Local +workflow and from the existing iOS Dev TestFlight build. ## Fixed profile @@ -26,6 +27,60 @@ profile are unchanged. ## Build locally +Run this from the repository root on Linux or macOS (with pinned Xcode on macOS): + +```bash +nix run --no-update-lock-file .#research-dev-desktop +``` + +Nix supplies the pinned build tools; the script selects the fixed hosted Dev +profile. Stop running frontend development servers first: this script reinstalls +frontend dependencies and rebuilds `dist`. It does not change `.env.local` or +publish, upload, sign, or install the result. A Dev account is required; a +production account is not automatically available on hosted Dev. + +### Fedora / Linux RPM + +The Linux build produces an unsigned host-architecture RPM: + +```text +apps/maple-research/frontend/src-tauri/target/research-dev/maple-research-dev--1..rpm +apps/maple-research/frontend/src-tauri/target/research-dev/build-profile.json +``` + +Install the exact RPM printed at the end of the build, for example on x86_64: + +```bash +sudo dnf install ./apps/maple-research/frontend/src-tauri/target/research-dev/maple-research-dev--1.x86_64.rpm +``` + +Replace `` with the built version. DNF installs WebKitGTK automatically. +The Dev profile declares its own `libwebkit2gtk-4.1.so.0()(64bit)` RPM capability; +it does not inherit or require a change to the production RPM dependency policy. +This capability is provided by Fedora's `webkit2gtk4.1` and openSUSE's +`libwebkit2gtk-4_1-0` packages. Package metadata compatibility is not a substitute +for install-and-launch validation on each target distribution. +Launch **Maple Research Dev** from KDE's application launcher or run +`maple-research-dev`. Production keeps the `maple` RPM and command; Dev uses +`maple-research-dev` for the package, command, desktop entry, icon, and private +ONNX Runtime directory. Config/data and single-instance routing use the separate +`cloud.opensecret.maple.dev` app ID. The script verifies the final RPM's package +name, dependencies, ELF metadata, callback registration, and isolated file paths. + +The intermediate DEB is not a supported install artifact. Linux build files live +under `target/research-dev/cargo` rather than replacing ordinary desktop outputs. +Only this explicit local command builds the Dev RPM; existing PR/release package +jobs and production identities are unchanged. + +Older `ci-desktop-pr` RPMs use Dev services but retain the production `maple` +package identity. They do **not** install beside production. Restore the website +RPM to that slot separately; for equal versions use `sudo dnf reinstall ./`, +or `sudo dnf downgrade ./` if the website version is older. Sign in again if +needed; do not copy credentials between the two apps. No automatic data migration +or deletion is performed by the Dev build. + +### macOS + From the repository root on macOS, with the pinned Xcode installed: ```bash diff --git a/apps/maple-research/frontend/src-tauri/build.rs b/apps/maple-research/frontend/src-tauri/build.rs index 828b9bece..7dd44b653 100644 --- a/apps/maple-research/frontend/src-tauri/build.rs +++ b/apps/maple-research/frontend/src-tauri/build.rs @@ -39,7 +39,7 @@ fn validate_desktop_dev_profile() { std::env::var("VITE_MAPLE_DESKTOP_VARIANT").is_err(), "use the Research Dev desktop build script" ); - if target_os == "macos" { + if matches!(target_os.as_str(), "macos" | "linux") { assert_ne!( config["identifier"], "cloud.opensecret.maple.dev", "use the Research Dev desktop build script for this bundle" @@ -47,9 +47,9 @@ fn validate_desktop_dev_profile() { } return; } - assert_eq!( - target_os, "macos", - "Research Dev desktop packaging currently supports macOS" + assert!( + matches!(target_os.as_str(), "macos" | "linux"), + "Research Dev desktop packaging supports macOS and Linux" ); let profile: serde_json::Value = serde_json::from_str(include_str!("desktop-dev-profile.json")).unwrap(); @@ -72,6 +72,9 @@ fn validate_desktop_dev_profile() { serde_json::json!([]) ); assert_eq!(config["bundle"]["createUpdaterArtifacts"], false); + if target_os == "linux" { + assert_eq!(config["mainBinaryName"], profile["linux"]["packageName"]); + } } #[allow(dead_code)] diff --git a/apps/maple-research/frontend/src-tauri/desktop-dev-profile.json b/apps/maple-research/frontend/src-tauri/desktop-dev-profile.json index 4061a7313..e8db4171a 100644 --- a/apps/maple-research/frontend/src-tauri/desktop-dev-profile.json +++ b/apps/maple-research/frontend/src-tauri/desktop-dev-profile.json @@ -2,6 +2,10 @@ "identifier": "cloud.opensecret.maple.dev", "productName": "Maple Research Dev", "scheme": "cloud.opensecret.maple.dev", + "linux": { + "packageName": "maple-research-dev", + "runtimeDependencies": ["libwebkit2gtk-4.1.so.0()(64bit)"] + }, "environment": { "MAPLE_DESKTOP_VARIANT": "dev", "VITE_MAPLE_DESKTOP_VARIANT": "dev", diff --git a/apps/maple-research/frontend/src-tauri/src/onnxruntime.rs b/apps/maple-research/frontend/src-tauri/src/onnxruntime.rs index c7431e29d..bc31e850f 100644 --- a/apps/maple-research/frontend/src-tauri/src/onnxruntime.rs +++ b/apps/maple-research/frontend/src-tauri/src/onnxruntime.rs @@ -45,7 +45,15 @@ fn runtime_library_path() -> PathBuf { if let Some(directory) = executable.parent() { #[cfg(target_os = "linux")] { - let bundled = directory.join("../lib/maple/libonnxruntime.so"); + let library_directory = if option_env!("MAPLE_DESKTOP_VARIANT") == Some("dev") { + "maple-research-dev" + } else { + "maple" + }; + let bundled = directory + .join("../lib") + .join(library_directory) + .join("libonnxruntime.so"); if bundled.exists() { return bundled; } diff --git a/apps/maple-research/frontend/src-tauri/tauri.desktop-dev.conf.json b/apps/maple-research/frontend/src-tauri/tauri.desktop-dev.conf.json index 0b6bcf6e6..3bccc59ff 100644 --- a/apps/maple-research/frontend/src-tauri/tauri.desktop-dev.conf.json +++ b/apps/maple-research/frontend/src-tauri/tauri.desktop-dev.conf.json @@ -25,7 +25,8 @@ "main" ], "platforms": [ - "macOS" + "macOS", + "linux" ], "permissions": [ "core:default", diff --git a/docs/repository-workflows.md b/docs/repository-workflows.md index a26449235..00025025e 100644 --- a/docs/repository-workflows.md +++ b/docs/repository-workflows.md @@ -240,6 +240,15 @@ Research Linux packaging builds AppImage and DEB with Tauri, then the RPM from the normalized DEB payload. `scripts/ci/sccache-stats.sh` reports compiler-cache statistics with the build's own cache configuration. +For a side-by-side hosted Dev installation, use +`nix run --no-update-lock-file .#research-dev-desktop`, not an ordinary Research +PR package. The Linux Dev command stages its own DEB payload and normalizes it +into the separately named `maple-research-dev` RPM under `target/research-dev`. +It uses the fixed Dev native identity and callback scheme, disables updates, +and verifies the final package contains only Dev-owned executable, launcher, +icon, and runtime paths. See the +[Research Dev package guide](../apps/maple-research/docs/research-dev-desktop.md). + ## SDK consumption Follow the [consumer version policy](sdk-publishing.md#consumer-version-policy). diff --git a/flake.nix b/flake.nix index 4f53131e1..4dbf5a6ff 100644 --- a/flake.nix +++ b/flake.nix @@ -799,6 +799,18 @@ touch "$out" ''; }; + apps.research-dev-desktop = flake-utils.lib.mkApp { + drv = pkgs.writeShellApplication { + name = "maple-research-dev-desktop"; + text = '' + if ! command -v nix >/dev/null 2>&1; then + echo "error: nix must be available on PATH" >&2 + exit 127 + fi + exec nix develop --no-update-lock-file ".#${if pkgs.stdenv.isLinux then "desktop-linux" else "ci"}" -c ./scripts/ci/research-dev-desktop.sh "$@" + ''; + }; + }; } ); } diff --git a/scripts/ci/_common.sh b/scripts/ci/_common.sh index f9886112a..c91ca1620 100755 --- a/scripts/ci/_common.sh +++ b/scripts/ci/_common.sh @@ -1876,7 +1876,9 @@ extract_appimage_tool() { tmp="$(mktemp -d)" rm -rf "${out}" - if ! unsquashfs -d "${tmp}/squashfs-root" -o "${offset}" "${appimage}" >/dev/null; then + # Host SELinux labels and other privileged attributes must not be restored + # from an AppImage during an unprivileged build. + if ! unsquashfs -xattrs-include '^user\.' -d "${tmp}/squashfs-root" -o "${offset}" "${appimage}" >/dev/null; then rm -rf "${tmp}" return 1 fi @@ -3922,8 +3924,9 @@ rpm_arch() { build_rpm_package_from_payload() { local rpm="$1" local payload="$2" + local config="${3:-${TAURI_DIR}/tauri.conf.json}" local topdir spec built_rpm - local version release arch rpm_build_shell + local version release arch rpm_build_shell requires name summary command -v rpmbuild >/dev/null 2>&1 || { echo "rpmbuild is required to build RPM packages. Run through the flake CI shell." >&2 @@ -3931,8 +3934,13 @@ build_rpm_package_from_payload() { } rpm_build_shell="$(command -v bash)" - version="$(jq -r '.version' "${TAURI_DIR}/tauri.conf.json")" - release="$(jq -r '.bundle.linux.rpm.release // "1"' "${TAURI_DIR}/tauri.conf.json")" + version="$(jq -r '.version' "${config}")" + release="$(jq -r '.bundle.linux.rpm.release // "1"' "${config}")" + name="$(jq -r '.mainBinaryName // "maple"' "${config}")" + summary="$(jq -r 'if .mainBinaryName then .productName else "Maple AI" end' "${config}")" + # The normalized RPM bypasses Tauri's bundler and automatic RPM requirements. + # Preserve the explicit runtime dependencies from the shared bundle config. + requires="$(jq -r '.bundle.linux.rpm.depends // [] | map("Requires: " + .) | join("\n")' "${config}")" arch="$(rpm_arch)" topdir="$(mktemp -d)" spec="${topdir}/SPECS/maple.spec" @@ -3941,17 +3949,18 @@ build_rpm_package_from_payload() { mkdir -p "${topdir}/BUILD" "${topdir}/BUILDROOT" "${topdir}/RPMS" "${topdir}/SOURCES" "${topdir}/SPECS" "${topdir}/SRPMS" "${topdir}/tmp" cat > "${spec}" <&2 - exit 1 -fi +case "$(host_os)" in + linux | darwin) ;; + *) + echo "Research Dev desktop packaging supports macOS and Linux." >&2 + exit 1 + ;; +esac print_source_provenance verify_rust_lockfile install_frontend_deps @@ -18,6 +21,53 @@ unset CARGO_TARGET_DIR MAPLE_IOS_VARIANT TAURI_CONFIG APPLE_SIGNING_IDENTITY TAU configure_reproducible_build_metadata remove_generated_ios_cargo_config build_frontend_dist +if [ "$(host_os)" = "linux" ]; then + # Keep intermediate packages and executables out of the production build tree. + evidence_dir="${TAURI_DIR}/target/research-dev" + export CARGO_TARGET_DIR="${evidence_dir}/cargo" + mkdir -p "${evidence_dir}" + prepare_linux_onnxruntime + config="${evidence_dir}/tauri.linux.conf.json" + python3 "${SCRIPT_DIR}/research-dev-profile.py" linux-config --ort-library "${ORT_DYLIB_PATH}" > "${config}" + cd "${FRONTEND_DIR}" + # The DEB is only an intermediate payload. Distribute the normalized RPM below. + remove_build_tree "${CARGO_TARGET_DIR}/release/bundle/deb" + bun tauri build --bundles deb --no-sign --config "${config}" + debs=() + while IFS= read -r -d '' deb; do + debs+=("${deb}") + done < <(find "${CARGO_TARGET_DIR}/release/bundle/deb" -type f -name '*.deb' -print0) + if [ "${#debs[@]}" -ne 1 ]; then + echo "Research Dev packaging requires exactly one Debian payload source." >&2 + exit 1 + fi + name="$(jq -r '.mainBinaryName' "${config}")" + rpm="${evidence_dir}/${name}-$(jq -r '.version' "${config}")-1.$(rpm_arch).rpm" + payload="$(mktemp -d)" + trap 'rm -rf "${payload}"' EXIT + extract_deb_payload "${debs[0]}" "${payload}/staged" + sanitize_linux_package_executable "${payload}/staged/usr/bin/${name}" + python3 "${SCRIPT_DIR}/tauri_bundle_type.py" --expect deb --set rpm "${payload}/staged/usr/bin/${name}" + python3 "${SCRIPT_DIR}/research-dev-profile.py" stage-linux --bundle "${payload}/staged" > /dev/null + build_rpm_package_from_payload "${rpm}" "${payload}/staged" "${config}" + if [ "$(rpm -qp --queryformat '%{NAME}' "${rpm}")" != "${name}" ]; then + echo "Research Dev RPM has the wrong package name." >&2 + exit 1 + fi + mkdir "${payload}/installed" + # cpio may stop at the archive trailer before rpm2cpio finishes writing its + # padding. Avoid a timing-dependent SIGPIPE under pipefail. + rpm2cpio "${rpm}" > "${payload}/installed.cpio" + (cd "${payload}/installed" && cpio -id --quiet --no-absolute-filenames < "${payload}/installed.cpio") + # Do not use production's RPM verifier: its dependency policy is independent + # of the Dev profile. Verify this package's executable and requirements here. + verify_linux_package_executable_metadata "${payload}/installed/usr/bin/${name}" + python3 "${SCRIPT_DIR}/tauri_bundle_type.py" --expect rpm "${payload}/installed/usr/bin/${name}" + python3 "${SCRIPT_DIR}/research-dev-profile.py" verify-linux --bundle "${payload}/installed" --rpm "${rpm}" > "${evidence_dir}/build-profile.json" + verify_frontend_dist_unchanged + printf 'Verified Research Dev RPM: %s\nProfile evidence: %s\n' "${rpm}" "${evidence_dir}/build-profile.json" + exit 0 +fi use_xcode_toolchain prepare_macos_onnxruntime export MACOSX_DEPLOYMENT_TARGET="13.4" diff --git a/scripts/ci/research-dev-profile.py b/scripts/ci/research-dev-profile.py index ba889e8d9..2632a2df9 100644 --- a/scripts/ci/research-dev-profile.py +++ b/scripts/ci/research-dev-profile.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 -"""Fixed local Research Dev packaging inputs and macOS artifact checks.""" +"""Fixed local Research Dev packaging inputs and artifact checks.""" import argparse +import configparser +import copy import hashlib import json import os @@ -27,6 +29,98 @@ def validate_config(profile, overlay): raise ValueError("Dev capabilities must not inherit legacy production storage access") +def linux_config(profile, base, overlay, ort_library): + def merge(left, right): + result = copy.deepcopy(left) + for key, value in right.items(): + result[key] = merge(result.get(key, {}), value) if isinstance(value, dict) else copy.deepcopy(value) + return result + + config = merge(base, overlay) + name = profile["linux"]["packageName"] + config["mainBinaryName"] = name + config["bundle"]["targets"] = ["deb"] + config["bundle"]["linux"]["deb"]["files"] = {f"/usr/lib/{name}/libonnxruntime.so": ort_library} + # The Dev package owns its requirements, independently of production's RPM + # policy. Use the shared-library capability rather than a distro package name. + config["bundle"]["linux"].setdefault("rpm", {})["depends"] = copy.deepcopy(profile["linux"]["runtimeDependencies"]) + validate_config(profile, config) + return config + + +def verify_linux_rpm_requirements(package, profile): + name = subprocess.check_output(["rpm", "-qp", "--queryformat", "%{NAME}", str(package)], text=True).strip() + if name != profile["linux"]["packageName"]: + raise ValueError("Dev RPM has the wrong package name") + requires = set(subprocess.check_output(["rpm", "-qp", "--requires", str(package)], text=True).splitlines()) + expected = set(profile["linux"]["runtimeDependencies"]) + if not expected.issubset(requires): + raise ValueError("Dev RPM is missing a required runtime-library capability") + return {"runtime_requirements": sorted(expected)} + + +def normalize_linux_payload(payload, profile): + name = profile["linux"]["packageName"] + # Tauri names the launcher after productName, not mainBinaryName, and its + # recursive config merge retains the base application's extra file mappings. + applications = payload / "usr/share/applications" + original = applications / f'{profile["productName"]}.desktop' + destination = applications / f"{name}.desktop" + if original.exists(): + if destination.exists(): + raise ValueError("Dev staging has conflicting launchers") + original.rename(destination) + desktop = configparser.ConfigParser(interpolation=None) + desktop.optionxform = str + desktop.read(destination) + entry = desktop["Desktop Entry"] + if shlex.split(entry["Exec"]) not in ([name], [name, "%U"], [name, "%u"]): + raise ValueError("Dev staging launcher has the wrong executable") + entry["Exec"] = f"{name} %u" + with destination.open("w") as stream: + desktop.write(stream, space_around_delimiters=False) + inherited = payload / "usr/lib/maple/libonnxruntime.so" + if inherited.exists(): + inherited.unlink() + return verify_linux_payload(payload, profile) + + +def verify_linux_payload(payload, profile): + name = profile["linux"]["packageName"] + executable = payload / "usr/bin" / name + if not executable.is_file() or not os.access(executable, os.X_OK): + raise ValueError("Dev executable is missing or not executable") + allowed = {Path("usr/bin") / name, Path("usr/lib") / name / "libonnxruntime.so", + Path("usr/share/applications") / f"{name}.desktop"} + icons = [] + for path in payload.rglob("*"): + if path.is_symlink(): + raise ValueError("Dev payload must not link to another installation") + if not path.is_file() and not path.is_symlink(): + continue + relative = path.relative_to(payload) + if relative in allowed: + continue + if relative.parts[:4] == ("usr", "share", "icons", "hicolor") and path.name == f"{name}.png": + icons.append(relative) + continue + raise ValueError(f"Unexpected or production-owned path in Dev payload: {relative}") + if not icons or not (payload / "usr/lib" / name / "libonnxruntime.so").is_file(): + raise ValueError("Dev payload is missing its isolated icon or ONNX Runtime") + desktop = configparser.ConfigParser(interpolation=None) + desktop.read(payload / "usr/share/applications" / f"{name}.desktop") + entry = desktop["Desktop Entry"] + if entry["Name"] != profile["productName"] or entry["Icon"] != name: + raise ValueError("Dev launcher has the wrong name or icon") + if shlex.split(entry["Exec"]) not in ([name, "%U"], [name, "%u"], [f"/usr/bin/{name}", "%U"], [f"/usr/bin/{name}", "%u"]): + raise ValueError("Dev launcher does not invoke only its own executable") + schemes = [value for value in entry.get("MimeType", "").split(";") if value] + if schemes != [f'x-scheme-handler/{profile["scheme"]}']: + raise ValueError("Dev launcher must own only its Dev callback scheme") + return {"package_name": name, "bundle_identifier": profile["identifier"], "scheme": profile["scheme"], + "executable_sha256": hashlib.sha256(executable.read_bytes()).hexdigest()} + + def verify_bundle(bundle, profile): with (bundle / "Contents/Info.plist").open("rb") as stream: info = plistlib.load(stream) @@ -67,21 +161,36 @@ def verify_macos_runtime_paths(binary): def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("command", choices=["environment", "verify"]) + parser.add_argument("command", choices=["environment", "linux-config", "stage-linux", "verify", "verify-linux"]) parser.add_argument("--bundle", type=Path) + parser.add_argument("--rpm", type=Path) + parser.add_argument("--ort-library") args = parser.parse_args() profile = json.loads(PROFILE.read_text()) - validate_config(profile, json.loads((TAURI / "tauri.desktop-dev.conf.json").read_text())) + overlay = json.loads((TAURI / "tauri.desktop-dev.conf.json").read_text()) + validate_config(profile, overlay) if args.command == "environment": for key, value in profile["environment"].items(): print(f"export {key}={shlex.quote(value)}") + elif args.command == "linux-config": + if not args.ort_library: + parser.error("linux-config requires --ort-library") + print(json.dumps(linux_config(profile, json.loads((TAURI / "tauri.conf.json").read_text()), overlay, args.ort_library))) else: if args.bundle is None: parser.error("verify requires --bundle") if any(os.environ.get(key) != value for key, value in profile["environment"].items()): raise ValueError("Build environment drifted from the fixed Dev profile") - evidence = verify_bundle(args.bundle, profile) - verify_macos_runtime_paths(args.bundle / "Contents/MacOS/maple") + if args.command == "stage-linux": + evidence = normalize_linux_payload(args.bundle, profile) + elif args.command == "verify-linux": + if args.rpm is None: + parser.error("verify-linux requires --rpm") + evidence = verify_linux_payload(args.bundle, profile) + evidence.update(verify_linux_rpm_requirements(args.rpm, profile)) + else: + evidence = verify_bundle(args.bundle, profile) + verify_macos_runtime_paths(args.bundle / "Contents/MacOS/maple") print(json.dumps({"profile": profile, "artifact": evidence, "frontend_sha256": os.environ["MAPLE_FRONTEND_DIST_TREE_SHA256"]}, indent=2)) diff --git a/scripts/ci/test_linux_rpm_packaging.py b/scripts/ci/test_linux_rpm_packaging.py index 0466a3cbb..1ac45af51 100644 --- a/scripts/ci/test_linux_rpm_packaging.py +++ b/scripts/ci/test_linux_rpm_packaging.py @@ -1,8 +1,10 @@ #!/usr/bin/env python3 """Exercise RPM staging identity and packaging failures without native builds.""" +import json import os from pathlib import Path +import shutil import subprocess import tempfile import unittest @@ -76,10 +78,26 @@ def test_failed_extraction_is_not_hidden_by_cleanup(self): ''') self.assertEqual(result.returncode, 19) + def test_appimage_extraction_excludes_privileged_xattrs(self): + result = self.run_shell(r''' +appimage_squashfs_offset() { echo 123; } +unsquashfs() { + [ "$1" = -xattrs-include ] && [ "$2" = '^user\.' ] || return 19 + [ "$3" = -d ] || return 19 + mkdir -p "$4" + touch "$4/AppRun" + chmod +x "$4/AppRun" +} +extract_appimage_tool fixture.AppImage "$TAURI_DIR/extracted" +''') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((self.tauri / "extracted/AppRun").exists()) + def fake_rpmbuild(self, exit_code): tool = self.bin / "rpmbuild" - tool.write_text(f'''#!/usr/bin/env bash + tool.write_text(f'''#!{shutil.which("bash")} set -eu +cp "$2" "$FIXTURE_TAURI/maple.spec" while [ "$#" -gt 0 ]; do if [ "$1" = --define ]; then case "$2" in "_topdir "*) topdir="${{2#_topdir }}" ;; esac @@ -110,6 +128,45 @@ def test_failed_rpmbuild_cannot_publish_partial_output(self): self.assertNotEqual(result.returncode, 0, result.stderr) self.assertFalse(self.rpm.exists()) + def run_dev_factory(self): + config = {"version": "1.2.3", "productName": "Maple Research Dev", "mainBinaryName": "maple-research-dev", + "bundle": {"linux": {"rpm": {"depends": ["libwebkit2gtk-4.1.so.0()(64bit)"]}}}} + (self.tauri / "dev.conf.json").write_text(json.dumps(config)) + return self.run_shell(''' +mkdir -p "$TAURI_DIR/payload/usr/bin" "$TAURI_DIR/target/release/bundle/rpm" +printf fixture > "$TAURI_DIR/payload/usr/bin/maple-research-dev" +build_rpm_package_from_payload "$TAURI_DIR/target/release/bundle/rpm/Maple-1.2.3-1.x86_64.rpm" "$TAURI_DIR/payload" "$TAURI_DIR/dev.conf.json" +''') + + def test_dev_rpm_uses_its_own_identity_and_dependencies(self): + self.fake_rpmbuild(0) + result = self.run_dev_factory() + self.assertEqual(result.returncode, 0, result.stderr) + spec = (self.tauri / "maple.spec").read_text() + self.assertIn("Name: maple-research-dev\n", spec) + self.assertIn("Summary: Maple Research Dev\n", spec) + self.assertIn("Requires: libwebkit2gtk-4.1.so.0()(64bit)\n", spec) + self.assertNotIn("Name: maple\n", spec) + self.assertNotIn("Requires: webkit2gtk4.1\n", spec) + + def test_default_rpm_does_not_gain_dev_requirements(self): + self.fake_rpmbuild(0) + result = self.run_factory() + self.assertEqual(result.returncode, 0, result.stderr) + spec = (self.tauri / "maple.spec").read_text() + self.assertIn("Name: maple\n", spec) + self.assertNotIn("Requires:", spec) + + @unittest.skipUnless(shutil.which("rpmbuild") and shutil.which("rpm"), "RPM tools are unavailable") + def test_built_dev_rpm_declares_the_runtime_capability(self): + result = self.run_dev_factory() + self.assertEqual(result.returncode, 0, result.stderr) + name = subprocess.check_output(["rpm", "-qp", "--queryformat", "%{NAME}", str(self.rpm)], text=True) + requires = subprocess.check_output(["rpm", "-qp", "--requires", str(self.rpm)], text=True).splitlines() + self.assertEqual(name, "maple-research-dev") + self.assertIn("libwebkit2gtk-4.1.so.0()(64bit)", requires) + self.assertNotIn("webkit2gtk4.1", requires) + def test_failed_final_move_is_not_hidden_by_cleanup(self): self.fake_rpmbuild(0) result = self.run_factory('mv() { return 19; }\n') diff --git a/scripts/ci/test_research_dev_profile.py b/scripts/ci/test_research_dev_profile.py index 44fa564dd..21cfa621e 100644 --- a/scripts/ci/test_research_dev_profile.py +++ b/scripts/ci/test_research_dev_profile.py @@ -48,6 +48,121 @@ def test_checks_actual_package_identity_and_scheme(self): with self.assertRaises(ValueError): profile_module.verify_bundle(bundle, self.profile) + def test_linux_configuration_is_isolated_without_changing_production(self): + base = json.loads((profile_module.TAURI / "tauri.conf.json").read_text()) + original = copy.deepcopy(base) + config = profile_module.linux_config(self.profile, base, self.overlay, "fixture/libonnxruntime.so") + self.assertEqual(base, original) + self.assertEqual(config["mainBinaryName"], "maple-research-dev") + self.assertEqual(config["identifier"], self.profile["identifier"]) + self.assertEqual(config["bundle"]["targets"], ["deb"]) + self.assertEqual(config["bundle"]["linux"]["deb"]["files"], + {"/usr/lib/maple-research-dev/libonnxruntime.so": "fixture/libonnxruntime.so"}) + self.assertEqual(config["bundle"]["linux"]["rpm"]["depends"], ["libwebkit2gtk-4.1.so.0()(64bit)"]) + self.assertIn("linux", config["app"]["security"]["capabilities"][0]["platforms"]) + profile_module.validate_config(self.profile, config) + + def test_dev_requirements_do_not_depend_on_production_rpm_policy(self): + for production_requires in (None, ["webkit2gtk4.1"], ["future-production-only-requirement"]): + with self.subTest(production_requires=production_requires): + base = json.loads((profile_module.TAURI / "tauri.conf.json").read_text()) + if production_requires is None: + base["bundle"]["linux"].pop("rpm", None) + else: + base["bundle"]["linux"]["rpm"] = {"depends": production_requires} + original = copy.deepcopy(base) + config = profile_module.linux_config(self.profile, base, self.overlay, "fixture/libonnxruntime.so") + self.assertEqual(base, original) + self.assertEqual(config["bundle"]["linux"]["rpm"]["depends"], self.profile["linux"]["runtimeDependencies"]) + + def test_dev_rpm_verifies_its_own_runtime_capability(self): + capability = "libwebkit2gtk-4.1.so.0()(64bit)" + for package_name, requires, valid in ( + ("maple-research-dev", capability + "\n", True), + ("maple-research-dev", "webkit2gtk4.1\n", False), + ("maple-research-dev", "", False), + ("maple", capability + "\n", False), + ): + with self.subTest(package_name=package_name, requires=requires): + with patch.object(profile_module.subprocess, "check_output", side_effect=[package_name, requires]): + if valid: + self.assertEqual(profile_module.verify_linux_rpm_requirements(Path("dev.rpm"), self.profile), + {"runtime_requirements": [capability]}) + else: + with self.assertRaises(ValueError): + profile_module.verify_linux_rpm_requirements(Path("dev.rpm"), self.profile) + + def linux_payload(self, root): + files = { + "usr/bin/maple-research-dev": b"fixture executable", + "usr/lib/maple-research-dev/libonnxruntime.so": b"fixture library", + "usr/share/icons/hicolor/128x128/apps/maple-research-dev.png": b"fixture icon", + "usr/share/applications/maple-research-dev.desktop": b"""[Desktop Entry] +Name=Maple Research Dev +Exec=maple-research-dev %U +Icon=maple-research-dev +MimeType=x-scheme-handler/cloud.opensecret.maple.dev; +""", + } + for name, content in files.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + (root / "usr/bin/maple-research-dev").chmod(0o755) + + def test_linux_payload_checks_installed_names_and_rejects_collisions(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.linux_payload(root) + self.assertEqual(profile_module.verify_linux_payload(root, self.profile)["package_name"], "maple-research-dev") + for collision in ("usr/bin/maple", "usr/lib/maple/libonnxruntime.so", "usr/share/applications/maple.desktop"): + with self.subTest(collision=collision): + path = root / collision + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"production collision") + with self.assertRaisesRegex(ValueError, "production-owned"): + profile_module.verify_linux_payload(root, self.profile) + path.unlink() + + def test_linux_staging_normalizes_tauri_launcher_and_inherited_library(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.linux_payload(root) + desktop = root / "usr/share/applications/maple-research-dev.desktop" + desktop.write_text(desktop.read_text().replace("Exec=maple-research-dev %U", "Exec=maple-research-dev")) + desktop.rename(desktop.with_name("Maple Research Dev.desktop")) + inherited = root / "usr/lib/maple/libonnxruntime.so" + inherited.parent.mkdir(parents=True) + inherited.write_bytes(b"inherited production mapping") + evidence = profile_module.normalize_linux_payload(root, self.profile) + self.assertEqual(evidence["package_name"], "maple-research-dev") + self.assertFalse(inherited.exists()) + self.assertIn("Exec=maple-research-dev %u", desktop.read_text()) + + def test_linux_payload_rejects_links_into_another_installation(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.linux_payload(root) + icon = root / "usr/share/icons/hicolor/128x128/apps/maple-research-dev.png" + icon.unlink() + icon.symlink_to("/usr/share/icons/maple.png") + with self.assertRaisesRegex(ValueError, "another installation"): + profile_module.verify_linux_payload(root, self.profile) + + def test_linux_payload_rejects_production_launcher_and_scheme(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.linux_payload(root) + path = root / "usr/share/applications/maple-research-dev.desktop" + original = path.read_text() + for old, new in (("Exec=maple-research-dev", "Exec=maple"), + ("cloud.opensecret.maple.dev", "cloud.opensecret.maple"), + ("Icon=maple-research-dev", "Icon=maple")): + with self.subTest(replacement=new): + path.write_text(original.replace(old, new)) + with self.assertRaises(ValueError): + profile_module.verify_linux_payload(root, self.profile) + def test_runtime_audit_rejects_host_dependencies_and_search_paths(self): system_loads = "maple:\n\t/usr/lib/libiconv.2.dylib (compatibility version 7.0.0, current version 7.0.0)\n" bundle_rpath = "Load command 0\n cmd LC_RPATH\n path @executable_path/../Frameworks (offset 12)\n"