Skip to content

Commit 3c10cbc

Browse files
committed
feat(dist-apk): one stage-manifest reader in the lib root, and the closure criteria as a script (mcpp#634)
`mcpp::plugins::stage::read_manifest` parses the header and the `needs` lines of `<staged tree>.stage-manifest`; dist-apk reads through it, and dist-apple is its second reader. tests/apk-consumer-shared/check-apk-closure.sh states five criteria: two packs in a row carry the dependency's library; two triples give one signed APK whose native code lists arm64-v8a and x86_64; --format aab gives a bundle that bundletool validates, jarsigner verifies, and that yields a universal APK; a refusal's reason is in mcpp pack's output; a stage manifest without `needs` lines is refused through mcpp::warning, naming 2026.9.14.2. Local readings (Linux, mcpp feat/634-cmake-parity at 99378fc): all five hold. Against the 0.9.3 member under the same engine, criteria 2, 4 and 5 fail (the two-triple pack exits 1 with "no action claimed"; the refusal's reason is absent; the build program plans nothing about the floor).
1 parent 3956f63 commit 3c10cbc

4 files changed

Lines changed: 208 additions & 37 deletions

File tree

dist/apk.cppm

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -547,37 +547,6 @@ inline plan& refuse(plan& p, std::string reason, const std::string& message) {
547547
return p;
548548
}
549549

550-
// What the engine wrote beside the staged tree: `<stage>.stage-manifest`
551-
// (mcpp's docs/50, "The stage manifest"). Only the header and the `needs`
552-
// lines are read; the file list that follows them is not.
553-
struct stage_manifest {
554-
bool found = false;
555-
bool walked = true;
556-
std::string reason;
557-
struct need { std::string name; std::string where; };
558-
std::vector<need> needs; // `where`: a staged path, `platform` or `unresolved`
559-
};
560-
561-
inline stage_manifest read_stage_manifest(std::string stage) {
562-
stage_manifest m;
563-
while (stage.size() > 1 && (stage.back() == '/' || stage.back() == '\\'))
564-
stage.pop_back();
565-
std::ifstream in(stage + ".stage-manifest", std::ios::binary);
566-
if (!in) return m;
567-
m.found = true;
568-
std::string line;
569-
while (std::getline(in, line)) {
570-
if (!line.empty() && line.back() == '\r') line.pop_back();
571-
if (line == "closure = not-walked") { m.walked = false; continue; }
572-
if (line.starts_with("reason = ")) { m.reason = line.substr(9); continue; }
573-
if (!line.starts_with("needs\t")) continue;
574-
const auto second = line.find('\t', 6);
575-
if (second == std::string::npos) continue;
576-
m.needs.push_back({ line.substr(6, second - 6), line.substr(second + 1) });
577-
}
578-
return m;
579-
}
580-
581550
// Android's own ABI names, the directory names a several-triple tree stages
582551
// under (`lib/<abi>/`) and an APK stores its native libraries under.
583552
inline bool is_android_abi(std::string_view name) {
@@ -645,7 +614,7 @@ inline plan plan_for(options opt = {}) {
645614
// manifest without a single `needs` line comes from an engine that staged
646615
// the application object alone, and packing that tree would produce a
647616
// package whose object cannot load, so it is refused rather than packed.
648-
const auto manifest = read_stage_manifest(stage);
617+
const auto manifest = mcpp::plugins::stage::read_manifest(stage);
649618
if (!manifest.found) {
650619
return refuse(p, "no stage manifest", std::format(
651620
"mcpp.dist.apk: the staged tree {} has no stage manifest beside it. "

src/plugins.cppm

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,51 @@ inline constexpr std::string_view version = "0.9.3";
5353

5454
} // namespace mcpp::plugins
5555

56+
// mcpp::plugins::stage -- what the engine wrote beside a staged tree.
57+
//
58+
// SHARED BECAUSE TWO DIST MEMBERS READ IT. `mcpp pack` writes
59+
// `<staged tree>.stage-manifest` (mcpp's docs/50, "The stage manifest"): a
60+
// header, one `needs` line per library name the closure read (mcpp
61+
// 2026.9.14.2+), and the list of staged files. `dist-apk` reads it to trust the
62+
// native libraries under `lib/`, and `dist-apple` reads it to tell the dylibs
63+
// staged beside a program from the resources staged there. Only the header and
64+
// the `needs` lines are parsed; the file list is not.
65+
export namespace mcpp::plugins::stage {
66+
67+
struct need {
68+
std::string name; // as the needing object spells it
69+
std::string where; // a staged path relative to the tree, `platform` or `unresolved`
70+
};
71+
72+
struct manifest {
73+
bool found = false; // the file exists beside the tree
74+
bool walked = true; // `closure = walked`
75+
std::string reason; // `reason = ...`, with `closure = not-walked`
76+
std::vector<need> needs;
77+
};
78+
79+
inline manifest read_manifest(std::string tree) {
80+
manifest m;
81+
while (tree.size() > 1 && (tree.back() == '/' || tree.back() == '\\'))
82+
tree.pop_back();
83+
std::ifstream in(tree + ".stage-manifest", std::ios::binary);
84+
if (!in) return m;
85+
m.found = true;
86+
std::string line;
87+
while (std::getline(in, line)) {
88+
if (!line.empty() && line.back() == '\r') line.pop_back();
89+
if (line == "closure = not-walked") { m.walked = false; continue; }
90+
if (line.starts_with("reason = ")) { m.reason = line.substr(9); continue; }
91+
if (!line.starts_with("needs\t")) continue;
92+
const auto second = line.find('\t', 6);
93+
if (second == std::string::npos) continue;
94+
m.needs.push_back({ line.substr(6, second - 6), line.substr(second + 1) });
95+
}
96+
return m;
97+
}
98+
99+
} // namespace mcpp::plugins::stage
100+
56101
// mcpp::plugins::names -- the derivations that turn a path into a C++ name.
57102
//
58103
// THESE ARE SHARED BECAUSE THEY WERE COPIED. `common_base_dir` and
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
#!/usr/bin/env bash
2+
# dist-apk reads the native closure the engine staged (mcpp#634, B5).
3+
#
4+
# Five criteria. The first four run `mcpp pack` and read the package with the
5+
# Android tools; the fifth runs the compiled build program against a fabricated
6+
# stage, so it holds whichever engine packs.
7+
#
8+
# 1. Two packs in a row both carry the dependency's library. Under 0.9.3 the
9+
# second APK lacked it: the member walked the object's NEEDED entries
10+
# behind a stamp that outlived the stage the plan wipes, so the walk did
11+
# not run again.
12+
# 2. Two `--target` rows give one APK whose native code lists both ABIs, each
13+
# with the application object, the dependency and `libc++_shared.so`.
14+
# Under 0.9.3 this pack exited 1: the member did not descend into
15+
# `lib/<abi>/`.
16+
# 3. `--format aab` gives an App Bundle that bundletool validates, that
17+
# jarsigner verifies, and from which bundletool builds a universal APK
18+
# carrying the same libraries.
19+
# 4. A refusal reaches the user: `--format apk` for the host row prints the
20+
# member's reason. Under 0.9.3 the reason went to the build program's
21+
# stderr, which the engine discards when the program exits 0, and the
22+
# user read only "no action claimed".
23+
# 5. A stage manifest without `needs` lines, which every engine below
24+
# 2026.9.14.2 writes, is refused through `mcpp::warning`, naming that
25+
# floor.
26+
#
27+
# Usage: MCPP=<mcpp 2026.9.14.2+> ./check-apk-closure.sh (run from this directory)
28+
set -eu
29+
30+
MCPP="${MCPP:-mcpp}"
31+
fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; }
32+
# The path of the artifact a pack reports on its `Packed` line.
33+
packed() { sed -n 's/^ *Packed //p' "$1" | tail -1; }
34+
35+
"$MCPP" self env > mcpp-env.txt
36+
MCPP_HOME_DIR=$(awk -F'= *' '/^MCPP_HOME/{print $2; exit}' mcpp-env.txt)
37+
[ -n "$MCPP_HOME_DIR" ] || fail "could not read MCPP_HOME" mcpp-env.txt
38+
XPKGS="$MCPP_HOME_DIR/registry/data/xpkgs"
39+
40+
# tool <package> <path under the package> : the file in whichever installed
41+
# version carries it.
42+
tool() {
43+
local d
44+
for d in "$XPKGS/xim-x-$1"/*/; do
45+
[ -e "$d$2" ] && { echo "$d$2"; return 0; }
46+
done
47+
return 1
48+
}
49+
50+
# lists <archive> <listing file> : the member names of a zip archive.
51+
lists() { "$JAR" tf "$1" > "$2"; }
52+
53+
# ── 1. two packs in a row ──────────────────────────────────────────────────
54+
echo "== 1. two packs in a row carry the dependency's library =="
55+
rm -rf target
56+
for n in 1 2; do
57+
"$MCPP" pack --target x86_64-linux-android --format apk > "closure-pack$n.log" 2>&1 \
58+
|| fail "pack $n exited non-zero" "closure-pack$n.log"
59+
apk=$(packed "closure-pack$n.log")
60+
[ -f "$apk" ] || fail "pack $n reported no APK" "closure-pack$n.log"
61+
if [ "$n" = 1 ]; then
62+
JAR=$(tool jdk-temurin bin/jar) || fail "no jar under $XPKGS/xim-x-jdk-temurin"
63+
JARSIGNER=$(tool jdk-temurin bin/jarsigner) || fail "no jarsigner under $XPKGS/xim-x-jdk-temurin"
64+
AAPT2=$(tool android-build-tools aapt2) || fail "no aapt2 under $XPKGS/xim-x-android-build-tools"
65+
APKSIGNER=$(tool android-build-tools bin/apksigner) || fail "no apksigner under $XPKGS/xim-x-android-build-tools"
66+
fi
67+
lists "$apk" "closure-list$n.log"
68+
for f in lib/x86_64/libapk-consumer-shared.so lib/x86_64/libapk-consumer-dep.so \
69+
lib/x86_64/libc++_shared.so; do
70+
grep -qxF "$f" "closure-list$n.log" || fail "the APK of pack $n does not list $f" "closure-list$n.log"
71+
done
72+
echo "ok: pack $n: $(grep -c '^lib/' "closure-list$n.log") native libraries, the dependency's among them"
73+
done
74+
75+
# ── 2. two triples, one APK ────────────────────────────────────────────────
76+
echo "== 2. two triples give one APK listing both ABIs =="
77+
"$MCPP" pack --target x86_64-linux-android --target aarch64-linux-android --format apk \
78+
> closure-multi.log 2>&1 || fail "the two-triple pack exited non-zero" closure-multi.log
79+
apk=$(packed closure-multi.log)
80+
[ -f "$apk" ] || fail "the two-triple pack reported no APK" closure-multi.log
81+
lists "$apk" closure-multi-list.log
82+
for abi in x86_64 arm64-v8a; do
83+
for f in libapk-consumer-shared.so libapk-consumer-dep.so libc++_shared.so; do
84+
grep -qxF "lib/$abi/$f" closure-multi-list.log \
85+
|| fail "the two-triple APK does not list lib/$abi/$f" closure-multi-list.log
86+
done
87+
done
88+
"$AAPT2" dump badging "$apk" > closure-badging.log 2>&1 || fail "aapt2 could not read the APK" closure-badging.log
89+
tr -d '\r' < closure-badging.log | grep -qx "native-code: 'arm64-v8a' 'x86_64'" \
90+
|| fail "aapt2 does not report native-code 'arm64-v8a' 'x86_64'" closure-badging.log
91+
"$APKSIGNER" verify "$apk" > closure-verify.log 2>&1 || fail "apksigner does not verify the APK" closure-verify.log
92+
echo "ok: one signed APK, $(tr -d '\r' < closure-badging.log | grep '^native-code:')"
93+
94+
# ── 3. an App Bundle ───────────────────────────────────────────────────────
95+
echo "== 3. --format aab =="
96+
"$MCPP" pack --target x86_64-linux-android --format aab > closure-aab.log 2>&1 \
97+
|| fail "the aab pack exited non-zero" closure-aab.log
98+
aab=$(packed closure-aab.log)
99+
case "$aab" in *.aab) ;; *) fail "the aab pack reported '$aab', not an .aab" closure-aab.log ;; esac
100+
[ -f "$aab" ] || fail "the reported bundle $aab does not exist" closure-aab.log
101+
BUNDLETOOL=$(tool bundletool bin/bundletool) || fail "no bundletool under $XPKGS/xim-x-bundletool"
102+
lists "$aab" closure-aab-list.log
103+
for f in BundleConfig.pb base/manifest/AndroidManifest.xml base/resources.pb \
104+
base/lib/x86_64/libapk-consumer-shared.so base/lib/x86_64/libapk-consumer-dep.so \
105+
base/lib/x86_64/libc++_shared.so base/assets/mcpp-run.json; do
106+
grep -qxF "$f" closure-aab-list.log || fail "the bundle does not list $f" closure-aab-list.log
107+
done
108+
"$BUNDLETOOL" validate --bundle="$aab" > closure-validate.log 2>&1 \
109+
|| fail "bundletool validate refused the bundle" closure-validate.log
110+
"$JARSIGNER" -verify "$aab" > closure-jarsigner.log 2>&1 || fail "jarsigner -verify failed" closure-jarsigner.log
111+
grep -q '^jar verified' closure-jarsigner.log || fail "jarsigner did not report 'jar verified'" closure-jarsigner.log
112+
work=$(mktemp -d)
113+
"$BUNDLETOOL" build-apks --bundle="$aab" --output="$work/universal.apks" --mode=universal \
114+
> closure-build-apks.log 2>&1 || fail "bundletool build-apks --mode=universal failed" closure-build-apks.log
115+
(cd "$work" && "$JAR" xf universal.apks universal.apk) || fail "the .apks set carries no universal.apk"
116+
lists "$work/universal.apk" closure-universal-list.log
117+
grep -qxF lib/x86_64/libapk-consumer-dep.so closure-universal-list.log \
118+
|| fail "the universal APK does not carry the dependency's library" closure-universal-list.log
119+
echo "ok: the bundle validates, is signed, and yields a universal APK with the dependency's library"
120+
121+
# ── 4. a refusal reaches the user ──────────────────────────────────────────
122+
echo "== 4. a refusal prints its reason =="
123+
rc=0
124+
"$MCPP" pack --format apk > closure-refusal.log 2>&1 || rc=$?
125+
[ "$rc" -ne 0 ] || fail "an APK for the host row was not refused" closure-refusal.log
126+
grep -q 'mcpp.dist.apk: an APK is an Android format' closure-refusal.log \
127+
|| fail "the refusal's reason is not in mcpp pack's output (exit $rc)" closure-refusal.log
128+
echo "ok: exit $rc, and the output names the reason: $(grep -m1 'mcpp.dist.apk:' closure-refusal.log)"
129+
130+
# ── 5. an engine below the floor ───────────────────────────────────────────
131+
#
132+
# The engine's output is not read here, so the build program is run with the
133+
# environment a pack sets (mcpp's docs/30) and a stage manifest in the form an
134+
# engine below 2026.9.14.2 writes: header and file list, no `needs` line.
135+
echo "== 5. a stage manifest without needs lines is refused, naming the floor =="
136+
BIN=target/.build-mcpp/build.mcpp.bin
137+
[ -x "$BIN" ] || fail "no compiled build.mcpp to run" closure-refusal.log
138+
stage="$work/apk-consumer-shared-0.2.0-x86_64-linux-android"
139+
mkdir -p "$stage/lib"
140+
head -c 4096 /dev/zero > "$stage/lib/libapk-consumer-shared.so"
141+
printf 'closure = not-walked\nreason = the Android closure is not read\n4096 lib/libapk-consumer-shared.so\n' \
142+
> "$stage.stage-manifest"
143+
env -i PATH="$PATH" \
144+
MCPP_TARGET_ARCH=x86_64 MCPP_TARGET_OS=linux MCPP_TARGET_ENV=android \
145+
MCPP_OUT_DIR="$work/out" MCPP_MANIFEST_DIR="$PWD" \
146+
MCPP_PKG_NAME=apk-consumer-shared MCPP_PKG_VERSION=0.2.0 \
147+
MCPP_PACK_FORMAT=apk MCPP_PACK_STAGE_DIR="$stage" \
148+
"$BIN" > closure-floor.log 2>&1 || true
149+
if grep -q '^mcpp:action=' closure-floor.log; then
150+
fail "a stage without needs lines planned actions" closure-floor.log
151+
fi
152+
grep '^mcpp:warning=' closure-floor.log | grep -q 'no `needs` line.*2026\.9\.14\.2 or newer' \
153+
|| fail "the refusal is not a warning naming mcpp 2026.9.14.2" closure-floor.log
154+
echo "ok: refused, as a warning naming the engine floor"
155+
156+
echo "PASS: dist-apk reads the staged closure, packs two ABIs into one APK, builds an App Bundle, and reports its refusals"

tests/apk-consumer-shared/mcpp.toml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
# Fixture: an app target whose dependency is a SHARED object on the Android
22
# row -- the shape a framework takes there (the Java host loads it by name),
3-
# and the one `dist-apk` used to lose: the engine stages the app's own object
4-
# and the deployed files, the closure on this row is `not-walked`, and
5-
# `lib<dep>.so` reached neither `lib/` nor the APK (0.9.2). From 0.9.3 the
6-
# member walks the app's NEEDED at command time and copies what it finds
7-
# beside the object.
3+
# and the one `dist-apk` used to lose. Under 0.9.2 `lib<dep>.so` reached
4+
# neither `lib/` nor the APK; 0.9.3 walked the app's NEEDED at command time,
5+
# behind a stamp that lost the library on a second pack. From mcpp 2026.9.14.2
6+
# the engine stages the closure under `lib/` (`lib/<abi>/` for several targets)
7+
# and names it in the stage manifest, and 0.10.0 packs that tree.
8+
# `check-apk-closure.sh` states the criteria.
89
[package]
910
name = "apk-consumer-shared"
1011
version = "0.2.0"

0 commit comments

Comments
 (0)