Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<stage>' 'installed-rootfs/iiab/.iiab-rootfs.json' " or null
String manifestArg = stageIdentityManifest(ctx); // "-C '<stage>' '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 - "
Expand Down Expand Up @@ -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 '<stage>' '<relpath>' } 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.
*
* <p>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 {
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<String> integrityNorms = new java.util.HashSet<>();
for (String m : INTEGRITY_MEMBERS) {
integrityNorms.add(RootfsTreeHash.norm(m));
}
boolean integritySeen = false;
String declaredAlgo = null;
String declaredHash = null;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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<String> KIND_ROOTFS =
java.util.Arrays.asList("iiab-rootfs", "k2go-rootfs");

private RootfsIdentity() {
}
Expand All @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
}
19 changes: 11 additions & 8 deletions tools/rootfs-builder/build-iiab-rootfs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" <<JSON
{"schema":1,"kind":"iiab-rootfs","arch":"${ARCH}","deb_arch":"${DEB_ARCH}","tier":"${TIER_NAME}","iiab_commit":"${IIAB_SHA}","built":"${STAMP}","base":"debian-trixie","proot_distro":"${PD_VERSION}","builder":"build-iiab-rootfs.sh"}
cat > "$ROOTFS/.k2go-rootfs.json" <<JSON
{"schema":1,"kind":"k2go-rootfs","arch":"${ARCH}","deb_arch":"${DEB_ARCH}","tier":"${TIER_NAME}","iiab_commit":"${IIAB_SHA}","built":"${STAMP}","base":"debian-trixie","proot_distro":"${PD_VERSION}","builder":"build-iiab-rootfs.sh"}
JSON

# (b) deterministic member list: identity FIRST, then everything else (LC_ALL=C
Expand All @@ -813,7 +816,7 @@ TREEHASH="$( ( cd "$WORKDIR" && tar -c --no-recursion -T "$MEMBER_LIST" ) \
[[ -n "$TREEHASH" ]] || { rm -f "$MEMBER_LIST"; die "empty treehash"; }

# (d) integrity member -- packed last, NOT hashed
cat > "$ROOTFS/.iiab-rootfs.integrity.json" <<JSON
cat > "$ROOTFS/.k2go-rootfs.integrity.json" <<JSON
{"schema":1,"algo":"iiab-tree-sha256-v1","treehash":"${TREEHASH}"}
JSON

Expand Down
Loading