diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5aa0ec1..8953cad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,7 @@ Pin an official upstream release, refresh the complete ARM64 transaction lock, and verify the source contract with one command: ```sh -make update-omarchy OMARCHY_RELEASE=4.0.1 +make update-omarchy OMARCHY_RELEASE=4.0.2 ``` The command keeps a complete shallow source checkout under `.build/upstream/`, diff --git a/guest/packages.lock.json b/guest/packages.lock.json index 1d3f5f2..e402f14 100644 --- a/guest/packages.lock.json +++ b/guest/packages.lock.json @@ -1,7 +1,7 @@ { "architecture": "aarch64", "packages": { - "abseil-cpp": "20260817.0-1", + "abseil-cpp": "20260817.0-2", "acl": "2.4.0-1", "adwaita-cursors": "50.0-1", "adwaita-fonts": "50.0-1", @@ -36,7 +36,7 @@ "ca-certificates-utils": "20240618-1", "cairo": "1.18.4-1", "cfitsio": "1:4.7.0-1", - "chromium": "151.0.7922.137-1", + "chromium": "152.0.7977.64-1", "coreutils": "9.11-2", "cpptrace": "1.0.4-2", "cryptsetup": "2.8.7-1", @@ -54,18 +54,18 @@ "diffutils": "3.12-2", "dkms": "3.4.3-2", "double-conversion": "3.4.0-1", - "dua-cli": "2.43.1-1", + "dua-cli": "2.44.0-1", "duktape": "2.7.0-7", "e2fsprogs": "1.47.4-1", "elfutils": "0.196-1", "exempi": "2.6.6-3", - "exiv2": "0.28.8-2", + "exiv2": "0.28.9-1", "expat": "2.8.3-1", "eza": "0.23.5-2", "fakeroot": "1:1.37.2-3", "fastfetch": "2.67.1-1", "fcft": "3.3.3-1", - "fd": "10.4.2-2", + "fd": "10.5.0-2", "ffmpeg": "2:9.0.1-4", "fftw": "3.3.11-1", "file": "5.48-1", @@ -124,7 +124,7 @@ "gst-plugins-bad-libs": "1.28.6-3", "gst-plugins-base-libs": "1.28.6-3", "gstreamer": "1.28.6-3", - "gtest": "1.17.0-2", + "gtest": "1.18.0-1", "gtk-update-icon-cache": "1:4.22.4-1", "gtk3": "1:3.24.52-1", "gtk4": "1:4.22.4-1", @@ -136,6 +136,7 @@ "gvfs": "1.60.2-4", "gzip": "1.14-2", "harfbuzz": "14.4.0-1", + "hdf5": "2.2.0-1", "hicolor-icon-theme": "0.18-1", "hidapi": "0.15.0-1", "highway": "1.4.0-1", @@ -181,6 +182,7 @@ "leancrypto": "1.8.0-1", "less": "1:704-1", "libadwaita": "1:1.9.3-1", + "libaec": "1.1.7-1", "libarchive": "3.8.9-1", "libasan": "16.1.1+r12+g301eb08fa2c5-1", "libass": "0.17.5-1", @@ -270,6 +272,7 @@ "liblsan": "16.1.1+r12+g301eb08fa2c5-1", "libluv": "1.52.1-1", "libmakepkg-dropins": "20-2", + "libmatio": "1.5.30-1", "libmd": "1.2.0-1", "libmm-glib": "1.24.2-1", "libmng": "2.0.3-4", @@ -277,7 +280,7 @@ "libmodplug": "0.8.9.0-7", "libmpc": "1.4.1-1", "libmysofa": "1.3.5-1", - "libnautilus-extension": "50.2.2-1", + "libnautilus-extension": "50.3-1", "libndp": "1.9-1", "libnetfilter_conntrack": "1.1.1-1", "libnewt": "0.52.25-2", @@ -313,6 +316,7 @@ "libpulse": "17.0+r98+gb096704c0-1", "libquadmath": "16.1.1+r12+g301eb08fa2c5-1", "libraqm": "0.11.0-1", + "libraw": "0.22.2-1", "libraw1394": "2.1.2-4", "librsvg": "2:2.62.3-1", "libsamplerate": "0.2.2-3", @@ -352,13 +356,13 @@ "libva": "2.24.1-1", "libvdpau": "1.5-4", "libverto": "0.3.2-6", - "libvips": "8.18.5-1", + "libvips": "8.18.6-1", "libvorbis": "1.3.7-4", "libvpx": "1.17.0-1", "libvterm": "0.3.3-2", "libwacom": "2.19.1-1", "libwebp": "1.6.0-2", - "libwireplumber": "0.5.15-1", + "libwireplumber": "0.5.16-1", "libx11": "1.8.13-1", "libxau": "1.0.12-1", "libxcb": "1.17.0-1", @@ -394,14 +398,14 @@ "libzip": "1.11.4-1", "licenses": "20240728-1", "lilv": "0.28.0-1", - "linux-aarch64": "7.2.2-1", - "linux-aarch64-headers": "7.2.2-1", + "linux-aarch64": "7.2.2-2", + "linux-aarch64-headers": "7.2.2-2", "linux-api-headers": "7.2-1", "llhttp": "9.3.1-1", "llvm-libs": "22.1.8-2", "lm_sensors": "1:3.6.2-1", "lmdb": "0.9.35-1", - "localsearch": "3.11.1-2", + "localsearch": "3.11.2-1", "lua": "5.5.1-1", "lua51-lpeg": "1.1.0-5", "lua54": "5.4.9-1", @@ -425,13 +429,13 @@ "mtdev": "1.1.7-1", "mujs": "1.3.9-1", "muparser": "2.3.5-2", - "nautilus": "50.2.2-1", + "nautilus": "50.3-1", "ncurses": "6.6-2", "neovim": "0.12.5-1", "nettle": "4.0-1", "networkmanager": "1.58.1-1", "nftables": "1:1.1.6-3", - "noto-fonts": "1:2026.08.01-1", + "noto-fonts": "1:2026.09.01-1", "noto-fonts-emoji": "1:2.051-1", "npth": "1.8-1", "nspr": "4.40-1", @@ -550,7 +554,7 @@ "uchardet": "0.0.8-4", "udiskie": "2.7.0-2", "udisks2": "2.11.2-1", - "unibilium": "2.1.2-1", + "unibilium": "2.1.4-1", "unzip": "6.0-23", "upower": "1.91.3-1", "util-linux": "2.42.2-1", @@ -568,7 +572,7 @@ "wayland": "1.26.0-1", "wayland-protocols": "1.49-1", "webrtc-audio-processing-1": "1.3-5", - "wireplumber": "0.5.15-1", + "wireplumber": "0.5.16-1", "wl-clipboard": "1:2.3.0-1", "woff2-font-awesome": "7.3.1-1", "wpa_supplicant": "2:2.12-1", diff --git a/guest/scripts/apply-omarchy-backports.py b/guest/scripts/apply-omarchy-backports.py index 76c188e..7f17ea0 100644 --- a/guest/scripts/apply-omarchy-backports.py +++ b/guest/scripts/apply-omarchy-backports.py @@ -6,7 +6,10 @@ import argparse import hashlib import json +import os +import shutil import subprocess +import tempfile from pathlib import Path, PurePosixPath @@ -33,6 +36,27 @@ def contained_file(base: Path, relative: str, label: str) -> Path: return candidate +def staged_target_file(root: Path, omarchy_root: Path, relative: str) -> Path: + logical = PurePosixPath(relative) + if logical.is_absolute() or ".." in logical.parts or logical.as_posix() != relative: + fail(f"unsafe target path: {relative}") + + candidate = omarchy_root.joinpath(*logical.parts) + if candidate.is_symlink(): + link = PurePosixPath(os.readlink(candidate)) + if not link.is_absolute() or ".." in link.parts: + fail(f"target has an unsafe staged-root symlink: {relative}") + candidate = root.joinpath(*link.parts[1:]) + + if candidate.is_symlink() or not candidate.is_file(): + fail(f"target is not a regular file: {relative}") + try: + candidate.resolve().relative_to(root.resolve()) + except ValueError: + fail(f"target escapes the staged root: {relative}") + return candidate + + def require_digest(value: object, label: str) -> str: rendered = str(value or "") if len(rendered) != 64 or any(character not in "0123456789abcdef" for character in rendered): @@ -40,11 +64,10 @@ def require_digest(value: object, label: str) -> str: return rendered -def verify_target(omarchy_root: Path, target: dict, digest_key: str, backport_id: str) -> None: +def verify_target(path: Path, target: dict, digest_key: str, backport_id: str) -> None: if not isinstance(target, dict): fail(f"backport {backport_id} has a non-object target") relative = str(target.get("path", "")) - path = contained_file(omarchy_root, relative, "target") expected = require_digest(target.get(digest_key), f"{backport_id} {relative} {digest_key}") actual = sha256(path) if actual != expected: @@ -54,7 +77,7 @@ def verify_target(omarchy_root: Path, target: dict, digest_key: str, backport_id ) -def apply_backport(spec_dir: Path, omarchy_root: Path, backport: dict) -> None: +def apply_backport(spec_dir: Path, root: Path, omarchy_root: Path, backport: dict) -> None: if not isinstance(backport, dict): fail("backport metadata must contain JSON objects") backport_id = str(backport.get("id", "")) @@ -76,23 +99,60 @@ def apply_backport(spec_dir: Path, omarchy_root: Path, backport: dict) -> None: targets = backport.get("targets") if not isinstance(targets, list) or not targets: fail(f"backport {backport_id} must declare at least one target") + target_paths: dict[str, Path] = {} for target in targets: - verify_target(omarchy_root, target, "beforeSha256", backport_id) - - result = subprocess.run( - ["git", "apply", "--no-index", "--whitespace=error", str(patch)], - cwd=omarchy_root, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=False, - ) - if result.returncode != 0: - detail = result.stderr.strip() or result.stdout.strip() or "git apply failed" - fail(f"backport {backport_id} did not apply: {detail}") - - for target in targets: - verify_target(omarchy_root, target, "afterSha256", backport_id) + if not isinstance(target, dict): + fail(f"backport {backport_id} has a non-object target") + relative = str(target.get("path", "")) + if relative in target_paths: + fail(f"backport {backport_id} repeats target: {relative}") + target_path = staged_target_file(root, omarchy_root, relative) + verify_target(target_path, target, "beforeSha256", backport_id) + target_paths[relative] = target_path + + # Patch isolated regular-file copies so package-path symlinks such as + # /usr/share/omarchy/bin/* remain intact and no undeclared staged file can + # be changed by a reviewed patch. + with tempfile.TemporaryDirectory() as temporary: + patch_root = Path(temporary) + for relative, target_path in target_paths.items(): + destination = patch_root.joinpath(*PurePosixPath(relative).parts) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(target_path, destination) + + result = subprocess.run( + ["git", "apply", "--no-index", "--whitespace=error", str(patch)], + cwd=patch_root, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() or "git apply failed" + fail(f"backport {backport_id} did not apply: {detail}") + + actual_paths = { + path.relative_to(patch_root).as_posix() + for path in patch_root.rglob("*") + if path.is_file() or path.is_symlink() + } + if actual_paths != set(target_paths): + fail(f"backport {backport_id} changed files outside its declared targets") + + for target in targets: + relative = str(target["path"]) + verify_target( + patch_root.joinpath(*PurePosixPath(relative).parts), + target, + "afterSha256", + backport_id, + ) + for relative, target_path in target_paths.items(): + shutil.copy2( + patch_root.joinpath(*PurePosixPath(relative).parts), + target_path, + ) print(f"Applied Omarchy backport {backport_id}") @@ -107,7 +167,8 @@ def main() -> None: spec = args.spec.resolve() if not spec.is_file(): fail(f"spec not found: {spec}") - omarchy_root = args.root.resolve() / "usr/share/omarchy" + root = args.root.resolve() + omarchy_root = root / "usr/share/omarchy" if not omarchy_root.is_dir(): fail(f"materialized Omarchy tree not found: {omarchy_root}") @@ -120,7 +181,7 @@ def main() -> None: fail("authenticity.backports must be an array") for backport in backports: - apply_backport(spec.parent, omarchy_root, backport) + apply_backport(spec.parent, root, omarchy_root, backport) if __name__ == "__main__": diff --git a/guest/scripts/write-provenance.py b/guest/scripts/write-provenance.py index caddfe8..3a791b8 100755 --- a/guest/scripts/write-provenance.py +++ b/guest/scripts/write-provenance.py @@ -7,7 +7,7 @@ import hashlib import json import os -from pathlib import Path +from pathlib import Path, PurePosixPath def digest_path(path: Path) -> str: @@ -40,6 +40,27 @@ def digest_file(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() +def installed_target_file(root: Path, omarchy: Path, relative: str) -> Path: + logical = PurePosixPath(relative) + if logical.is_absolute() or ".." in logical.parts or logical.as_posix() != relative: + raise SystemExit(f"unsafe backport target path: {relative}") + + installed = omarchy.joinpath(*logical.parts) + if installed.is_symlink(): + link = PurePosixPath(os.readlink(installed)) + if not link.is_absolute() or ".." in link.parts: + raise SystemExit(f"unsafe backport target symlink: {relative}") + installed = root.joinpath(*link.parts[1:]) + + if installed.is_symlink() or not installed.is_file(): + raise SystemExit(f"missing installed backport target: {relative}") + try: + installed.resolve().relative_to(root.resolve()) + except ValueError: + raise SystemExit(f"backport target escapes staged root: {relative}") + return installed + + def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--root", required=True, type=Path) @@ -48,7 +69,8 @@ def main() -> None: args = parser.parse_args() spec = json.loads(args.spec.read_text()) - omarchy = args.root / "usr/share/omarchy" + root = args.root.resolve() + omarchy = root / "usr/share/omarchy" authenticity = spec["authenticity"] verbatim_trees = authenticity["verbatimRuntimeTrees"] backported_trees = authenticity.get("backportedRuntimeTrees", []) @@ -70,7 +92,7 @@ def main() -> None: if patch_digest != backport["patchSha256"]: raise SystemExit(f"backport patch digest mismatch: {backport['id']}") for target in backport["targets"]: - installed = omarchy / target["path"] + installed = installed_target_file(root, omarchy, target["path"]) if digest_file(installed) != target["afterSha256"]: raise SystemExit(f"backport target digest mismatch: {backport['id']} {target['path']}") diff --git a/guest/spec.json b/guest/spec.json index 0e95365..370570a 100644 --- a/guest/spec.json +++ b/guest/spec.json @@ -6,7 +6,7 @@ "filesystemLabel": "omarchy-factory", "filesystemUuid": "89054943-1f4e-4f14-b934-d6db3fba4254", "sizeMiB": 6144, - "sourceDateEpoch": 1787652758 + "sourceDateEpoch": 1788130066 }, "guest": { "profile": "factory", @@ -23,10 +23,10 @@ }, "upstream": { "repository": "https://github.com/basecamp/omarchy", - "commit": "13f18b2cb7286fb54f87daf571a031aa6af3d8f0", - "tree": "c458272b5d2b895185633255de88a3ce563c5028", - "treeSha256": "3613aca0bf1861d2beee24768d11d3e61e2c719af35c12145a23081f288a2abb", - "release": "4.0.1", + "commit": "346e69e1cec6c4e8924531874af6ba010a1bc99e", + "tree": "24ff1b25bf85aeffa234c95c3474b7703a062c08", + "treeSha256": "677f90b302dc1c622a3342ca2929084475caafb12db62790f13a9f4aea8f49ae", + "release": "4.0.2", "version": "4.0.0.alpha", "channel": "quattro", "license": "MIT" @@ -157,15 +157,15 @@ }, { "id": "notification-hover-close", - "description": "Restore the upstream hover-revealed notification dismiss control omitted from v4.0.1.", + "description": "Restore the upstream hover-revealed notification dismiss control omitted from v4.0.2.", "reference": "https://github.com/basecamp/omarchy/commit/9b72edcc94513cba016f145b1ac4ffdaea54b577", "patch": "patches/omarchy/notification-hover-close.patch", "patchSha256": "f9c57fba4e3ce99cbfa32c2e497a58c1afd8627302991454ca023c1f9e149d95", "targets": [ { "path": "shell/plugins/notifications/components/NotificationCard.qml", - "beforeSha256": "3ffb66ebc065886fc05b6215bd6fff9cd9e5769fac15f807748d550c91efc2f4", - "afterSha256": "03db278b7301b41e5b21925bf22f92f3f3ff718a74d5b17121da993cd2342ae3" + "beforeSha256": "3f023446cfe26b9f70570d7088baab31e56ca374c4a206a7c098b09d36cdff3d", + "afterSha256": "e1b5bae943d2e3b495741bc845b4fd981c274be538ea01799c46026f9db0fe49" } ] }, diff --git a/guest/tests/test_apply_omarchy_backports.py b/guest/tests/test_apply_omarchy_backports.py index 46a8e57..377f3ab 100644 --- a/guest/tests/test_apply_omarchy_backports.py +++ b/guest/tests/test_apply_omarchy_backports.py @@ -101,6 +101,40 @@ def test_rejects_a_target_outside_the_omarchy_tree(self) -> None: self.assertIn("unsafe target path", result.stderr) self.assertEqual(target.read_bytes(), self.before) + def test_patches_a_staged_root_command_without_replacing_its_symlink(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root, spec, target = self.fixture(temporary) + target.unlink() + target = root / "usr/share/omarchy/bin/example" + target.parent.mkdir() + command = root / "usr/bin/example" + command.parent.mkdir(parents=True) + command.write_bytes(self.before) + target.symlink_to("/usr/bin/example") + + payload = json.loads(spec.read_text(encoding="utf-8")) + backport = payload["authenticity"]["backports"][0] + backport["targets"][0]["path"] = "bin/example" + patch_payload = self.patch.replace(b"example.txt", b"bin/example") + (spec.parent / "example.patch").write_bytes(patch_payload) + backport["patchSha256"] = digest(patch_payload) + spec.write_text(json.dumps(payload), encoding="utf-8") + + result = self.run_script(root, spec) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(target.is_symlink()) + self.assertEqual(target.readlink(), Path("/usr/bin/example")) + self.assertEqual(command.read_bytes(), self.after) + + def test_rejects_a_staged_command_symlink_outside_the_root(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root, spec, target = self.fixture(temporary) + target.unlink() + target.symlink_to("/tmp/example") + result = self.run_script(root, spec) + self.assertNotEqual(result.returncode, 0) + self.assertIn("target is not a regular file", result.stderr) + if __name__ == "__main__": unittest.main() diff --git a/guest/tests/test_write_provenance.py b/guest/tests/test_write_provenance.py index 464476e..17ab34c 100644 --- a/guest/tests/test_write_provenance.py +++ b/guest/tests/test_write_provenance.py @@ -100,6 +100,30 @@ def test_rejects_an_installed_target_that_differs_from_the_postimage(self) -> No self.assertIn("backport target digest mismatch", result.stderr) self.assertFalse(output.exists()) + def test_verifies_a_staged_root_command_through_its_package_path_symlink(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root, spec, _, output = self.fixture(temporary) + package_path = root / "usr/share/omarchy/bin/example" + package_path.unlink() + command = root / "usr/bin/example" + command.parent.mkdir(parents=True) + command.write_bytes(b"patched command\n") + package_path.symlink_to("/usr/bin/example") + + payload = json.loads(spec.read_text(encoding="utf-8")) + payload["authenticity"]["backports"][0]["targets"].append( + { + "path": "bin/example", + "beforeSha256": digest(b"upstream command\n"), + "afterSha256": digest(command.read_bytes()), + } + ) + spec.write_text(json.dumps(payload), encoding="utf-8") + + result = self.run_writer(root, spec, output) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(package_path.is_symlink()) + if __name__ == "__main__": unittest.main()