From 3c58f9b08f11ed8e109ad6dcad2b14823bad8d4f Mon Sep 17 00:00:00 2001 From: Luis-ADFA Date: Thu, 3 Sep 2026 02:30:35 -0600 Subject: [PATCH 1/3] K2GO-90 fix(rootfs): accept both manifest names, so the rename has no broken window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The identity manifest, its integrity sibling and the kind they declare are matched under both the iiab- and k2go- names. The builder still writes the old one: this only removes the reason it cannot stop. The reader has to ship before the writer changes. An app that only knew the new name would reject every archive built before the switch — including the backups users are told to make before migrating, which is the one moment when a rejected restore is unrecoverable. The integrity member is matched the same way for a sharper reason: it is excluded from the tree hash it declares, so failing to recognise it would fold it into its own digest and read every archive as CORRUPT. --- .../k2go/deploy/data/RootfsIntegrity.java | 15 ++++++++--- .../k2go/deploy/data/RootfsManifest.java | 18 +++++++++++-- .../k2go/deploy/domain/RootfsIdentity.java | 15 ++++++++--- .../deploy/domain/RootfsIdentityTest.java | 26 +++++++++++++++++++ 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/controller/app/src/main/java/org/appdevforall/k2go/deploy/data/RootfsIntegrity.java b/controller/app/src/main/java/org/appdevforall/k2go/deploy/data/RootfsIntegrity.java index e4199eb9..ec6deb7a 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/deploy/data/RootfsIntegrity.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/deploy/data/RootfsIntegrity.java @@ -39,7 +39,13 @@ public final class RootfsIntegrity { private static final String TAG = "IIAB-RootfsIntegrity"; - private static final String INTEGRITY_MEMBER = "installed-rootfs/iiab/.iiab-rootfs.integrity.json"; + // K2GO-90: matched under either name, like the identity manifest. This one is excluded from the + // tree hash it declares, so failing to recognise it would fold it into its own digest and every + // archive would read as CORRUPT. + private static final String[] INTEGRITY_MEMBERS = { + "installed-rootfs/iiab/.iiab-rootfs.integrity.json", + "installed-rootfs/iiab/.k2go-rootfs.integrity.json" + }; private static final int MAX_DECL_BYTES = 64 * 1024; public enum Status { @@ -84,7 +90,10 @@ public static Result verify(String archivePath) { : new BufferedInputStream(rawFile)) { final RootfsTreeHash.Accumulator acc = new RootfsTreeHash.Accumulator(); - final String integrityNorm = RootfsTreeHash.norm(INTEGRITY_MEMBER); + final java.util.Set integrityNorms = new java.util.HashSet<>(); + for (String m : INTEGRITY_MEMBERS) { + integrityNorms.add(RootfsTreeHash.norm(m)); + } boolean integritySeen = false; String declaredAlgo = null; String declaredHash = null; @@ -139,7 +148,7 @@ public static Result verify(String archivePath) { : cString(header, 157, 100); longName = longLink = paxPath = paxLink = null; // consumed - if (integrityNorm.equals(RootfsTreeHash.norm(name))) { + if (integrityNorms.contains(RootfsTreeHash.norm(name))) { // The integrity member itself: read its declaration, do NOT hash it. byte[] decl = readBlock(in, Math.min(size, MAX_DECL_BYTES), padded); integritySeen = true; diff --git a/controller/app/src/main/java/org/appdevforall/k2go/deploy/data/RootfsManifest.java b/controller/app/src/main/java/org/appdevforall/k2go/deploy/data/RootfsManifest.java index b1cf8f40..12252c09 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/deploy/data/RootfsManifest.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/deploy/data/RootfsManifest.java @@ -32,7 +32,11 @@ public final class RootfsManifest { private static final String TAG = "IIAB-RootfsManifest"; - private static final String MEMBER_SUFFIX = "iiab/.iiab-rootfs.json"; + // K2GO-90: both names are matched for the same reason RootfsIdentity accepts both kinds — + // the reader ships first, so a later rename cannot orphan archives already in the field. + private static final String[] MEMBER_SUFFIXES = { + "iiab/.iiab-rootfs.json", "iiab/.k2go-rootfs.json" + }; private static final int MAX_HEADERS = 8; // identity is first; scan a few in case of pax/dir entries private static final int MAX_JSON_BYTES = 64 * 1024; @@ -97,7 +101,7 @@ public static Identity read(InputStream raw, boolean isGzip) { if (size < 0) { break; } - if (normalizeEndsWith(name, MEMBER_SUFFIX)) { + if (matchesManifestMember(name)) { int toRead = (int) Math.min(size, MAX_JSON_BYTES); byte[] json = new byte[toRead]; if (!readFully(in, json, toRead)) { @@ -132,6 +136,16 @@ private static Identity parse(String jsonText) { } } + /** True when {@code rawName} is the identity manifest under either of its accepted names. */ + private static boolean matchesManifestMember(String rawName) { + for (String suffix : MEMBER_SUFFIXES) { + if (normalizeEndsWith(rawName, suffix)) { + return true; + } + } + return false; + } + private static boolean normalizeEndsWith(String rawName, String suffix) { if (rawName == null) { return false; diff --git a/controller/app/src/main/java/org/appdevforall/k2go/deploy/domain/RootfsIdentity.java b/controller/app/src/main/java/org/appdevforall/k2go/deploy/domain/RootfsIdentity.java index 93317c48..21563162 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/deploy/domain/RootfsIdentity.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/deploy/domain/RootfsIdentity.java @@ -31,8 +31,17 @@ public enum Verdict { WRONG_ARCH } - /** The kind every rootfs manifest declares; anything else is a different sort of archive. */ - private static final String KIND_ROOTFS = "iiab-rootfs"; + /** + * The kinds a rootfs manifest may declare; anything else is a different sort of archive. + * + *

K2GO-90: two are accepted so the manifest can be renamed later without a broken window. + * The reader has to ship before the writer changes — an app that only knew the new name would + * reject every archive built before the switch, including the backups users are told to make + * before migrating. The builder still writes {@code iiab-rootfs}; this only removes the reason + * it cannot stop. + */ + private static final java.util.List KIND_ROOTFS = + java.util.Arrays.asList("iiab-rootfs", "k2go-rootfs"); private RootfsIdentity() { } @@ -56,7 +65,7 @@ public static Verdict check(boolean present, String kind, String arch, String ap if (!present) { return Verdict.OK; } - if (!KIND_ROOTFS.equals(kind)) { + if (!KIND_ROOTFS.contains(kind)) { return Verdict.NOT_A_ROOTFS; } if (arch != null && !arch.isEmpty() && appAbi != null && !arch.equals(appAbi)) { diff --git a/controller/app/src/test/java/org/appdevforall/k2go/deploy/domain/RootfsIdentityTest.java b/controller/app/src/test/java/org/appdevforall/k2go/deploy/domain/RootfsIdentityTest.java index d5f6b9f2..80ffb8e1 100644 --- a/controller/app/src/test/java/org/appdevforall/k2go/deploy/domain/RootfsIdentityTest.java +++ b/controller/app/src/test/java/org/appdevforall/k2go/deploy/domain/RootfsIdentityTest.java @@ -76,4 +76,30 @@ public void kindIsCheckedBeforeArch() { assertEquals(RootfsIdentity.Verdict.NOT_A_ROOTFS, RootfsIdentity.check(true, "not-a-rootfs", ARM32, ARM64)); } + + // ---- K2GO-90: the manifest may be renamed later; the reader ships tolerant first ---- + + @Test public void acceptsTheRenamedKind() { + assertEquals(RootfsIdentity.Verdict.OK, + RootfsIdentity.check(true, "k2go-rootfs", "arm64-v8a", "arm64-v8a")); + } + + @Test public void stillAcceptsTheOriginalKind() { + // The backups users are told to make before migrating carry this one. + assertEquals(RootfsIdentity.Verdict.OK, + RootfsIdentity.check(true, "iiab-rootfs", "arm64-v8a", "arm64-v8a")); + } + + @Test public void theRenamedKindIsStillArchChecked() { + // Accepting a second name must not become a way around the ABI rule. + assertEquals(RootfsIdentity.Verdict.WRONG_ARCH, + RootfsIdentity.check(true, "k2go-rootfs", "armeabi-v7a", "arm64-v8a")); + } + + @Test public void anyOtherKindIsStillRejected() { + assertEquals(RootfsIdentity.Verdict.NOT_A_ROOTFS, + RootfsIdentity.check(true, "k2go-backup", "arm64-v8a", "arm64-v8a")); + assertEquals(RootfsIdentity.Verdict.NOT_A_ROOTFS, + RootfsIdentity.check(true, "rootfs", "arm64-v8a", "arm64-v8a")); + } } From 181484b6c182c6c277aac8c682c9868b8a1b19b9 Mon Sep 17 00:00:00 2001 From: Luis-ADFA Date: Thu, 3 Sep 2026 03:08:19 -0600 Subject: [PATCH 2/3] K2GO-90 fix(update): stage the downloaded APK under the product's name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallback name for the staged OTA APK was iiab_update.apk. It is only used when the download URL does not end in .apk, so it rarely surfaces — but it was spelled out twice, once when staging the file and once as the default when reading the name back out of SharedPreferences. Two literals for one fact: drifting apart would stage the file under one name and have the verifier look for it under another, on the one path nobody exercises. It is k2go_update.apk now, in a single constant both sides read. --- .../k2go/update/presentation/UpdateController.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/controller/app/src/main/java/org/appdevforall/k2go/update/presentation/UpdateController.java b/controller/app/src/main/java/org/appdevforall/k2go/update/presentation/UpdateController.java index b95fa27b..360bdffc 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/update/presentation/UpdateController.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/update/presentation/UpdateController.java @@ -187,10 +187,16 @@ private void showUpdateDialog(String versionName, String changelog, String downl .show(); } + /** Fallback name for the staged APK, used only when the download URL does not end in .apk. + * It is the same default on both sides of the SharedPreferences round-trip, so it lives in one + * place: the two spellings drifting apart would stage a file the verifier then looks for by + * another name. */ + private static final String STAGED_APK_FALLBACK = "k2go_update.apk"; + private void startDownload(String downloadUrl) { String apkName = android.net.Uri.parse(downloadUrl).getLastPathSegment(); if (apkName == null || !apkName.endsWith(".apk")) { - apkName = "iiab_update.apk"; + apkName = STAGED_APK_FALLBACK; } activity.getSharedPreferences(activity.getString(R.string.pref_file_internal), Context.MODE_PRIVATE) @@ -293,7 +299,7 @@ private boolean isDownloadSuccessful(long id) { /** Verify the staged APK exists and is signed by this app's certificate. Returns the file, or null. */ private File verifyDownloadedApk() { String apkName = activity.getSharedPreferences(activity.getString(R.string.pref_file_internal), Context.MODE_PRIVATE) - .getString("ota_apk_name", "iiab_update.apk"); + .getString("ota_apk_name", STAGED_APK_FALLBACK); File apkFile = new File(activity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), apkName); From d952aff7e21cb7a9c6758e14a64d5bbade217719 Mon Sep 17 00:00:00 2001 From: Luis-ADFA Date: Thu, 3 Sep 2026 03:49:33 -0600 Subject: [PATCH 3/3] K2GO-90 fix(rootfs): write the manifest under the product's name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both writers move together: BackupEngine, which stamps the manifest into user backups, and build-iiab-rootfs.sh, which stamps it into the images. They have to agree, because the validator compares an archive against what a rootfs image looks like. Done now rather than later on purpose. The installed base is developers and a handful of users, so the set of archives carrying the old name closes today instead of growing with every backup written from here on — which is what would have made the tolerance for it impossible to ever drop. Verified on device: an image built with the old writer installs, a backup taken from it carries the new name as the tar's first member, and restoring that backup completes. The integrity member is recognised under the new name too, which is the failure that would otherwise have read every archive as CORRUPT. --- .../k2go/backup/domain/BackupEngine.java | 20 ++++++++++++++----- tools/rootfs-builder/build-iiab-rootfs.sh | 19 ++++++++++-------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/controller/app/src/main/java/org/appdevforall/k2go/backup/domain/BackupEngine.java b/controller/app/src/main/java/org/appdevforall/k2go/backup/domain/BackupEngine.java index a40a67a5..83cc910a 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/backup/domain/BackupEngine.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/backup/domain/BackupEngine.java @@ -58,7 +58,7 @@ public static boolean streamBackup(Context ctx, OutputStream dest) { String tarBin = staticTar.exists() ? staticTar.getAbsolutePath() : "tar"; String gzipBin = staticGzip.exists() ? staticGzip.getAbsolutePath() : "gzip"; - String manifestArg = stageIdentityManifest(ctx); // "-C '' 'installed-rootfs/iiab/.iiab-rootfs.json' " or null + String manifestArg = stageIdentityManifest(ctx); // "-C '' 'installed-rootfs/iiab/.k2go-rootfs.json' " or null // Single-quote the interpolated paths (robust if a path ever holds spaces/metacharacters). String cmd = "'" + tarBin + "' -cf - " @@ -100,11 +100,21 @@ public static boolean streamBackup(Context ctx, OutputStream dest) { } /** - * Stage {@code .iiab-rootfs.json} (origin=device-backup, no checksum — the phone is not a builder) in + * Stage {@code .k2go-rootfs.json} (origin=device-backup, no checksum — the phone is not a builder) in * a temp tree and return the extra {@code -C '' '' } so it is packed FIRST, letting * RootfsArchiveValidator read identity from the first tar header without decompressing everything. * Returns null if staging failed (backup still proceeds, just without the manifest). */ + /** + * Stage the identity manifest that becomes the archive's first tar entry. + * + *

K2GO-90: writes the {@code k2go-} name. Done now, while the installed base is developers + * and a handful of users, because every backup written under the old name would otherwise join a + * pile that has to age out before the tolerance for it can ever be dropped. Writing it now closes + * that set today. The app reads both, so backups made before this still restore; the only thing + * that cannot read one of these is a build older than v0.8.0, which is a downgrade across the + * identity change and unsupported anyway. + */ private static String stageIdentityManifest(Context ctx) { File stageRoot = new File(ctx.getCacheDir(), "mfstage"); try { @@ -116,13 +126,13 @@ private static String stageIdentityManifest(Context ctx) { java.util.Calendar c = java.util.Calendar.getInstance(); String built = String.format(java.util.Locale.US, "%04d.%03d", c.get(java.util.Calendar.YEAR), c.get(java.util.Calendar.DAY_OF_YEAR)); - String json = "{\"schema\":1,\"kind\":\"iiab-rootfs\",\"arch\":\"" + appAbi + String json = "{\"schema\":1,\"kind\":\"k2go-rootfs\",\"arch\":\"" + appAbi + "\",\"deb_arch\":\"" + debArch + "\",\"built\":\"" + built + "\",\"builder\":\"knowledgetogo-app\",\"origin\":\"device-backup\"}"; - try (java.io.FileOutputStream o = new java.io.FileOutputStream(new File(iiabStage, ".iiab-rootfs.json"))) { + try (java.io.FileOutputStream o = new java.io.FileOutputStream(new File(iiabStage, ".k2go-rootfs.json"))) { o.write(json.getBytes("UTF-8")); } - return "-C '" + stageRoot.getAbsolutePath() + "' 'installed-rootfs/iiab/.iiab-rootfs.json' "; + return "-C '" + stageRoot.getAbsolutePath() + "' 'installed-rootfs/iiab/.k2go-rootfs.json' "; } catch (Exception e) { Log.w(TAG, "Could not stage identity manifest: " + e.getMessage()); return null; diff --git a/tools/rootfs-builder/build-iiab-rootfs.sh b/tools/rootfs-builder/build-iiab-rootfs.sh index e30e79ad..79ecb6bd 100755 --- a/tools/rootfs-builder/build-iiab-rootfs.sh +++ b/tools/rootfs-builder/build-iiab-rootfs.sh @@ -779,20 +779,23 @@ rm -f "$ROOTFS/usr/local/sbin/hostnamectl" \ "$ROOTFS/usr/local/sbin/reboot" 2>/dev/null || true # Embed the self-validation manifest (INTEGRITY only). Frozen spec + recipe: # docs/ROOTFS_MANIFEST.md (algo: iiab-tree-sha256-v1) -# Two members inside the tree: identity (.iiab-rootfs.json, packed FIRST, hashed) -# and integrity (.iiab-rootfs.integrity.json, packed LAST, excluded from its own +# K2GO-90: the manifest carries the product's name. The app reads both spellings, so images built +# before this still validate; nothing outside the app reads these files. +# Two members inside the tree: identity (.k2go-rootfs.json, packed FIRST, hashed) +# and integrity (.k2go-rootfs.integrity.json, packed LAST, excluded from its own # hash). Lets a manually-imported rootfs (no sidecar .meta4) detect corruption. TREEHASH_PY="$(dirname "$SELF")/iiab_tree_hash.py" [[ -f "$TREEHASH_PY" ]] || die "missing $(basename "$TREEHASH_PY") next to the build script (treehash recipe)." -ID_MEMBER="installed-rootfs/iiab/.iiab-rootfs.json" -INTEG_MEMBER="installed-rootfs/iiab/.iiab-rootfs.integrity.json" +ID_MEMBER="installed-rootfs/iiab/.k2go-rootfs.json" +INTEG_MEMBER="installed-rootfs/iiab/.k2go-rootfs.integrity.json" log "Embedding rootfs manifest (identity + integrity, iiab-tree-sha256-v1) ..." -rm -f "$ROOTFS/.iiab-rootfs.json" "$ROOTFS/.iiab-rootfs.integrity.json" 2>/dev/null || true +rm -f "$ROOTFS/.iiab-rootfs.json" "$ROOTFS/.iiab-rootfs.integrity.json" \ + "$ROOTFS/.k2go-rootfs.json" "$ROOTFS/.k2go-rootfs.integrity.json" 2>/dev/null || true # (a) identity member -- packed first, IS hashed (single-line JSON, no trailing newline issues) -cat > "$ROOTFS/.iiab-rootfs.json" < "$ROOTFS/.k2go-rootfs.json" < "$ROOTFS/.iiab-rootfs.integrity.json" < "$ROOTFS/.k2go-rootfs.integrity.json" <