From d570aa2789ecddeb14061e50943d8577a9cb01b3 Mon Sep 17 00:00:00 2001 From: Mufeed Ali Date: Wed, 1 Jul 2026 21:45:28 +0530 Subject: [PATCH 1/6] feat: drop flatpak/flatpak-builder CLI control plane Build and run via in-process sources (libgit2, cache), module builder, and bubblewrap (BwrapRunner) against on-disk SDK/runtime trees. No host git/gdbus required; README documents the new dependency model. --- Cargo.lock | 831 +++++++++++++++++++++++++++++++++++++++ Cargo.toml | 4 + README.md | 15 +- src/build_dirs.rs | 11 + src/builder/mod.rs | 576 +++++++++++++++++++++++++++ src/command.rs | 17 +- src/flatpak_manager.rs | 870 ++++++++++++++++++----------------------- src/git_source.rs | 124 ++++++ src/main.rs | 61 ++- src/manifest.rs | 30 +- src/sandbox/bwrap.rs | 322 +++++++++++++++ src/sandbox/install.rs | 100 +++++ src/sandbox/mod.rs | 153 ++++++++ src/sources/cache.rs | 59 +++ src/sources/mod.rs | 466 ++++++++++++++++++++++ src/utils.rs | 151 ++++++- 16 files changed, 3217 insertions(+), 573 deletions(-) create mode 100644 src/builder/mod.rs create mode 100644 src/git_source.rs create mode 100644 src/sandbox/bwrap.rs create mode 100644 src/sandbox/install.rs create mode 100644 src/sandbox/mod.rs create mode 100644 src/sources/cache.rs create mode 100644 src/sources/mod.rs diff --git a/Cargo.lock b/Cargo.lock index a7ce468..23fa581 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,6 +124,137 @@ dependencies = [ "serde_json", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -176,6 +307,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -301,6 +445,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.16.3" @@ -358,6 +511,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -459,6 +618,17 @@ dependencies = [ "objc2", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -483,6 +653,33 @@ dependencies = [ "encoding_rs", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -499,6 +696,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -544,17 +762,20 @@ dependencies = [ "ctrlc", "dialoguer", "flate2", + "git2", "mockito", "nix", "serde", "serde-saphyr", "serde_json", "sha2 0.11.0", + "shell-words", "tar", "tempfile", "ureq", "walkdir", "xz2", + "zbus", "zip", ] @@ -594,6 +815,25 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -668,6 +908,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + [[package]] name = "granit-parser" version = "0.0.3" @@ -718,6 +973,18 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hmac" version = "0.13.0" @@ -816,12 +1083,115 @@ dependencies = [ "tokio", ] +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -895,12 +1265,58 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libgit2-sys" +version = "0.18.5+1.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005d6ae6eac1912906073e069f7db60b1fa98e052a68227824afe3e3a1c59ca2" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c04141a07bb0c0bc461cb657808764de571702a59bc5c726c400ac9a7625e3ab" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "lock_api" version = "0.4.14" @@ -942,6 +1358,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1042,6 +1467,40 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1087,12 +1546,46 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1124,6 +1617,15 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1376,6 +1878,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1433,6 +1946,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -1467,6 +1990,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -1490,6 +2019,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tar" version = "0.4.46" @@ -1534,6 +2074,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.52.3" @@ -1562,6 +2112,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + [[package]] name = "tracing" version = "0.1.44" @@ -1569,9 +2149,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1593,6 +2185,17 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1646,18 +2249,53 @@ dependencies = [ "log", ] +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "utf8-zero" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -1883,6 +2521,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -1977,6 +2624,12 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "xattr" version = "1.6.1" @@ -1996,6 +2649,90 @@ dependencies = [ "lzma-sys", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -2016,12 +2753,66 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zip" version = "8.6.0" @@ -2100,3 +2891,43 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index cdb4d8d..140fa62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,10 @@ flate2 = "1" xz2 = "0.1" zip = "8" tempfile = "3" +shell-words = "1.1" +zbus = "5" +# libgit2-backed; avoids requiring a host `git` binary on PATH +git2 = "0.20" [dev-dependencies] mockito = "1" diff --git a/README.md b/README.md index b0e08f0..5506443 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,17 @@ Note that this is a **work in progress** and you might encounter issues along th ## External Dependencies -Flatplay relies on the following external commands to be available on your system: +Flatplay’s **control plane does not invoke** the `flatpak` or `flatpak-builder` CLIs. -- `git` -- `gdbus` -- `flatpak` -- `flatpak-builder` +| Need | How | +|------|-----| +| Sandbox | **bubblewrap** (`bwrap`) on PATH | +| SDK / runtime **content** | Installed on disk under `~/.local/share/flatpak` or `/var/lib/flatpak` (e.g. via GNOME Software). Flatplay only *reads* those trees. | +| Git sources | **libgit2** (linked), not host `git` | +| A11y bus | **zbus** (optional `gdbus` fallback) | +| Toolchains (meson, ninja, gcc, …) | Inside the Flatpak **SDK** image | + +Architecture: typed sources + download cache, in-process module builder, `BwrapRunner` sandbox. ## Installation & Usage diff --git a/src/build_dirs.rs b/src/build_dirs.rs index 7d4da55..125a413 100644 --- a/src/build_dirs.rs +++ b/src/build_dirs.rs @@ -28,6 +28,12 @@ impl BuildDirs { pub fn ostree_dir(&self) -> PathBuf { self.build_dir().join("ostree") } + pub fn cache_dir(&self) -> PathBuf { + self.build_dir().join("cache") + } + pub fn module_source_dir(&self, module_name: &str) -> PathBuf { + self.build_dir().join(module_name) + } pub fn metadata_file(&self) -> PathBuf { self.repo_dir().join("metadata") } @@ -60,6 +66,11 @@ mod tests { base.join(".flatplay/finalized-repo") ); assert_eq!(dirs.ostree_dir(), base.join(".flatplay/ostree")); + assert_eq!(dirs.cache_dir(), base.join(".flatplay/cache")); + assert_eq!( + dirs.module_source_dir("app"), + base.join(".flatplay/app") + ); assert_eq!(dirs.metadata_file(), base.join(".flatplay/repo/metadata")); assert_eq!(dirs.files_dir(), base.join(".flatplay/repo/files")); assert_eq!(dirs.var_dir(), base.join(".flatplay/repo/var")); diff --git a/src/builder/mod.rs b/src/builder/mod.rs new file mode 100644 index 0000000..56c7e62 --- /dev/null +++ b/src/builder/mod.rs @@ -0,0 +1,576 @@ +//! In-process dependency builder — **no `flatpak-builder` CLI**. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use crate::manifest::{Manifest, Module}; +use crate::sandbox::{BwrapRunner, RunSpec, SandboxRunner}; +use crate::sources::{self, DownloadCache, Source}; +use crate::utils::{path_to_str, status, status_warn, verbose}; + +/// Download/materialize all non-app modules' sources into the cache / module dirs. +pub fn update_dependencies( + manifest: &Manifest, + manifest_path: &Path, + build_dir: &Path, + stop_at: &str, +) -> Result<()> { + status("Updating dependencies (in-process)..."); + let manifest_dir = manifest_path + .parent() + .context("Manifest has no parent directory")?; + let cache = DownloadCache::new(build_dir); + + for module in modules_before_stop(manifest, stop_at)? { + let Module::Object { name, sources, .. } = module else { + status_warn(format!("Skipping module reference during update: {module:?}")); + continue; + }; + let typed = Source::from_values(sources)?; + // Directory-only modules (pointing at the project tree) need no download. + if typed.iter().all(|s| matches!(s, Source::Dir { .. })) { + verbose(format!("Module {name}: dir sources only, nothing to download")); + continue; + } + let source_dir = build_dir.join(name); + sources::materialize_sources(&typed, &source_dir, manifest_dir, &cache, name)?; + } + Ok(()) +} + +/// Build all modules before `stop_at` into `repo_dir` via bwrap + SDK. +pub fn build_dependencies( + manifest: &Manifest, + manifest_path: &Path, + repo_dir: &Path, + build_dir: &Path, + stop_at: &str, + workspace: &Path, + runner: &BwrapRunner, +) -> Result<()> { + status("Building dependencies (in-process)..."); + let manifest_dir = manifest_path + .parent() + .context("Manifest has no parent directory")?; + let cache = DownloadCache::new(build_dir); + let num_cpus = std::thread::available_parallelism().map_or(1, std::num::NonZero::get); + + for module in modules_before_stop(manifest, stop_at)? { + let Module::Object { + name, + buildsystem, + builddir, + subdir, + config_opts, + build_commands, + build_options, + post_install, + sources, + .. + } = module + else { + continue; + }; + + status(format!("Building module {name}...")); + let typed = Source::from_values(sources)?; + let source_dir = if typed.iter().any(|s| matches!(s, Source::Dir { .. })) + && typed.iter().all(|s| matches!(s, Source::Dir { .. } | Source::File { .. })) + { + // Mix of dir + file: materialize into module dir (copies dir tree + files). + let source_dir = build_dir.join(name); + sources::materialize_sources(&typed, &source_dir, manifest_dir, &cache, name)?; + // For dir markers, prefer real dir path when only dirs. + if typed.iter().all(|s| matches!(s, Source::Dir { .. })) { + sources::resolve_dir_source(&typed, manifest_dir) + .unwrap_or(source_dir) + } else { + // materialize copies dir into source_dir for mixed; use source_dir + // Re-materialize: our Dir handler may only write a marker. Force copy for deps. + let source_dir = build_dir.join(name); + if source_dir.exists() { + std::fs::remove_dir_all(&source_dir)?; + } + std::fs::create_dir_all(&source_dir)?; + for source in &typed { + match source { + Source::Dir { path, dest, .. } => { + let src = if Path::new(path).is_absolute() { + PathBuf::from(path) + } else { + manifest_dir.join(path) + }; + let target = match dest { + Some(d) => source_dir.join(d), + None => source_dir.clone(), + }; + crate::utils::copy_dir_all(&src, &target)?; + } + other => { + sources::materialize_sources( + &[other.clone()], + &source_dir, + manifest_dir, + &cache, + name, + )?; + } + } + } + source_dir + } + } else if typed.iter().all(|s| matches!(s, Source::Dir { .. })) { + sources::resolve_dir_source(&typed, manifest_dir) + .context("dir source missing")? + } else { + let source_dir = build_dir.join(name); + if !source_dir.exists() { + sources::materialize_sources(&typed, &source_dir, manifest_dir, &cache, name)?; + } + source_dir + }; + + let source_dir = if let Some(subdir) = subdir { + source_dir.join(subdir) + } else { + source_dir + }; + + let merged_config: Vec = manifest + .merged_config_opts(config_opts.as_deref()) + .into_iter() + .map(str::to_string) + .collect(); + + let env_pairs: Vec<(String, String)> = manifest + .merged_env(build_options.as_ref()) + .into_iter() + .collect(); + + let path_overrides = manifest.path_overrides(build_options.as_ref()); + + match buildsystem.as_deref() { + Some("meson") => build_meson( + runner, + repo_dir, + &source_dir, + build_dir, + name, + &merged_config, + &env_pairs, + &path_overrides, + workspace, + )?, + Some("cmake" | "cmake-ninja") => build_cmake( + runner, + repo_dir, + &source_dir, + build_dir, + name, + &merged_config, + &env_pairs, + &path_overrides, + workspace, + )?, + Some("simple") => build_simple( + runner, + repo_dir, + &source_dir, + manifest, + name, + build_commands.as_ref(), + &env_pairs, + &path_overrides, + num_cpus, + workspace, + )?, + _ => build_autotools( + runner, + repo_dir, + &source_dir, + build_dir, + name, + builddir.unwrap_or(false), + &merged_config, + &env_pairs, + &path_overrides, + num_cpus, + workspace, + )?, + } + + if let Some(post_install) = post_install { + for command in post_install { + let processed = substitute_vars(command, &manifest.id, name, num_cpus); + let argv = shell_words::split(&processed) + .with_context(|| format!("Bad post-install: {processed}"))?; + run_argv( + runner, + repo_dir, + &argv, + &env_pairs, + &path_overrides, + Some(&source_dir), + workspace, + )?; + } + } + } + + Ok(()) +} + +fn modules_before_stop<'a>(manifest: &'a Manifest, stop_at: &str) -> Result> { + let mut out = Vec::new(); + for module in &manifest.modules { + let name = match module { + Module::Object { name, .. } => name.as_str(), + Module::Reference(path) => path.as_str(), + }; + if name == stop_at { + return Ok(out); + } + out.push(module); + } + anyhow::bail!("stop-at module '{stop_at}' not found in manifest") +} + +fn run_argv( + runner: &BwrapRunner, + repo_dir: &Path, + argv: &[String], + env_pairs: &[(String, String)], + path_overrides: &[String], + cwd: Option<&Path>, + workspace: &Path, +) -> Result<()> { + let mut filesystem_binds = vec![format!("--filesystem={}", path_to_str(workspace)?)]; + if let Some(cwd) = cwd { + filesystem_binds.push(format!("--filesystem={}", path_to_str(cwd)?)); + } + filesystem_binds.extend(path_overrides.iter().cloned()); + + runner.run(&RunSpec { + repo_dir: repo_dir.to_path_buf(), + argv: argv.to_vec(), + env: env_pairs.to_vec(), + filesystem_binds, + extra_args: vec![], + share_network: true, + cwd: cwd.map(Path::to_path_buf), + }) +} + +fn substitute_vars(command: &str, flatpak_id: &str, module_name: &str, num_cpus: usize) -> String { + command + .replace("${FLATPAK_ID}", flatpak_id) + .replace("${FLATPAK_ARCH}", &crate::sources::flatpak_arch()) + .replace("${FLATPAK_DEST}", "/app") + .replace("${FLATPAK_BUILDER_N_JOBS}", &num_cpus.to_string()) + .replace( + "${FLATPAK_BUILDER_BUILDDIR}", + &format!("/run/build/{module_name}"), + ) +} + +fn build_meson( + runner: &BwrapRunner, + repo_dir: &Path, + source_dir: &Path, + build_dir: &Path, + name: &str, + config: &[String], + env_pairs: &[(String, String)], + path_overrides: &[String], + workspace: &Path, +) -> Result<()> { + let module_build = build_dir.join(format!("_build-{name}")); + std::fs::create_dir_all(&module_build)?; + let source_canon = source_dir.canonicalize()?; + let source_s = path_to_str(&source_canon)?.to_string(); + let build_s = path_to_str(&module_build)?.to_string(); + + let mut setup = vec![ + "meson".into(), + "setup".into(), + "--prefix=/app".into(), + ]; + setup.extend(config.iter().cloned()); + setup.push(source_s.clone()); + setup.push(build_s.clone()); + run_argv( + runner, + repo_dir, + &setup, + env_pairs, + path_overrides, + None, + workspace, + )?; + run_argv( + runner, + repo_dir, + &["ninja".into(), "-C".into(), build_s.clone()], + env_pairs, + path_overrides, + None, + workspace, + )?; + run_argv( + runner, + repo_dir, + &[ + "meson".into(), + "install".into(), + "-C".into(), + build_s, + ], + env_pairs, + path_overrides, + None, + workspace, + ) +} + +fn build_cmake( + runner: &BwrapRunner, + repo_dir: &Path, + source_dir: &Path, + build_dir: &Path, + name: &str, + config: &[String], + env_pairs: &[(String, String)], + path_overrides: &[String], + workspace: &Path, +) -> Result<()> { + let module_build = build_dir.join(format!("_build-{name}")); + std::fs::create_dir_all(&module_build)?; + let source_canon = source_dir.canonicalize()?; + let source_s = path_to_str(&source_canon)?.to_string(); + let build_s = path_to_str(&module_build)?.to_string(); + let mut configure = vec![ + "cmake".into(), + "-G".into(), + "Ninja".into(), + format!("-B{build_s}"), + "-DCMAKE_BUILD_TYPE=RelWithDebInfo".into(), + "-DCMAKE_INSTALL_PREFIX=/app".into(), + ]; + configure.extend(config.iter().cloned()); + configure.push(source_s); + run_argv( + runner, + repo_dir, + &configure, + env_pairs, + path_overrides, + None, + workspace, + )?; + run_argv( + runner, + repo_dir, + &["ninja".into(), "-C".into(), build_s.clone()], + env_pairs, + path_overrides, + None, + workspace, + )?; + run_argv( + runner, + repo_dir, + &[ + "ninja".into(), + "-C".into(), + build_s, + "install".into(), + ], + env_pairs, + path_overrides, + None, + workspace, + ) +} + +fn build_simple( + runner: &BwrapRunner, + repo_dir: &Path, + source_dir: &Path, + manifest: &Manifest, + name: &str, + build_commands: Option<&Vec>, + env_pairs: &[(String, String)], + path_overrides: &[String], + num_cpus: usize, + workspace: &Path, +) -> Result<()> { + let Some(commands) = build_commands else { + return Ok(()); + }; + let source_canon = source_dir + .canonicalize() + .unwrap_or_else(|_| source_dir.to_path_buf()); + for command in commands { + let processed = substitute_vars(command, &manifest.id, name, num_cpus) + // flatpak-builder uses ${PWD} as the module build directory. + .replace("${PWD}", path_to_str(&source_canon)?) + .replace("$PWD", path_to_str(&source_canon)?); + let argv = shell_words::split(&processed) + .with_context(|| format!("Bad build-command: {processed}"))?; + run_argv( + runner, + repo_dir, + &argv, + env_pairs, + path_overrides, + Some(&source_canon), + workspace, + )?; + } + Ok(()) +} + +fn build_autotools( + runner: &BwrapRunner, + repo_dir: &Path, + source_dir: &Path, + build_dir: &Path, + name: &str, + use_builddir: bool, + config: &[String], + env_pairs: &[(String, String)], + path_overrides: &[String], + num_cpus: usize, + workspace: &Path, +) -> Result<()> { + let source_canon = source_dir.canonicalize()?; + let source_s = path_to_str(&source_canon)?; + + // Generate configure if needed. + if !source_canon.join("configure").exists() { + for candidate in ["autogen.sh", "bootstrap", "bootstrap.sh"] { + let script = source_canon.join(candidate); + if script.exists() { + run_argv( + runner, + repo_dir, + &["/bin/sh".into(), path_to_str(&script)?.into()], + env_pairs, + path_overrides, + Some(&source_canon), + workspace, + )?; + break; + } + } + } + + let jobs = format!("-j{num_cpus}"); + if use_builddir { + let module_build = build_dir.join(format!("_build-{name}")); + std::fs::create_dir_all(&module_build)?; + let build_s = path_to_str(&module_build)?; + let mut configure = vec![ + format!("{source_s}/configure"), + "--prefix=/app".into(), + ]; + configure.extend(config.iter().cloned()); + run_argv( + runner, + repo_dir, + &configure, + env_pairs, + path_overrides, + Some(&module_build), + workspace, + )?; + run_argv( + runner, + repo_dir, + &[ + "make".into(), + "V=0".into(), + jobs.clone(), + "install".into(), + ], + env_pairs, + path_overrides, + Some(&module_build), + workspace, + )?; + } else { + let mut configure = vec![ + format!("{source_s}/configure"), + "--prefix=/app".into(), + ]; + configure.extend(config.iter().cloned()); + run_argv( + runner, + repo_dir, + &configure, + env_pairs, + path_overrides, + Some(&source_canon), + workspace, + )?; + run_argv( + runner, + repo_dir, + &["make".into(), "V=0".into(), jobs, "install".into()], + env_pairs, + path_overrides, + Some(&source_canon), + workspace, + )?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::{BuildOptions, Manifest, Module}; + + #[test] + fn modules_before_stop_excludes_app() { + let manifest = Manifest { + id: "org.example.App".into(), + sdk: "org.gnome.Sdk".into(), + runtime: "org.gnome.Platform".into(), + runtime_version: "47".into(), + command: "app".into(), + x_run_args: None, + modules: vec![ + Module::Object { + name: "dep".into(), + buildsystem: Some("meson".into()), + builddir: None, + subdir: None, + config_opts: None, + build_commands: None, + build_options: None, + post_install: None, + sources: vec![], + }, + Module::Object { + name: "app".into(), + buildsystem: Some("meson".into()), + builddir: None, + subdir: None, + config_opts: None, + build_commands: None, + build_options: None, + post_install: None, + sources: vec![], + }, + ], + finish_args: vec![], + build_options: BuildOptions::default(), + sdk_extensions: vec![], + cleanup: vec![], + }; + let mods = modules_before_stop(&manifest, "app").unwrap(); + assert_eq!(mods.len(), 1); + } +} diff --git a/src/command.rs b/src/command.rs index 84e3302..6475762 100644 --- a/src/command.rs +++ b/src/command.rs @@ -108,19 +108,4 @@ pub fn run_command(command: &str, args: &[&str], working_dir: Option<&Path>) -> Ok(()) } -// Runs flatpak-builder, preferring the native binary, then the Flatpak app. -pub fn flatpak_builder(args: &[&str], working_dir: Option<&Path>) -> Result<()> { - if command_succeeds("flatpak-builder", &["--version"]) { - verbose("Using native flatpak-builder"); - run_command("flatpak-builder", args, working_dir) - } else if command_succeeds("flatpak", &["run", "org.flatpak.Builder", "--version"]) { - verbose("Using org.flatpak.Builder via flatpak run"); - let mut new_args = vec!["run", "org.flatpak.Builder"]; - new_args.extend_from_slice(args); - run_command("flatpak", &new_args, working_dir) - } else { - Err(anyhow::anyhow!( - "Flatpak builder not found. Please install either `flatpak-builder` from your distro repositories or `org.flatpak.Builder` through `flatpak install`." - )) - } -} +// Legacy helper removed: dependency builds are in-process (see `builder` + `sandbox::BwrapRunner`). diff --git a/src/flatpak_manager.rs b/src/flatpak_manager.rs index 71ceeca..fb1ce8d 100644 --- a/src/flatpak_manager.rs +++ b/src/flatpak_manager.rs @@ -9,13 +9,14 @@ use dialoguer::{Select, theme::SimpleTheme}; use nix::unistd::geteuid; use crate::build_dirs::BuildDirs; -use crate::command::{flatpak_builder, run_command}; +use crate::builder; use crate::manifest::{BuildOptions, Manifest, Module, find_manifests_in_path}; +use crate::sandbox::{self, BwrapRunner, RunSpec, SandboxRunner, ensure_sdk_and_runtime}; +use crate::sources::{self, DownloadCache, Source}; use crate::state::State; use crate::utils::{ - build_font_config, download_file, extract_archive, get_a11y_bus_args, get_fonts_args, - get_host_env, guess_archive_type, path_to_str, status, status_info, status_success, - status_warn, verbose, verify_sha256_hex, version_less_than, + build_font_config, copy_dir_all, get_a11y_bus_args, get_fonts_args, get_host_env, path_to_str, + status, status_info, status_success, status_warn, verbose, }; use sha2::{Digest, Sha256}; @@ -54,11 +55,29 @@ impl<'a> FlatpakManager<'a> { manifest.last_module_name(manifest_path) } fn compute_manifest_hash(path: &Path) -> Result { - let content = fs::read(path)?; let mut hasher = Sha256::new(); - hasher.update(&content); + Self::hash_file_into(&mut hasher, path)?; + // Include referenced module files and source fingerprints so edits invalidate state. + if let Ok(manifest) = Manifest::from_file(path) { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + for module in &manifest.modules { + match module { + Module::Reference(name) => { + let ref_path = parent.join(name); + if ref_path.is_file() { + Self::hash_file_into(&mut hasher, &ref_path)?; + } + } + Module::Object { sources, name, .. } => { + hasher.update(name.as_bytes()); + if let Ok(typed) = Source::from_values(sources) { + hasher.update(sources::sources_fingerprint(&typed).as_bytes()); + } + } + } + } + } let result = hasher.finalize(); - let mut hash = String::with_capacity(64); for b in result { write!(&mut hash, "{b:02x}")?; @@ -66,6 +85,21 @@ impl<'a> FlatpakManager<'a> { Ok(hash) } + fn hash_file_into(hasher: &mut Sha256, path: &Path) -> Result<()> { + use std::io::Read; + let mut file = fs::File::open(path) + .with_context(|| format!("Failed to open {} for hashing", path.display()))?; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(()) + } + fn find_manifests(&self) -> Result> { let current_dir = env::current_dir()?; let current_dir_canon = current_dir.canonicalize()?; @@ -138,34 +172,63 @@ impl<'a> FlatpakManager<'a> { } fn check_required_version(manifest: &Manifest) -> Result<()> { - let required = manifest.finish_args.iter().find_map(|arg| { + // Without the flatpak CLI we only ensure the SDK/runtime trees exist. + if let Some(required) = manifest.finish_args.iter().find_map(|arg| { let (key, value) = arg.split_once('=')?; - if key == "--require-version" { - Some(value) - } else { - None - } - }); - if let Some(required) = required { - let version = String::from_utf8_lossy( - &std::process::Command::new("flatpak") - .arg("--version") - .output() - .context("Failed to get flatpak version")? - .stdout, - ) - .replace("Flatpak ", "") - .trim() - .to_string(); - if version_less_than(&version, required) { - return Err(anyhow::anyhow!( - "Manifest requires flatpak >= {required} but {version} is installed" - )); - } + (key == "--require-version").then_some(value) + }) { + verbose(format!( + "Manifest requests flatpak >= {required}; skipping CLI version probe (no flatpak CLI dependency)" + )); } Ok(()) } + fn bwrap_runner(&self) -> Result { + let manifest = self.manifest.as_ref().context("No manifest available")?; + let (sdk, _runtime) = + ensure_sdk_and_runtime(&manifest.sdk, &manifest.runtime, &manifest.runtime_version)?; + Ok(BwrapRunner::for_sdk( + &sdk, + manifest.id.clone(), + self.state.base_dir.as_path(), + )) + } + + fn run_sandboxed( + &self, + runner: &BwrapRunner, + sandbox: &BuildSandbox, + repo_dir: &Path, + argv: &[String], + extra_fs: &[&str], + share_network: bool, + cwd: Option<&Path>, + ) -> Result<()> { + let mut filesystem_binds = vec![sandbox.fs_ws.clone(), sandbox.fs_repo.clone()]; + filesystem_binds.extend(extra_fs.iter().map(|s| (*s).to_string())); + filesystem_binds.extend(sandbox.path_overrides.iter().cloned()); + + let mut env = Vec::new(); + for arg in &sandbox.env_args { + if let Some(rest) = arg.strip_prefix("--env=") + && let Some((k, v)) = rest.split_once('=') + { + env.push((k.to_string(), v.to_string())); + } + } + + runner.run(&RunSpec { + repo_dir: repo_dir.to_path_buf(), + argv: argv.to_vec(), + env, + filesystem_binds, + extra_args: vec![], + share_network, + cwd: cwd.map(Path::to_path_buf), + }) + } + pub fn validate_manifest(&self, allow_auto_select: bool) -> Result<()> { if let Some(manifest) = &self.manifest { Self::check_required_version(manifest)?; @@ -241,38 +304,24 @@ impl<'a> FlatpakManager<'a> { metadata_file.is_file() && files_dir.is_dir() && var_dir.is_dir() } - fn init_build(&self) -> Result<()> { - let manifest = self.manifest.as_ref().context("No manifest available")?; - let repo_dir = self.build_dirs.repo_dir(); - - status(format!("{}", "Initializing build environment...".bold())); - run_command( - "flatpak", - &[ - "build-init", - path_to_str(&repo_dir)?, - &manifest.id, - &manifest.sdk, - &manifest.runtime, - &manifest.runtime_version, - ], - Some(self.state.base_dir.as_path()), - ) - } - fn init(&self) -> Result<()> { if self.is_build_initialized() { return Ok(()); } - - self.init_build()?; - Ok(()) + let manifest = self.manifest.as_ref().context("No manifest available")?; + let repo_dir = self.build_dirs.repo_dir(); + sandbox::ensure_build_initialized( + &repo_dir, + &manifest.id, + &manifest.sdk, + &manifest.runtime, + &manifest.runtime_version, + Some(self.state.base_dir.as_path()), + ) } fn build_application(&self, rebuild: bool) -> Result<()> { let manifest = self.manifest.as_ref().context("No manifest available")?; - let repo_dir = self.build_dirs.repo_dir(); - let repo_dir_str = path_to_str(&repo_dir)?; self.download_application_sources()?; @@ -296,14 +345,20 @@ impl<'a> FlatpakManager<'a> { let module_bo = module_build_options.as_ref(); let num_cpus = std::thread::available_parallelism().map_or(1, std::num::NonZero::get); + let runner = self.bwrap_runner()?; + let repo_dir = self.build_dirs.repo_dir(); + match buildsystem.as_deref() { - Some("meson") => self.run_meson(repo_dir_str, rebuild, &merged_config, module_bo)?, + Some("meson") => { + self.run_meson(&runner, &repo_dir, rebuild, &merged_config, module_bo)?; + } Some("cmake" | "cmake-ninja") => { - self.run_cmake(repo_dir_str, rebuild, &merged_config, module_bo)?; + self.run_cmake(&runner, &repo_dir, rebuild, &merged_config, module_bo)?; } Some("simple") => self.run_simple( + &runner, + &repo_dir, manifest, - repo_dir_str, build_commands.as_ref(), module_bo, &name, @@ -312,14 +367,21 @@ impl<'a> FlatpakManager<'a> { Some("qmake") => { return Err(anyhow::anyhow!("qmake build system is not supported")); } - _ => self.run_autotools(repo_dir_str, rebuild, &merged_config, module_bo, num_cpus)?, + _ => self.run_autotools( + &runner, + &repo_dir, + rebuild, + &merged_config, + module_bo, + num_cpus, + )?, } if let Some(post_install) = post_install { let sandbox = self.build_sandbox(module_bo, manifest); for command in &post_install { let processed = Self::substitute_vars(command, &manifest.id, &name, num_cpus); - let args = Self::build_command(&sandbox, repo_dir_str, &processed, &[], &[]); - run_command("flatpak", &args, Some(self.state.base_dir.as_path()))?; + let argv = Self::parse_command_line(&processed)?; + self.run_sandboxed(&runner, &sandbox, &repo_dir, &argv, &[], true, None)?; } } @@ -333,214 +395,26 @@ impl<'a> FlatpakManager<'a> { "Application module is not a defined module" )); }; - let source_dir = self.build_dirs.build_dir().join(&name); - - if source_dir.exists() { - fs::remove_dir_all(&source_dir)?; - } - - for source in &sources { - let Some(source_type) = source.get("type").and_then(|v| v.as_str()) else { - return Err(anyhow::anyhow!( - "Source in module '{name}' is missing a type field" - )); - }; - match source_type { - "git" => self.handle_git_source(source, &name, &source_dir)?, - "dir" => verbose(format!("Using local directory source for {name}")), - "archive" => self.handle_archive_source(source, &name, &source_dir)?, - "file" => self.handle_file_source(source, &name, &source_dir)?, - other => { - return Err(anyhow::anyhow!( - "Source type '{other}' in module '{name}' is not yet supported" - )); - } - } - } - Ok(()) - } - - fn handle_git_source( - &self, - source: &serde_json::Value, - name: &str, - source_dir: &Path, - ) -> Result<()> { - let url = source - .get("url") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Git source in module '{name}' must specify url"))?; - let tag = source.get("tag").and_then(|v| v.as_str()); - let commit = source.get("commit").and_then(|v| v.as_str()); - let branch = source.get("branch").and_then(|v| v.as_str()); - - match (commit, tag, branch) { - (Some(commit), _, _) => { - status(format!("Cloning {name} from {url} (commit {commit})")); - run_command( - "git", - &[ - "clone", - "--recurse-submodules", - url, - path_to_str(source_dir)?, - ], - Some(self.state.base_dir.as_path()), - )?; - run_command( - "git", - &["-C", path_to_str(source_dir)?, "checkout", commit], - Some(self.state.base_dir.as_path()), - )?; - } - (None, Some(tag), _) => { - status(format!("Cloning {name} from {url} (tag {tag})")); - run_command( - "git", - &[ - "clone", - "--recurse-submodules", - "--branch", - tag, - "--depth", - "1", - url, - path_to_str(source_dir)?, - ], - Some(self.state.base_dir.as_path()), - )?; - } - (None, None, Some(branch)) => { - status(format!("Cloning {name} from {url} (branch {branch})")); - run_command( - "git", - &[ - "clone", - "--recurse-submodules", - "--branch", - branch, - "--depth", - "1", - url, - path_to_str(source_dir)?, - ], - Some(self.state.base_dir.as_path()), - )?; - } - (None, None, None) => { - return Err(anyhow::anyhow!( - "Git source in module '{name}' must specify one of: tag, commit, branch" - )); - } - } - Ok(()) - } - - fn handle_archive_source( - &self, - source: &serde_json::Value, - name: &str, - source_dir: &Path, - ) -> Result<()> { - let is_url = source.get("url").and_then(|v| v.as_str()).is_some(); - let url = source - .get("url") - .and_then(|v| v.as_str()) - .or_else(|| source.get("path").and_then(|v| v.as_str())) - .ok_or_else(|| { - anyhow::anyhow!("Archive source in module '{name}' must specify url or path") - })?; - let sha256 = source - .get("sha256") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - anyhow::anyhow!("Archive source in module '{name}' must specify sha256") - })?; - let strip = source - .get("strip-components") - .and_then(serde_json::Value::as_u64) - .and_then(|v| usize::try_from(v).ok()) - .unwrap_or(1); - let archive_type = source - .get("archive-type") - .and_then(|v| v.as_str()) - .map_or_else(|| guess_archive_type(url), ToString::to_string); - let filename = source - .get("dest-filename") - .and_then(|v| v.as_str()) - .map_or_else( - || { - Path::new(url) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("archive") - .to_string() - }, - ToString::to_string, - ); - let archive_path = self.build_dirs.build_dir().join(&filename); - - if is_url { - status(format!("Downloading {name} from {url}")); - download_file(url, &archive_path)?; - } else { - status(format!("Copying {name} from {url}")); - fs::copy(url, &archive_path)?; + let typed = Source::from_values(&sources)?; + // Pure `dir` sources are resolved at build time (no copy into .flatplay). + if typed.iter().all(|s| matches!(s, Source::Dir { .. })) { + verbose(format!( + "Application module {name} uses directory sources only; skipping materialize" + )); + return Ok(()); } - verify_sha256_hex(&archive_path, sha256)?; - extract_archive(&archive_path, &archive_type, source_dir, strip)?; - fs::remove_file(&archive_path).ok(); - Ok(()) - } - fn handle_file_source( - &self, - source: &serde_json::Value, - name: &str, - source_dir: &Path, - ) -> Result<()> { - let url = source - .get("url") - .and_then(|v| v.as_str()) - .or_else(|| source.get("path").and_then(|v| v.as_str())) - .ok_or_else(|| { - anyhow::anyhow!("File source in module '{name}' must specify url or path") - })?; - let filename = source - .get("dest-filename") - .and_then(|v| v.as_str()) - .map_or_else( - || { - Path::new(url) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("file") - .to_string() - }, - ToString::to_string, - ); - - if let Some(expected) = source.get("sha256").and_then(|v| v.as_str()) { - let is_url = source.get("url").and_then(|v| v.as_str()).is_some(); - let temp_path = self.build_dirs.build_dir().join(&filename); - if is_url { - status(format!("Downloading {name} from {url}")); - download_file(url, &temp_path)?; - } else { - status(format!("Copying {name} from {url}")); - fs::copy(url, &temp_path)?; - } - verify_sha256_hex(&temp_path, expected)?; - let dest_path = source_dir.join(&filename); - if let Some(parent) = dest_path.parent() { - fs::create_dir_all(parent)?; - } - fs::rename(&temp_path, &dest_path)?; - } else { - status(format!("Copying {name} from {url}")); - fs::copy(url, source_dir.join(&filename))?; - } - Ok(()) + let manifest_path = self + .state + .active_manifest + .as_ref() + .context("No active manifest")?; + let manifest_dir = manifest_path + .parent() + .context("Manifest path has no parent directory")?; + let source_dir = self.build_dirs.module_source_dir(&name); + let cache = DownloadCache::new(&self.build_dirs.build_dir()); + sources::materialize_sources(&typed, &source_dir, manifest_dir, &cache, &name) } fn build_sandbox( @@ -574,20 +448,26 @@ impl<'a> FlatpakManager<'a> { args } + /// Build a `flatpak build …` argv. `command_argv` is a pre-split command (use + /// [`Self::parse_command_line`] for manifest `build-commands` / `post-install`). fn build_command<'s>( sandbox: &'s BuildSandbox, repo_dir_str: &'s str, - command: &'s str, + command_argv: &'s [String], extra_fs: &'s [&'s str], extra_args: &'s [&'s str], ) -> Vec<&'s str> { let mut args = Self::sandbox_args(sandbox, repo_dir_str, extra_fs); - let split: Vec<&str> = command.split_whitespace().collect(); - args.extend(&split); + args.extend(command_argv.iter().map(String::as_str)); args.extend_from_slice(extra_args); args } + fn parse_command_line(command: &str) -> Result> { + shell_words::split(command) + .with_context(|| format!("Failed to parse command line: {command}")) + } + fn substitute_vars( command: &str, flatpak_id: &str, @@ -607,7 +487,8 @@ impl<'a> FlatpakManager<'a> { fn run_meson( &self, - repo_dir_str: &str, + runner: &BwrapRunner, + repo_dir: &Path, rebuild: bool, config_opts: &[&str], module_build_options: Option<&BuildOptions>, @@ -625,66 +506,71 @@ impl<'a> FlatpakManager<'a> { "Application module is not a defined module" )); }; - let source_dir = { - let base = if let Some(source) = sources.first() { - if let (Some("dir"), Some(path)) = ( - source.get("type").and_then(|v| v.as_str()), - source.get("path").and_then(|v| v.as_str()), - ) { - let manifest_path = self - .state - .active_manifest - .as_ref() - .context("No active manifest")?; - let manifest_dir = manifest_path - .parent() - .context("Manifest path has no parent directory")?; - manifest_dir.join(path) - } else { - self.build_dirs.build_dir().join(&name) - } - } else { - self.build_dirs.build_dir().join(&name) - }; - if let Some(subdir) = &subdir { - base.join(subdir) - } else { - base - } - }; - let source_dir = source_dir - .canonicalize() - .context("Source directory not found")?; + let source_dir = self.resolve_module_source_dir(&name, &sources, subdir.as_deref())?; let source_dir_str = path_to_str(&source_dir)?; let build_dir = self.build_dirs.build_system_dir(); + std::fs::create_dir_all(&build_dir)?; let build_dir_str = path_to_str(&build_dir)?; let sandbox = self.build_sandbox(module_build_options, manifest); let fs_builddir = format!("--filesystem={build_dir_str}"); let extra_fs = [fs_builddir.as_str()]; if !rebuild { - let mut args = Self::sandbox_args(&sandbox, repo_dir_str, &extra_fs); - args.extend(&["meson", "setup"]); - args.extend_from_slice(config_opts); - args.extend(&["--prefix=/app", source_dir_str, build_dir_str]); - run_command("flatpak", &args, Some(self.state.base_dir.as_path()))?; + let mut setup: Vec = vec!["meson".into(), "setup".into()]; + setup.extend(config_opts.iter().map(|s| (*s).to_string())); + setup.extend([ + "--prefix=/app".into(), + source_dir_str.into(), + build_dir_str.into(), + ]); + self.run_sandboxed(runner, &sandbox, repo_dir, &setup, &extra_fs, true, None)?; } - { - let ninja_cmd = format!("ninja -C {build_dir_str}"); - let args = Self::build_command(&sandbox, repo_dir_str, &ninja_cmd, &extra_fs, &[]); - run_command("flatpak", &args, Some(self.state.base_dir.as_path()))?; - } - { - let install_cmd = format!("meson install -C {build_dir_str}"); - let args = Self::build_command(&sandbox, repo_dir_str, &install_cmd, &extra_fs, &[]); - run_command("flatpak", &args, Some(self.state.base_dir.as_path())) - } + let ninja_cmd = vec![ + "ninja".to_string(), + "-C".to_string(), + build_dir_str.to_string(), + ]; + self.run_sandboxed(runner, &sandbox, repo_dir, &ninja_cmd, &extra_fs, true, None)?; + let install_cmd = vec![ + "meson".to_string(), + "install".to_string(), + "-C".to_string(), + build_dir_str.to_string(), + ]; + self.run_sandboxed(runner, &sandbox, repo_dir, &install_cmd, &extra_fs, true, None) + } + + fn resolve_module_source_dir( + &self, + name: &str, + sources: &[serde_json::Value], + subdir: Option<&str>, + ) -> Result { + let manifest_path = self + .state + .active_manifest + .as_ref() + .context("No active manifest")?; + let manifest_dir = manifest_path + .parent() + .context("Manifest path has no parent directory")?; + let typed = Source::from_values(sources).unwrap_or_else(|_| vec![]); + let base = sources::resolve_dir_source(&typed, manifest_dir) + .unwrap_or_else(|| self.build_dirs.module_source_dir(name)); + let path = if let Some(subdir) = subdir { + base.join(subdir) + } else { + base + }; + path.canonicalize() + .with_context(|| format!("Source directory not found: {}", path.display())) } fn run_cmake( &self, - repo_dir_str: &str, + runner: &BwrapRunner, + repo_dir: &Path, rebuild: bool, config_opts: &[&str], module_build_options: Option<&BuildOptions>, @@ -702,35 +588,7 @@ impl<'a> FlatpakManager<'a> { "Application module is not a defined module" )); }; - let source_dir = { - let base = if let Some(source) = sources.first() { - if let (Some("dir"), Some(path)) = ( - source.get("type").and_then(|v| v.as_str()), - source.get("path").and_then(|v| v.as_str()), - ) { - let manifest_path = self - .state - .active_manifest - .as_ref() - .context("No active manifest")?; - let manifest_dir = manifest_path - .parent() - .context("Manifest path has no parent directory")?; - manifest_dir.join(path) - } else { - self.build_dirs.build_dir().join(&name) - } - } else { - self.build_dirs.build_dir().join(&name) - }; - if let Some(subdir) = &subdir { - base.join(subdir) - } else { - base - } - } - .canonicalize() - .context("Source directory not found")?; + let source_dir = self.resolve_module_source_dir(&name, &sources, subdir.as_deref())?; let source_dir_str = path_to_str(&source_dir)?; let build_dir = self.build_dirs.build_system_dir(); let build_dir_str = path_to_str(&build_dir)?; @@ -739,35 +597,40 @@ impl<'a> FlatpakManager<'a> { let extra_fs = [fs_builddir.as_str()]; if !rebuild { - let b_flag = format!("-B{build_dir_str}"); - let mut args = Self::sandbox_args(&sandbox, repo_dir_str, &extra_fs); - args.extend(&["cmake", "-G", "Ninja", &b_flag]); - args.extend(&[ - "-DCMAKE_EXPORT_COMPILE_COMMANDS=1", - "-DCMAKE_BUILD_TYPE=RelWithDebInfo", - "-DCMAKE_INSTALL_PREFIX=/app", - ]); - args.extend_from_slice(config_opts); - args.push(source_dir_str); - run_command("flatpak", &args, Some(self.state.base_dir.as_path()))?; + let mut configure = vec![ + "cmake".into(), + "-G".into(), + "Ninja".into(), + format!("-B{build_dir_str}"), + "-DCMAKE_EXPORT_COMPILE_COMMANDS=1".into(), + "-DCMAKE_BUILD_TYPE=RelWithDebInfo".into(), + "-DCMAKE_INSTALL_PREFIX=/app".into(), + ]; + configure.extend(config_opts.iter().map(|s| (*s).to_string())); + configure.push(source_dir_str.to_string()); + self.run_sandboxed(runner, &sandbox, repo_dir, &configure, &extra_fs, true, None)?; } - { - let ninja_cmd = format!("ninja -C {build_dir_str}"); - let args = Self::build_command(&sandbox, repo_dir_str, &ninja_cmd, &extra_fs, &[]); - run_command("flatpak", &args, Some(self.state.base_dir.as_path()))?; - } - { - let install_cmd = format!("ninja -C {build_dir_str} install"); - let args = Self::build_command(&sandbox, repo_dir_str, &install_cmd, &extra_fs, &[]); - run_command("flatpak", &args, Some(self.state.base_dir.as_path())) - } + let ninja_cmd = vec![ + "ninja".to_string(), + "-C".to_string(), + build_dir_str.to_string(), + ]; + self.run_sandboxed(runner, &sandbox, repo_dir, &ninja_cmd, &extra_fs, true, None)?; + let install_cmd = vec![ + "ninja".to_string(), + "-C".to_string(), + build_dir_str.to_string(), + "install".to_string(), + ]; + self.run_sandboxed(runner, &sandbox, repo_dir, &install_cmd, &extra_fs, true, None) } fn run_simple( &self, + runner: &BwrapRunner, + repo_dir: &Path, manifest: &Manifest, - repo_dir_str: &str, build_commands: Option<&Vec>, module_build_options: Option<&BuildOptions>, module_name: &str, @@ -777,8 +640,8 @@ impl<'a> FlatpakManager<'a> { let sandbox = self.build_sandbox(module_build_options, manifest); for command in commands { let processed = Self::substitute_vars(command, &manifest.id, module_name, num_cpus); - let args = Self::build_command(&sandbox, repo_dir_str, &processed, &[], &[]); - run_command("flatpak", &args, Some(self.state.base_dir.as_path()))?; + let argv = Self::parse_command_line(&processed)?; + self.run_sandboxed(runner, &sandbox, repo_dir, &argv, &[], true, None)?; } } Ok(()) @@ -786,7 +649,8 @@ impl<'a> FlatpakManager<'a> { fn run_autotools( &self, - repo_dir_str: &str, + runner: &BwrapRunner, + repo_dir: &Path, rebuild: bool, config_opts: &[&str], module_build_options: Option<&BuildOptions>, @@ -818,35 +682,43 @@ impl<'a> FlatpakManager<'a> { let sandbox = self.build_sandbox(module_build_options, manifest); let source_dir_str = path_to_str(&source_dir)?; + let make_argv = |jobs_flag: &str| { + vec![ + "make".to_string(), + "V=0".to_string(), + jobs_flag.to_string(), + "install".to_string(), + ] + }; + match (rebuild, use_builddir) { (false, true) => { - let mut args = Self::sandbox_args(&sandbox, repo_dir_str, &[]); - let configure_path = format!("{source_dir_str}/configure"); - args.extend(&[&configure_path, "--prefix=/app"]); - args.extend_from_slice(config_opts); - run_command("flatpak", &args, Some(self.state.base_dir.as_path()))?; + let mut configure = vec![ + format!("{source_dir_str}/configure"), + "--prefix=/app".into(), + ]; + configure.extend(config_opts.iter().map(|s| (*s).to_string())); + self.run_sandboxed(runner, &sandbox, repo_dir, &configure, &[], true, None)?; let build_dir = self.build_dirs.build_system_dir(); let build_dir_str = path_to_str(&build_dir)?; let fs_builddir = format!("--filesystem={build_dir_str}"); let extra_fs = [fs_builddir.as_str()]; let jobs_flag = format!("-j{num_cpus}"); - let make_args = ["V=0", jobs_flag.as_str(), "install"]; - let args = - Self::build_command(&sandbox, repo_dir_str, "make", &extra_fs, &make_args); - run_command("flatpak", &args, Some(self.state.base_dir.as_path())) + let make_cmd = make_argv(&jobs_flag); + self.run_sandboxed(runner, &sandbox, repo_dir, &make_cmd, &extra_fs, true, None) } (false, false) => { - let mut args = Self::sandbox_args(&sandbox, repo_dir_str, &[]); - let configure_path = format!("{source_dir_str}/configure"); - args.extend(&[&configure_path, "--prefix=/app"]); - args.extend_from_slice(config_opts); - run_command("flatpak", &args, Some(self.state.base_dir.as_path()))?; + let mut configure = vec![ + format!("{source_dir_str}/configure"), + "--prefix=/app".into(), + ]; + configure.extend(config_opts.iter().map(|s| (*s).to_string())); + self.run_sandboxed(runner, &sandbox, repo_dir, &configure, &[], true, None)?; let jobs_flag = format!("-j{num_cpus}"); - let make_args = ["V=0", jobs_flag.as_str(), "install"]; - let args = Self::build_command(&sandbox, repo_dir_str, "make", &[], &make_args); - run_command("flatpak", &args, Some(self.state.base_dir.as_path())) + let make_cmd = make_argv(&jobs_flag); + self.run_sandboxed(runner, &sandbox, repo_dir, &make_cmd, &[], true, None) } (true, true) => { let build_dir = self.build_dirs.build_system_dir(); @@ -854,73 +726,53 @@ impl<'a> FlatpakManager<'a> { let fs_builddir = format!("--filesystem={build_dir_str}"); let extra_fs = [fs_builddir.as_str()]; let jobs_flag = format!("-j{num_cpus}"); - let make_args = ["V=0", jobs_flag.as_str(), "install"]; - let args = - Self::build_command(&sandbox, repo_dir_str, "make", &extra_fs, &make_args); - run_command("flatpak", &args, Some(self.state.base_dir.as_path())) + let make_cmd = make_argv(&jobs_flag); + self.run_sandboxed(runner, &sandbox, repo_dir, &make_cmd, &extra_fs, true, None) } (true, false) => { let jobs_flag = format!("-j{num_cpus}"); - let make_args = ["V=0", jobs_flag.as_str(), "install"]; - let args = Self::build_command(&sandbox, repo_dir_str, "make", &[], &make_args); - run_command("flatpak", &args, Some(self.state.base_dir.as_path())) + let make_cmd = make_argv(&jobs_flag); + self.run_sandboxed(runner, &sandbox, repo_dir, &make_cmd, &[], true, None) } } } fn build_dependencies(&mut self) -> Result<()> { - status(format!("{}", "Building dependencies...".bold())); let manifest_path = self .state .active_manifest .as_ref() - .context("No active manifest")?; + .context("No active manifest")? + .clone(); + let manifest = self.manifest.as_ref().context("No manifest available")?; let repo_dir = self.build_dirs.repo_dir(); - let state_dir = self.build_dirs.flatpak_builder_dir(); + let build_dir = self.build_dirs.build_dir(); let stop_at = self.last_module_name()?; - flatpak_builder( - &[ - "--ccache", - "--force-clean", - "--disable-updates", - "--disable-download", - "--build-only", - "--keep-build-dirs", - &format!("--state-dir={}", path_to_str(&state_dir)?), - &format!("--stop-at={stop_at}"), - path_to_str(&repo_dir)?, - path_to_str(manifest_path)?, - ], - Some(self.state.base_dir.as_path()), + let runner = self.bwrap_runner()?; + builder::build_dependencies( + manifest, + &manifest_path, + &repo_dir, + &build_dir, + &stop_at, + self.state.base_dir.as_path(), + &runner, )?; self.state.dependencies_built = true; self.state.save() } pub fn update_dependencies(&mut self) -> Result<()> { - status(format!("{}", "Updating dependencies...".bold())); - let manifest_path = self .state .active_manifest .as_ref() - .context("No active manifest")?; - let repo_dir = self.build_dirs.repo_dir(); - let state_dir = self.build_dirs.flatpak_builder_dir(); + .context("No active manifest")? + .clone(); + let manifest = self.manifest.as_ref().context("No manifest available")?; + let build_dir = self.build_dirs.build_dir(); let stop_at = self.last_module_name()?; - flatpak_builder( - &[ - "--ccache", - "--force-clean", - "--disable-updates", - "--download-only", - &format!("--state-dir={}", path_to_str(&state_dir)?), - &format!("--stop-at={stop_at}"), - path_to_str(&repo_dir)?, - path_to_str(manifest_path)?, - ], - Some(self.state.base_dir.as_path()), - )?; + builder::update_dependencies(manifest, &manifest_path, &build_dir, &stop_at)?; self.state.dependencies_updated = true; self.state.save() } @@ -1033,16 +885,63 @@ impl<'a> FlatpakManager<'a> { } let manifest = self.manifest.as_ref().context("No manifest available")?; let repo_dir = self.build_dirs.repo_dir(); + let runner = self.bwrap_runner()?; + let mut argv = vec![manifest.command.clone()]; + if let Some(x_run_args) = &manifest.x_run_args { + argv.extend(x_run_args.clone()); + } + self.run_app_spec(&runner, &repo_dir, manifest, argv, false) + } + + fn run_app_spec( + &self, + runner: &BwrapRunner, + repo_dir: &Path, + manifest: &Manifest, + argv: Vec, + with_dev_paths: bool, + ) -> Result<()> { let sandbox = self.build_sandbox(None, manifest); + let mut filesystem_binds = vec![sandbox.fs_ws.clone(), sandbox.fs_repo.clone()]; + filesystem_binds.extend(manifest.finish_args_filtered()); + if with_dev_paths { + filesystem_binds.extend(sandbox.path_overrides.clone()); + } - let mut args = Self::sandbox_run_args(manifest, &repo_dir, &sandbox, false)?; - args.push(manifest.command.clone()); - if let Some(x_run_args) = &manifest.x_run_args { - args.extend(x_run_args.clone()); + let mut env = Vec::new(); + for (k, v) in get_host_env() { + env.push((k, v)); + } + for arg in &sandbox.env_args { + if let Some(rest) = arg.strip_prefix("--env=") + && let Some((k, v)) = rest.split_once('=') + { + env.push((k.to_string(), v.to_string())); + } } - let args_str: Vec<&str> = args.iter().map(String::as_str).collect(); - run_command("flatpak", &args_str, Some(self.state.base_dir.as_path())) + match get_a11y_bus_args() { + Ok(a11y_args) => filesystem_binds.extend(a11y_args), + Err(error) => verbose(format!("a11y bus not available: {error:#}")), + } + match build_font_config().and_then(|config_path| get_fonts_args(&config_path)) { + Ok(fonts_args) => filesystem_binds.extend(fonts_args), + Err(error) => verbose(format!("fonts not available: {error:#}")), + } + + runner.run(&RunSpec { + repo_dir: repo_dir.to_path_buf(), + argv, + env, + filesystem_binds, + extra_args: vec![], + share_network: with_dev_paths + || manifest + .finish_args + .iter() + .any(|a| a.contains("share=network")), + cwd: None, + }) } pub fn export_bundle(&self) -> Result<()> { @@ -1054,59 +953,47 @@ impl<'a> FlatpakManager<'a> { let manifest = self.manifest.as_ref().context("No manifest available")?; let repo_dir = self.build_dirs.repo_dir(); let finalized_repo_dir = self.build_dirs.finalized_repo_dir(); - let ostree_dir = self.build_dirs.ostree_dir(); - // Remove finalized repo if finalized_repo_dir.is_dir() { fs::remove_dir_all(&finalized_repo_dir)?; } + copy_dir_all(&repo_dir, &finalized_repo_dir)?; + + // Write finish metadata without the flatpak CLI. + let meta_path = finalized_repo_dir.join("metadata"); + let mut meta = fs::read_to_string(&meta_path).unwrap_or_default(); + if !meta.contains("[Context]") { + use std::fmt::Write as _; + let _ = writeln!(meta, "\n[Context]"); + for arg in manifest.finish_args_filtered() { + let _ = writeln!(meta, "# finish-arg: {arg}"); + } + let _ = writeln!(meta, "\n[Application]"); + let _ = writeln!(meta, "command={}", manifest.command); + fs::write(&meta_path, meta)?; + } - run_command( - "cp", - &[ - "-r", - path_to_str(&repo_dir)?, - path_to_str(&finalized_repo_dir)?, - ], - Some(self.state.base_dir.as_path()), - )?; - - // Finalize build - let mut args: Vec = vec!["build-finish".to_string()]; - - args.extend(manifest.finish_args_filtered()); - args.push(format!("--command={}", manifest.command)); - args.push(path_to_str(&finalized_repo_dir)?.to_string()); - - let args_str: Vec<&str> = args.iter().map(String::as_str).collect(); - - run_command("flatpak", &args_str, Some(self.state.base_dir.as_path()))?; - - // Export build - run_command( - "flatpak", - &[ - "build-export", - path_to_str(&ostree_dir)?, - path_to_str(&finalized_repo_dir)?, - ], - Some(self.state.base_dir.as_path()), - )?; - - // Bundle build - let bundle_name = format!("{}.flatpak", manifest.id); - run_command( - "flatpak", - &[ - "build-bundle", - path_to_str(&ostree_dir)?, - &bundle_name, - manifest.id.as_str(), - ], - Some(self.state.base_dir.as_path()), - )?; + let bundle_name = format!("{}-files.tar", manifest.id); + let bundle_path = self.state.base_dir.join(&bundle_name); + let files = finalized_repo_dir.join("files"); + // Portable "bundle": tar of /app files (not an OSTree .flatpak; no flatpak CLI). + let status = std::process::Command::new("tar") + .args([ + "-cf", + path_to_str(&bundle_path)?, + "-C", + path_to_str(&files)?, + ".", + ]) + .status() + .context("Failed to spawn tar for export (host tar is used only for packaging)")?; + if !status.success() { + anyhow::bail!("tar export failed with {status}"); + } - status_success(format!("Exported {bundle_name}")); + status_success(format!( + "Exported {bundle_name} (files tree; install-time OSTree bundles need a separate ostree toolchain)" + )); Ok(()) } @@ -1122,24 +1009,17 @@ impl<'a> FlatpakManager<'a> { pub fn runtime_terminal(&self) -> Result<()> { let manifest = self.manifest.as_ref().context("No manifest available")?; - let sdk_id = format!("{}//{}", manifest.sdk, manifest.runtime_version); - run_command( - "flatpak", - &["run", "--command=bash", &sdk_id], - Some(self.state.base_dir.as_path()), - ) + let repo_dir = self.build_dirs.repo_dir(); + // Empty app tree is fine; drop into SDK with bash. + let runner = self.bwrap_runner()?; + self.run_app_spec(&runner, &repo_dir, manifest, vec!["bash".into()], true) } pub fn build_terminal(&self) -> Result<()> { let manifest = self.manifest.as_ref().context("No manifest available")?; let repo_dir = self.build_dirs.repo_dir(); - let sandbox = self.build_sandbox(None, manifest); - - let mut args = Self::sandbox_run_args(manifest, &repo_dir, &sandbox, true)?; - args.push("bash".to_string()); - - let args_str: Vec<&str> = args.iter().map(String::as_str).collect(); - run_command("flatpak", &args_str, Some(self.state.base_dir.as_path())) + let runner = self.bwrap_runner()?; + self.run_app_spec(&runner, &repo_dir, manifest, vec!["bash".into()], true) } pub fn select_manifest(&mut self, path: Option) -> Result<()> { diff --git a/src/git_source.rs b/src/git_source.rs new file mode 100644 index 0000000..f11f11f --- /dev/null +++ b/src/git_source.rs @@ -0,0 +1,124 @@ +//! Fetch application git sources without invoking the `git` CLI (via libgit2/`git2`). + +use std::path::Path; + +use anyhow::{Context, Result}; +use git2::build::RepoBuilder; +use git2::{FetchOptions, Repository, SubmoduleUpdateOptions}; + +use crate::utils::{path_to_str, status, verbose}; + +pub struct GitRef<'a> { + pub url: &'a str, + pub commit: Option<&'a str>, + pub tag: Option<&'a str>, + pub branch: Option<&'a str>, +} + +/// Clone `url` into `source_dir`, checking out commit, tag, or branch, then update submodules. +pub fn fetch_git_source(source_dir: &Path, name: &str, git_ref: GitRef<'_>) -> Result<()> { + if source_dir.exists() { + std::fs::remove_dir_all(source_dir).with_context(|| { + format!( + "Failed to remove existing source dir {}", + source_dir.display() + ) + })?; + } + if let Some(parent) = source_dir.parent() { + std::fs::create_dir_all(parent)?; + } + + let repo = match (git_ref.commit, git_ref.tag, git_ref.branch) { + (Some(commit), _, _) => { + status(format!( + "Cloning {name} from {} (commit {commit})", + git_ref.url + )); + let repo = clone_repository(git_ref.url, source_dir, None)?; + checkout_commit(&repo, commit)?; + repo + } + (None, Some(tag), _) => { + status(format!("Cloning {name} from {} (tag {tag})", git_ref.url)); + clone_repository(git_ref.url, source_dir, Some(tag))? + } + (None, None, Some(branch)) => { + status(format!( + "Cloning {name} from {} (branch {branch})", + git_ref.url + )); + clone_repository(git_ref.url, source_dir, Some(branch))? + } + (None, None, None) => { + anyhow::bail!( + "Git source in module '{name}' must specify one of: tag, commit, branch" + ); + } + }; + + update_submodules(&repo)?; + + verbose(format!( + "Git source for {name} ready at {}", + path_to_str(source_dir)? + )); + Ok(()) +} + +fn clone_repository(url: &str, source_dir: &Path, branch_or_tag: Option<&str>) -> Result { + let mut builder = RepoBuilder::new(); + let mut fetch_opts = FetchOptions::new(); + // Depth is only applied for tip refs; arbitrary commits need a full fetch history. + if branch_or_tag.is_some() { + fetch_opts.depth(1); + } + builder.fetch_options(fetch_opts); + if let Some(reference) = branch_or_tag { + builder.branch(reference); + } + + builder + .clone(url, source_dir) + .with_context(|| format!("Failed to clone {url} into {}", source_dir.display())) +} + +fn checkout_commit(repo: &Repository, commit: &str) -> Result<()> { + let object = repo + .revparse_single(commit) + .with_context(|| format!("Failed to resolve commit '{commit}'"))?; + repo.checkout_tree(&object, None) + .with_context(|| format!("Failed to checkout tree for '{commit}'"))?; + repo.set_head_detached(object.id()) + .with_context(|| format!("Failed to detach HEAD at '{commit}'"))?; + Ok(()) +} + +fn update_submodules(repo: &Repository) -> Result<()> { + fn update_recursive(repo: &Repository) -> Result<()> { + for mut submodule in repo.submodules().context("Failed to list submodules")? { + let mut opts = SubmoduleUpdateOptions::new(); + submodule + .update(true, Some(&mut opts)) + .with_context(|| { + format!( + "Failed to update submodule {}", + submodule.name().unwrap_or("") + ) + })?; + if let Ok(sub_repo) = submodule.open() { + update_recursive(&sub_repo)?; + } + } + Ok(()) + } + + match update_recursive(repo) { + Ok(()) => Ok(()), + Err(error) => { + // Submodules are best-effort when the superproject has none configured oddly. + verbose(format!("Submodule update warning: {error:#}")); + Ok(()) + } + } +} diff --git a/src/main.rs b/src/main.rs index df4d422..4b4eeb6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,10 +6,14 @@ use std::process::{Command, ExitCode, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; mod build_dirs; +mod builder; mod command; mod flatpak_manager; +mod git_source; mod instance_lock; mod manifest; +mod sandbox; +mod sources; mod state; mod utils; @@ -70,54 +74,41 @@ enum Commands { }, } +/// Resolve the project base directory by walking parents for a `.git` entry. +/// Falls back to the current working directory (no `git` binary required). fn get_base_dir() -> anyhow::Result { - let output = Command::new("git") - .arg("rev-parse") - .arg("--show-toplevel") - .output(); - - let raw = if let Ok(output) = output - && output.status.success() - { - PathBuf::from(String::from_utf8_lossy(&output.stdout).trim()) - } else { - verbose("Not in a git repository, using current directory as base"); - PathBuf::from(".") - }; - raw.canonicalize() + let cwd = std::env::current_dir().context("Failed to get current directory")?; + let mut dir = cwd.as_path(); + loop { + let git_entry = dir.join(".git"); + if git_entry.exists() { + return dir + .canonicalize() + .context("Failed to resolve base directory"); + } + match dir.parent() { + Some(parent) => dir = parent, + None => break, + } + } + verbose("Not in a git repository, using current directory as base"); + cwd.canonicalize() .context("Failed to resolve base directory") } fn check_dependencies() -> anyhow::Result<()> { - let required = [("git", "git"), ("flatpak", "flatpak")]; + // Control plane: bubblewrap only. Flatpak *runtimes/SDKs* must exist on disk + // (user/system install tree); we never invoke the `flatpak` or `flatpak-builder` CLIs. let mut missing = Vec::new(); - for (cmd, name) in &required { - if Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_err() - { - missing.push(*name); - } - } - - if Command::new("flatpak-builder") + if Command::new("bwrap") .arg("--version") .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .is_err() - && Command::new("flatpak") - .args(["run", "org.flatpak.Builder", "--version"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_err() { - missing.push("flatpak-builder or org.flatpak.Builder"); + missing.push("bubblewrap (bwrap)"); } if !missing.is_empty() { diff --git a/src/manifest.rs b/src/manifest.rs index 88d2aae..d34125f 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -1,5 +1,4 @@ use std::collections::HashMap; -use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -257,11 +256,11 @@ impl Manifest { manifest_append: Option<&str>, module_append: Option<&str>, ) -> Option { - let host_value = env::var(var).ok(); + // Do not inject host PATH/LD_LIBRARY_PATH/PKG_CONFIG_PATH into the sandbox; + // host libraries cause subtle link failures inside the SDK. let parts: Vec<&str> = manifest_prepend .into_iter() .chain(module_prepend) - .chain(host_value.as_deref()) .chain(defaults.iter().copied()) .chain(manifest_append) .chain(module_append) @@ -301,15 +300,30 @@ pub fn find_manifests_in_path(path: &Path, exclude_prefix: Option<&Path>) -> Vec } }); + const SKIP_DIRS: &[&str] = &[ + "node_modules", + "target", + "vendor", + "_build", + "build", + "dist", + ".flatplay", + "__pycache__", + ".venv", + "venv", + ".tox", + ".git", + ]; + let walker = WalkDir::new(&path).into_iter().filter_entry(|entry| { if entry.depth() == 0 { return true; } - if entry - .file_name() - .to_str() - .is_some_and(|s| s.starts_with('.')) - { + let name = entry.file_name().to_string_lossy(); + if name.starts_with('.') { + return false; + } + if entry.file_type().is_dir() && SKIP_DIRS.iter().any(|d| *d == name) { return false; } if let Some(prefix) = &exclude_prefix diff --git a/src/sandbox/bwrap.rs b/src/sandbox/bwrap.rs new file mode 100644 index 0000000..652e7a0 --- /dev/null +++ b/src/sandbox/bwrap.rs @@ -0,0 +1,322 @@ +//! Bubblewrap-based sandbox (no `flatpak` / `flatpak-builder` CLI). + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result}; + +use super::install::DeployedRef; +use super::{RunSpec, SandboxRunner}; +use crate::command::{InterruptedError, is_interrupted_error}; +use crate::utils::{command_header, path_to_str, verbose}; + +/// Build/run commands inside an SDK or runtime tree via `bwrap`. +#[derive(Debug, Clone)] +pub struct BwrapRunner { + /// SDK (or runtime when running apps) files directory (`.../active/files`). + pub usr_files: PathBuf, + pub app_id: String, + /// Host paths always exposed to the sandbox (project tree, etc.). + pub default_filesystems: Vec, +} + +impl BwrapRunner { + pub fn for_sdk(sdk: &DeployedRef, app_id: impl Into, workspace: &Path) -> Self { + Self { + usr_files: sdk.files_dir.clone(), + app_id: app_id.into(), + default_filesystems: vec![workspace.to_path_buf()], + } + } + + fn base_bwrap_args(&self, repo_dir: &Path, share_network: bool) -> Result> { + let usr = path_to_str(&self.usr_files)?; + let files = repo_dir.join("files"); + let var = repo_dir.join("var"); + std::fs::create_dir_all(&files)?; + std::fs::create_dir_all(&var)?; + + let mut args = vec![ + "--die-with-parent".into(), + "--unshare-pid".into(), + "--unshare-uts".into(), + "--unshare-cgroup-try".into(), + "--proc".into(), + "/proc".into(), + "--dev".into(), + "/dev".into(), + "--tmpfs".into(), + "/tmp".into(), + "--tmpfs".into(), + "/run".into(), + "--ro-bind".into(), + usr.into(), + "/usr".into(), + "--symlink".into(), + "usr/bin".into(), + "/bin".into(), + "--symlink".into(), + "usr/sbin".into(), + "/sbin".into(), + "--symlink".into(), + "usr/lib".into(), + "/lib".into(), + ]; + + // Multi-arch SDK layouts often provide lib64. + if self.usr_files.join("lib64").is_dir() { + args.extend([ + "--symlink".into(), + "usr/lib64".into(), + "/lib64".into(), + ]); + } + + // /etc from the SDK when present; otherwise minimal host networking files. + let sdk_etc = self.usr_files.join("etc"); + if sdk_etc.is_dir() { + args.extend([ + "--ro-bind".into(), + path_to_str(&sdk_etc)?.into(), + "/etc".into(), + ]); + } + for host_file in ["/etc/resolv.conf", "/etc/hosts"] { + if Path::new(host_file).exists() { + // Overlay on top of SDK /etc when possible; bwrap last bind wins for same dest + // only for exact paths — bind individual files. + args.extend([ + "--ro-bind".into(), + host_file.into(), + host_file.into(), + ]); + } + } + + args.extend([ + "--bind".into(), + path_to_str(&files)?.into(), + "/app".into(), + "--bind".into(), + path_to_str(&var)?.into(), + "/var".into(), + "--setenv".into(), + "PATH".into(), + "/app/bin:/usr/bin".into(), + "--setenv".into(), + "LD_LIBRARY_PATH".into(), + "/app/lib".into(), + "--setenv".into(), + "PKG_CONFIG_PATH".into(), + "/app/lib/pkgconfig:/app/share/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig".into(), + "--setenv".into(), + "FLATPAK_ID".into(), + self.app_id.clone(), + "--setenv".into(), + "FLATPAK_DEST".into(), + "/app".into(), + "--setenv".into(), + "FLATPAK_ARCH".into(), + crate::sources::flatpak_arch(), + "--chdir".into(), + "/".into(), + ]); + + if share_network { + // Default bwrap shares net namespace with host unless --unshare-net. + } else { + args.push("--unshare-net".into()); + } + + for fs in &self.default_filesystems { + if let Ok(canon) = fs.canonicalize() { + let s = path_to_str(&canon)?; + args.extend(["--bind".into(), s.into(), s.into()]); + } + } + + Ok(args) + } + + fn apply_spec_overlays(args: &mut Vec, spec: &RunSpec) -> Result<()> { + for (key, value) in &spec.env { + args.extend(["--setenv".into(), key.clone(), value.clone()]); + } + + for fs in &spec.filesystem_binds { + // Accept either `--filesystem=PATH` / `--filesystem=PATH:ro` or raw paths, + // and `--bind-mount=DEST=SRC` style from existing helpers. + if let Some(rest) = fs.strip_prefix("--filesystem=") { + let (path, ro) = rest + .split_once(':') + .map_or((rest, false), |(p, mode)| (p, mode == "ro")); + let path = expand_user(path); + if Path::new(&path).exists() { + let flag = if ro { "--ro-bind" } else { "--bind" }; + args.extend([flag.into(), path.clone(), path]); + } + } else if let Some(rest) = fs.strip_prefix("--bind-mount=") { + if let Some((dest, src)) = rest.split_once('=') { + if Path::new(src).exists() { + args.extend([ + "--bind".into(), + src.into(), + dest.into(), + ]); + } + } + } else if let Some(rest) = fs.strip_prefix("--bind=") { + // uncommon + let _ = rest; + } else if fs.starts_with("--") { + // Permissions like --socket=wayland are not mapped 1:1; bind common sockets. + apply_permission_flag(args, fs); + } else { + let path = expand_user(fs); + if Path::new(&path).exists() { + args.extend(["--bind".into(), path.clone(), path]); + } + } + } + + for extra in &spec.extra_args { + if extra.starts_with("--env=") { + if let Some((k, v)) = extra.trim_start_matches("--env=").split_once('=') { + args.extend(["--setenv".into(), k.into(), v.into()]); + } + } else if extra.starts_with("--filesystem=") + || extra.starts_with("--bind-mount=") + || extra.starts_with("--socket") + || extra.starts_with("--device") + || extra.starts_with("--share") + || extra.starts_with("--talk-name") + || extra.starts_with("--allow") + { + apply_permission_flag(args, extra); + } + } + + Ok(()) + } +} + +fn expand_user(path: &str) -> String { + if let Some(rest) = path.strip_prefix("~/") { + if let Ok(home) = std::env::var("HOME") { + return format!("{home}/{rest}"); + } + } + path.to_string() +} + +fn apply_permission_flag(args: &mut Vec, flag: &str) { + // Best-effort host integration without the flatpak portal stack. + if flag == "--socket=wayland" || flag.starts_with("--socket=wayland") { + if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { + let wayland = format!("{runtime_dir}/wayland-0"); + if Path::new(&wayland).exists() { + args.extend([ + "--bind".into(), + wayland.clone(), + wayland, + "--setenv".into(), + "WAYLAND_DISPLAY".into(), + "wayland-0".into(), + "--setenv".into(), + "XDG_RUNTIME_DIR".into(), + runtime_dir, + ]); + } + } + } else if flag.contains("pulseaudio") { + if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { + let pulse = format!("{runtime_dir}/pulse/native"); + if Path::new(&pulse).exists() { + args.extend(["--bind".into(), pulse.clone(), pulse]); + } + } + } else if flag.contains("dri") { + if Path::new("/dev/dri").exists() { + args.extend(["--dev-bind".into(), "/dev/dri".into(), "/dev/dri".into()]); + } + } else if flag == "--share=network" || flag.starts_with("--share=network") { + // Network shared by not passing --unshare-net (handled via share_network). + } else if flag.starts_with("--talk-name=") || flag.starts_with("--own-name=") { + // Session bus: bind the user bus socket when available. + if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { + let bus = format!("{runtime_dir}/bus"); + if Path::new(&bus).exists() { + args.extend([ + "--bind".into(), + bus.clone(), + bus.clone(), + "--setenv".into(), + "DBUS_SESSION_BUS_ADDRESS".into(), + format!("unix:path={bus}"), + ]); + } + } + } + verbose(format!("permission flag best-effort: {flag}")); +} + +impl SandboxRunner for BwrapRunner { + fn run(&self, spec: &RunSpec) -> Result<()> { + let mut args = self.base_bwrap_args(&spec.repo_dir, spec.share_network)?; + Self::apply_spec_overlays(&mut args, spec)?; + + if let Some(cwd) = &spec.cwd { + let cwd_str = path_to_str(cwd)?; + // Ensure cwd is visible (bind if under a path we might not have). + if cwd.exists() { + let canon = cwd.canonicalize().unwrap_or_else(|_| cwd.clone()); + let s = path_to_str(&canon)?; + if !args.windows(3).any(|w| { + w[0] == "--bind" && (w[1] == s || w[2] == s) + }) { + args.extend(["--bind".into(), s.into(), s.into()]); + } + args.extend(["--chdir".into(), s.into()]); + } else { + let _ = cwd_str; + } + } + + args.push("--".into()); + args.extend(spec.argv.iter().cloned()); + + let args_display: Vec<&str> = args.iter().map(String::as_str).collect(); + command_header("bwrap", &args_display); + + let mut child = Command::new("bwrap") + .args(&args) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .context( + "Failed to spawn bwrap — install bubblewrap (package often named bubblewrap)", + )?; + + let status = child.wait()?; + if status.success() { + return Ok(()); + } + if status.code() == Some(130) || crate::is_interrupted() { + return Err(InterruptedError.into()); + } + let _ = is_interrupted_error; + anyhow::bail!("Sandbox command failed with {status}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expand_user_home() { + // Only checks non-crash; HOME may vary. + let p = expand_user("/tmp/x"); + assert_eq!(p, "/tmp/x"); + } +} diff --git a/src/sandbox/install.rs b/src/sandbox/install.rs new file mode 100644 index 0000000..a299279 --- /dev/null +++ b/src/sandbox/install.rs @@ -0,0 +1,100 @@ +//! Locate installed Flatpak runtimes/SDKs on disk (no `flatpak` CLI). + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use crate::sources::flatpak_arch; + +/// A resolved runtime or SDK deployment. +#[derive(Debug, Clone)] +pub struct DeployedRef { + pub id: String, + pub arch: String, + pub branch: String, + /// `.../active` directory. + pub active_dir: PathBuf, + /// `.../active/files` — bind this at `/usr` in the sandbox. + pub files_dir: PathBuf, + pub metadata_path: PathBuf, +} + +impl DeployedRef { + pub fn ref_string(&self) -> String { + format!("{}/{}/{}", self.id, self.arch, self.branch) + } +} + +/// Search user then system Flatpak installations for a runtime/SDK. +pub fn find_deployed(id: &str, branch: &str) -> Result { + let arch = flatpak_arch(); + let mut tried = Vec::new(); + for root in installation_roots() { + let active = root + .join("runtime") + .join(id) + .join(&arch) + .join(branch) + .join("active"); + tried.push(active.display().to_string()); + let files = active.join("files"); + let metadata = active.join("metadata"); + if files.is_dir() && metadata.is_file() { + return Ok(DeployedRef { + id: id.to_string(), + arch: arch.clone(), + branch: branch.to_string(), + active_dir: active, + files_dir: files, + metadata_path: metadata, + }); + } + } + anyhow::bail!( + "Flatpak ref {id}/{arch}/{branch} not found on disk (looked in: {}).\n\ + Install the SDK/runtime (e.g. via GNOME Software or `flatpak install`) — \ + flatplay reads the install tree and does not invoke the flatpak CLI.", + tried.join(", ") + ) +} + +pub fn installation_roots() -> Vec { + let mut roots = Vec::new(); + if let Ok(home) = std::env::var("HOME") { + roots.push(PathBuf::from(home).join(".local/share/flatpak")); + } + if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { + let p = PathBuf::from(xdg).join("flatpak"); + if !roots.contains(&p) { + roots.push(p); + } + } + roots.push(PathBuf::from("/var/lib/flatpak")); + roots +} + +/// Parse a flatpak-style version string from an on-disk metadata file if present. +pub fn read_flatpak_version_from_lib() -> Option { + // Best-effort: package managers may ship a version file; not required for builds. + None +} + +pub fn ensure_sdk_and_runtime(sdk: &str, runtime: &str, branch: &str) -> Result<(DeployedRef, DeployedRef)> { + let sdk_ref = find_deployed(sdk, branch) + .with_context(|| format!("SDK {sdk}//{branch} is required"))?; + let runtime_ref = find_deployed(runtime, branch) + .with_context(|| format!("Runtime {runtime}//{branch} is required"))?; + Ok((sdk_ref, runtime_ref)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn installation_roots_include_user_and_system() { + let roots = installation_roots(); + assert!(roots.iter().any(|r| r.ends_with("flatpak"))); + assert!(roots.iter().any(|r| r == Path::new("/var/lib/flatpak"))); + } +} diff --git a/src/sandbox/mod.rs b/src/sandbox/mod.rs new file mode 100644 index 0000000..0bc63a4 --- /dev/null +++ b/src/sandbox/mod.rs @@ -0,0 +1,153 @@ +//! Sandbox execution — **no `flatpak` / `flatpak-builder` CLI**. +//! +//! Runtimes/SDKs are located on disk under the Flatpak install tree; commands run +//! via bubblewrap (`bwrap`). + +mod bwrap; +mod install; + +pub use bwrap::BwrapRunner; +pub use install::{DeployedRef, ensure_sdk_and_runtime, find_deployed, installation_roots}; + +use std::path::{Path, PathBuf}; +use std::process::ExitStatus; + +use anyhow::{Context, Result}; + +use crate::utils::{status, verbose}; + +/// Specification for a sandboxed command. +#[derive(Debug, Clone)] +pub struct RunSpec { + /// Build directory (metadata / files / var). + pub repo_dir: PathBuf, + pub argv: Vec, + pub env: Vec<(String, String)>, + /// Host filesystem exposes and permission-like flags (`--filesystem=…`, `--socket=…`, …). + pub filesystem_binds: Vec, + pub extra_args: Vec, + pub share_network: bool, + pub cwd: Option, +} + +pub trait SandboxRunner { + fn run(&self, spec: &RunSpec) -> Result<()>; +} + +/// Create build dir layout without the `flatpak` CLI. +pub fn ensure_build_initialized( + repo_dir: &Path, + app_id: &str, + sdk: &str, + runtime: &str, + runtime_version: &str, + _cwd: Option<&Path>, +) -> Result<()> { + let metadata = repo_dir.join("metadata"); + let files = repo_dir.join("files"); + let var = repo_dir.join("var"); + if metadata.is_file() && files.is_dir() && var.is_dir() { + // Still verify SDK/runtime exist so failures are early and clear. + ensure_sdk_and_runtime(sdk, runtime, runtime_version)?; + return Ok(()); + } + + status("Initializing build environment..."); + ensure_sdk_and_runtime(sdk, runtime, runtime_version)?; + write_minimal_build_layout(repo_dir, app_id, sdk, runtime, runtime_version) +} + +pub fn write_minimal_build_layout( + repo_dir: &Path, + app_id: &str, + sdk: &str, + runtime: &str, + runtime_version: &str, +) -> Result<()> { + std::fs::create_dir_all(repo_dir.join("files"))?; + std::fs::create_dir_all(repo_dir.join("var"))?; + let arch = crate::sources::flatpak_arch(); + let metadata = format!( + "[Application]\n\ + name={app_id}\n\ + runtime={runtime}/{arch}/{runtime_version}\n\ + sdk={sdk}/{arch}/{runtime_version}\n" + ); + std::fs::write(repo_dir.join("metadata"), metadata) + .context("Failed to write build metadata")?; + verbose(format!( + "Wrote build metadata for {app_id} using {sdk}//{runtime_version}" + )); + Ok(()) +} + +/// Host runner for unit tests only. +#[derive(Debug, Default)] +pub struct HostRunner; + +impl SandboxRunner for HostRunner { + fn run(&self, spec: &RunSpec) -> Result<()> { + let program = spec + .argv + .first() + .context("RunSpec argv must not be empty")?; + let args: Vec<&str> = spec.argv.iter().skip(1).map(String::as_str).collect(); + let mut cmd = std::process::Command::new(program); + cmd.args(&args); + for (key, value) in &spec.env { + cmd.env(key, value); + } + if let Some(cwd) = &spec.cwd { + cmd.current_dir(cwd); + } + let status: ExitStatus = cmd.status()?; + if status.success() { + Ok(()) + } else { + anyhow::bail!("Host command failed with {status}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn minimal_layout_writes_metadata() { + let dir = tempdir().unwrap(); + let repo = dir.path().join("repo"); + write_minimal_build_layout( + &repo, + "org.example.App", + "org.gnome.Sdk", + "org.gnome.Platform", + "47", + ) + .unwrap(); + assert!(repo.join("metadata").is_file()); + assert!(repo.join("files").is_dir()); + assert!(repo.join("var").is_dir()); + let meta = std::fs::read_to_string(repo.join("metadata")).unwrap(); + assert!(meta.contains("name=org.example.App")); + assert!(meta.contains("org.gnome.Sdk")); + } + + #[test] + fn host_runner_echo() { + let runner = HostRunner; + let dir = tempdir().unwrap(); + runner + .run(&RunSpec { + repo_dir: dir.path().to_path_buf(), + argv: vec!["true".into()], + env: vec![], + filesystem_binds: vec![], + extra_args: vec![], + share_network: false, + cwd: None, + }) + .unwrap(); + } +} diff --git a/src/sources/cache.rs b/src/sources/cache.rs new file mode 100644 index 0000000..4ebf32c --- /dev/null +++ b/src/sources/cache.rs @@ -0,0 +1,59 @@ +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +/// Content-addressed download cache under `.flatplay/cache`. +#[derive(Debug, Clone)] +pub struct DownloadCache { + root: PathBuf, +} + +impl DownloadCache { + pub fn new(build_dir: &Path) -> Self { + Self { + root: build_dir.join("cache"), + } + } + + pub fn root(&self) -> &Path { + &self.root + } + + /// Path for a cached blob keyed by sha256 (and a stable filename suffix). + pub fn cached_path(&self, filename: &str, sha256: &str) -> PathBuf { + let safe_name = filename.replace('/', "_"); + self.root.join(sha256).join(safe_name) + } + + /// Path keyed only by URL/path hash when no sha256 is known yet. + pub fn path_for_url(&self, url_or_path: &str) -> PathBuf { + let mut hasher = Sha256::new(); + hasher.update(url_or_path.as_bytes()); + let digest = hasher.finalize(); + let mut hex = String::with_capacity(64); + for byte in digest { + use std::fmt::Write; + let _ = write!(&mut hex, "{byte:02x}"); + } + let name = Path::new(url_or_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("blob"); + self.root.join("by-url").join(hex).join(name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cache_paths_include_hash() { + let cache = DownloadCache::new(Path::new("/tmp/proj/.flatplay")); + let p = cache.cached_path("foo.tar.gz", "abc123"); + assert_eq!( + p, + PathBuf::from("/tmp/proj/.flatplay/cache/abc123/foo.tar.gz") + ); + } +} diff --git a/src/sources/mod.rs b/src/sources/mod.rs new file mode 100644 index 0000000..3139a8b --- /dev/null +++ b/src/sources/mod.rs @@ -0,0 +1,466 @@ +//! Typed Flatpak sources and content-addressed download cache. + +mod cache; + +pub use cache::DownloadCache; + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::git_source::{GitRef, fetch_git_source}; +use crate::utils::{ + copy_dir_all, download_file, extract_archive, guess_archive_type, status, verify_sha256_hex, +}; + +/// A Flatpak module source entry. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum Source { + Git { + url: String, + #[serde(default)] + tag: Option, + #[serde(default)] + commit: Option, + #[serde(default)] + branch: Option, + #[serde(default)] + dest: Option, + }, + Archive { + #[serde(default)] + url: Option, + #[serde(default)] + path: Option, + sha256: String, + #[serde(default, rename = "archive-type")] + archive_type: Option, + #[serde(default, rename = "strip-components")] + strip_components: Option, + #[serde(default, rename = "dest-filename")] + dest_filename: Option, + #[serde(default)] + dest: Option, + #[serde(default, rename = "only-arches")] + only_arches: Option>, + }, + File { + #[serde(default)] + url: Option, + #[serde(default)] + path: Option, + #[serde(default)] + sha256: Option, + #[serde(default, rename = "dest-filename")] + dest_filename: Option, + #[serde(default)] + dest: Option, + #[serde(default, rename = "only-arches")] + only_arches: Option>, + }, + Dir { + path: String, + #[serde(default)] + dest: Option, + }, + Patch { + path: String, + #[serde(default)] + dest: Option, + }, + /// Unknown / not-yet-supported source type (kept for forward compatibility). + #[serde(rename = "_flatplay_other")] + Other, +} + +impl Source { + pub fn from_value(value: &serde_json::Value) -> Result { + match serde_json::from_value::(value.clone()) { + Ok(source) => Ok(source), + Err(_) => { + // Unknown type or extra fields we do not model yet. + if value.get("type").and_then(|t| t.as_str()).is_some() { + Ok(Self::Other) + } else { + Err(anyhow::anyhow!("Failed to parse source entry: {value}")) + } + } + } + } + + pub fn from_values(values: &[serde_json::Value]) -> Result> { + values.iter().map(Self::from_value).collect() + } + + fn matches_arch(&self) -> bool { + let only = match self { + Self::Archive { only_arches, .. } | Self::File { only_arches, .. } => { + only_arches.as_ref() + } + _ => None, + }; + match only { + None => true, + Some(arches) => { + let host = flatpak_arch(); + arches.iter().any(|a| a == &host) + } + } + } + + fn dest_subdir(&self) -> Option<&str> { + match self { + Self::Git { dest, .. } + | Self::Archive { dest, .. } + | Self::File { dest, .. } + | Self::Dir { dest, .. } + | Self::Patch { dest, .. } => dest.as_deref(), + Self::Other => None, + } + } +} + +pub fn flatpak_arch() -> String { + match std::env::consts::ARCH { + "x86_64" => "x86_64".into(), + "aarch64" => "aarch64".into(), + "arm" => "arm".into(), + "x86" | "i686" => "i386".into(), + other => other.into(), + } +} + +/// Materialize all sources for a module into `source_dir` (created fresh). +pub fn materialize_sources( + sources: &[Source], + source_dir: &Path, + manifest_dir: &Path, + cache: &DownloadCache, + module_name: &str, +) -> Result<()> { + if source_dir.exists() { + std::fs::remove_dir_all(source_dir)?; + } + std::fs::create_dir_all(source_dir)?; + + for source in sources { + if !source.matches_arch() { + continue; + } + let target = match source.dest_subdir() { + Some(sub) => { + let p = source_dir.join(sub); + std::fs::create_dir_all(&p)?; + p + } + None => source_dir.to_path_buf(), + }; + materialize_one(source, &target, manifest_dir, cache, module_name)?; + } + Ok(()) +} + +fn materialize_one( + source: &Source, + target: &Path, + manifest_dir: &Path, + cache: &DownloadCache, + module_name: &str, +) -> Result<()> { + match source { + Source::Git { + url, + tag, + commit, + branch, + .. + } => { + // Git clones into `target` directly. + if target.exists() && target != Path::new("") { + // If target is source_dir itself and empty, ok; if dest subdir, fetch there. + } + let clone_dir = if target + .read_dir() + .map(|mut d| d.next().is_none()) + .unwrap_or(true) + { + target.to_path_buf() + } else { + // Should not normally happen for first source. + target.to_path_buf() + }; + fetch_git_source( + &clone_dir, + module_name, + GitRef { + url, + commit: commit.as_deref(), + tag: tag.as_deref(), + branch: branch.as_deref(), + }, + )?; + } + Source::Archive { + url, + path, + sha256, + archive_type, + strip_components, + dest_filename, + .. + } => { + let location = url + .as_deref() + .or(path.as_deref()) + .context("Archive source must specify url or path")?; + let is_url = url.is_some(); + let filename = dest_filename.clone().unwrap_or_else(|| { + Path::new(location) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("archive") + .to_string() + }); + let archive_path = cache.cached_path(&filename, sha256); + if !archive_path.exists() { + if is_url { + status(format!("Downloading {module_name} from {location}")); + download_file(location, &archive_path)?; + } else { + let src = resolve_path(manifest_dir, location); + status(format!("Copying {module_name} from {}", src.display())); + if let Some(parent) = archive_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(&src, &archive_path)?; + } + verify_sha256_hex(&archive_path, sha256)?; + } else { + verify_sha256_hex(&archive_path, sha256)?; + } + let strip = strip_components + .and_then(|v| usize::try_from(v).ok()) + .unwrap_or(1); + let atype = archive_type + .clone() + .unwrap_or_else(|| guess_archive_type(location)); + extract_archive(&archive_path, &atype, target, strip)?; + } + Source::File { + url, + path, + sha256, + dest_filename, + .. + } => { + let location = url + .as_deref() + .or(path.as_deref()) + .context("File source must specify url or path")?; + let is_url = url.is_some(); + let filename = dest_filename.clone().unwrap_or_else(|| { + Path::new(location) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_string() + }); + let dest_path = target.join(&filename); + if let Some(parent) = dest_path.parent() { + std::fs::create_dir_all(parent)?; + } + + if let Some(expected) = sha256 { + let cached = cache.cached_path(&filename, expected); + if !cached.exists() { + if is_url { + status(format!("Downloading {module_name} from {location}")); + download_file(location, &cached)?; + } else { + let src = resolve_path(manifest_dir, location); + status(format!("Copying {module_name} from {}", src.display())); + if let Some(parent) = cached.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(&src, &cached)?; + } + verify_sha256_hex(&cached, expected)?; + } + if cached != dest_path { + std::fs::copy(&cached, &dest_path)?; + } + } else if is_url { + status(format!("Downloading {module_name} from {location}")); + download_file(location, &dest_path)?; + } else { + let src = resolve_path(manifest_dir, location); + status(format!("Copying {module_name} from {}", src.display())); + std::fs::copy(&src, &dest_path)?; + } + } + Source::Dir { path, .. } => { + let src = resolve_path(manifest_dir, path); + status(format!( + "Using directory source for {module_name}: {}", + src.display() + )); + // Prefer lightweight presence: if target is empty module dir and path is project + // root, leave a marker file with the resolved path for buildsystems to read. + // For dependency builds that need full tree, copy (can be large). + if target + .read_dir() + .map(|mut d| d.next().is_none()) + .unwrap_or(true) + && source_is_module_root(target) + { + std::fs::write( + target.join(".flatplay-dir-source"), + src.canonicalize() + .unwrap_or(src) + .to_string_lossy() + .as_bytes(), + )?; + } else { + copy_dir_all(&src, target)?; + } + } + Source::Patch { path, .. } => { + let patch_path = resolve_path(manifest_dir, path); + apply_patch(&patch_path, target)?; + } + Source::Other => { + anyhow::bail!("Unsupported source type in module '{module_name}'"); + } + } + Ok(()) +} + +fn source_is_module_root(target: &Path) -> bool { + target + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| !n.is_empty()) +} + +fn resolve_path(manifest_dir: &Path, path: &str) -> PathBuf { + let p = Path::new(path); + if p.is_absolute() { + p.to_path_buf() + } else { + manifest_dir.join(p) + } +} + +fn apply_patch(patch_path: &Path, target: &Path) -> Result<()> { + // Minimal unified-diff apply via `patch` if present; otherwise error with guidance. + // Prefer pure approach: read patch and use the `patch` crate — avoid host CLI. + // For Phase B we shell to `patch -p1` only when the binary exists; Wordbook has no patches. + let patch_body = + std::fs::read_to_string(patch_path).with_context(|| patch_path.display().to_string())?; + let _ = (patch_body, target); + // Try lib: apply with patch crate if we add it; for now use std process patch as last resort + // for rare manifests — documented limitation. + let status = std::process::Command::new("patch") + .args(["-p1", "--forward", "--batch"]) + .current_dir(target) + .stdin(std::process::Stdio::piped()) + .spawn() + .and_then(|mut child| { + use std::io::Write; + if let Some(stdin) = child.stdin.as_mut() { + let data = std::fs::read(patch_path)?; + stdin.write_all(&data)?; + } + child.wait() + }); + match status { + Ok(s) if s.success() => Ok(()), + Ok(s) => anyhow::bail!( + "Failed to apply patch {} (exit {:?})", + patch_path.display(), + s.code() + ), + Err(error) => Err(error).context(format!( + "Failed to apply patch {} (is `patch` available for rare patch sources?)", + patch_path.display() + )), + } +} + +/// Resolve the directory path for a `type: dir` source (for meson/cmake). +pub fn resolve_dir_source(sources: &[Source], manifest_dir: &Path) -> Option { + for source in sources { + if let Source::Dir { path, .. } = source { + return Some(resolve_path(manifest_dir, path)); + } + } + None +} + +/// Fingerprint sources for cache invalidation (stable-ish string). +pub fn sources_fingerprint(sources: &[Source]) -> String { + serde_json::to_string(sources).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_git_source() { + let v = serde_json::json!({ + "type": "git", + "url": "https://example.com/repo.git", + "branch": "main" + }); + let s = Source::from_value(&v).unwrap(); + match s { + Source::Git { url, branch, .. } => { + assert_eq!(url, "https://example.com/repo.git"); + assert_eq!(branch.as_deref(), Some("main")); + } + other => panic!("unexpected {other:?}"), + } + } + + #[test] + fn only_arches_filters() { + let host = flatpak_arch(); + let other = if host == "x86_64" { + "aarch64" + } else { + "x86_64" + }; + let skip = Source::File { + url: Some("http://x".into()), + path: None, + sha256: None, + dest_filename: None, + dest: None, + only_arches: Some(vec![other.into()]), + }; + assert!(!skip.matches_arch()); + let keep = Source::File { + url: Some("http://x".into()), + path: None, + sha256: None, + dest_filename: None, + dest: None, + only_arches: Some(vec![host]), + }; + assert!(keep.matches_arch()); + } + + #[test] + fn resolve_dir_source_relative() { + let sources = vec![Source::Dir { + path: "../../.".into(), + dest: None, + }]; + let manifest_dir = PathBuf::from("/proj/build-aux/flatpak"); + let resolved = resolve_dir_source(&sources, &manifest_dir).unwrap(); + assert_eq!(resolved, PathBuf::from("/proj/build-aux/flatpak/../../.")); + } +} diff --git a/src/utils.rs b/src/utils.rs index 425d2e7..c6d1c78 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -119,6 +119,48 @@ fn parse_a11y_address(address: &str) -> Result<(String, String)> { } pub fn get_a11y_bus_args() -> Result> { + let address = query_a11y_bus_address()?; + let (unix_path, suffix) = parse_a11y_address(&address)?; + + Ok(vec![ + format!("--bind-mount=/run/flatpak/at-spi-bus={}", unix_path), + if suffix.is_empty() { + "--env=AT_SPI_BUS_ADDRESS=unix:path=/run/flatpak/at-spi-bus".to_string() + } else { + format!("--env=AT_SPI_BUS_ADDRESS=unix:path=/run/flatpak/at-spi-bus{suffix}") + }, + ]) +} + +fn query_a11y_bus_address() -> Result { + // Prefer in-process D-Bus (no `gdbus` CLI). Fall back to gdbus if zbus fails + // (e.g. missing session bus in odd environments). + match query_a11y_bus_address_zbus() { + Ok(address) => Ok(address), + Err(zbus_error) => { + verbose(format!("zbus a11y query failed ({zbus_error:#}); trying gdbus")); + query_a11y_bus_address_gdbus() + } + } +} + +fn query_a11y_bus_address_zbus() -> Result { + let conn = zbus::blocking::Connection::session() + .context("Failed to connect to session bus for a11y")?; + let proxy = zbus::blocking::Proxy::new( + &conn, + "org.a11y.Bus", + "/org/a11y/bus", + "org.a11y.Bus", + ) + .context("Failed to create a11y bus proxy")?; + let address: String = proxy + .call("GetAddress", &()) + .context("org.a11y.Bus.GetAddress failed")?; + Ok(address) +} + +fn query_a11y_bus_address_gdbus() -> Result { let output = Command::new("gdbus") .args([ "call", @@ -134,17 +176,7 @@ pub fn get_a11y_bus_args() -> Result> { anyhow::bail!("gdbus a11y bus query failed with status: {}", output.status); } - let address = String::from_utf8_lossy(&output.stdout); - let (unix_path, suffix) = parse_a11y_address(&address)?; - - Ok(vec![ - format!("--bind-mount=/run/flatpak/at-spi-bus={}", unix_path), - if suffix.is_empty() { - "--env=AT_SPI_BUS_ADDRESS=unix:path=/run/flatpak/at-spi-bus".to_string() - } else { - format!("--env=AT_SPI_BUS_ADDRESS=unix:path=/run/flatpak/at-spi-bus{suffix}") - }, - ]) + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } fn path_exists(path: &str) -> bool { @@ -251,9 +283,21 @@ pub fn download_file(url: &str, dest: &Path) -> Result<()> { } pub fn verify_sha256_hex(path: &Path, expected: &str) -> Result<()> { - let content = std::fs::read(path)?; + use std::io::Read; + + let mut file = std::fs::File::open(path) + .with_context(|| format!("Failed to open {} for hashing", path.display()))?; let mut hasher = Sha256::new(); - hasher.update(&content); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file + .read(&mut buffer) + .with_context(|| format!("Failed to read {} for hashing", path.display()))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } let result = hasher.finalize(); let mut hash = String::with_capacity(64); for byte in result { @@ -268,6 +312,52 @@ pub fn verify_sha256_hex(path: &Path, expected: &str) -> Result<()> { Ok(()) } +/// Move a file, falling back to copy+remove when rename fails across devices (EXDEV). +pub fn move_file(src: &Path, dest: &Path) -> Result<()> { + match std::fs::rename(src, dest) { + Ok(()) => Ok(()), + Err(error) + if error.raw_os_error() == Some(18) /* EXDEV on Linux */ + || error.kind() == std::io::ErrorKind::CrossesDevices => + { + std::fs::copy(src, dest).with_context(|| { + format!("Failed to copy {} -> {}", src.display(), dest.display()) + })?; + std::fs::remove_file(src).ok(); + Ok(()) + } + Err(error) => Err(error).with_context(|| { + format!("Failed to move {} -> {}", src.display(), dest.display()) + }), + } +} + +/// Recursively copy a directory tree. +pub fn copy_dir_all(src: &Path, dest: &Path) -> Result<()> { + std::fs::create_dir_all(dest) + .with_context(|| format!("Failed to create directory {}", dest.display()))?; + for entry in walkdir::WalkDir::new(src).min_depth(1) { + let entry = entry?; + let rel = entry.path().strip_prefix(src)?; + let dest_path = dest.join(rel); + if entry.file_type().is_dir() { + std::fs::create_dir_all(&dest_path)?; + } else { + if let Some(parent) = dest_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(entry.path(), &dest_path).with_context(|| { + format!( + "Failed to copy {} -> {}", + entry.path().display(), + dest_path.display() + ) + })?; + } + } + Ok(()) +} + pub fn extract_archive( path: &Path, archive_type: &str, @@ -312,7 +402,7 @@ pub fn extract_archive( if let Some(parent) = dest_path.parent() { std::fs::create_dir_all(parent)?; } - std::fs::rename(entry.path(), &dest_path)?; + move_file(entry.path(), &dest_path)?; } } @@ -521,4 +611,37 @@ mod tests { assert!(download_file(&url, &archive_path).is_err()); mock.assert(); } + + #[test] + fn test_copy_dir_all() { + let src = tempfile::tempdir().unwrap(); + let dest = tempfile::tempdir().unwrap(); + let nested = src.path().join("a/b"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(nested.join("file.txt"), b"hello").unwrap(); + + let dest_root = dest.path().join("copy"); + copy_dir_all(src.path(), &dest_root).unwrap(); + assert_eq!( + std::fs::read_to_string(dest_root.join("a/b/file.txt")).unwrap(), + "hello" + ); + } + + #[test] + fn test_move_file_same_fs() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("src.bin"); + let dest = dir.path().join("dest.bin"); + std::fs::write(&src, b"data").unwrap(); + move_file(&src, &dest).unwrap(); + assert!(!src.exists()); + assert_eq!(std::fs::read(&dest).unwrap(), b"data"); + } + + #[test] + fn test_shell_words_roundtrip_quotes() { + let argv = shell_words::split(r#"echo "hello world" '--flag=a b'"#).unwrap(); + assert_eq!(argv, ["echo", "hello world", "--flag=a b"]); + } } From cf95b05f60f2dbadfb2e308beaa689a7a5c4f084 Mon Sep 17 00:00:00 2001 From: Mufeed Ali Date: Wed, 1 Jul 2026 22:19:34 +0530 Subject: [PATCH 2/6] fix: make in-process Wordbook build succeed under bwrap Create download cache dirs, fix /etc resolv overlay vs read-only SDK etc, skip .flatplay when copying dir sources, use project tree for dir+file modules, and run shell-style build-commands via /bin/sh -c. --- src/builder/mod.rs | 99 +++++++++++++++++++++--------------------- src/flatpak_manager.rs | 7 ++- src/sandbox/bwrap.rs | 38 ++++++++++++---- src/sources/mod.rs | 15 ++++--- src/utils.rs | 25 ++++++++++- 5 files changed, 115 insertions(+), 69 deletions(-) diff --git a/src/builder/mod.rs b/src/builder/mod.rs index 56c7e62..4a84a1c 100644 --- a/src/builder/mod.rs +++ b/src/builder/mod.rs @@ -75,54 +75,28 @@ pub fn build_dependencies( status(format!("Building module {name}...")); let typed = Source::from_values(sources)?; - let source_dir = if typed.iter().any(|s| matches!(s, Source::Dir { .. })) - && typed.iter().all(|s| matches!(s, Source::Dir { .. } | Source::File { .. })) + let source_dir = if let Some(dir_path) = sources::resolve_dir_source(&typed, manifest_dir) { - // Mix of dir + file: materialize into module dir (copies dir tree + files). - let source_dir = build_dir.join(name); - sources::materialize_sources(&typed, &source_dir, manifest_dir, &cache, name)?; - // For dir markers, prefer real dir path when only dirs. - if typed.iter().all(|s| matches!(s, Source::Dir { .. })) { - sources::resolve_dir_source(&typed, manifest_dir) - .unwrap_or(source_dir) - } else { - // materialize copies dir into source_dir for mixed; use source_dir - // Re-materialize: our Dir handler may only write a marker. Force copy for deps. - let source_dir = build_dir.join(name); - if source_dir.exists() { - std::fs::remove_dir_all(&source_dir)?; - } - std::fs::create_dir_all(&source_dir)?; - for source in &typed { - match source { - Source::Dir { path, dest, .. } => { - let src = if Path::new(path).is_absolute() { - PathBuf::from(path) - } else { - manifest_dir.join(path) - }; - let target = match dest { - Some(d) => source_dir.join(d), - None => source_dir.clone(), - }; - crate::utils::copy_dir_all(&src, &target)?; - } - other => { - sources::materialize_sources( - &[other.clone()], - &source_dir, - manifest_dir, - &cache, - name, - )?; - } + // Prefer the live project/dir tree (no recursive copy into .flatplay). + // Non-dir sources (files/archives) are materialized directly into that tree. + let non_dir: Vec<_> = typed + .iter() + .filter(|s| !matches!(s, Source::Dir { .. })) + .cloned() + .collect(); + if !non_dir.is_empty() { + // Materialize into a temp module folder, then copy files to dir_path. + let staging = build_dir.join(format!("{name}-staging")); + sources::materialize_sources(&non_dir, &staging, manifest_dir, &cache, name)?; + for entry in std::fs::read_dir(&staging)? { + let entry = entry?; + let dest = dir_path.join(entry.file_name()); + if entry.file_type()?.is_file() { + std::fs::copy(entry.path(), &dest)?; } } - source_dir } - } else if typed.iter().all(|s| matches!(s, Source::Dir { .. })) { - sources::resolve_dir_source(&typed, manifest_dir) - .context("dir source missing")? + dir_path } else { let source_dir = build_dir.join(name); if !source_dir.exists() { @@ -203,8 +177,7 @@ pub fn build_dependencies( if let Some(post_install) = post_install { for command in post_install { let processed = substitute_vars(command, &manifest.id, name, num_cpus); - let argv = shell_words::split(&processed) - .with_context(|| format!("Bad post-install: {processed}"))?; + let argv = command_to_argv(&processed)?; run_argv( runner, repo_dir, @@ -249,12 +222,20 @@ fn run_argv( if let Some(cwd) = cwd { filesystem_binds.push(format!("--filesystem={}", path_to_str(cwd)?)); } - filesystem_binds.extend(path_overrides.iter().cloned()); + + let mut env = env_pairs.to_vec(); + for override_arg in path_overrides { + if let Some(rest) = override_arg.strip_prefix("--env=") + && let Some((key, value)) = rest.split_once('=') + { + env.push((key.to_string(), value.to_string())); + } + } runner.run(&RunSpec { repo_dir: repo_dir.to_path_buf(), argv: argv.to_vec(), - env: env_pairs.to_vec(), + env, filesystem_binds, extra_args: vec![], share_network: true, @@ -416,8 +397,7 @@ fn build_simple( // flatpak-builder uses ${PWD} as the module build directory. .replace("${PWD}", path_to_str(&source_canon)?) .replace("$PWD", path_to_str(&source_canon)?); - let argv = shell_words::split(&processed) - .with_context(|| format!("Bad build-command: {processed}"))?; + let argv = command_to_argv(&processed)?; run_argv( runner, repo_dir, @@ -431,6 +411,25 @@ fn build_simple( Ok(()) } +/// flatpak-builder runs build-commands through a shell; use `/bin/sh -c` when needed. +fn command_to_argv(command: &str) -> Result> { + let needs_shell = command.contains('$') + || command.contains('`') + || command.contains("&&") + || command.contains("||") + || command.contains(';') + || command.contains('|') + || command.contains('>') + || command.contains('<') + || command.contains('(') + || command.contains(')'); + if needs_shell { + Ok(vec!["/bin/sh".into(), "-c".into(), command.to_string()]) + } else { + shell_words::split(command).with_context(|| format!("Bad build-command: {command}")) + } +} + fn build_autotools( runner: &BwrapRunner, repo_dir: &Path, diff --git a/src/flatpak_manager.rs b/src/flatpak_manager.rs index fb1ce8d..38ae239 100644 --- a/src/flatpak_manager.rs +++ b/src/flatpak_manager.rs @@ -207,10 +207,13 @@ impl<'a> FlatpakManager<'a> { ) -> Result<()> { let mut filesystem_binds = vec![sandbox.fs_ws.clone(), sandbox.fs_repo.clone()]; filesystem_binds.extend(extra_fs.iter().map(|s| (*s).to_string())); - filesystem_binds.extend(sandbox.path_overrides.iter().cloned()); let mut env = Vec::new(); - for arg in &sandbox.env_args { + for arg in sandbox + .env_args + .iter() + .chain(sandbox.path_overrides.iter()) + { if let Some(rest) = arg.strip_prefix("--env=") && let Some((k, v)) = rest.split_once('=') { diff --git a/src/sandbox/bwrap.rs b/src/sandbox/bwrap.rs index 652e7a0..6c3e08a 100644 --- a/src/sandbox/bwrap.rs +++ b/src/sandbox/bwrap.rs @@ -72,21 +72,41 @@ impl BwrapRunner { ]); } - // /etc from the SDK when present; otherwise minimal host networking files. + // Writable tmpfs /etc so we can add host resolv.conf (cannot overlay files onto a + // read-only SDK /etc bind — bwrap fails with "Can't create file at /etc/resolv.conf"). + args.extend(["--tmpfs".into(), "/etc".into()]); let sdk_etc = self.usr_files.join("etc"); if sdk_etc.is_dir() { - args.extend([ - "--ro-bind".into(), - path_to_str(&sdk_etc)?.into(), - "/etc".into(), - ]); + // Expose useful SDK etc trees (ssl certs, fonts, …). Skip dangling symlinks + // (e.g. mtab) via --ro-bind-try. + if let Ok(entries) = std::fs::read_dir(&sdk_etc) { + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { + continue; + }; + // Skip files we override from the host. + if matches!(name_str, "resolv.conf" | "hosts" | "mtab") { + continue; + } + let src = entry.path(); + // Path::exists follows symlinks; skip broken ones. + if src.is_symlink() && !src.exists() { + continue; + } + let dest = format!("/etc/{name_str}"); + args.extend([ + "--ro-bind-try".into(), + path_to_str(&src)?.into(), + dest, + ]); + } + } } for host_file in ["/etc/resolv.conf", "/etc/hosts"] { if Path::new(host_file).exists() { - // Overlay on top of SDK /etc when possible; bwrap last bind wins for same dest - // only for exact paths — bind individual files. args.extend([ - "--ro-bind".into(), + "--ro-bind-try".into(), host_file.into(), host_file.into(), ]); diff --git a/src/sources/mod.rs b/src/sources/mod.rs index 3139a8b..a9a8b1e 100644 --- a/src/sources/mod.rs +++ b/src/sources/mod.rs @@ -224,6 +224,9 @@ fn materialize_one( .to_string() }); let archive_path = cache.cached_path(&filename, sha256); + if let Some(parent) = archive_path.parent() { + std::fs::create_dir_all(parent)?; + } if !archive_path.exists() { if is_url { status(format!("Downloading {module_name} from {location}")); @@ -231,9 +234,6 @@ fn materialize_one( } else { let src = resolve_path(manifest_dir, location); status(format!("Copying {module_name} from {}", src.display())); - if let Some(parent) = archive_path.parent() { - std::fs::create_dir_all(parent)?; - } std::fs::copy(&src, &archive_path)?; } verify_sha256_hex(&archive_path, sha256)?; @@ -274,6 +274,9 @@ fn materialize_one( if let Some(expected) = sha256 { let cached = cache.cached_path(&filename, expected); + if let Some(parent) = cached.parent() { + std::fs::create_dir_all(parent)?; + } if !cached.exists() { if is_url { status(format!("Downloading {module_name} from {location}")); @@ -281,14 +284,14 @@ fn materialize_one( } else { let src = resolve_path(manifest_dir, location); status(format!("Copying {module_name} from {}", src.display())); - if let Some(parent) = cached.parent() { - std::fs::create_dir_all(parent)?; - } std::fs::copy(&src, &cached)?; } verify_sha256_hex(&cached, expected)?; } if cached != dest_path { + if let Some(parent) = dest_path.parent() { + std::fs::create_dir_all(parent)?; + } std::fs::copy(&cached, &dest_path)?; } } else if is_url { diff --git a/src/utils.rs b/src/utils.rs index c6d1c78..e136430 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -274,10 +274,15 @@ pub fn path_to_str(path: &Path) -> Result<&str> { } pub fn download_file(url: &str, dest: &Path) -> Result<()> { + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create download directory {}", parent.display()))?; + } let response = ureq::get(url) .call() .map_err(|error| anyhow::anyhow!("Failed to download {url}: {error}"))?; - let mut file = std::fs::File::create(dest)?; + let mut file = std::fs::File::create(dest) + .with_context(|| format!("Failed to create download target {}", dest.display()))?; std::io::copy(&mut response.into_body().into_reader(), &mut file)?; Ok(()) } @@ -334,14 +339,30 @@ pub fn move_file(src: &Path, dest: &Path) -> Result<()> { /// Recursively copy a directory tree. pub fn copy_dir_all(src: &Path, dest: &Path) -> Result<()> { + copy_dir_all_excluding(src, dest, &[".flatplay"]) +} + +/// Recursively copy a directory tree, skipping path components listed in `exclude_names` +/// (e.g. `.flatplay` to avoid copying the build dir into itself). +pub fn copy_dir_all_excluding(src: &Path, dest: &Path, exclude_names: &[&str]) -> Result<()> { std::fs::create_dir_all(dest) .with_context(|| format!("Failed to create directory {}", dest.display()))?; - for entry in walkdir::WalkDir::new(src).min_depth(1) { + let walker = walkdir::WalkDir::new(src) + .min_depth(1) + .into_iter() + .filter_entry(|entry| { + let name = entry.file_name().to_string_lossy(); + !exclude_names.iter().any(|ex| *ex == name) + }); + for entry in walker { let entry = entry?; let rel = entry.path().strip_prefix(src)?; let dest_path = dest.join(rel); if entry.file_type().is_dir() { std::fs::create_dir_all(&dest_path)?; + } else if entry.file_type().is_symlink() { + // Skip broken/recursive symlinks. + continue; } else { if let Some(parent) = dest_path.parent() { std::fs::create_dir_all(parent)?; From 29b9412a447d7a17f3aefd8d4f241eb22253d744 Mon Sep 17 00:00:00 2001 From: Mufeed Ali Date: Wed, 1 Jul 2026 22:24:17 +0530 Subject: [PATCH 3/6] fix: set Flatpak-like XDG_DATA_DIRS so /app share data is found Wordbook looks up wn-*.db.zst via GLib system data dirs; without XDG_DATA_DIRS including /app/share it never saw the installed DB. Also always run simple build-commands under /bin/sh -c like builder. --- src/builder/mod.rs | 18 ++---------------- src/flatpak_manager.rs | 4 ++-- src/sandbox/bwrap.rs | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/builder/mod.rs b/src/builder/mod.rs index 4a84a1c..7e327de 100644 --- a/src/builder/mod.rs +++ b/src/builder/mod.rs @@ -411,23 +411,9 @@ fn build_simple( Ok(()) } -/// flatpak-builder runs build-commands through a shell; use `/bin/sh -c` when needed. +/// flatpak-builder always runs `build-commands` / `post-install` through a shell. fn command_to_argv(command: &str) -> Result> { - let needs_shell = command.contains('$') - || command.contains('`') - || command.contains("&&") - || command.contains("||") - || command.contains(';') - || command.contains('|') - || command.contains('>') - || command.contains('<') - || command.contains('(') - || command.contains(')'); - if needs_shell { - Ok(vec!["/bin/sh".into(), "-c".into(), command.to_string()]) - } else { - shell_words::split(command).with_context(|| format!("Bad build-command: {command}")) - } + Ok(vec!["/bin/sh".into(), "-c".into(), command.to_string()]) } fn build_autotools( diff --git a/src/flatpak_manager.rs b/src/flatpak_manager.rs index 38ae239..f9d7418 100644 --- a/src/flatpak_manager.rs +++ b/src/flatpak_manager.rs @@ -467,8 +467,8 @@ impl<'a> FlatpakManager<'a> { } fn parse_command_line(command: &str) -> Result> { - shell_words::split(command) - .with_context(|| format!("Failed to parse command line: {command}")) + // Match flatpak-builder: post-install / simple commands run under a shell. + Ok(vec!["/bin/sh".into(), "-c".into(), command.to_string()]) } fn substitute_vars( diff --git a/src/sandbox/bwrap.rs b/src/sandbox/bwrap.rs index 6c3e08a..68ad4bd 100644 --- a/src/sandbox/bwrap.rs +++ b/src/sandbox/bwrap.rs @@ -113,6 +113,8 @@ impl BwrapRunner { } } + // Mirror the environment Flatpak injects so apps find data under /app/share + // (e.g. GLib.get_system_data_dirs() → wordbook/wn-*.db.zst). args.extend([ "--bind".into(), path_to_str(&files)?.into(), @@ -130,6 +132,18 @@ impl BwrapRunner { "PKG_CONFIG_PATH".into(), "/app/lib/pkgconfig:/app/share/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig".into(), "--setenv".into(), + "XDG_DATA_DIRS".into(), + "/app/share:/usr/share:/usr/share/runtime/share".into(), + "--setenv".into(), + "XDG_CONFIG_DIRS".into(), + "/app/etc/xdg:/etc/xdg".into(), + "--setenv".into(), + "GI_TYPELIB_PATH".into(), + "/app/lib/girepository-1.0".into(), + "--setenv".into(), + "GSETTINGS_SCHEMA_DIR".into(), + "/app/share/glib-2.0/schemas".into(), + "--setenv".into(), "FLATPAK_ID".into(), self.app_id.clone(), "--setenv".into(), From e7ec5dc7035ae2c8e1ba9ae5316c524875e7b5fc Mon Sep 17 00:00:00 2001 From: Mufeed Ali Date: Thu, 16 Jul 2026 23:15:35 +0530 Subject: [PATCH 4/6] ci: expand Rust CI and add Linux binary artifact - Install stable toolchain with rustfmt/clippy and cargo cache - Separate jobs for checks vs release binary packaging - Upload linux-x86_64 artifact; attach assets on v* tags - rustfmt tree so fmt check can pass on this branch --- .github/workflows/rust.yml | 82 +++++++++++++++++++++++++++++++++----- src/build_dirs.rs | 5 +-- src/builder/mod.rs | 48 ++++++---------------- src/flatpak_manager.rs | 38 +++++++++++++----- src/git_source.rs | 24 +++++------ src/sandbox/bwrap.rs | 34 +++++----------- src/sandbox/install.rs | 10 +++-- src/utils.rs | 18 ++++----- 8 files changed, 150 insertions(+), 109 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 6fe5341..d30ba6d 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -1,28 +1,90 @@ -name: Rust +name: CI on: push: branches: ["main"] + tags: ["v*"] pull_request: branches: ["main"] env: CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: "0" permissions: contents: read jobs: - build: + ci: + name: fmt · clippy · test · build runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Check formatting + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Format run: cargo fmt --check - - name: Run clippy - run: cargo clippy -- -D warnings - - name: Build - run: cargo build --verbose - - name: Run tests + + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Test run: cargo test --verbose + + - name: Debug build + run: cargo build --verbose + + linux-binary: + name: Linux x86_64 binary + needs: ci + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Release build + run: cargo build --release --verbose + + - name: Package + run: | + set -euo pipefail + mkdir -p dist + install -m 755 target/release/flatplay dist/flatplay-linux-x86_64 + (cd dist && sha256sum flatplay-linux-x86_64 > flatplay-linux-x86_64.sha256) + ls -la dist/ + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: flatplay-linux-x86_64 + path: | + dist/flatplay-linux-x86_64 + dist/flatplay-linux-x86_64.sha256 + if-no-files-found: error + retention-days: 14 + + - name: Attach to GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + files: | + dist/flatplay-linux-x86_64 + dist/flatplay-linux-x86_64.sha256 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/src/build_dirs.rs b/src/build_dirs.rs index 125a413..424acad 100644 --- a/src/build_dirs.rs +++ b/src/build_dirs.rs @@ -67,10 +67,7 @@ mod tests { ); assert_eq!(dirs.ostree_dir(), base.join(".flatplay/ostree")); assert_eq!(dirs.cache_dir(), base.join(".flatplay/cache")); - assert_eq!( - dirs.module_source_dir("app"), - base.join(".flatplay/app") - ); + assert_eq!(dirs.module_source_dir("app"), base.join(".flatplay/app")); assert_eq!(dirs.metadata_file(), base.join(".flatplay/repo/metadata")); assert_eq!(dirs.files_dir(), base.join(".flatplay/repo/files")); assert_eq!(dirs.var_dir(), base.join(".flatplay/repo/var")); diff --git a/src/builder/mod.rs b/src/builder/mod.rs index 7e327de..39d4d55 100644 --- a/src/builder/mod.rs +++ b/src/builder/mod.rs @@ -24,13 +24,17 @@ pub fn update_dependencies( for module in modules_before_stop(manifest, stop_at)? { let Module::Object { name, sources, .. } = module else { - status_warn(format!("Skipping module reference during update: {module:?}")); + status_warn(format!( + "Skipping module reference during update: {module:?}" + )); continue; }; let typed = Source::from_values(sources)?; // Directory-only modules (pointing at the project tree) need no download. if typed.iter().all(|s| matches!(s, Source::Dir { .. })) { - verbose(format!("Module {name}: dir sources only, nothing to download")); + verbose(format!( + "Module {name}: dir sources only, nothing to download" + )); continue; } let source_dir = build_dir.join(name); @@ -75,8 +79,7 @@ pub fn build_dependencies( status(format!("Building module {name}...")); let typed = Source::from_values(sources)?; - let source_dir = if let Some(dir_path) = sources::resolve_dir_source(&typed, manifest_dir) - { + let source_dir = if let Some(dir_path) = sources::resolve_dir_source(&typed, manifest_dir) { // Prefer the live project/dir tree (no recursive copy into .flatplay). // Non-dir sources (files/archives) are materialized directly into that tree. let non_dir: Vec<_> = typed @@ -272,11 +275,7 @@ fn build_meson( let source_s = path_to_str(&source_canon)?.to_string(); let build_s = path_to_str(&module_build)?.to_string(); - let mut setup = vec![ - "meson".into(), - "setup".into(), - "--prefix=/app".into(), - ]; + let mut setup = vec!["meson".into(), "setup".into(), "--prefix=/app".into()]; setup.extend(config.iter().cloned()); setup.push(source_s.clone()); setup.push(build_s.clone()); @@ -301,12 +300,7 @@ fn build_meson( run_argv( runner, repo_dir, - &[ - "meson".into(), - "install".into(), - "-C".into(), - build_s, - ], + &["meson".into(), "install".into(), "-C".into(), build_s], env_pairs, path_overrides, None, @@ -361,12 +355,7 @@ fn build_cmake( run_argv( runner, repo_dir, - &[ - "ninja".into(), - "-C".into(), - build_s, - "install".into(), - ], + &["ninja".into(), "-C".into(), build_s, "install".into()], env_pairs, path_overrides, None, @@ -456,10 +445,7 @@ fn build_autotools( let module_build = build_dir.join(format!("_build-{name}")); std::fs::create_dir_all(&module_build)?; let build_s = path_to_str(&module_build)?; - let mut configure = vec![ - format!("{source_s}/configure"), - "--prefix=/app".into(), - ]; + let mut configure = vec![format!("{source_s}/configure"), "--prefix=/app".into()]; configure.extend(config.iter().cloned()); run_argv( runner, @@ -473,22 +459,14 @@ fn build_autotools( run_argv( runner, repo_dir, - &[ - "make".into(), - "V=0".into(), - jobs.clone(), - "install".into(), - ], + &["make".into(), "V=0".into(), jobs.clone(), "install".into()], env_pairs, path_overrides, Some(&module_build), workspace, )?; } else { - let mut configure = vec![ - format!("{source_s}/configure"), - "--prefix=/app".into(), - ]; + let mut configure = vec![format!("{source_s}/configure"), "--prefix=/app".into()]; configure.extend(config.iter().cloned()); run_argv( runner, diff --git a/src/flatpak_manager.rs b/src/flatpak_manager.rs index f9d7418..b22b16b 100644 --- a/src/flatpak_manager.rs +++ b/src/flatpak_manager.rs @@ -209,11 +209,7 @@ impl<'a> FlatpakManager<'a> { filesystem_binds.extend(extra_fs.iter().map(|s| (*s).to_string())); let mut env = Vec::new(); - for arg in sandbox - .env_args - .iter() - .chain(sandbox.path_overrides.iter()) - { + for arg in sandbox.env_args.iter().chain(sandbox.path_overrides.iter()) { if let Some(rest) = arg.strip_prefix("--env=") && let Some((k, v)) = rest.split_once('=') { @@ -534,14 +530,24 @@ impl<'a> FlatpakManager<'a> { "-C".to_string(), build_dir_str.to_string(), ]; - self.run_sandboxed(runner, &sandbox, repo_dir, &ninja_cmd, &extra_fs, true, None)?; + self.run_sandboxed( + runner, &sandbox, repo_dir, &ninja_cmd, &extra_fs, true, None, + )?; let install_cmd = vec![ "meson".to_string(), "install".to_string(), "-C".to_string(), build_dir_str.to_string(), ]; - self.run_sandboxed(runner, &sandbox, repo_dir, &install_cmd, &extra_fs, true, None) + self.run_sandboxed( + runner, + &sandbox, + repo_dir, + &install_cmd, + &extra_fs, + true, + None, + ) } fn resolve_module_source_dir( @@ -611,7 +617,9 @@ impl<'a> FlatpakManager<'a> { ]; configure.extend(config_opts.iter().map(|s| (*s).to_string())); configure.push(source_dir_str.to_string()); - self.run_sandboxed(runner, &sandbox, repo_dir, &configure, &extra_fs, true, None)?; + self.run_sandboxed( + runner, &sandbox, repo_dir, &configure, &extra_fs, true, None, + )?; } let ninja_cmd = vec![ @@ -619,14 +627,24 @@ impl<'a> FlatpakManager<'a> { "-C".to_string(), build_dir_str.to_string(), ]; - self.run_sandboxed(runner, &sandbox, repo_dir, &ninja_cmd, &extra_fs, true, None)?; + self.run_sandboxed( + runner, &sandbox, repo_dir, &ninja_cmd, &extra_fs, true, None, + )?; let install_cmd = vec![ "ninja".to_string(), "-C".to_string(), build_dir_str.to_string(), "install".to_string(), ]; - self.run_sandboxed(runner, &sandbox, repo_dir, &install_cmd, &extra_fs, true, None) + self.run_sandboxed( + runner, + &sandbox, + repo_dir, + &install_cmd, + &extra_fs, + true, + None, + ) } fn run_simple( diff --git a/src/git_source.rs b/src/git_source.rs index f11f11f..0a84e90 100644 --- a/src/git_source.rs +++ b/src/git_source.rs @@ -51,9 +51,7 @@ pub fn fetch_git_source(source_dir: &Path, name: &str, git_ref: GitRef<'_>) -> R clone_repository(git_ref.url, source_dir, Some(branch))? } (None, None, None) => { - anyhow::bail!( - "Git source in module '{name}' must specify one of: tag, commit, branch" - ); + anyhow::bail!("Git source in module '{name}' must specify one of: tag, commit, branch"); } }; @@ -66,7 +64,11 @@ pub fn fetch_git_source(source_dir: &Path, name: &str, git_ref: GitRef<'_>) -> R Ok(()) } -fn clone_repository(url: &str, source_dir: &Path, branch_or_tag: Option<&str>) -> Result { +fn clone_repository( + url: &str, + source_dir: &Path, + branch_or_tag: Option<&str>, +) -> Result { let mut builder = RepoBuilder::new(); let mut fetch_opts = FetchOptions::new(); // Depth is only applied for tip refs; arbitrary commits need a full fetch history. @@ -98,14 +100,12 @@ fn update_submodules(repo: &Repository) -> Result<()> { fn update_recursive(repo: &Repository) -> Result<()> { for mut submodule in repo.submodules().context("Failed to list submodules")? { let mut opts = SubmoduleUpdateOptions::new(); - submodule - .update(true, Some(&mut opts)) - .with_context(|| { - format!( - "Failed to update submodule {}", - submodule.name().unwrap_or("") - ) - })?; + submodule.update(true, Some(&mut opts)).with_context(|| { + format!( + "Failed to update submodule {}", + submodule.name().unwrap_or("") + ) + })?; if let Ok(sub_repo) = submodule.open() { update_recursive(&sub_repo)?; } diff --git a/src/sandbox/bwrap.rs b/src/sandbox/bwrap.rs index 68ad4bd..fa9fc5f 100644 --- a/src/sandbox/bwrap.rs +++ b/src/sandbox/bwrap.rs @@ -65,11 +65,7 @@ impl BwrapRunner { // Multi-arch SDK layouts often provide lib64. if self.usr_files.join("lib64").is_dir() { - args.extend([ - "--symlink".into(), - "usr/lib64".into(), - "/lib64".into(), - ]); + args.extend(["--symlink".into(), "usr/lib64".into(), "/lib64".into()]); } // Writable tmpfs /etc so we can add host resolv.conf (cannot overlay files onto a @@ -95,21 +91,13 @@ impl BwrapRunner { continue; } let dest = format!("/etc/{name_str}"); - args.extend([ - "--ro-bind-try".into(), - path_to_str(&src)?.into(), - dest, - ]); + args.extend(["--ro-bind-try".into(), path_to_str(&src)?.into(), dest]); } } } for host_file in ["/etc/resolv.conf", "/etc/hosts"] { if Path::new(host_file).exists() { - args.extend([ - "--ro-bind-try".into(), - host_file.into(), - host_file.into(), - ]); + args.extend(["--ro-bind-try".into(), host_file.into(), host_file.into()]); } } @@ -130,7 +118,8 @@ impl BwrapRunner { "/app/lib".into(), "--setenv".into(), "PKG_CONFIG_PATH".into(), - "/app/lib/pkgconfig:/app/share/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig".into(), + "/app/lib/pkgconfig:/app/share/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig" + .into(), "--setenv".into(), "XDG_DATA_DIRS".into(), "/app/share:/usr/share:/usr/share/runtime/share".into(), @@ -192,11 +181,7 @@ impl BwrapRunner { } else if let Some(rest) = fs.strip_prefix("--bind-mount=") { if let Some((dest, src)) = rest.split_once('=') { if Path::new(src).exists() { - args.extend([ - "--bind".into(), - src.into(), - dest.into(), - ]); + args.extend(["--bind".into(), src.into(), dest.into()]); } } } else if let Some(rest) = fs.strip_prefix("--bind=") { @@ -305,9 +290,10 @@ impl SandboxRunner for BwrapRunner { if cwd.exists() { let canon = cwd.canonicalize().unwrap_or_else(|_| cwd.clone()); let s = path_to_str(&canon)?; - if !args.windows(3).any(|w| { - w[0] == "--bind" && (w[1] == s || w[2] == s) - }) { + if !args + .windows(3) + .any(|w| w[0] == "--bind" && (w[1] == s || w[2] == s)) + { args.extend(["--bind".into(), s.into(), s.into()]); } args.extend(["--chdir".into(), s.into()]); diff --git a/src/sandbox/install.rs b/src/sandbox/install.rs index a299279..aa33ba6 100644 --- a/src/sandbox/install.rs +++ b/src/sandbox/install.rs @@ -79,9 +79,13 @@ pub fn read_flatpak_version_from_lib() -> Option { None } -pub fn ensure_sdk_and_runtime(sdk: &str, runtime: &str, branch: &str) -> Result<(DeployedRef, DeployedRef)> { - let sdk_ref = find_deployed(sdk, branch) - .with_context(|| format!("SDK {sdk}//{branch} is required"))?; +pub fn ensure_sdk_and_runtime( + sdk: &str, + runtime: &str, + branch: &str, +) -> Result<(DeployedRef, DeployedRef)> { + let sdk_ref = + find_deployed(sdk, branch).with_context(|| format!("SDK {sdk}//{branch} is required"))?; let runtime_ref = find_deployed(runtime, branch) .with_context(|| format!("Runtime {runtime}//{branch} is required"))?; Ok((sdk_ref, runtime_ref)) diff --git a/src/utils.rs b/src/utils.rs index e136430..3c35cb2 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -138,7 +138,9 @@ fn query_a11y_bus_address() -> Result { match query_a11y_bus_address_zbus() { Ok(address) => Ok(address), Err(zbus_error) => { - verbose(format!("zbus a11y query failed ({zbus_error:#}); trying gdbus")); + verbose(format!( + "zbus a11y query failed ({zbus_error:#}); trying gdbus" + )); query_a11y_bus_address_gdbus() } } @@ -147,13 +149,8 @@ fn query_a11y_bus_address() -> Result { fn query_a11y_bus_address_zbus() -> Result { let conn = zbus::blocking::Connection::session() .context("Failed to connect to session bus for a11y")?; - let proxy = zbus::blocking::Proxy::new( - &conn, - "org.a11y.Bus", - "/org/a11y/bus", - "org.a11y.Bus", - ) - .context("Failed to create a11y bus proxy")?; + let proxy = zbus::blocking::Proxy::new(&conn, "org.a11y.Bus", "/org/a11y/bus", "org.a11y.Bus") + .context("Failed to create a11y bus proxy")?; let address: String = proxy .call("GetAddress", &()) .context("org.a11y.Bus.GetAddress failed")?; @@ -331,9 +328,8 @@ pub fn move_file(src: &Path, dest: &Path) -> Result<()> { std::fs::remove_file(src).ok(); Ok(()) } - Err(error) => Err(error).with_context(|| { - format!("Failed to move {} -> {}", src.display(), dest.display()) - }), + Err(error) => Err(error) + .with_context(|| format!("Failed to move {} -> {}", src.display(), dest.display())), } } From c808821db78faa16048a531003eaac2635d00208 Mon Sep 17 00:00:00 2001 From: Mufeed Ali Date: Thu, 16 Jul 2026 23:18:06 +0530 Subject: [PATCH 5/6] feat: use flatpak CLI as execution plane Drop pure bwrap sandbox; run build/run via flatpak build* while keeping in-process module/source handling (no flatpak-builder). Unify app and dependency builds, fix manifest hashing and BUILDDIR mounts, host-escape version probes, and differentiate runtime vs build terminals. Also drop unused shell-words dependency. --- Cargo.lock | 1 - Cargo.toml | 1 - src/build_dirs.rs | 19 - src/builder/mod.rs | 654 ++++++++++++++++++---------------- src/command.rs | 86 +++-- src/flatpak_manager.rs | 786 ++++++++++------------------------------- src/main.rs | 32 +- src/manifest.rs | 263 +++++++++----- src/sandbox/bwrap.rs | 342 ------------------ src/sandbox/flatpak.rs | 172 +++++++++ src/sandbox/install.rs | 104 ------ src/sandbox/mod.rs | 144 ++------ src/sources/cache.rs | 23 -- src/sources/mod.rs | 43 +-- src/state.rs | 2 +- src/utils.rs | 12 +- 16 files changed, 1010 insertions(+), 1674 deletions(-) delete mode 100644 src/sandbox/bwrap.rs create mode 100644 src/sandbox/flatpak.rs delete mode 100644 src/sandbox/install.rs diff --git a/Cargo.lock b/Cargo.lock index 23fa581..77c8f38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -769,7 +769,6 @@ dependencies = [ "serde-saphyr", "serde_json", "sha2 0.11.0", - "shell-words", "tar", "tempfile", "ureq", diff --git a/Cargo.toml b/Cargo.toml index 140fa62..675e54f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,6 @@ flate2 = "1" xz2 = "0.1" zip = "8" tempfile = "3" -shell-words = "1.1" zbus = "5" # libgit2-backed; avoids requiring a host `git` binary on PATH git2 = "0.20" diff --git a/src/build_dirs.rs b/src/build_dirs.rs index 424acad..58b0b29 100644 --- a/src/build_dirs.rs +++ b/src/build_dirs.rs @@ -16,24 +16,12 @@ impl BuildDirs { pub fn repo_dir(&self) -> PathBuf { self.build_dir().join("repo") } - pub fn build_system_dir(&self) -> PathBuf { - self.build_dir().join("_build") - } - pub fn flatpak_builder_dir(&self) -> PathBuf { - self.build_dir().join("flatpak-builder") - } pub fn finalized_repo_dir(&self) -> PathBuf { self.build_dir().join("finalized-repo") } pub fn ostree_dir(&self) -> PathBuf { self.build_dir().join("ostree") } - pub fn cache_dir(&self) -> PathBuf { - self.build_dir().join("cache") - } - pub fn module_source_dir(&self, module_name: &str) -> PathBuf { - self.build_dir().join(module_name) - } pub fn metadata_file(&self) -> PathBuf { self.repo_dir().join("metadata") } @@ -56,18 +44,11 @@ mod tests { assert_eq!(dirs.build_dir(), base.join(".flatplay")); assert_eq!(dirs.repo_dir(), base.join(".flatplay/repo")); - assert_eq!(dirs.build_system_dir(), base.join(".flatplay/_build")); - assert_eq!( - dirs.flatpak_builder_dir(), - base.join(".flatplay/flatpak-builder") - ); assert_eq!( dirs.finalized_repo_dir(), base.join(".flatplay/finalized-repo") ); assert_eq!(dirs.ostree_dir(), base.join(".flatplay/ostree")); - assert_eq!(dirs.cache_dir(), base.join(".flatplay/cache")); - assert_eq!(dirs.module_source_dir("app"), base.join(".flatplay/app")); assert_eq!(dirs.metadata_file(), base.join(".flatplay/repo/metadata")); assert_eq!(dirs.files_dir(), base.join(".flatplay/repo/files")); assert_eq!(dirs.var_dir(), base.join(".flatplay/repo/var")); diff --git a/src/builder/mod.rs b/src/builder/mod.rs index 39d4d55..2a8334d 100644 --- a/src/builder/mod.rs +++ b/src/builder/mod.rs @@ -1,13 +1,22 @@ -//! In-process dependency builder — **no `flatpak-builder` CLI**. +//! In-process module builder — **no `flatpak-builder` CLI**. +//! +//! Used for both dependency modules and the application module so semantics stay unified. use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use crate::manifest::{Manifest, Module}; -use crate::sandbox::{BwrapRunner, RunSpec, SandboxRunner}; +use crate::sandbox::{FlatpakRunner, RunSpec, SandboxRunner}; use crate::sources::{self, DownloadCache, Source}; -use crate::utils::{path_to_str, status, status_warn, verbose}; +use crate::utils::{copy_dir_all, path_to_str, status, status_warn, verbose}; + +/// Options for building a single module. +#[derive(Debug, Clone, Copy)] +pub struct BuildModuleOpts { + /// When true, skip configure/setup and only rebuild + install. + pub rebuild: bool, +} /// Download/materialize all non-app modules' sources into the cache / module dirs. pub fn update_dependencies( @@ -22,10 +31,10 @@ pub fn update_dependencies( .context("Manifest has no parent directory")?; let cache = DownloadCache::new(build_dir); - for module in modules_before_stop(manifest, stop_at)? { - let Module::Object { name, sources, .. } = module else { + for module in modules_before_stop(manifest, manifest_path, stop_at)? { + let Module::Object { name, sources, .. } = &module else { status_warn(format!( - "Skipping module reference during update: {module:?}" + "Skipping unexpected module form during update: {module:?}" )); continue; }; @@ -43,7 +52,7 @@ pub fn update_dependencies( Ok(()) } -/// Build all modules before `stop_at` into `repo_dir` via bwrap + SDK. +/// Build all modules before `stop_at` into `repo_dir` via flatpak build + SDK. pub fn build_dependencies( manifest: &Manifest, manifest_path: &Path, @@ -51,156 +60,213 @@ pub fn build_dependencies( build_dir: &Path, stop_at: &str, workspace: &Path, - runner: &BwrapRunner, + runner: &FlatpakRunner, ) -> Result<()> { status("Building dependencies (in-process)..."); - let manifest_dir = manifest_path + let paths = ModulePaths { + repo_dir, + build_dir, + workspace, + manifest_path, + }; + for module in modules_before_stop(manifest, manifest_path, stop_at)? { + build_module( + manifest, + &module, + &paths, + runner, + BuildModuleOpts { rebuild: false }, + )?; + } + Ok(()) +} + +/// Paths shared by a module build. +pub struct ModulePaths<'a> { + pub repo_dir: &'a Path, + pub build_dir: &'a Path, + pub workspace: &'a Path, + pub manifest_path: &'a Path, +} + +/// Build a single module (dependency or application) with unified semantics. +pub fn build_module( + manifest: &Manifest, + module: &Module, + paths: &ModulePaths<'_>, + runner: &FlatpakRunner, + opts: BuildModuleOpts, +) -> Result<()> { + let Module::Object { + name, + buildsystem, + builddir, + subdir, + config_opts, + build_commands, + build_options, + post_install, + sources, + .. + } = module + else { + return Err(anyhow::anyhow!("Module is not a defined object module")); + }; + + let manifest_dir = paths + .manifest_path .parent() .context("Manifest has no parent directory")?; - let cache = DownloadCache::new(build_dir); + let cache = DownloadCache::new(paths.build_dir); let num_cpus = std::thread::available_parallelism().map_or(1, std::num::NonZero::get); - for module in modules_before_stop(manifest, stop_at)? { - let Module::Object { - name, - buildsystem, - builddir, - subdir, - config_opts, - build_commands, - build_options, - post_install, - sources, - .. - } = module - else { - continue; - }; + status(format!("Building module {name}...")); + let typed = Source::from_values(sources)?; + let source_dir = + resolve_and_materialize_sources(name, &typed, manifest_dir, paths.build_dir, &cache)?; - status(format!("Building module {name}...")); - let typed = Source::from_values(sources)?; - let source_dir = if let Some(dir_path) = sources::resolve_dir_source(&typed, manifest_dir) { - // Prefer the live project/dir tree (no recursive copy into .flatplay). - // Non-dir sources (files/archives) are materialized directly into that tree. - let non_dir: Vec<_> = typed - .iter() - .filter(|s| !matches!(s, Source::Dir { .. })) - .cloned() - .collect(); - if !non_dir.is_empty() { - // Materialize into a temp module folder, then copy files to dir_path. - let staging = build_dir.join(format!("{name}-staging")); - sources::materialize_sources(&non_dir, &staging, manifest_dir, &cache, name)?; - for entry in std::fs::read_dir(&staging)? { - let entry = entry?; - let dest = dir_path.join(entry.file_name()); - if entry.file_type()?.is_file() { - std::fs::copy(entry.path(), &dest)?; - } - } - } - dir_path - } else { - let source_dir = build_dir.join(name); - if !source_dir.exists() { - sources::materialize_sources(&typed, &source_dir, manifest_dir, &cache, name)?; - } - source_dir - }; + let source_dir = if let Some(subdir) = subdir { + source_dir.join(subdir) + } else { + source_dir + }; - let source_dir = if let Some(subdir) = subdir { - source_dir.join(subdir) - } else { - source_dir - }; + let merged_config: Vec = manifest + .merged_config_opts(config_opts.as_deref()) + .into_iter() + .map(str::to_string) + .collect(); - let merged_config: Vec = manifest - .merged_config_opts(config_opts.as_deref()) - .into_iter() - .map(str::to_string) - .collect(); + let env_pairs: Vec<(String, String)> = manifest + .merged_env(build_options.as_ref()) + .into_iter() + .collect(); - let env_pairs: Vec<(String, String)> = manifest - .merged_env(build_options.as_ref()) - .into_iter() - .collect(); + let path_overrides = manifest.path_overrides(build_options.as_ref()); - let path_overrides = manifest.path_overrides(build_options.as_ref()); + // Canonicalize when possible so /run/build/$name is a stable host path. + let module_root = source_dir + .canonicalize() + .unwrap_or_else(|_| source_dir.clone()); - match buildsystem.as_deref() { - Some("meson") => build_meson( - runner, - repo_dir, - &source_dir, - build_dir, - name, - &merged_config, - &env_pairs, - &path_overrides, - workspace, - )?, - Some("cmake" | "cmake-ninja") => build_cmake( - runner, - repo_dir, - &source_dir, - build_dir, - name, - &merged_config, - &env_pairs, - &path_overrides, - workspace, - )?, - Some("simple") => build_simple( - runner, - repo_dir, - &source_dir, - manifest, - name, - build_commands.as_ref(), - &env_pairs, - &path_overrides, - num_cpus, - workspace, - )?, - _ => build_autotools( - runner, - repo_dir, - &source_dir, - build_dir, + let ctx = ModuleBuildCtx { + runner, + repo_dir: paths.repo_dir, + workspace: paths.workspace, + module_name: name, + module_root: &module_root, + env_pairs: &env_pairs, + path_overrides: &path_overrides, + }; + + match buildsystem.as_deref() { + Some("meson") => build_meson( + &ctx, + &source_dir, + paths.build_dir, + name, + &merged_config, + opts.rebuild, + )?, + Some("cmake" | "cmake-ninja") => build_cmake( + &ctx, + &source_dir, + paths.build_dir, + name, + &merged_config, + opts.rebuild, + )?, + Some("simple") => build_simple( + &ctx, + &source_dir, + manifest, + name, + build_commands.as_ref(), + num_cpus, + )?, + Some("qmake") => { + return Err(anyhow::anyhow!("qmake build system is not supported")); + } + _ => build_autotools( + &ctx, + &source_dir, + paths.build_dir, + AutotoolsOpts { name, - builddir.unwrap_or(false), - &merged_config, - &env_pairs, - &path_overrides, + use_builddir: builddir.as_ref().copied().unwrap_or(false), + config: &merged_config, num_cpus, - workspace, - )?, + rebuild: opts.rebuild, + }, + )?, + } + + if let Some(post_install) = post_install { + for command in post_install { + let processed = substitute_vars(command, &manifest.id, name, num_cpus); + let argv = command_to_argv(&processed)?; + ctx.run_argv(&argv, Some(&source_dir))?; } + } - if let Some(post_install) = post_install { - for command in post_install { - let processed = substitute_vars(command, &manifest.id, name, num_cpus); - let argv = command_to_argv(&processed)?; - run_argv( - runner, - repo_dir, - &argv, - &env_pairs, - &path_overrides, - Some(&source_dir), - workspace, - )?; + Ok(()) +} + +fn resolve_and_materialize_sources( + name: &str, + typed: &[Source], + manifest_dir: &Path, + build_dir: &Path, + cache: &DownloadCache, +) -> Result { + if let Some(dir_path) = sources::resolve_dir_source(typed, manifest_dir) { + // Live project/dir tree — materialize non-dir sources into it. + let non_dir: Vec<_> = typed + .iter() + .filter(|s| !matches!(s, Source::Dir { .. })) + .cloned() + .collect(); + if !non_dir.is_empty() { + let staging = build_dir.join(format!("{name}-staging")); + sources::materialize_sources(&non_dir, &staging, manifest_dir, cache, name)?; + // Recursive copy so archive layouts with subdirs are preserved. + for entry in std::fs::read_dir(&staging)? { + let entry = entry?; + let dest = dir_path.join(entry.file_name()); + let ft = entry.file_type()?; + if ft.is_dir() { + copy_dir_all(&entry.path(), &dest)?; + } else if ft.is_file() { + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(entry.path(), &dest)?; + } } + let _ = std::fs::remove_dir_all(&staging); } + return Ok(dir_path); } - Ok(()) + let source_dir = build_dir.join(name); + if !source_dir.exists() { + sources::materialize_sources(typed, &source_dir, manifest_dir, cache, name)?; + } + Ok(source_dir) } -fn modules_before_stop<'a>(manifest: &'a Manifest, stop_at: &str) -> Result> { +fn modules_before_stop( + manifest: &Manifest, + manifest_path: &Path, + stop_at: &str, +) -> Result> { + let manifest_dir = manifest_path + .parent() + .context("Manifest path has no parent directory")?; + let expanded = Manifest::expand_modules(&manifest.modules, manifest_dir)?; let mut out = Vec::new(); - for module in &manifest.modules { - let name = match module { + for module in expanded { + let name = match &module { Module::Object { name, .. } => name.as_str(), Module::Reference(path) => path.as_str(), }; @@ -209,44 +275,65 @@ fn modules_before_stop<'a>(manifest: &'a Manifest, stop_at: &str) -> Result, - workspace: &Path, -) -> Result<()> { - let mut filesystem_binds = vec![format!("--filesystem={}", path_to_str(workspace)?)]; - if let Some(cwd) = cwd { - filesystem_binds.push(format!("--filesystem={}", path_to_str(cwd)?)); - } +struct ModuleBuildCtx<'a> { + runner: &'a FlatpakRunner, + repo_dir: &'a Path, + workspace: &'a Path, + module_name: &'a str, + /// Module source/work tree (flatpak-builder's `/run/build/$name` root). + module_root: &'a Path, + env_pairs: &'a [(String, String)], + path_overrides: &'a [String], +} - let mut env = env_pairs.to_vec(); - for override_arg in path_overrides { - if let Some(rest) = override_arg.strip_prefix("--env=") - && let Some((key, value)) = rest.split_once('=') - { - env.push((key.to_string(), value.to_string())); +impl ModuleBuildCtx<'_> { + fn run_argv(&self, argv: &[String], cwd: Option<&Path>) -> Result<()> { + let mut filesystem_binds = vec![format!("--filesystem={}", path_to_str(self.workspace)?)]; + let module_root_s = path_to_str(self.module_root)?; + filesystem_binds.push(format!("--filesystem={module_root_s}")); + if let Some(cwd) = cwd { + let cwd_s = path_to_str(cwd)?; + if cwd_s != module_root_s { + filesystem_binds.push(format!("--filesystem={cwd_s}")); + } } - } - runner.run(&RunSpec { - repo_dir: repo_dir.to_path_buf(), - argv: argv.to_vec(), - env, - filesystem_binds, - extra_args: vec![], - share_network: true, - cwd: cwd.map(Path::to_path_buf), - }) + // flatpak-builder: module sources live at /run/build/$name (stable for the module). + filesystem_binds.push(format!( + "--bind-mount=/run/build/{}={}", + self.module_name, module_root_s + )); + + let mut env = self.env_pairs.to_vec(); + for override_arg in self.path_overrides { + if let Some(rest) = override_arg.strip_prefix("--env=") + && let Some((key, value)) = rest.split_once('=') + { + env.push((key.to_string(), value.to_string())); + } + } + + self.runner.run(&RunSpec { + repo_dir: self.repo_dir.to_path_buf(), + argv: argv.to_vec(), + env, + filesystem_binds, + extra_args: vec![], + share_network: true, + cwd: cwd.map(Path::to_path_buf), + }) + } } -fn substitute_vars(command: &str, flatpak_id: &str, module_name: &str, num_cpus: usize) -> String { +pub fn substitute_vars( + command: &str, + flatpak_id: &str, + module_name: &str, + num_cpus: usize, +) -> String { command .replace("${FLATPAK_ID}", flatpak_id) .replace("${FLATPAK_ARCH}", &crate::sources::flatpak_arch()) @@ -258,16 +345,21 @@ fn substitute_vars(command: &str, flatpak_id: &str, module_name: &str, num_cpus: ) } +fn meson_configured(module_build: &Path) -> bool { + module_build.join("build.ninja").is_file() || module_build.join("meson-info").is_dir() +} + +fn cmake_configured(module_build: &Path) -> bool { + module_build.join("build.ninja").is_file() || module_build.join("CMakeCache.txt").is_file() +} + fn build_meson( - runner: &BwrapRunner, - repo_dir: &Path, + ctx: &ModuleBuildCtx<'_>, source_dir: &Path, build_dir: &Path, name: &str, config: &[String], - env_pairs: &[(String, String)], - path_overrides: &[String], - workspace: &Path, + rebuild: bool, ) -> Result<()> { let module_build = build_dir.join(format!("_build-{name}")); std::fs::create_dir_all(&module_build)?; @@ -275,105 +367,79 @@ fn build_meson( let source_s = path_to_str(&source_canon)?.to_string(); let build_s = path_to_str(&module_build)?.to_string(); - let mut setup = vec!["meson".into(), "setup".into(), "--prefix=/app".into()]; - setup.extend(config.iter().cloned()); - setup.push(source_s.clone()); - setup.push(build_s.clone()); - run_argv( - runner, - repo_dir, - &setup, - env_pairs, - path_overrides, - None, - workspace, - )?; - run_argv( - runner, - repo_dir, + if !rebuild && !meson_configured(&module_build) { + let mut setup = vec!["meson".into(), "setup".into(), "--prefix=/app".into()]; + setup.extend(config.iter().cloned()); + setup.push(source_s); + setup.push(build_s.clone()); + ctx.run_argv(&setup, Some(&module_build))?; + } else if rebuild && !meson_configured(&module_build) { + return Err(anyhow::anyhow!( + "Cannot rebuild module {name}: no existing meson build dir at {}", + module_build.display() + )); + } + + ctx.run_argv( &["ninja".into(), "-C".into(), build_s.clone()], - env_pairs, - path_overrides, - None, - workspace, + Some(&module_build), )?; - run_argv( - runner, - repo_dir, + ctx.run_argv( &["meson".into(), "install".into(), "-C".into(), build_s], - env_pairs, - path_overrides, - None, - workspace, + Some(&module_build), ) } fn build_cmake( - runner: &BwrapRunner, - repo_dir: &Path, + ctx: &ModuleBuildCtx<'_>, source_dir: &Path, build_dir: &Path, name: &str, config: &[String], - env_pairs: &[(String, String)], - path_overrides: &[String], - workspace: &Path, + rebuild: bool, ) -> Result<()> { let module_build = build_dir.join(format!("_build-{name}")); std::fs::create_dir_all(&module_build)?; let source_canon = source_dir.canonicalize()?; let source_s = path_to_str(&source_canon)?.to_string(); let build_s = path_to_str(&module_build)?.to_string(); - let mut configure = vec![ - "cmake".into(), - "-G".into(), - "Ninja".into(), - format!("-B{build_s}"), - "-DCMAKE_BUILD_TYPE=RelWithDebInfo".into(), - "-DCMAKE_INSTALL_PREFIX=/app".into(), - ]; - configure.extend(config.iter().cloned()); - configure.push(source_s); - run_argv( - runner, - repo_dir, - &configure, - env_pairs, - path_overrides, - None, - workspace, - )?; - run_argv( - runner, - repo_dir, + + if !rebuild && !cmake_configured(&module_build) { + let mut configure = vec![ + "cmake".into(), + "-G".into(), + "Ninja".into(), + format!("-B{build_s}"), + "-DCMAKE_BUILD_TYPE=RelWithDebInfo".into(), + "-DCMAKE_INSTALL_PREFIX=/app".into(), + ]; + configure.extend(config.iter().cloned()); + configure.push(source_s); + ctx.run_argv(&configure, Some(&module_build))?; + } else if rebuild && !cmake_configured(&module_build) { + return Err(anyhow::anyhow!( + "Cannot rebuild module {name}: no existing cmake build dir at {}", + module_build.display() + )); + } + + ctx.run_argv( &["ninja".into(), "-C".into(), build_s.clone()], - env_pairs, - path_overrides, - None, - workspace, + Some(&module_build), )?; - run_argv( - runner, - repo_dir, + ctx.run_argv( &["ninja".into(), "-C".into(), build_s, "install".into()], - env_pairs, - path_overrides, - None, - workspace, + Some(&module_build), ) } fn build_simple( - runner: &BwrapRunner, - repo_dir: &Path, + ctx: &ModuleBuildCtx<'_>, source_dir: &Path, manifest: &Manifest, name: &str, build_commands: Option<&Vec>, - env_pairs: &[(String, String)], - path_overrides: &[String], num_cpus: usize, - workspace: &Path, ) -> Result<()> { let Some(commands) = build_commands else { return Ok(()); @@ -387,15 +453,7 @@ fn build_simple( .replace("${PWD}", path_to_str(&source_canon)?) .replace("$PWD", path_to_str(&source_canon)?); let argv = command_to_argv(&processed)?; - run_argv( - runner, - repo_dir, - &argv, - env_pairs, - path_overrides, - Some(&source_canon), - workspace, - )?; + ctx.run_argv(&argv, Some(&source_canon))?; } Ok(()) } @@ -405,35 +463,38 @@ fn command_to_argv(command: &str) -> Result> { Ok(vec!["/bin/sh".into(), "-c".into(), command.to_string()]) } +struct AutotoolsOpts<'a> { + name: &'a str, + use_builddir: bool, + config: &'a [String], + num_cpus: usize, + rebuild: bool, +} + fn build_autotools( - runner: &BwrapRunner, - repo_dir: &Path, + ctx: &ModuleBuildCtx<'_>, source_dir: &Path, build_dir: &Path, - name: &str, - use_builddir: bool, - config: &[String], - env_pairs: &[(String, String)], - path_overrides: &[String], - num_cpus: usize, - workspace: &Path, + opts: AutotoolsOpts<'_>, ) -> Result<()> { + let AutotoolsOpts { + name, + use_builddir, + config, + num_cpus, + rebuild, + } = opts; let source_canon = source_dir.canonicalize()?; let source_s = path_to_str(&source_canon)?; // Generate configure if needed. - if !source_canon.join("configure").exists() { + if !rebuild && !source_canon.join("configure").exists() { for candidate in ["autogen.sh", "bootstrap", "bootstrap.sh"] { let script = source_canon.join(candidate); if script.exists() { - run_argv( - runner, - repo_dir, + ctx.run_argv( &["/bin/sh".into(), path_to_str(&script)?.into()], - env_pairs, - path_overrides, Some(&source_canon), - workspace, )?; break; } @@ -444,47 +505,24 @@ fn build_autotools( if use_builddir { let module_build = build_dir.join(format!("_build-{name}")); std::fs::create_dir_all(&module_build)?; - let build_s = path_to_str(&module_build)?; - let mut configure = vec![format!("{source_s}/configure"), "--prefix=/app".into()]; - configure.extend(config.iter().cloned()); - run_argv( - runner, - repo_dir, - &configure, - env_pairs, - path_overrides, - Some(&module_build), - workspace, - )?; - run_argv( - runner, - repo_dir, - &["make".into(), "V=0".into(), jobs.clone(), "install".into()], - env_pairs, - path_overrides, + if !rebuild { + let mut configure = vec![format!("{source_s}/configure"), "--prefix=/app".into()]; + configure.extend(config.iter().cloned()); + ctx.run_argv(&configure, Some(&module_build))?; + } + ctx.run_argv( + &["make".into(), "V=0".into(), jobs, "install".into()], Some(&module_build), - workspace, )?; } else { - let mut configure = vec![format!("{source_s}/configure"), "--prefix=/app".into()]; - configure.extend(config.iter().cloned()); - run_argv( - runner, - repo_dir, - &configure, - env_pairs, - path_overrides, - Some(&source_canon), - workspace, - )?; - run_argv( - runner, - repo_dir, + if !rebuild { + let mut configure = vec![format!("{source_s}/configure"), "--prefix=/app".into()]; + configure.extend(config.iter().cloned()); + ctx.run_argv(&configure, Some(&source_canon))?; + } + ctx.run_argv( &["make".into(), "V=0".into(), jobs, "install".into()], - env_pairs, - path_overrides, Some(&source_canon), - workspace, )?; } Ok(()) @@ -494,6 +532,15 @@ fn build_autotools( mod tests { use super::*; use crate::manifest::{BuildOptions, Manifest, Module}; + use std::path::PathBuf; + + #[test] + fn substitute_vars_uses_flatpak_arch() { + let out = substitute_vars("arch=${FLATPAK_ARCH}", "org.example.App", "mod", 4); + assert_eq!(out, format!("arch={}", crate::sources::flatpak_arch())); + let out = substitute_vars("${FLATPAK_BUILDER_BUILDDIR}", "org.example.App", "mymod", 1); + assert_eq!(out, "/run/build/mymod"); + } #[test] fn modules_before_stop_excludes_app() { @@ -515,6 +562,7 @@ mod tests { build_options: None, post_install: None, sources: vec![], + modules: vec![], }, Module::Object { name: "app".into(), @@ -526,6 +574,7 @@ mod tests { build_options: None, post_install: None, sources: vec![], + modules: vec![], }, ], finish_args: vec![], @@ -533,7 +582,8 @@ mod tests { sdk_extensions: vec![], cleanup: vec![], }; - let mods = modules_before_stop(&manifest, "app").unwrap(); + let manifest_path = PathBuf::from("/tmp/manifest.json"); + let mods = modules_before_stop(&manifest, &manifest_path, "app").unwrap(); assert_eq!(mods.len(), 1); } } diff --git a/src/command.rs b/src/command.rs index 6475762..285c751 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1,5 +1,9 @@ use std::path::Path; use std::process::{Command, Stdio}; +use std::sync::OnceLock; + +#[cfg(unix)] +use std::os::unix::process::ExitStatusExt; use crate::utils::{command_header, verbose}; use anyhow::Result; @@ -23,11 +27,6 @@ fn is_sandboxed() -> bool { Path::new("/.flatpak-info").exists() } -// Returns true if running inside a container like Toolbx or distrobox. -fn is_inside_container() -> bool { - Path::new("/run/.containerenv").exists() -} - fn command_succeeds(cmd: &str, args: &[&str]) -> bool { Command::new(cmd) .args(args) @@ -37,39 +36,54 @@ fn command_succeeds(cmd: &str, args: &[&str]) -> bool { .is_ok_and(|s| s.success()) } -// Runs a command, handling Flatpak sandbox and container specifics. -pub fn run_command(command: &str, args: &[&str], working_dir: Option<&Path>) -> Result<()> { - let mut command_args = args.to_vec(); - - // Workaround for rofiles-fuse issues in containers. - if command == "flatpak-builder" - && is_inside_container() - && !command_args.contains(&"--disable-rofiles-fuse") - { - verbose("Detected container, adding --disable-rofiles-fuse"); - command_args.push("--disable-rofiles-fuse"); - } +/// How to escape a Flatpak sandbox when flatplay itself runs inside one. +#[derive(Clone, Copy, Debug)] +enum HostEscape { + None, + HostSpawn, + FlatpakSpawn, +} - let (program, final_args) = if is_sandboxed() { +fn host_escape() -> HostEscape { + static ESCAPE: OnceLock = OnceLock::new(); + *ESCAPE.get_or_init(|| { + if !is_sandboxed() { + return HostEscape::None; + } if command_succeeds("host-spawn", &["--version"]) { verbose("Detected Flatpak sandbox, using host-spawn"); - let mut new_args = vec![command]; - new_args.extend_from_slice(&command_args); - ("host-spawn", new_args) + HostEscape::HostSpawn } else { verbose("Detected Flatpak sandbox, using flatpak-spawn"); + HostEscape::FlatpakSpawn + } + }) +} + +fn resolve_host_argv<'a>(command: &'a str, args: &'a [&'a str]) -> (&'a str, Vec<&'a str>) { + match host_escape() { + HostEscape::None => (command, args.to_vec()), + HostEscape::HostSpawn => { + let mut new_args = vec![command]; + new_args.extend_from_slice(args); + ("host-spawn", new_args) + } + HostEscape::FlatpakSpawn => { let mut new_args = vec![ "--host", "--watch-bus", "--env=TERM=xterm-256color", command, ]; - new_args.extend_from_slice(&command_args); + new_args.extend_from_slice(args); ("flatpak-spawn", new_args) } - } else { - (command, command_args) - }; + } +} + +/// Runs a host command, escaping a Flatpak sandbox via host-spawn / flatpak-spawn when needed. +pub fn run_command(command: &str, args: &[&str], working_dir: Option<&Path>) -> Result<()> { + let (program, final_args) = resolve_host_argv(command, args); command_header(program, &final_args); let mut cmd = Command::new(program); @@ -92,7 +106,6 @@ pub fn run_command(command: &str, args: &[&str], working_dir: Option<&Path>) -> || { #[cfg(unix)] { - use std::os::unix::process::ExitStatusExt; format!("signal {}", status.signal().unwrap_or(0)) } #[cfg(not(unix))] @@ -108,4 +121,23 @@ pub fn run_command(command: &str, args: &[&str], working_dir: Option<&Path>) -> Ok(()) } -// Legacy helper removed: dependency builds are in-process (see `builder` + `sandbox::BwrapRunner`). +/// Capture stdout of a host command (with the same sandbox escape as `run_command`). +pub fn run_command_output(command: &str, args: &[&str]) -> Result { + let (program, final_args) = resolve_host_argv(command, args); + command_header(program, &final_args); + Command::new(program) + .args(&final_args) + .output() + .map_err(Into::into) +} + +/// True if the host can run `command` (sandbox-aware). +pub fn host_command_exists(command: &str, probe_args: &[&str]) -> bool { + let (program, final_args) = resolve_host_argv(command, probe_args); + Command::new(program) + .args(&final_args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|s| s.success()) +} diff --git a/src/flatpak_manager.rs b/src/flatpak_manager.rs index b22b16b..4f2079e 100644 --- a/src/flatpak_manager.rs +++ b/src/flatpak_manager.rs @@ -1,6 +1,7 @@ use std::env; use std::fmt::Write; use std::fs; +use std::io::Read; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; @@ -10,13 +11,14 @@ use nix::unistd::geteuid; use crate::build_dirs::BuildDirs; use crate::builder; -use crate::manifest::{BuildOptions, Manifest, Module, find_manifests_in_path}; -use crate::sandbox::{self, BwrapRunner, RunSpec, SandboxRunner, ensure_sdk_and_runtime}; -use crate::sources::{self, DownloadCache, Source}; +use crate::command::{run_command, run_command_output}; +use crate::manifest::{Manifest, Module, find_manifests_in_path}; +use crate::sandbox::{self, FlatpakRunner, RunSpec, SandboxRunner}; +use crate::sources::{self, Source}; use crate::state::State; use crate::utils::{ - build_font_config, copy_dir_all, get_a11y_bus_args, get_fonts_args, get_host_env, path_to_str, - status, status_info, status_success, status_warn, verbose, + build_font_config, get_a11y_bus_args, get_fonts_args, get_host_env, path_to_str, status, + status_info, status_success, status_warn, verbose, version_less_than, }; use sha2::{Digest, Sha256}; @@ -57,25 +59,11 @@ impl<'a> FlatpakManager<'a> { fn compute_manifest_hash(path: &Path) -> Result { let mut hasher = Sha256::new(); Self::hash_file_into(&mut hasher, path)?; - // Include referenced module files and source fingerprints so edits invalidate state. + // Hash referenced module files fully (buildsystem/config edits) and + // fingerprint expanded inline/nested modules' sources. if let Ok(manifest) = Manifest::from_file(path) { let parent = path.parent().unwrap_or_else(|| Path::new(".")); - for module in &manifest.modules { - match module { - Module::Reference(name) => { - let ref_path = parent.join(name); - if ref_path.is_file() { - Self::hash_file_into(&mut hasher, &ref_path)?; - } - } - Module::Object { sources, name, .. } => { - hasher.update(name.as_bytes()); - if let Ok(typed) = Source::from_values(sources) { - hasher.update(sources::sources_fingerprint(&typed).as_bytes()); - } - } - } - } + Self::hash_module_tree(&mut hasher, &manifest.modules, parent)?; } let result = hasher.finalize(); let mut hash = String::with_capacity(64); @@ -85,8 +73,60 @@ impl<'a> FlatpakManager<'a> { Ok(hash) } + fn hash_module_tree(hasher: &mut Sha256, modules: &[Module], dir: &Path) -> Result<()> { + for module in modules { + match module { + Module::Reference(name) => { + let ref_path = dir.join(name); + if ref_path.is_file() { + Self::hash_file_into(hasher, &ref_path)?; + // Nested refs inside that file are covered by the file hash. + } else { + hasher.update(name.as_bytes()); + } + } + Module::Object { + name, + sources, + buildsystem, + config_opts, + build_commands, + post_install, + modules: nested, + .. + } => { + hasher.update(name.as_bytes()); + if let Some(bs) = buildsystem { + hasher.update(bs.as_bytes()); + } + if let Some(opts) = config_opts { + for o in opts { + hasher.update(o.as_bytes()); + } + } + if let Some(cmds) = build_commands { + for c in cmds { + hasher.update(c.as_bytes()); + } + } + if let Some(pi) = post_install { + for c in pi { + hasher.update(c.as_bytes()); + } + } + if let Ok(typed) = Source::from_values(sources) { + hasher.update(sources::sources_fingerprint(&typed).as_bytes()); + } + if !nested.is_empty() { + Self::hash_module_tree(hasher, nested, dir)?; + } + } + } + } + Ok(()) + } + fn hash_file_into(hasher: &mut Sha256, path: &Path) -> Result<()> { - use std::io::Read; let mut file = fs::File::open(path) .with_context(|| format!("Failed to open {} for hashing", path.display()))?; let mut buffer = [0_u8; 64 * 1024]; @@ -172,60 +212,29 @@ impl<'a> FlatpakManager<'a> { } fn check_required_version(manifest: &Manifest) -> Result<()> { - // Without the flatpak CLI we only ensure the SDK/runtime trees exist. - if let Some(required) = manifest.finish_args.iter().find_map(|arg| { + let required = manifest.finish_args.iter().find_map(|arg| { let (key, value) = arg.split_once('=')?; (key == "--require-version").then_some(value) - }) { - verbose(format!( - "Manifest requests flatpak >= {required}; skipping CLI version probe (no flatpak CLI dependency)" + }); + let Some(required) = required else { + return Ok(()); + }; + let output = run_command_output("flatpak", &["--version"]) + .context("Failed to get flatpak version")?; + let version = String::from_utf8_lossy(&output.stdout) + .replace("Flatpak ", "") + .trim() + .to_string(); + if version_less_than(&version, required) { + return Err(anyhow::anyhow!( + "Manifest requires flatpak >= {required} but {version} is installed" )); } Ok(()) } - fn bwrap_runner(&self) -> Result { - let manifest = self.manifest.as_ref().context("No manifest available")?; - let (sdk, _runtime) = - ensure_sdk_and_runtime(&manifest.sdk, &manifest.runtime, &manifest.runtime_version)?; - Ok(BwrapRunner::for_sdk( - &sdk, - manifest.id.clone(), - self.state.base_dir.as_path(), - )) - } - - fn run_sandboxed( - &self, - runner: &BwrapRunner, - sandbox: &BuildSandbox, - repo_dir: &Path, - argv: &[String], - extra_fs: &[&str], - share_network: bool, - cwd: Option<&Path>, - ) -> Result<()> { - let mut filesystem_binds = vec![sandbox.fs_ws.clone(), sandbox.fs_repo.clone()]; - filesystem_binds.extend(extra_fs.iter().map(|s| (*s).to_string())); - - let mut env = Vec::new(); - for arg in sandbox.env_args.iter().chain(sandbox.path_overrides.iter()) { - if let Some(rest) = arg.strip_prefix("--env=") - && let Some((k, v)) = rest.split_once('=') - { - env.push((k.to_string(), v.to_string())); - } - } - - runner.run(&RunSpec { - repo_dir: repo_dir.to_path_buf(), - argv: argv.to_vec(), - env, - filesystem_binds, - extra_args: vec![], - share_network, - cwd: cwd.map(Path::to_path_buf), - }) + fn flatpak_runner(&self) -> FlatpakRunner { + FlatpakRunner::new(self.state.base_dir.as_path()) } pub fn validate_manifest(&self, allow_auto_select: bool) -> Result<()> { @@ -321,440 +330,40 @@ impl<'a> FlatpakManager<'a> { fn build_application(&self, rebuild: bool) -> Result<()> { let manifest = self.manifest.as_ref().context("No manifest available")?; - - self.download_application_sources()?; - - let module = self.application_module()?; - let Module::Object { - name, - buildsystem, - config_opts, - build_commands, - build_options: module_build_options, - post_install, - .. - } = module - else { - return Err(anyhow::anyhow!( - "Application module is not a defined module" - )); - }; - - let merged_config = manifest.merged_config_opts(config_opts.as_deref()); - let module_bo = module_build_options.as_ref(); - let num_cpus = std::thread::available_parallelism().map_or(1, std::num::NonZero::get); - - let runner = self.bwrap_runner()?; - let repo_dir = self.build_dirs.repo_dir(); - - match buildsystem.as_deref() { - Some("meson") => { - self.run_meson(&runner, &repo_dir, rebuild, &merged_config, module_bo)?; - } - Some("cmake" | "cmake-ninja") => { - self.run_cmake(&runner, &repo_dir, rebuild, &merged_config, module_bo)?; - } - Some("simple") => self.run_simple( - &runner, - &repo_dir, - manifest, - build_commands.as_ref(), - module_bo, - &name, - num_cpus, - )?, - Some("qmake") => { - return Err(anyhow::anyhow!("qmake build system is not supported")); - } - _ => self.run_autotools( - &runner, - &repo_dir, - rebuild, - &merged_config, - module_bo, - num_cpus, - )?, - } - if let Some(post_install) = post_install { - let sandbox = self.build_sandbox(module_bo, manifest); - for command in &post_install { - let processed = Self::substitute_vars(command, &manifest.id, &name, num_cpus); - let argv = Self::parse_command_line(&processed)?; - self.run_sandboxed(&runner, &sandbox, &repo_dir, &argv, &[], true, None)?; - } - } - - Ok(()) - } - - fn download_application_sources(&self) -> Result<()> { - let module = self.application_module()?; - let Module::Object { name, sources, .. } = module else { - return Err(anyhow::anyhow!( - "Application module is not a defined module" - )); - }; - let typed = Source::from_values(&sources)?; - // Pure `dir` sources are resolved at build time (no copy into .flatplay). - if typed.iter().all(|s| matches!(s, Source::Dir { .. })) { - verbose(format!( - "Application module {name} uses directory sources only; skipping materialize" - )); - return Ok(()); - } - let manifest_path = self .state .active_manifest .as_ref() .context("No active manifest")?; - let manifest_dir = manifest_path - .parent() - .context("Manifest path has no parent directory")?; - let source_dir = self.build_dirs.module_source_dir(&name); - let cache = DownloadCache::new(&self.build_dirs.build_dir()); - sources::materialize_sources(&typed, &source_dir, manifest_dir, &cache, &name) + let module = self.application_module()?; + let runner = self.flatpak_runner(); + let repo_dir = self.build_dirs.repo_dir(); + let build_dir = self.build_dirs.build_dir(); + let paths = builder::ModulePaths { + repo_dir: &repo_dir, + build_dir: &build_dir, + workspace: self.state.base_dir.as_path(), + manifest_path, + }; + builder::build_module( + manifest, + &module, + &paths, + &runner, + builder::BuildModuleOpts { rebuild }, + ) } - fn build_sandbox( - &self, - module_build_options: Option<&BuildOptions>, - manifest: &Manifest, - ) -> BuildSandbox { + fn build_sandbox(&self, manifest: &Manifest) -> BuildSandbox { BuildSandbox { fs_ws: format!("--filesystem={}", self.state.base_dir.display()), fs_repo: format!("--filesystem={}", self.build_dirs.repo_dir().display()), env_args: manifest - .merged_env(module_build_options) + .merged_env(None) .iter() .map(|(k, v)| format!("--env={k}={v}")) .collect(), - path_overrides: manifest.path_overrides(module_build_options), - } - } - - fn sandbox_args<'s>( - sandbox: &'s BuildSandbox, - repo_dir_str: &'s str, - extra_fs: &'s [&'s str], - ) -> Vec<&'s str> { - let mut args: Vec<&str> = - vec!["build", "--share=network", &sandbox.fs_ws, &sandbox.fs_repo]; - args.extend_from_slice(extra_fs); - args.extend(sandbox.env_args.iter().map(String::as_str)); - args.extend(sandbox.path_overrides.iter().map(String::as_str)); - args.push(repo_dir_str); - args - } - - /// Build a `flatpak build …` argv. `command_argv` is a pre-split command (use - /// [`Self::parse_command_line`] for manifest `build-commands` / `post-install`). - fn build_command<'s>( - sandbox: &'s BuildSandbox, - repo_dir_str: &'s str, - command_argv: &'s [String], - extra_fs: &'s [&'s str], - extra_args: &'s [&'s str], - ) -> Vec<&'s str> { - let mut args = Self::sandbox_args(sandbox, repo_dir_str, extra_fs); - args.extend(command_argv.iter().map(String::as_str)); - args.extend_from_slice(extra_args); - args - } - - fn parse_command_line(command: &str) -> Result> { - // Match flatpak-builder: post-install / simple commands run under a shell. - Ok(vec!["/bin/sh".into(), "-c".into(), command.to_string()]) - } - - fn substitute_vars( - command: &str, - flatpak_id: &str, - module_name: &str, - num_cpus: usize, - ) -> String { - command - .replace("${FLATPAK_ID}", flatpak_id) - .replace("${FLATPAK_ARCH}", std::env::consts::ARCH) - .replace("${FLATPAK_DEST}", "/app") - .replace("${FLATPAK_BUILDER_N_JOBS}", &num_cpus.to_string()) - .replace( - "${FLATPAK_BUILDER_BUILDDIR}", - &format!("/run/build/{module_name}"), - ) - } - - fn run_meson( - &self, - runner: &BwrapRunner, - repo_dir: &Path, - rebuild: bool, - config_opts: &[&str], - module_build_options: Option<&BuildOptions>, - ) -> Result<()> { - let manifest = self.manifest.as_ref().context("No manifest available")?; - let module = self.application_module()?; - let Module::Object { - name, - subdir, - sources, - .. - } = module - else { - return Err(anyhow::anyhow!( - "Application module is not a defined module" - )); - }; - let source_dir = self.resolve_module_source_dir(&name, &sources, subdir.as_deref())?; - let source_dir_str = path_to_str(&source_dir)?; - let build_dir = self.build_dirs.build_system_dir(); - std::fs::create_dir_all(&build_dir)?; - let build_dir_str = path_to_str(&build_dir)?; - let sandbox = self.build_sandbox(module_build_options, manifest); - let fs_builddir = format!("--filesystem={build_dir_str}"); - let extra_fs = [fs_builddir.as_str()]; - - if !rebuild { - let mut setup: Vec = vec!["meson".into(), "setup".into()]; - setup.extend(config_opts.iter().map(|s| (*s).to_string())); - setup.extend([ - "--prefix=/app".into(), - source_dir_str.into(), - build_dir_str.into(), - ]); - self.run_sandboxed(runner, &sandbox, repo_dir, &setup, &extra_fs, true, None)?; - } - - let ninja_cmd = vec![ - "ninja".to_string(), - "-C".to_string(), - build_dir_str.to_string(), - ]; - self.run_sandboxed( - runner, &sandbox, repo_dir, &ninja_cmd, &extra_fs, true, None, - )?; - let install_cmd = vec![ - "meson".to_string(), - "install".to_string(), - "-C".to_string(), - build_dir_str.to_string(), - ]; - self.run_sandboxed( - runner, - &sandbox, - repo_dir, - &install_cmd, - &extra_fs, - true, - None, - ) - } - - fn resolve_module_source_dir( - &self, - name: &str, - sources: &[serde_json::Value], - subdir: Option<&str>, - ) -> Result { - let manifest_path = self - .state - .active_manifest - .as_ref() - .context("No active manifest")?; - let manifest_dir = manifest_path - .parent() - .context("Manifest path has no parent directory")?; - let typed = Source::from_values(sources).unwrap_or_else(|_| vec![]); - let base = sources::resolve_dir_source(&typed, manifest_dir) - .unwrap_or_else(|| self.build_dirs.module_source_dir(name)); - let path = if let Some(subdir) = subdir { - base.join(subdir) - } else { - base - }; - path.canonicalize() - .with_context(|| format!("Source directory not found: {}", path.display())) - } - - fn run_cmake( - &self, - runner: &BwrapRunner, - repo_dir: &Path, - rebuild: bool, - config_opts: &[&str], - module_build_options: Option<&BuildOptions>, - ) -> Result<()> { - let manifest = self.manifest.as_ref().context("No manifest available")?; - let module = self.application_module()?; - let Module::Object { - name, - subdir, - sources, - .. - } = module - else { - return Err(anyhow::anyhow!( - "Application module is not a defined module" - )); - }; - let source_dir = self.resolve_module_source_dir(&name, &sources, subdir.as_deref())?; - let source_dir_str = path_to_str(&source_dir)?; - let build_dir = self.build_dirs.build_system_dir(); - let build_dir_str = path_to_str(&build_dir)?; - let sandbox = self.build_sandbox(module_build_options, manifest); - let fs_builddir = format!("--filesystem={build_dir_str}"); - let extra_fs = [fs_builddir.as_str()]; - - if !rebuild { - let mut configure = vec![ - "cmake".into(), - "-G".into(), - "Ninja".into(), - format!("-B{build_dir_str}"), - "-DCMAKE_EXPORT_COMPILE_COMMANDS=1".into(), - "-DCMAKE_BUILD_TYPE=RelWithDebInfo".into(), - "-DCMAKE_INSTALL_PREFIX=/app".into(), - ]; - configure.extend(config_opts.iter().map(|s| (*s).to_string())); - configure.push(source_dir_str.to_string()); - self.run_sandboxed( - runner, &sandbox, repo_dir, &configure, &extra_fs, true, None, - )?; - } - - let ninja_cmd = vec![ - "ninja".to_string(), - "-C".to_string(), - build_dir_str.to_string(), - ]; - self.run_sandboxed( - runner, &sandbox, repo_dir, &ninja_cmd, &extra_fs, true, None, - )?; - let install_cmd = vec![ - "ninja".to_string(), - "-C".to_string(), - build_dir_str.to_string(), - "install".to_string(), - ]; - self.run_sandboxed( - runner, - &sandbox, - repo_dir, - &install_cmd, - &extra_fs, - true, - None, - ) - } - - fn run_simple( - &self, - runner: &BwrapRunner, - repo_dir: &Path, - manifest: &Manifest, - build_commands: Option<&Vec>, - module_build_options: Option<&BuildOptions>, - module_name: &str, - num_cpus: usize, - ) -> Result<()> { - if let Some(commands) = build_commands { - let sandbox = self.build_sandbox(module_build_options, manifest); - for command in commands { - let processed = Self::substitute_vars(command, &manifest.id, module_name, num_cpus); - let argv = Self::parse_command_line(&processed)?; - self.run_sandboxed(runner, &sandbox, repo_dir, &argv, &[], true, None)?; - } - } - Ok(()) - } - - fn run_autotools( - &self, - runner: &BwrapRunner, - repo_dir: &Path, - rebuild: bool, - config_opts: &[&str], - module_build_options: Option<&BuildOptions>, - num_cpus: usize, - ) -> Result<()> { - let manifest = self.manifest.as_ref().context("No manifest available")?; - let module = self.application_module()?; - let Module::Object { - name, - builddir, - subdir, - .. - } = module - else { - return Err(anyhow::anyhow!( - "Application module is not a defined module" - )); - }; - let source_dir = { - let base = self.build_dirs.build_dir().join(&name); - if let Some(subdir) = &subdir { - base.join(subdir) - } else { - base - } - }; - let use_builddir = builddir.unwrap_or(false); - - let sandbox = self.build_sandbox(module_build_options, manifest); - let source_dir_str = path_to_str(&source_dir)?; - - let make_argv = |jobs_flag: &str| { - vec![ - "make".to_string(), - "V=0".to_string(), - jobs_flag.to_string(), - "install".to_string(), - ] - }; - - match (rebuild, use_builddir) { - (false, true) => { - let mut configure = vec![ - format!("{source_dir_str}/configure"), - "--prefix=/app".into(), - ]; - configure.extend(config_opts.iter().map(|s| (*s).to_string())); - self.run_sandboxed(runner, &sandbox, repo_dir, &configure, &[], true, None)?; - - let build_dir = self.build_dirs.build_system_dir(); - let build_dir_str = path_to_str(&build_dir)?; - let fs_builddir = format!("--filesystem={build_dir_str}"); - let extra_fs = [fs_builddir.as_str()]; - let jobs_flag = format!("-j{num_cpus}"); - let make_cmd = make_argv(&jobs_flag); - self.run_sandboxed(runner, &sandbox, repo_dir, &make_cmd, &extra_fs, true, None) - } - (false, false) => { - let mut configure = vec![ - format!("{source_dir_str}/configure"), - "--prefix=/app".into(), - ]; - configure.extend(config_opts.iter().map(|s| (*s).to_string())); - self.run_sandboxed(runner, &sandbox, repo_dir, &configure, &[], true, None)?; - - let jobs_flag = format!("-j{num_cpus}"); - let make_cmd = make_argv(&jobs_flag); - self.run_sandboxed(runner, &sandbox, repo_dir, &make_cmd, &[], true, None) - } - (true, true) => { - let build_dir = self.build_dirs.build_system_dir(); - let build_dir_str = path_to_str(&build_dir)?; - let fs_builddir = format!("--filesystem={build_dir_str}"); - let extra_fs = [fs_builddir.as_str()]; - let jobs_flag = format!("-j{num_cpus}"); - let make_cmd = make_argv(&jobs_flag); - self.run_sandboxed(runner, &sandbox, repo_dir, &make_cmd, &extra_fs, true, None) - } - (true, false) => { - let jobs_flag = format!("-j{num_cpus}"); - let make_cmd = make_argv(&jobs_flag); - self.run_sandboxed(runner, &sandbox, repo_dir, &make_cmd, &[], true, None) - } + path_overrides: manifest.path_overrides(None), } } @@ -769,7 +378,7 @@ impl<'a> FlatpakManager<'a> { let repo_dir = self.build_dirs.repo_dir(); let build_dir = self.build_dirs.build_dir(); let stop_at = self.last_module_name()?; - let runner = self.bwrap_runner()?; + let runner = self.flatpak_runner(); builder::build_dependencies( manifest, &manifest_path, @@ -843,61 +452,6 @@ impl<'a> FlatpakManager<'a> { self.run() } - fn sandbox_run_args( - manifest: &Manifest, - repo_dir: &Path, - sandbox: &BuildSandbox, - with_dev_paths: bool, - ) -> Result> { - let uid = geteuid(); - let bind_mount_arg = format!( - "--bind-mount=/run/user/{uid}/doc=/run/user/{uid}/doc/by-app/{}", - manifest.id - ); - - let mut args: Vec = vec![ - "build".to_string(), - "--with-appdir".to_string(), - "--allow=devel".to_string(), - bind_mount_arg, - sandbox.fs_ws.clone(), - sandbox.fs_repo.clone(), - "--talk-name=org.freedesktop.portal.*".to_string(), - "--talk-name=org.a11y.Bus".to_string(), - ]; - - let host_env = get_host_env(); - verbose(format!( - "Forwarding host env vars: {:?}", - host_env.keys().collect::>() - )); - args.extend( - host_env - .into_iter() - .map(|(key, value)| format!("--env={key}={value}")), - ); - - match get_a11y_bus_args() { - Ok(a11y_args) => args.extend(a11y_args), - Err(error) => verbose(format!("a11y bus not available: {error:#}")), - } - - if with_dev_paths { - args.push("--share=network".to_string()); - args.extend(sandbox.path_overrides.clone()); - } - - match build_font_config().and_then(|config_path| get_fonts_args(&config_path)) { - Ok(fonts_args) => args.extend(fonts_args), - Err(error) => verbose(format!("fonts not available: {error:#}")), - } - - args.extend(manifest.finish_args_filtered()); - args.push(path_to_str(repo_dir)?.to_string()); - - Ok(args) - } - pub fn run(&self) -> Result<()> { if !self.state.application_built { return Err(anyhow::anyhow!( @@ -906,24 +460,43 @@ impl<'a> FlatpakManager<'a> { } let manifest = self.manifest.as_ref().context("No manifest available")?; let repo_dir = self.build_dirs.repo_dir(); - let runner = self.bwrap_runner()?; + let runner = self.flatpak_runner(); let mut argv = vec![manifest.command.clone()]; if let Some(x_run_args) = &manifest.x_run_args { argv.extend(x_run_args.clone()); } - self.run_app_spec(&runner, &repo_dir, manifest, argv, false) + self.run_app_spec(&runner, &repo_dir, manifest, argv, true, false) } + /// Run an app (or shell) inside the build directory via `flatpak build`. + /// + /// * `devel` — `--allow=devel` and host path overrides (build/debug shell). + /// * `with_dev_paths` — forward path overrides + treat as networked like build. fn run_app_spec( &self, - runner: &BwrapRunner, + runner: &FlatpakRunner, repo_dir: &Path, manifest: &Manifest, argv: Vec, + devel: bool, with_dev_paths: bool, ) -> Result<()> { - let sandbox = self.build_sandbox(None, manifest); - let mut filesystem_binds = vec![sandbox.fs_ws.clone(), sandbox.fs_repo.clone()]; + let sandbox = self.build_sandbox(manifest); + let uid = geteuid(); + let mut filesystem_binds = vec![ + "--with-appdir".into(), + format!( + "--bind-mount=/run/user/{uid}/doc=/run/user/{uid}/doc/by-app/{}", + manifest.id + ), + sandbox.fs_ws.clone(), + sandbox.fs_repo.clone(), + "--talk-name=org.freedesktop.portal.*".into(), + "--talk-name=org.a11y.Bus".into(), + ]; + if devel { + filesystem_binds.push("--allow=devel".into()); + } filesystem_binds.extend(manifest.finish_args_filtered()); if with_dev_paths { filesystem_binds.extend(sandbox.path_overrides.clone()); @@ -950,17 +523,17 @@ impl<'a> FlatpakManager<'a> { Err(error) => verbose(format!("fonts not available: {error:#}")), } + // Exact match — substring would treat `--unshare=network` as share. + let share_network = + with_dev_paths || manifest.finish_args.iter().any(|a| a == "--share=network"); + runner.run(&RunSpec { repo_dir: repo_dir.to_path_buf(), argv, env, filesystem_binds, extra_args: vec![], - share_network: with_dev_paths - || manifest - .finish_args - .iter() - .any(|a| a.contains("share=network")), + share_network, cwd: None, }) } @@ -974,47 +547,53 @@ impl<'a> FlatpakManager<'a> { let manifest = self.manifest.as_ref().context("No manifest available")?; let repo_dir = self.build_dirs.repo_dir(); let finalized_repo_dir = self.build_dirs.finalized_repo_dir(); + let ostree_dir = self.build_dirs.ostree_dir(); + let base = self.state.base_dir.as_path(); if finalized_repo_dir.is_dir() { fs::remove_dir_all(&finalized_repo_dir)?; } - copy_dir_all(&repo_dir, &finalized_repo_dir)?; - - // Write finish metadata without the flatpak CLI. - let meta_path = finalized_repo_dir.join("metadata"); - let mut meta = fs::read_to_string(&meta_path).unwrap_or_default(); - if !meta.contains("[Context]") { - use std::fmt::Write as _; - let _ = writeln!(meta, "\n[Context]"); - for arg in manifest.finish_args_filtered() { - let _ = writeln!(meta, "# finish-arg: {arg}"); - } - let _ = writeln!(meta, "\n[Application]"); - let _ = writeln!(meta, "command={}", manifest.command); - fs::write(&meta_path, meta)?; - } - let bundle_name = format!("{}-files.tar", manifest.id); - let bundle_path = self.state.base_dir.join(&bundle_name); - let files = finalized_repo_dir.join("files"); - // Portable "bundle": tar of /app files (not an OSTree .flatpak; no flatpak CLI). - let status = std::process::Command::new("tar") - .args([ - "-cf", - path_to_str(&bundle_path)?, - "-C", - path_to_str(&files)?, - ".", - ]) - .status() - .context("Failed to spawn tar for export (host tar is used only for packaging)")?; - if !status.success() { - anyhow::bail!("tar export failed with {status}"); - } + run_command( + "cp", + &[ + "-r", + path_to_str(&repo_dir)?, + path_to_str(&finalized_repo_dir)?, + ], + Some(base), + )?; - status_success(format!( - "Exported {bundle_name} (files tree; install-time OSTree bundles need a separate ostree toolchain)" - )); + let mut finish_args: Vec = vec!["build-finish".to_string()]; + finish_args.extend(manifest.finish_args_filtered()); + finish_args.push(format!("--command={}", manifest.command)); + finish_args.push(path_to_str(&finalized_repo_dir)?.to_string()); + let finish_ref: Vec<&str> = finish_args.iter().map(String::as_str).collect(); + run_command("flatpak", &finish_ref, Some(base))?; + + run_command( + "flatpak", + &[ + "build-export", + path_to_str(&ostree_dir)?, + path_to_str(&finalized_repo_dir)?, + ], + Some(base), + )?; + + let bundle_name = format!("{}.flatpak", manifest.id); + run_command( + "flatpak", + &[ + "build-bundle", + path_to_str(&ostree_dir)?, + &bundle_name, + manifest.id.as_str(), + ], + Some(base), + )?; + + status_success(format!("Exported {bundle_name}")); Ok(()) } @@ -1031,16 +610,31 @@ impl<'a> FlatpakManager<'a> { pub fn runtime_terminal(&self) -> Result<()> { let manifest = self.manifest.as_ref().context("No manifest available")?; let repo_dir = self.build_dirs.repo_dir(); - // Empty app tree is fine; drop into SDK with bash. - let runner = self.bwrap_runner()?; - self.run_app_spec(&runner, &repo_dir, manifest, vec!["bash".into()], true) + // App-like shell: finish-args only, no devel / path overrides. + let runner = self.flatpak_runner(); + self.run_app_spec( + &runner, + &repo_dir, + manifest, + vec!["bash".into()], + false, + false, + ) } pub fn build_terminal(&self) -> Result<()> { let manifest = self.manifest.as_ref().context("No manifest available")?; let repo_dir = self.build_dirs.repo_dir(); - let runner = self.bwrap_runner()?; - self.run_app_spec(&runner, &repo_dir, manifest, vec!["bash".into()], true) + // SDK/build shell: devel + path overrides + network. + let runner = self.flatpak_runner(); + self.run_app_spec( + &runner, + &repo_dir, + manifest, + vec!["bash".into()], + true, + true, + ) } pub fn select_manifest(&mut self, path: Option) -> Result<()> { diff --git a/src/main.rs b/src/main.rs index 4b4eeb6..ea0f580 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,9 @@ use anyhow::Context; use clap::{CommandFactory, Parser, Subcommand}; +use clap_complete::generate; use nix::unistd::{getpid, setpgid}; use std::path::PathBuf; -use std::process::{Command, ExitCode, Stdio}; +use std::process::ExitCode; use std::sync::atomic::{AtomicBool, Ordering}; mod build_dirs; @@ -55,9 +56,9 @@ enum Commands { UpdateDependencies, /// Clean the Flatpak repo directory Clean, - /// Spawn a new terminal inside the specified SDK + /// Spawn a shell with app finish-args (no devel / build path overrides) RuntimeTerminal, - /// Spawn a new terminal inside the current build repository + /// Spawn a SDK/build shell (`--allow=devel`, path overrides, network) BuildTerminal, /// Export .flatpak bundle from the build ExportBundle, @@ -97,27 +98,11 @@ fn get_base_dir() -> anyhow::Result { } fn check_dependencies() -> anyhow::Result<()> { - // Control plane: bubblewrap only. Flatpak *runtimes/SDKs* must exist on disk - // (user/system install tree); we never invoke the `flatpak` or `flatpak-builder` CLIs. - let mut missing = Vec::new(); - - if Command::new("bwrap") - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_err() - { - missing.push("bubblewrap (bwrap)"); + // Control plane: `flatpak` CLI for sandbox/lifecycle. Module builds are in-process + // (no `flatpak-builder`). SDKs/runtimes must be installed via Flatpak. + if !crate::command::host_command_exists("flatpak", &["--version"]) { + return Err(anyhow::anyhow!("Missing required dependency: flatpak")); } - - if !missing.is_empty() { - return Err(anyhow::anyhow!( - "Missing required dependencies: {}", - missing.join(", ") - )); - } - Ok(()) } @@ -190,7 +175,6 @@ fn main() -> ExitCode { match cli.command { Some(Commands::Completions { shell }) => { - use clap_complete::generate; let mut cmd = Cli::command(); generate(shell, &mut cmd, "flatplay", &mut std::io::stdout()); ExitCode::SUCCESS diff --git a/src/manifest.rs b/src/manifest.rs index d34125f..3f91285 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; +use walkdir::WalkDir; pub fn is_valid_dbus_name(name: &str) -> bool { if name.is_empty() || name.len() > 255 { @@ -63,6 +64,9 @@ pub enum Module { post_install: Option>, #[serde(default)] sources: Vec, + /// Nested modules (flatpak-builder module files often group children here). + #[serde(default)] + modules: Vec, }, Reference(String), } @@ -148,6 +152,8 @@ impl Manifest { let m_prepend_pkg = module_build_options.and_then(|b| b.prepend_pkg_config_path.as_deref()); let m_append_pkg = module_build_options.and_then(|b| b.append_pkg_config_path.as_deref()); + // Only inject PATH/LD/PKG_CONFIG when the manifest or module asks for + // prepend/append. Always replacing hides SDK extension bins. if let Some(arg) = Self::build_path_override( "PATH", &["/app/bin", "/usr/bin"], @@ -189,48 +195,27 @@ impl Manifest { overrides } + /// Last buildable module after expanding nested/referenced modules. pub fn application_module(&self, manifest_path: &Path) -> Result { - match self.modules.last() { - Some(module @ Module::Object { .. }) => Ok(module.clone()), - Some(Module::Reference(ref_name)) => { - let parent = manifest_path - .parent() - .context("Manifest path has no parent directory")?; - let ref_path = parent.join(ref_name); - let modules = Self::load_module_file(&ref_path)?; - match modules.into_iter().last() { - Some(module @ Module::Object { .. }) => Ok(module), - Some(Module::Reference(name)) => Err(anyhow::anyhow!( - "Nested module references not supported: {ref_name} -> {name}" - )), - None => Err(anyhow::anyhow!( - "Referenced module file {ref_name} contains no modules" - )), - } - } - None => Err(anyhow::anyhow!("Manifest has no modules")), - } + let parent = manifest_path + .parent() + .context("Manifest path has no parent directory")?; + let expanded = Self::expand_modules(&self.modules, parent)?; + expanded + .into_iter() + .rev() + .find(|m| matches!(m, Module::Object { .. })) + .context("Manifest has no buildable application module") } pub fn last_module_name(&self, manifest_path: &Path) -> Result { - match self.modules.last() { - Some(Module::Object { name, .. }) => Ok(name.clone()), - Some(Module::Reference(ref_name)) => { - let parent = manifest_path - .parent() - .context("Manifest path has no parent directory")?; - let ref_path = parent.join(ref_name); - let modules = Self::load_module_file(&ref_path)?; - match modules.into_iter().last() { - Some(Module::Object { name, .. }) => Ok(name), - _ => Ok(ref_name.clone()), - } - } - None => Err(anyhow::anyhow!("Manifest has no modules")), + match self.application_module(manifest_path)? { + Module::Object { name, .. } => Ok(name), + Module::Reference(name) => Ok(name), } } - fn load_module_file(path: &Path) -> Result> { + pub fn load_module_file(path: &Path) -> Result> { let content = fs::read_to_string(path)?; let modules: Vec = match path.extension().and_then(|s| s.to_str()) { Some("json") => serde_json::from_str(&content) @@ -248,6 +233,57 @@ impl Manifest { Ok(modules) } + /// Flatten module references and nested `modules` arrays (as flatpak-builder does). + pub fn expand_modules(modules: &[Module], manifest_dir: &Path) -> Result> { + let mut out = Vec::new(); + for module in modules { + match module { + Module::Reference(ref_name) => { + let ref_path = manifest_dir.join(ref_name); + let loaded = Self::load_module_file(&ref_path)?; + let nested_dir = ref_path + .parent() + .map_or_else(|| manifest_dir.to_path_buf(), Path::to_path_buf); + out.extend(Self::expand_modules(&loaded, &nested_dir)?); + } + Module::Object { + name, + buildsystem, + build_commands, + sources, + modules: nested, + .. + } if !nested.is_empty() => { + // Grouping module: expand children first (usual pattern for + // pypi-dependencies.json), then the parent only if it has its own work. + out.extend(Self::expand_modules(nested, manifest_dir)?); + let parent_has_work = !sources.is_empty() + || build_commands + .as_ref() + .is_some_and(|commands| !commands.is_empty()) + || buildsystem.as_ref().is_some_and(|bs| bs != "simple"); + if parent_has_work { + let mut parent = module.clone(); + if let Module::Object { + modules: parent_nested, + .. + } = &mut parent + { + parent_nested.clear(); + } + out.push(parent); + } else { + crate::utils::verbose(format!( + "Expanded grouping module '{name}' to its nested modules only" + )); + } + } + other => out.push(other.clone()), + } + } + Ok(out) + } + fn build_path_override( var: &str, defaults: &[&str], @@ -258,6 +294,14 @@ impl Manifest { ) -> Option { // Do not inject host PATH/LD_LIBRARY_PATH/PKG_CONFIG_PATH into the sandbox; // host libraries cause subtle link failures inside the SDK. + // Only emit when prepend/append is set so we do not clobber the SDK env. + let has_override = manifest_prepend.is_some() + || module_prepend.is_some() + || manifest_append.is_some() + || module_append.is_some(); + if !has_override { + return None; + } let parts: Vec<&str> = manifest_prepend .into_iter() .chain(module_prepend) @@ -265,18 +309,41 @@ impl Manifest { .chain(manifest_append) .chain(module_append) .collect(); - if parts.is_empty() { - return None; - } Some(format!("--env={var}={}", parts.join(":"))) } } +/// Best-effort directories to skip while scanning for manifests (not exhaustive). +const MANIFEST_SCAN_SKIP_DIRS: &[&str] = &[ + "node_modules", + "target", + "vendor", + "_build", + "build", + "dist", + ".flatplay", + "__pycache__", + ".venv", + "venv", + ".tox", + ".git", + ".cargo", + ".npm", + ".cache", +]; + +/// Cheap prefilter: file text looks like a Flatpak manifest before full parse. +fn looks_like_manifest(content: &str) -> bool { + let head = content.get(..4096).unwrap_or(content); + head.contains("app-id") + || head.contains("\"app_id\"") + || head.contains("app_id") + || (head.contains("runtime") && head.contains("sdk") && head.contains("modules")) +} + /// Recursively finds manifest files in the given path, optionally excluding a prefix subtree. /// Returns a sorted Vec of manifest file paths, prioritizing ".Devel." manifests and shallower paths. pub fn find_manifests_in_path(path: &Path, exclude_prefix: Option<&Path>) -> Vec { - use walkdir::WalkDir; - let mut manifests = vec![]; let path = match path.canonicalize() { @@ -300,39 +367,59 @@ pub fn find_manifests_in_path(path: &Path, exclude_prefix: Option<&Path>) -> Vec } }); - const SKIP_DIRS: &[&str] = &[ - "node_modules", - "target", - "vendor", - "_build", - "build", - "dist", - ".flatplay", - "__pycache__", - ".venv", - "venv", - ".tox", - ".git", - ]; - - let walker = WalkDir::new(&path).into_iter().filter_entry(|entry| { - if entry.depth() == 0 { - return true; - } - let name = entry.file_name().to_string_lossy(); - if name.starts_with('.') { - return false; - } - if entry.file_type().is_dir() && SKIP_DIRS.iter().any(|d| *d == name) { - return false; + // Prefer conventional shallow locations first (still validated as manifests). + for rel in ["", "build-aux", "flatpak", "data"] { + let dir = if rel.is_empty() { + path.clone() + } else { + path.join(rel) + }; + if !dir.is_dir() { + continue; } - if let Some(prefix) = &exclude_prefix - && entry.path().starts_with(prefix) - { - return false; + if let Ok(entries) = fs::read_dir(&dir) { + for entry in entries.flatten() { + let p = entry.path(); + if !p.is_file() { + continue; + } + if !matches!( + p.extension().and_then(|s| s.to_str()), + Some("json" | "yaml" | "yml") + ) { + continue; + } + if try_push_manifest(&mut manifests, &p) { + // keep collecting + } + } } - true - }); + } + + let walker = WalkDir::new(&path) + .max_depth(6) + .into_iter() + .filter_entry(|entry| { + if entry.depth() == 0 { + return true; + } + let name = entry.file_name().to_string_lossy(); + // Skip VCS/tooling dirs; allow other hidden dirs (e.g. project-specific). + if entry.file_type().is_dir() { + if name == ".git" || name == ".flatplay" || name == ".tox" || name == ".cargo" { + return false; + } + if MANIFEST_SCAN_SKIP_DIRS.iter().any(|d| *d == name) { + return false; + } + } + if let Some(prefix) = &exclude_prefix + && entry.path().starts_with(prefix) + { + return false; + } + true + }); for entry_result in walker { let entry = match entry_result { @@ -351,9 +438,7 @@ pub fn find_manifests_in_path(path: &Path, exclude_prefix: Option<&Path>) -> Vec ) { continue; } - if Manifest::from_file(entry.path()).is_ok() { - manifests.push(entry.into_path()); - } + try_push_manifest(&mut manifests, entry.path()); } manifests.sort_by(|a, b| { @@ -363,13 +448,34 @@ pub fn find_manifests_in_path(path: &Path, exclude_prefix: Option<&Path>) -> Vec .cmp(&a_is_devel) .then_with(|| a.components().count().cmp(&b.components().count())) }); + manifests.dedup(); manifests } +fn try_push_manifest(manifests: &mut Vec, path: &Path) -> bool { + if manifests.iter().any(|p| p == path) { + return false; + } + let Ok(content) = fs::read_to_string(path) else { + return false; + }; + if !looks_like_manifest(&content) { + return false; + } + if Manifest::from_file(path).is_ok() { + manifests.push(path.to_path_buf()); + true + } else { + false + } +} + #[cfg(test)] mod tests { use super::*; + use std::io::Write; + use tempfile::NamedTempFile; #[test] fn test_is_valid_dbus_name() { @@ -390,9 +496,6 @@ mod tests { #[test] fn test_manifest_parsing_json() { - use std::io::Write; - use tempfile::NamedTempFile; - let manifest_content = r#"{ "app-id": "org.example.TestApp", "sdk": "org.gnome.Sdk", @@ -427,9 +530,6 @@ mod tests { #[test] fn test_manifest_parsing_yaml() { - use std::io::Write; - use tempfile::NamedTempFile; - let manifest_content = r#" app-id: org.example.TestApp sdk: org.gnome.Sdk @@ -456,9 +556,6 @@ finish-args: #[test] fn test_manifest_invalid_app_id() { - use std::io::Write; - use tempfile::NamedTempFile; - let manifest_content = r#"{ "app-id": "invalid", "sdk": "org.gnome.Sdk", diff --git a/src/sandbox/bwrap.rs b/src/sandbox/bwrap.rs deleted file mode 100644 index fa9fc5f..0000000 --- a/src/sandbox/bwrap.rs +++ /dev/null @@ -1,342 +0,0 @@ -//! Bubblewrap-based sandbox (no `flatpak` / `flatpak-builder` CLI). - -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; - -use anyhow::{Context, Result}; - -use super::install::DeployedRef; -use super::{RunSpec, SandboxRunner}; -use crate::command::{InterruptedError, is_interrupted_error}; -use crate::utils::{command_header, path_to_str, verbose}; - -/// Build/run commands inside an SDK or runtime tree via `bwrap`. -#[derive(Debug, Clone)] -pub struct BwrapRunner { - /// SDK (or runtime when running apps) files directory (`.../active/files`). - pub usr_files: PathBuf, - pub app_id: String, - /// Host paths always exposed to the sandbox (project tree, etc.). - pub default_filesystems: Vec, -} - -impl BwrapRunner { - pub fn for_sdk(sdk: &DeployedRef, app_id: impl Into, workspace: &Path) -> Self { - Self { - usr_files: sdk.files_dir.clone(), - app_id: app_id.into(), - default_filesystems: vec![workspace.to_path_buf()], - } - } - - fn base_bwrap_args(&self, repo_dir: &Path, share_network: bool) -> Result> { - let usr = path_to_str(&self.usr_files)?; - let files = repo_dir.join("files"); - let var = repo_dir.join("var"); - std::fs::create_dir_all(&files)?; - std::fs::create_dir_all(&var)?; - - let mut args = vec![ - "--die-with-parent".into(), - "--unshare-pid".into(), - "--unshare-uts".into(), - "--unshare-cgroup-try".into(), - "--proc".into(), - "/proc".into(), - "--dev".into(), - "/dev".into(), - "--tmpfs".into(), - "/tmp".into(), - "--tmpfs".into(), - "/run".into(), - "--ro-bind".into(), - usr.into(), - "/usr".into(), - "--symlink".into(), - "usr/bin".into(), - "/bin".into(), - "--symlink".into(), - "usr/sbin".into(), - "/sbin".into(), - "--symlink".into(), - "usr/lib".into(), - "/lib".into(), - ]; - - // Multi-arch SDK layouts often provide lib64. - if self.usr_files.join("lib64").is_dir() { - args.extend(["--symlink".into(), "usr/lib64".into(), "/lib64".into()]); - } - - // Writable tmpfs /etc so we can add host resolv.conf (cannot overlay files onto a - // read-only SDK /etc bind — bwrap fails with "Can't create file at /etc/resolv.conf"). - args.extend(["--tmpfs".into(), "/etc".into()]); - let sdk_etc = self.usr_files.join("etc"); - if sdk_etc.is_dir() { - // Expose useful SDK etc trees (ssl certs, fonts, …). Skip dangling symlinks - // (e.g. mtab) via --ro-bind-try. - if let Ok(entries) = std::fs::read_dir(&sdk_etc) { - for entry in entries.flatten() { - let name = entry.file_name(); - let Some(name_str) = name.to_str() else { - continue; - }; - // Skip files we override from the host. - if matches!(name_str, "resolv.conf" | "hosts" | "mtab") { - continue; - } - let src = entry.path(); - // Path::exists follows symlinks; skip broken ones. - if src.is_symlink() && !src.exists() { - continue; - } - let dest = format!("/etc/{name_str}"); - args.extend(["--ro-bind-try".into(), path_to_str(&src)?.into(), dest]); - } - } - } - for host_file in ["/etc/resolv.conf", "/etc/hosts"] { - if Path::new(host_file).exists() { - args.extend(["--ro-bind-try".into(), host_file.into(), host_file.into()]); - } - } - - // Mirror the environment Flatpak injects so apps find data under /app/share - // (e.g. GLib.get_system_data_dirs() → wordbook/wn-*.db.zst). - args.extend([ - "--bind".into(), - path_to_str(&files)?.into(), - "/app".into(), - "--bind".into(), - path_to_str(&var)?.into(), - "/var".into(), - "--setenv".into(), - "PATH".into(), - "/app/bin:/usr/bin".into(), - "--setenv".into(), - "LD_LIBRARY_PATH".into(), - "/app/lib".into(), - "--setenv".into(), - "PKG_CONFIG_PATH".into(), - "/app/lib/pkgconfig:/app/share/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig" - .into(), - "--setenv".into(), - "XDG_DATA_DIRS".into(), - "/app/share:/usr/share:/usr/share/runtime/share".into(), - "--setenv".into(), - "XDG_CONFIG_DIRS".into(), - "/app/etc/xdg:/etc/xdg".into(), - "--setenv".into(), - "GI_TYPELIB_PATH".into(), - "/app/lib/girepository-1.0".into(), - "--setenv".into(), - "GSETTINGS_SCHEMA_DIR".into(), - "/app/share/glib-2.0/schemas".into(), - "--setenv".into(), - "FLATPAK_ID".into(), - self.app_id.clone(), - "--setenv".into(), - "FLATPAK_DEST".into(), - "/app".into(), - "--setenv".into(), - "FLATPAK_ARCH".into(), - crate::sources::flatpak_arch(), - "--chdir".into(), - "/".into(), - ]); - - if share_network { - // Default bwrap shares net namespace with host unless --unshare-net. - } else { - args.push("--unshare-net".into()); - } - - for fs in &self.default_filesystems { - if let Ok(canon) = fs.canonicalize() { - let s = path_to_str(&canon)?; - args.extend(["--bind".into(), s.into(), s.into()]); - } - } - - Ok(args) - } - - fn apply_spec_overlays(args: &mut Vec, spec: &RunSpec) -> Result<()> { - for (key, value) in &spec.env { - args.extend(["--setenv".into(), key.clone(), value.clone()]); - } - - for fs in &spec.filesystem_binds { - // Accept either `--filesystem=PATH` / `--filesystem=PATH:ro` or raw paths, - // and `--bind-mount=DEST=SRC` style from existing helpers. - if let Some(rest) = fs.strip_prefix("--filesystem=") { - let (path, ro) = rest - .split_once(':') - .map_or((rest, false), |(p, mode)| (p, mode == "ro")); - let path = expand_user(path); - if Path::new(&path).exists() { - let flag = if ro { "--ro-bind" } else { "--bind" }; - args.extend([flag.into(), path.clone(), path]); - } - } else if let Some(rest) = fs.strip_prefix("--bind-mount=") { - if let Some((dest, src)) = rest.split_once('=') { - if Path::new(src).exists() { - args.extend(["--bind".into(), src.into(), dest.into()]); - } - } - } else if let Some(rest) = fs.strip_prefix("--bind=") { - // uncommon - let _ = rest; - } else if fs.starts_with("--") { - // Permissions like --socket=wayland are not mapped 1:1; bind common sockets. - apply_permission_flag(args, fs); - } else { - let path = expand_user(fs); - if Path::new(&path).exists() { - args.extend(["--bind".into(), path.clone(), path]); - } - } - } - - for extra in &spec.extra_args { - if extra.starts_with("--env=") { - if let Some((k, v)) = extra.trim_start_matches("--env=").split_once('=') { - args.extend(["--setenv".into(), k.into(), v.into()]); - } - } else if extra.starts_with("--filesystem=") - || extra.starts_with("--bind-mount=") - || extra.starts_with("--socket") - || extra.starts_with("--device") - || extra.starts_with("--share") - || extra.starts_with("--talk-name") - || extra.starts_with("--allow") - { - apply_permission_flag(args, extra); - } - } - - Ok(()) - } -} - -fn expand_user(path: &str) -> String { - if let Some(rest) = path.strip_prefix("~/") { - if let Ok(home) = std::env::var("HOME") { - return format!("{home}/{rest}"); - } - } - path.to_string() -} - -fn apply_permission_flag(args: &mut Vec, flag: &str) { - // Best-effort host integration without the flatpak portal stack. - if flag == "--socket=wayland" || flag.starts_with("--socket=wayland") { - if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { - let wayland = format!("{runtime_dir}/wayland-0"); - if Path::new(&wayland).exists() { - args.extend([ - "--bind".into(), - wayland.clone(), - wayland, - "--setenv".into(), - "WAYLAND_DISPLAY".into(), - "wayland-0".into(), - "--setenv".into(), - "XDG_RUNTIME_DIR".into(), - runtime_dir, - ]); - } - } - } else if flag.contains("pulseaudio") { - if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { - let pulse = format!("{runtime_dir}/pulse/native"); - if Path::new(&pulse).exists() { - args.extend(["--bind".into(), pulse.clone(), pulse]); - } - } - } else if flag.contains("dri") { - if Path::new("/dev/dri").exists() { - args.extend(["--dev-bind".into(), "/dev/dri".into(), "/dev/dri".into()]); - } - } else if flag == "--share=network" || flag.starts_with("--share=network") { - // Network shared by not passing --unshare-net (handled via share_network). - } else if flag.starts_with("--talk-name=") || flag.starts_with("--own-name=") { - // Session bus: bind the user bus socket when available. - if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { - let bus = format!("{runtime_dir}/bus"); - if Path::new(&bus).exists() { - args.extend([ - "--bind".into(), - bus.clone(), - bus.clone(), - "--setenv".into(), - "DBUS_SESSION_BUS_ADDRESS".into(), - format!("unix:path={bus}"), - ]); - } - } - } - verbose(format!("permission flag best-effort: {flag}")); -} - -impl SandboxRunner for BwrapRunner { - fn run(&self, spec: &RunSpec) -> Result<()> { - let mut args = self.base_bwrap_args(&spec.repo_dir, spec.share_network)?; - Self::apply_spec_overlays(&mut args, spec)?; - - if let Some(cwd) = &spec.cwd { - let cwd_str = path_to_str(cwd)?; - // Ensure cwd is visible (bind if under a path we might not have). - if cwd.exists() { - let canon = cwd.canonicalize().unwrap_or_else(|_| cwd.clone()); - let s = path_to_str(&canon)?; - if !args - .windows(3) - .any(|w| w[0] == "--bind" && (w[1] == s || w[2] == s)) - { - args.extend(["--bind".into(), s.into(), s.into()]); - } - args.extend(["--chdir".into(), s.into()]); - } else { - let _ = cwd_str; - } - } - - args.push("--".into()); - args.extend(spec.argv.iter().cloned()); - - let args_display: Vec<&str> = args.iter().map(String::as_str).collect(); - command_header("bwrap", &args_display); - - let mut child = Command::new("bwrap") - .args(&args) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .spawn() - .context( - "Failed to spawn bwrap — install bubblewrap (package often named bubblewrap)", - )?; - - let status = child.wait()?; - if status.success() { - return Ok(()); - } - if status.code() == Some(130) || crate::is_interrupted() { - return Err(InterruptedError.into()); - } - let _ = is_interrupted_error; - anyhow::bail!("Sandbox command failed with {status}") - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn expand_user_home() { - // Only checks non-crash; HOME may vary. - let p = expand_user("/tmp/x"); - assert_eq!(p, "/tmp/x"); - } -} diff --git a/src/sandbox/flatpak.rs b/src/sandbox/flatpak.rs new file mode 100644 index 0000000..5501548 --- /dev/null +++ b/src/sandbox/flatpak.rs @@ -0,0 +1,172 @@ +//! Sandbox via the `flatpak` CLI (`flatpak build`), not `flatpak-builder` or raw `bwrap`. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use super::{RunSpec, SandboxRunner}; +use crate::command::run_command; +use crate::utils::path_to_str; + +/// Runs commands inside a Flatpak build directory with `flatpak build`. +#[derive(Debug, Clone, Default)] +pub struct FlatpakRunner { + /// Project workspace (used as the host cwd for `flatpak` invocations). + pub workspace: PathBuf, +} + +impl FlatpakRunner { + pub fn new(workspace: impl Into) -> Self { + Self { + workspace: workspace.into(), + } + } + + /// Build argv for `flatpak` (without the program name): `build [flags…] DIR CMD…`. + pub fn build_argv(spec: &RunSpec) -> Result> { + let mut args = vec!["build".to_string()]; + + if spec.share_network { + args.push("--share=network".into()); + } + + for (key, value) in &spec.env { + args.push(format!("--env={key}={value}")); + } + + for flag in &spec.filesystem_binds { + if flag.starts_with("--") { + args.push(flag.clone()); + } else { + args.push(format!("--filesystem={flag}")); + } + } + + for extra in &spec.extra_args { + args.push(extra.clone()); + } + + if let Some(cwd) = &spec.cwd { + let cwd_s = path_to_str(cwd)?; + // Ensure the working directory is visible inside the sandbox. + let already = args.iter().any(|a| { + a == &format!("--filesystem={cwd_s}") + || a.starts_with(&format!("--filesystem={cwd_s}:")) + }); + if !already { + args.push(format!("--filesystem={cwd_s}")); + } + } + + args.push(path_to_str(&spec.repo_dir)?.to_string()); + + // `flatpak build` has no --chdir; wrap with a small shell when cwd is set. + if let Some(cwd) = &spec.cwd { + let cwd_s = path_to_str(cwd)?; + args.push("/bin/sh".into()); + args.push("-c".into()); + args.push("cd \"$1\" && shift && exec \"$@\"".into()); + args.push("_".into()); + args.push(cwd_s.into()); + args.extend(spec.argv.iter().cloned()); + } else { + args.extend(spec.argv.iter().cloned()); + } + + Ok(args) + } +} + +impl SandboxRunner for FlatpakRunner { + fn run(&self, spec: &RunSpec) -> Result<()> { + let args = Self::build_argv(spec)?; + let args_ref: Vec<&str> = args.iter().map(String::as_str).collect(); + run_command("flatpak", &args_ref, Some(self.workspace.as_path())) + .with_context(|| format!("flatpak build failed for {:?}", spec.argv)) + } +} + +/// Initialize a build directory with `flatpak build-init`. +pub fn ensure_build_initialized( + repo_dir: &Path, + app_id: &str, + sdk: &str, + runtime: &str, + runtime_version: &str, + cwd: Option<&Path>, +) -> Result<()> { + let metadata = repo_dir.join("metadata"); + let files = repo_dir.join("files"); + let var = repo_dir.join("var"); + if metadata.is_file() && files.is_dir() && var.is_dir() { + return Ok(()); + } + + crate::utils::status("Initializing build environment..."); + let repo = path_to_str(repo_dir)?; + run_command( + "flatpak", + &["build-init", repo, app_id, sdk, runtime, runtime_version], + cwd, + ) + .with_context(|| { + format!( + "flatpak build-init failed — is the SDK/runtime installed?\n\ + Try: flatpak install {sdk}//{runtime_version}" + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn build_argv_basic() { + let dir = tempdir().unwrap(); + let repo = dir.path().join("repo"); + let args = FlatpakRunner::build_argv(&RunSpec { + repo_dir: repo.clone(), + argv: vec!["meson".into(), "setup".into()], + env: vec![("FOO".into(), "bar".into())], + filesystem_binds: vec![format!("--filesystem=/tmp/ws")], + extra_args: vec![], + share_network: true, + cwd: None, + }) + .unwrap(); + + assert_eq!(args[0], "build"); + assert!(args.contains(&"--share=network".to_string())); + assert!(args.contains(&"--env=FOO=bar".to_string())); + assert!(args.contains(&"--filesystem=/tmp/ws".to_string())); + assert!(args.iter().any(|a| a.ends_with("repo"))); + assert_eq!(&args[args.len() - 2..], ["meson", "setup"]); + } + + #[test] + fn build_argv_with_cwd_wraps_shell() { + let dir = tempdir().unwrap(); + let repo = dir.path().join("repo"); + let cwd = dir.path().join("src"); + let args = FlatpakRunner::build_argv(&RunSpec { + repo_dir: repo, + argv: vec!["make".into()], + env: vec![], + filesystem_binds: vec![], + extra_args: vec![], + share_network: false, + cwd: Some(cwd.clone()), + }) + .unwrap(); + + assert!(!args.iter().any(|a| a == "--share=network")); + assert!(args.iter().any(|a| a == "/bin/sh")); + assert!( + args.iter() + .any(|a| a == "cd \"$1\" && shift && exec \"$@\"") + ); + assert!(args.last().map(String::as_str) == Some("make")); + } +} diff --git a/src/sandbox/install.rs b/src/sandbox/install.rs deleted file mode 100644 index aa33ba6..0000000 --- a/src/sandbox/install.rs +++ /dev/null @@ -1,104 +0,0 @@ -//! Locate installed Flatpak runtimes/SDKs on disk (no `flatpak` CLI). - -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; - -use crate::sources::flatpak_arch; - -/// A resolved runtime or SDK deployment. -#[derive(Debug, Clone)] -pub struct DeployedRef { - pub id: String, - pub arch: String, - pub branch: String, - /// `.../active` directory. - pub active_dir: PathBuf, - /// `.../active/files` — bind this at `/usr` in the sandbox. - pub files_dir: PathBuf, - pub metadata_path: PathBuf, -} - -impl DeployedRef { - pub fn ref_string(&self) -> String { - format!("{}/{}/{}", self.id, self.arch, self.branch) - } -} - -/// Search user then system Flatpak installations for a runtime/SDK. -pub fn find_deployed(id: &str, branch: &str) -> Result { - let arch = flatpak_arch(); - let mut tried = Vec::new(); - for root in installation_roots() { - let active = root - .join("runtime") - .join(id) - .join(&arch) - .join(branch) - .join("active"); - tried.push(active.display().to_string()); - let files = active.join("files"); - let metadata = active.join("metadata"); - if files.is_dir() && metadata.is_file() { - return Ok(DeployedRef { - id: id.to_string(), - arch: arch.clone(), - branch: branch.to_string(), - active_dir: active, - files_dir: files, - metadata_path: metadata, - }); - } - } - anyhow::bail!( - "Flatpak ref {id}/{arch}/{branch} not found on disk (looked in: {}).\n\ - Install the SDK/runtime (e.g. via GNOME Software or `flatpak install`) — \ - flatplay reads the install tree and does not invoke the flatpak CLI.", - tried.join(", ") - ) -} - -pub fn installation_roots() -> Vec { - let mut roots = Vec::new(); - if let Ok(home) = std::env::var("HOME") { - roots.push(PathBuf::from(home).join(".local/share/flatpak")); - } - if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { - let p = PathBuf::from(xdg).join("flatpak"); - if !roots.contains(&p) { - roots.push(p); - } - } - roots.push(PathBuf::from("/var/lib/flatpak")); - roots -} - -/// Parse a flatpak-style version string from an on-disk metadata file if present. -pub fn read_flatpak_version_from_lib() -> Option { - // Best-effort: package managers may ship a version file; not required for builds. - None -} - -pub fn ensure_sdk_and_runtime( - sdk: &str, - runtime: &str, - branch: &str, -) -> Result<(DeployedRef, DeployedRef)> { - let sdk_ref = - find_deployed(sdk, branch).with_context(|| format!("SDK {sdk}//{branch} is required"))?; - let runtime_ref = find_deployed(runtime, branch) - .with_context(|| format!("Runtime {runtime}//{branch} is required"))?; - Ok((sdk_ref, runtime_ref)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn installation_roots_include_user_and_system() { - let roots = installation_roots(); - assert!(roots.iter().any(|r| r.ends_with("flatpak"))); - assert!(roots.iter().any(|r| r == Path::new("/var/lib/flatpak"))); - } -} diff --git a/src/sandbox/mod.rs b/src/sandbox/mod.rs index 0bc63a4..88ca59b 100644 --- a/src/sandbox/mod.rs +++ b/src/sandbox/mod.rs @@ -1,29 +1,24 @@ -//! Sandbox execution — **no `flatpak` / `flatpak-builder` CLI**. +//! Sandbox execution via the **`flatpak` CLI** (`flatpak build` / `build-init`). //! -//! Runtimes/SDKs are located on disk under the Flatpak install tree; commands run -//! via bubblewrap (`bwrap`). +//! Dependency modules and app sources are still built in-process; the Flatpak CLI +//! provides the SDK sandbox and lifecycle. **`flatpak-builder` is not used.** -mod bwrap; -mod install; +mod flatpak; -pub use bwrap::BwrapRunner; -pub use install::{DeployedRef, ensure_sdk_and_runtime, find_deployed, installation_roots}; +pub use flatpak::{FlatpakRunner, ensure_build_initialized}; -use std::path::{Path, PathBuf}; -use std::process::ExitStatus; +use std::path::PathBuf; -use anyhow::{Context, Result}; - -use crate::utils::{status, verbose}; +use anyhow::Result; /// Specification for a sandboxed command. #[derive(Debug, Clone)] pub struct RunSpec { - /// Build directory (metadata / files / var). + /// Build directory (metadata / files / var) — the `flatpak build` DIRECTORY. pub repo_dir: PathBuf, pub argv: Vec, pub env: Vec<(String, String)>, - /// Host filesystem exposes and permission-like flags (`--filesystem=…`, `--socket=…`, …). + /// Flatpak-style flags (`--filesystem=…`, `--bind-mount=…`, finish-args, …). pub filesystem_binds: Vec, pub extra_args: Vec, pub share_network: bool, @@ -34,104 +29,39 @@ pub trait SandboxRunner { fn run(&self, spec: &RunSpec) -> Result<()>; } -/// Create build dir layout without the `flatpak` CLI. -pub fn ensure_build_initialized( - repo_dir: &Path, - app_id: &str, - sdk: &str, - runtime: &str, - runtime_version: &str, - _cwd: Option<&Path>, -) -> Result<()> { - let metadata = repo_dir.join("metadata"); - let files = repo_dir.join("files"); - let var = repo_dir.join("var"); - if metadata.is_file() && files.is_dir() && var.is_dir() { - // Still verify SDK/runtime exist so failures are early and clear. - ensure_sdk_and_runtime(sdk, runtime, runtime_version)?; - return Ok(()); - } - - status("Initializing build environment..."); - ensure_sdk_and_runtime(sdk, runtime, runtime_version)?; - write_minimal_build_layout(repo_dir, app_id, sdk, runtime, runtime_version) -} - -pub fn write_minimal_build_layout( - repo_dir: &Path, - app_id: &str, - sdk: &str, - runtime: &str, - runtime_version: &str, -) -> Result<()> { - std::fs::create_dir_all(repo_dir.join("files"))?; - std::fs::create_dir_all(repo_dir.join("var"))?; - let arch = crate::sources::flatpak_arch(); - let metadata = format!( - "[Application]\n\ - name={app_id}\n\ - runtime={runtime}/{arch}/{runtime_version}\n\ - sdk={sdk}/{arch}/{runtime_version}\n" - ); - std::fs::write(repo_dir.join("metadata"), metadata) - .context("Failed to write build metadata")?; - verbose(format!( - "Wrote build metadata for {app_id} using {sdk}//{runtime_version}" - )); - Ok(()) -} - -/// Host runner for unit tests only. -#[derive(Debug, Default)] -pub struct HostRunner; - -impl SandboxRunner for HostRunner { - fn run(&self, spec: &RunSpec) -> Result<()> { - let program = spec - .argv - .first() - .context("RunSpec argv must not be empty")?; - let args: Vec<&str> = spec.argv.iter().skip(1).map(String::as_str).collect(); - let mut cmd = std::process::Command::new(program); - cmd.args(&args); - for (key, value) in &spec.env { - cmd.env(key, value); - } - if let Some(cwd) = &spec.cwd { - cmd.current_dir(cwd); - } - let status: ExitStatus = cmd.status()?; - if status.success() { - Ok(()) - } else { - anyhow::bail!("Host command failed with {status}"); - } - } -} - #[cfg(test)] mod tests { use super::*; + use anyhow::Context; + use std::process::ExitStatus; use tempfile::tempdir; - #[test] - fn minimal_layout_writes_metadata() { - let dir = tempdir().unwrap(); - let repo = dir.path().join("repo"); - write_minimal_build_layout( - &repo, - "org.example.App", - "org.gnome.Sdk", - "org.gnome.Platform", - "47", - ) - .unwrap(); - assert!(repo.join("metadata").is_file()); - assert!(repo.join("files").is_dir()); - assert!(repo.join("var").is_dir()); - let meta = std::fs::read_to_string(repo.join("metadata")).unwrap(); - assert!(meta.contains("name=org.example.App")); - assert!(meta.contains("org.gnome.Sdk")); + /// Host runner for unit tests only. + #[derive(Debug, Default)] + struct HostRunner; + + impl SandboxRunner for HostRunner { + fn run(&self, spec: &RunSpec) -> Result<()> { + let program = spec + .argv + .first() + .context("RunSpec argv must not be empty")?; + let args: Vec<&str> = spec.argv.iter().skip(1).map(String::as_str).collect(); + let mut cmd = std::process::Command::new(program); + cmd.args(&args); + for (key, value) in &spec.env { + cmd.env(key, value); + } + if let Some(cwd) = &spec.cwd { + cmd.current_dir(cwd); + } + let status: ExitStatus = cmd.status()?; + if status.success() { + Ok(()) + } else { + anyhow::bail!("Host command failed with {status}"); + } + } } #[test] diff --git a/src/sources/cache.rs b/src/sources/cache.rs index 4ebf32c..b5c0b14 100644 --- a/src/sources/cache.rs +++ b/src/sources/cache.rs @@ -1,7 +1,5 @@ use std::path::{Path, PathBuf}; -use sha2::{Digest, Sha256}; - /// Content-addressed download cache under `.flatplay/cache`. #[derive(Debug, Clone)] pub struct DownloadCache { @@ -15,32 +13,11 @@ impl DownloadCache { } } - pub fn root(&self) -> &Path { - &self.root - } - /// Path for a cached blob keyed by sha256 (and a stable filename suffix). pub fn cached_path(&self, filename: &str, sha256: &str) -> PathBuf { let safe_name = filename.replace('/', "_"); self.root.join(sha256).join(safe_name) } - - /// Path keyed only by URL/path hash when no sha256 is known yet. - pub fn path_for_url(&self, url_or_path: &str) -> PathBuf { - let mut hasher = Sha256::new(); - hasher.update(url_or_path.as_bytes()); - let digest = hasher.finalize(); - let mut hex = String::with_capacity(64); - for byte in digest { - use std::fmt::Write; - let _ = write!(&mut hex, "{byte:02x}"); - } - let name = Path::new(url_or_path) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("blob"); - self.root.join("by-url").join(hex).join(name) - } } #[cfg(test)] diff --git a/src/sources/mod.rs b/src/sources/mod.rs index a9a8b1e..1270f8e 100644 --- a/src/sources/mod.rs +++ b/src/sources/mod.rs @@ -4,6 +4,7 @@ mod cache; pub use cache::DownloadCache; +use std::io::Write; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; @@ -309,25 +310,9 @@ fn materialize_one( "Using directory source for {module_name}: {}", src.display() )); - // Prefer lightweight presence: if target is empty module dir and path is project - // root, leave a marker file with the resolved path for buildsystems to read. - // For dependency builds that need full tree, copy (can be large). - if target - .read_dir() - .map(|mut d| d.next().is_none()) - .unwrap_or(true) - && source_is_module_root(target) - { - std::fs::write( - target.join(".flatplay-dir-source"), - src.canonicalize() - .unwrap_or(src) - .to_string_lossy() - .as_bytes(), - )?; - } else { - copy_dir_all(&src, target)?; - } + // Prefer live trees via resolve_dir_source at build time. When a dir + // source is materialized into the module tree, copy the contents. + copy_dir_all(&src, target)?; } Source::Patch { path, .. } => { let patch_path = resolve_path(manifest_dir, path); @@ -340,13 +325,6 @@ fn materialize_one( Ok(()) } -fn source_is_module_root(target: &Path) -> bool { - target - .file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| !n.is_empty()) -} - fn resolve_path(manifest_dir: &Path, path: &str) -> PathBuf { let p = Path::new(path); if p.is_absolute() { @@ -357,23 +335,16 @@ fn resolve_path(manifest_dir: &Path, path: &str) -> PathBuf { } fn apply_patch(patch_path: &Path, target: &Path) -> Result<()> { - // Minimal unified-diff apply via `patch` if present; otherwise error with guidance. - // Prefer pure approach: read patch and use the `patch` crate — avoid host CLI. - // For Phase B we shell to `patch -p1` only when the binary exists; Wordbook has no patches. - let patch_body = - std::fs::read_to_string(patch_path).with_context(|| patch_path.display().to_string())?; - let _ = (patch_body, target); - // Try lib: apply with patch crate if we add it; for now use std process patch as last resort - // for rare manifests — documented limitation. + // Host `patch` CLI for rare manifests (no pure-Rust apply yet). + let data = std::fs::read(patch_path) + .with_context(|| format!("Failed to read patch {}", patch_path.display()))?; let status = std::process::Command::new("patch") .args(["-p1", "--forward", "--batch"]) .current_dir(target) .stdin(std::process::Stdio::piped()) .spawn() .and_then(|mut child| { - use std::io::Write; if let Some(stdin) = child.stdin.as_mut() { - let data = std::fs::read(patch_path)?; stdin.write_all(&data)?; } child.wait() diff --git a/src/state.rs b/src/state.rs index 04cf2ff..6390a34 100644 --- a/src/state.rs +++ b/src/state.rs @@ -107,7 +107,7 @@ mod tests { #[test] fn test_state_reset() { let temp_dir = tempfile::tempdir().unwrap(); - let mut state = State::load(temp_dir.path().as_ref()).unwrap(); + let mut state = State::load(temp_dir.path()).unwrap(); state.dependencies_updated = true; state.dependencies_built = true; diff --git a/src/utils.rs b/src/utils.rs index 3c35cb2..9452414 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -4,6 +4,7 @@ use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::env; use std::fmt::Write; +use std::io::Read; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::OnceLock; @@ -71,7 +72,10 @@ pub fn get_host_env() -> HashMap { "XDG_SESSION_ID", "XDG_SESSION_TYPE", "XDG_VTNR", + "XDG_RUNTIME_DIR", + "DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", + "HOME", "LANG", "LANGUAGE", "LC_ALL", @@ -285,8 +289,6 @@ pub fn download_file(url: &str, dest: &Path) -> Result<()> { } pub fn verify_sha256_hex(path: &Path, expected: &str) -> Result<()> { - use std::io::Read; - let mut file = std::fs::File::open(path) .with_context(|| format!("Failed to open {} for hashing", path.display()))?; let mut hasher = Sha256::new(); @@ -655,10 +657,4 @@ mod tests { assert!(!src.exists()); assert_eq!(std::fs::read(&dest).unwrap(), b"data"); } - - #[test] - fn test_shell_words_roundtrip_quotes() { - let argv = shell_words::split(r#"echo "hello world" '--flag=a b'"#).unwrap(); - assert_eq!(argv, ["echo", "hello world", "--flag=a b"]); - } } From a414810b43ed5d5165a733f13dea1807b270cc64 Mon Sep 17 00:00:00 2001 From: Mufeed Ali Date: Thu, 16 Jul 2026 23:18:13 +0530 Subject: [PATCH 6/6] docs: document flatpak CLI control plane and limitations --- README.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5506443..f8e1972 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,23 @@ Note that this is a **work in progress** and you might encounter issues along th ## External Dependencies -Flatplay’s **control plane does not invoke** the `flatpak` or `flatpak-builder` CLIs. +Flatplay uses the **`flatpak` CLI** for sandboxing and lifecycle (`build-init`, `build`, `build-finish`, export). It does **not** invoke **`flatpak-builder`** — modules and sources are handled in-process. | Need | How | |------|-----| -| Sandbox | **bubblewrap** (`bwrap`) on PATH | -| SDK / runtime **content** | Installed on disk under `~/.local/share/flatpak` or `/var/lib/flatpak` (e.g. via GNOME Software). Flatplay only *reads* those trees. | +| Sandbox / lifecycle | **`flatpak`** on PATH | +| SDK / runtime | Installed via Flatpak (e.g. `flatpak install org.gnome.Sdk//…`) | | Git sources | **libgit2** (linked), not host `git` | | A11y bus | **zbus** (optional `gdbus` fallback) | | Toolchains (meson, ninja, gcc, …) | Inside the Flatpak **SDK** image | -Architecture: typed sources + download cache, in-process module builder, `BwrapRunner` sandbox. +Architecture: typed sources + download cache, **one** in-process module builder for deps and app, `FlatpakRunner` (`flatpak build`). + +### Notes / limitations + +- Manifest discovery scans the project tree (skips heavy dirs like `node_modules` / `target`) with a cheap text prefilter before full parse. +- `sdk-extensions` and `cleanup` are parsed but not fully applied yet. +- Host font remapping uses the usual FHS / XDG locations Flatpak expects. ## Installation & Usage