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/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/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); 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")); + } } 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" <