From 96d78eb4827f0945ff93dabcfae6f562f30cc7ad Mon Sep 17 00:00:00 2001 From: "Luis Guzman (AppDevForAll)" Date: Wed, 24 Jun 2026 01:55:20 +0000 Subject: [PATCH 1/3] feat(controller): rootfs integrity verifier core (iiab-tree-sha256-v1) Part 2 of the rootfs import/restore validation series (follow-up to #31). - deploy/domain/RootfsTreeHash: pure-JVM treehash (norm + per-member digest + order-independent domain-separated combine), byte-parity with tools/iiab_tree_hash.py. Streaming Accumulator for single-pass tar. - deploy/data/RootfsIntegrity: one-pass, dependency-free tar reader (ustar + GNU long-name + pax path/linkpath) that recomputes the treehash (excluding the integrity member) and reads the integrity declaration -> ABSENT / DECLARED_NONE / MATCH / MISMATCH / ERROR. No Apache Commons Compress: minSdk 24 has no core-library desugaring and CC reaches into java.nio.file (API 26+); we hand parse the tar like RootfsManifest does. - Tests: RootfsTreeHashTest (golden parity) + RootfsIntegrityTest over checked-in ustar/GNU/pax/mismatch/none/absent fixtures. Wiring (Result.CORRUPT + gates) and the backup writer come in the next commits. --- .../deploy/data/RootfsIntegrity.java | 382 ++++++++++++++++++ .../deploy/domain/RootfsTreeHash.java | 217 ++++++++++ .../deploy/data/RootfsIntegrityTest.java | 89 ++++ .../deploy/domain/RootfsTreeHashTest.java | 117 ++++++ .../resources/rootfs-fixtures/absent.tar.gz | Bin 0 -> 200 bytes .../test/resources/rootfs-fixtures/gnu.tar.gz | Bin 0 -> 438 bytes .../resources/rootfs-fixtures/mismatch.tar.gz | Bin 0 -> 269 bytes .../resources/rootfs-fixtures/none.tar.gz | Bin 0 -> 254 bytes .../test/resources/rootfs-fixtures/pax.tar.gz | Bin 0 -> 512 bytes .../resources/rootfs-fixtures/ustar.tar.gz | Bin 0 -> 366 bytes 10 files changed, 805 insertions(+) create mode 100644 controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsIntegrity.java create mode 100644 controller/app/src/main/java/org/iiab/controller/deploy/domain/RootfsTreeHash.java create mode 100644 controller/app/src/test/java/org/iiab/controller/deploy/data/RootfsIntegrityTest.java create mode 100644 controller/app/src/test/java/org/iiab/controller/deploy/domain/RootfsTreeHashTest.java create mode 100644 controller/app/src/test/resources/rootfs-fixtures/absent.tar.gz create mode 100644 controller/app/src/test/resources/rootfs-fixtures/gnu.tar.gz create mode 100644 controller/app/src/test/resources/rootfs-fixtures/mismatch.tar.gz create mode 100644 controller/app/src/test/resources/rootfs-fixtures/none.tar.gz create mode 100644 controller/app/src/test/resources/rootfs-fixtures/pax.tar.gz create mode 100644 controller/app/src/test/resources/rootfs-fixtures/ustar.tar.gz diff --git a/controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsIntegrity.java b/controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsIntegrity.java new file mode 100644 index 0000000..8741162 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsIntegrity.java @@ -0,0 +1,382 @@ +/* + * ============================================================================ + * Name : RootfsIntegrity.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Integrity verifier for imported rootfs/backup tarballs. In ONE + * streaming pass it (a) recomputes the iiab-tree-sha256-v1 treehash + * over the tar's logical members (excluding the integrity member) + * and (b) reads the integrity member declaration, then compares. + * + * Dependency-free on purpose: minSdk is 24 with no core-library + * desugaring, and Apache Commons Compress reaches into + * java.nio.file on newer versions, so we hand-parse the tar the way + * RootfsManifest already does (ustar + GNU long name/link + pax + * path/linkpath). Byte-parity with tools/iiab_tree_hash.py is the + * contract; see RootfsTreeHashTest + the data-layer fixture tests. + * ============================================================================ + */ +package org.iiab.controller.deploy.data; + +import android.util.Log; + +import org.iiab.controller.deploy.domain.RootfsTreeHash; +import org.json.JSONObject; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Locale; +import java.util.zip.GZIPInputStream; + +/** + * Recompute-and-compare the embedded integrity treehash. Soft rollout: ABSENT and + * DECLARED_NONE are non-blocking; MISMATCH and ERROR map to a CORRUPT result that + * the caller fail-closes on (see RootfsArchiveValidator). + */ +public final class RootfsIntegrity { + + private static final String TAG = "IIAB-RootfsIntegrity"; + private static final String INTEGRITY_MEMBER = "installed-rootfs/iiab/.iiab-rootfs.integrity.json"; + private static final int MAX_DECL_BYTES = 64 * 1024; + + public enum Status { + /** No integrity member in the archive. */ + ABSENT, + /** Integrity member present and explicitly declares algo:"none" (e.g. a device backup). */ + DECLARED_NONE, + /** Real treehash present and recomputation matches. */ + MATCH, + /** Real treehash present and recomputation does NOT match -> corrupt/tampered. */ + MISMATCH, + /** Could not parse/recompute (unhashable member, bad JSON, unknown algo, I/O). */ + ERROR + } + + public static final class Result { + public final Status status; + public final String declaredHash; // nullable + public final String computedHash; // nullable + + Result(Status status, String declaredHash, String computedHash) { + this.status = status; + this.declaredHash = declaredHash; + this.computedHash = computedHash; + } + + static Result of(Status s) { + return new Result(s, null, null); + } + } + + private RootfsIntegrity() { + // Static utility; not instantiable. + } + + /** Verify integrity of the archive at {@code archivePath} (.tar or .tar.gz). */ + public static Result verify(String archivePath) { + final boolean isGzip = archivePath.toLowerCase(Locale.US).endsWith(".gz"); + try (InputStream rawFile = new FileInputStream(archivePath); + InputStream in = isGzip + ? new GZIPInputStream(new BufferedInputStream(rawFile)) + : new BufferedInputStream(rawFile)) { + + final RootfsTreeHash.Accumulator acc = new RootfsTreeHash.Accumulator(); + final String integrityNorm = RootfsTreeHash.norm(INTEGRITY_MEMBER); + boolean integritySeen = false; + String declaredAlgo = null; + String declaredHash = null; + + final byte[] header = new byte[512]; + String longName = null; // pending GNU 'L' + String longLink = null; // pending GNU 'K' + String paxPath = null; // pending pax path= + String paxLink = null; // pending pax linkpath= + + while (true) { + if (!readFully(in, header, 512)) { + break; + } + if (isAllZero(header)) { + break; // end-of-archive + } + + final char typeflag = (char) (header[156] & 0xFF); + final long size = parseSize(header, 124, 12); + if (size < 0) { + return Result.of(Status.ERROR); + } + final long padded = ((size + 511) / 512) * 512; + + // --- meta entries that decorate the NEXT logical member --- + if (typeflag == 'L') { // GNU long name + longName = stripTrailingNul(readBlock(in, size, padded)); + continue; + } + if (typeflag == 'K') { // GNU long link target + longLink = stripTrailingNul(readBlock(in, size, padded)); + continue; + } + if (typeflag == 'x') { // pax extended header (this entry) + String[] pl = parsePax(readBlock(in, size, padded)); + if (pl[0] != null) paxPath = pl[0]; + if (pl[1] != null) paxLink = pl[1]; + continue; + } + if (typeflag == 'g') { // pax GLOBAL header — ignore content + skipFully(in, padded); + continue; + } + + // --- resolve the effective name/linkname for this logical member --- + String name = (paxPath != null) ? paxPath + : (longName != null) ? longName + : ustarName(header); + String linkRaw = (paxLink != null) ? paxLink + : (longLink != null) ? longLink + : cString(header, 157, 100); + longName = longLink = paxPath = paxLink = null; // consumed + + if (integrityNorm.equals(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; + String[] av = parseDeclaration(decl); + declaredAlgo = av[0]; + declaredHash = av[1]; + continue; + } + + final char memberType = mapType(typeflag); + if (memberType == 0) { + // char/block/fifo or unknown -> unhashable per the recipe. + Log.w(TAG, "Unhashable member type '" + typeflag + "' for " + name); + return Result.of(Status.ERROR); + } + + if (memberType == RootfsTreeHash.TYPE_FILE) { + LimitedInputStream content = new LimitedInputStream(in, size); + try { + acc.addMember(name, memberType, null, content); + } catch (RootfsTreeHash.UnhashableMemberException e) { + return Result.of(Status.ERROR); + } + skipFully(in, padded - size); // padding after the (now drained) content + } else { + try { + acc.addMember(name, memberType, linkRaw, null); + } catch (RootfsTreeHash.UnhashableMemberException e) { + return Result.of(Status.ERROR); + } + skipFully(in, padded); // dirs/links carry no content payload + } + } + + if (!integritySeen) { + return Result.of(Status.ABSENT); + } + if ("none".equals(declaredAlgo)) { + return Result.of(Status.DECLARED_NONE); + } + if (!RootfsTreeHash.ALGO.equals(declaredAlgo) || declaredHash == null) { + return Result.of(Status.ERROR); // unknown algo or missing hash + } + final String computed = acc.finish(); + final boolean match = computed.equalsIgnoreCase(declaredHash.trim()); + return new Result(match ? Status.MATCH : Status.MISMATCH, declaredHash, computed); + + } catch (IOException e) { + Log.w(TAG, "Integrity verify I/O error: " + e.getMessage()); + return Result.of(Status.ERROR); + } catch (Exception e) { + Log.w(TAG, "Integrity verify error: " + e.getMessage()); + return Result.of(Status.ERROR); + } + } + + // --- tar helpers (ustar + GNU + pax), dependency-free --- + + private static char mapType(char t) { + switch (t) { + case '0': + case '\0': + case '7': // contiguous == regular (matches Python isreg) + return RootfsTreeHash.TYPE_FILE; + case '5': + return RootfsTreeHash.TYPE_DIR; + case '2': + return RootfsTreeHash.TYPE_SYMLINK; + case '1': + return RootfsTreeHash.TYPE_HARDLINK; + default: + return 0; // char/block/fifo/sparse/unknown -> unhashable + } + } + + private static String ustarName(byte[] h) { + String name = cString(h, 0, 100); + String prefix = cString(h, 345, 155); + return prefix.isEmpty() ? name : prefix + "/" + name; + } + + /** Read {@code size} content bytes (consuming pad-size separately is the caller's job here we read full block). */ + private static byte[] readBlock(InputStream in, long size, long padded) throws IOException { + int n = (int) size; + byte[] buf = new byte[n]; + if (!readFully(in, buf, n)) { + throw new IOException("short read"); + } + skipFully(in, padded - size); + return buf; + } + + /** pax records: " key=value\n" repeated. Returns {path, linkpath}. */ + private static String[] parsePax(byte[] data) { + String path = null; + String link = null; + int i = 0; + final int n = data.length; + while (i < n) { + int sp = i; + while (sp < n && data[sp] != ' ') sp++; + if (sp >= n) break; + int len; + try { + len = Integer.parseInt(new String(data, i, sp - i, "UTF-8").trim()); + } catch (Exception e) { + break; + } + if (len <= 0 || i + len > n) break; + // record body is between the space and the trailing newline + String body; + try { + body = new String(data, sp + 1, (i + len) - (sp + 1) - 1, "UTF-8"); + } catch (Exception e) { + break; + } + int eq = body.indexOf('='); + if (eq > 0) { + String key = body.substring(0, eq); + String val = body.substring(eq + 1); + if ("path".equals(key)) path = val; + else if ("linkpath".equals(key)) link = val; + } + i += len; + } + return new String[]{path, link}; + } + + private static String[] parseDeclaration(byte[] json) { + try { + JSONObject o = new JSONObject(new String(json, "UTF-8")); + return new String[]{o.optString("algo", null), o.optString("treehash", null)}; + } catch (Exception e) { + return new String[]{null, null}; + } + } + + private static String stripTrailingNul(byte[] b) { + int end = b.length; + while (end > 0 && b[end - 1] == 0) end--; + try { + return new String(b, 0, end, "UTF-8"); + } catch (Exception e) { + return ""; + } + } + + private static String cString(byte[] b, int off, int len) { + int end = off; + while (end < off + len && b[end] != 0) end++; + try { + return new String(b, off, end - off, "UTF-8"); + } catch (Exception e) { + return ""; + } + } + + /** Octal size, or GNU base-256 (high bit set) for large files. -1 on garbage. */ + private static long parseSize(byte[] b, int off, int len) { + if ((b[off] & 0x80) != 0) { // base-256 binary + long v = b[off] & 0x7F; + for (int i = off + 1; i < off + len; i++) { + v = (v << 8) | (b[i] & 0xFF); + } + return v; + } + long val = 0; + boolean any = false; + for (int i = off; i < off + len; i++) { + int c = b[i] & 0xFF; + if (c == 0 || c == ' ') { + if (any) break; + continue; + } + if (c < '0' || c > '7') return -1; + val = (val << 3) + (c - '0'); + any = true; + } + return any ? val : 0; + } + + private static boolean isAllZero(byte[] b) { + for (byte x : b) { + if (x != 0) return false; + } + return true; + } + + private static boolean readFully(InputStream in, byte[] buf, int n) throws IOException { + int off = 0; + while (off < n) { + int r = in.read(buf, off, n - off); + if (r == -1) return false; + off += r; + } + return true; + } + + private static void skipFully(InputStream in, long n) throws IOException { + long left = n; + byte[] tmp = new byte[8192]; + while (left > 0) { + int r = in.read(tmp, 0, (int) Math.min(tmp.length, left)); + if (r == -1) return; + left -= r; + } + } + + /** Reads at most {@code limit} bytes from {@code in}; never closes {@code in}. */ + private static final class LimitedInputStream extends InputStream { + private final InputStream in; + private long left; + + LimitedInputStream(InputStream in, long limit) { + this.in = in; + this.left = limit; + } + + @Override + public int read() throws IOException { + if (left <= 0) return -1; + int b = in.read(); + if (b != -1) left--; + return b; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + if (left <= 0) return -1; + int r = in.read(b, off, (int) Math.min(len, left)); + if (r != -1) left -= r; + return r; + } + + @Override + public void close() { + // Intentionally does NOT close the shared tar stream. + } + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/deploy/domain/RootfsTreeHash.java b/controller/app/src/main/java/org/iiab/controller/deploy/domain/RootfsTreeHash.java new file mode 100644 index 0000000..b90e0a7 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/deploy/domain/RootfsTreeHash.java @@ -0,0 +1,217 @@ +/* + * ============================================================================ + * Name : RootfsTreeHash.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Pure-JVM implementation of the "iiab-tree-sha256-v1" rootfs + * integrity digest. Byte-for-byte compatible with the frozen + * reference tools/iiab_tree_hash.py (spec: docs/ROOTFS_MANIFEST.md). + * + * DOMAIN layer: no Android, no tar library. The data layer feeds it + * logical tar members (either as an Iterable for tests, or + * incrementally via Accumulator for a single-pass tar stream). This + * class owns everything the spec defines (path normalization, + * per-member digest, order-independent domain-separated combine) so + * byte-parity stays testable here. + * ============================================================================ + */ +package org.iiab.controller.deploy.domain; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * Recomputes the {@code iiab-tree-sha256-v1} treehash over a tar's logical members. + * + *

Per-member digest: + *

+ *   digest(m) = SHA256( norm(name) + 0x00 + type + 0x00 + payload )
+ *     'f' regular  : payload = file content        (no trailing 0x00)
+ *     'd' directory: payload = ""                  (empty)
+ *     'l' symlink  : payload = raw linktarget + 0x00
+ *     'h' hardlink : payload = norm(linktarget) + 0x00
+ * 
+ * Any other tar member type is unhashable and aborts with + * {@link UnhashableMemberException} (mirrors the reference tool's exit 3 -> the + * caller maps this to a CORRUPT result, fail-closed). + * + *

Combine (order-independent, domain-separated): + *

+ *   treehash = lowercase_hex( SHA256( "iiab-tree-sha256-v1" + 0x00
+ *                                     + concat( digest(m_i) sorted ascending
+ *                                               by raw 32-byte value ) ) )
+ * 
+ */ +public final class RootfsTreeHash { + + /** Algorithm identifier; also the domain-separation prefix in the combine. */ + public static final String ALGO = "iiab-tree-sha256-v1"; + + public static final char TYPE_FILE = 'f'; + public static final char TYPE_DIR = 'd'; + public static final char TYPE_SYMLINK = 'l'; + public static final char TYPE_HARDLINK = 'h'; + + private RootfsTreeHash() { + // Static utility; not instantiable. + } + + /** A single logical member of the tar (used by the Iterable test path). */ + public interface Member { + String name(); + char type(); + String linkTarget(); + InputStream openContent() throws IOException; + } + + /** Thrown when a member type is not one of f/d/l/h (-> CORRUPT, fail-closed). */ + public static final class UnhashableMemberException extends Exception { + public UnhashableMemberException(String message) { + super(message); + } + } + + /** + * Incremental accumulator for single-pass tar streams. Call + * {@link #addMember} for each logical member in iteration order (the data + * layer skips the integrity member itself), then {@link #finish}. The content + * stream passed to addMember is DRAINED but never closed, so a shared + * {@code TarArchiveInputStream} stays usable for the next entry. + */ + public static final class Accumulator { + private final List digests = new ArrayList<>(); + + public void addMember(String name, char type, String linkTarget, InputStream content) + throws IOException, UnhashableMemberException { + digests.add(memberDigest(norm(name), type, linkTarget, content)); + } + + public String finish() { + Collections.sort(digests, UNSIGNED_LEX); + final MessageDigest fin = newSha256(); + fin.update(ALGO.getBytes(StandardCharsets.UTF_8)); + fin.update((byte) 0x00); + for (byte[] d : digests) { + fin.update(d); + } + return toHex(fin.digest()); + } + } + + /** + * Canonical path normalization (identical to {@code norm()} in the reference): + * UTF-8; {@code \} -> {@code /}; strip ONE leading {@code ./}; strip ALL leading + * {@code /}; strip ALL trailing {@code /}. No whitespace trimming. + */ + public static String norm(String name) { + String n = name.replace('\\', '/'); + if (n.startsWith("./")) { + n = n.substring(2); + } + int s = 0; + while (s < n.length() && n.charAt(s) == '/') { + s++; + } + int e = n.length(); + while (e > s && n.charAt(e - 1) == '/') { + e--; + } + return n.substring(s, e); + } + + /** + * Convenience for tests / in-memory members: compute over an Iterable, + * skipping the member whose normalized name equals {@code norm(excludeName)}. + */ + public static String compute(Iterable members, String excludeName) + throws IOException, UnhashableMemberException { + final String exclude = + (excludeName == null || excludeName.isEmpty()) ? null : norm(excludeName); + final Accumulator acc = new Accumulator(); + for (Member m : members) { + final String name = norm(m.name()); + if (exclude != null && exclude.equals(name)) { + continue; + } + acc.addMember(m.name(), m.type(), m.linkTarget(), + m.type() == TYPE_FILE ? m.openContent() : null); + } + return acc.finish(); + } + + private static byte[] memberDigest(String normalizedName, char type, String linkTarget, + InputStream content) + throws IOException, UnhashableMemberException { + final MessageDigest md = newSha256(); + md.update(normalizedName.getBytes(StandardCharsets.UTF_8)); + md.update((byte) 0x00); + md.update((byte) type); + md.update((byte) 0x00); + + switch (type) { + case TYPE_FILE: + // Drain (do NOT close): the data layer reuses the tar stream. + final byte[] buf = new byte[1 << 16]; + int r; + while ((r = content.read(buf)) != -1) { + md.update(buf, 0, r); + } + break; + case TYPE_DIR: + break; // empty payload + case TYPE_SYMLINK: + md.update(linkTarget.getBytes(StandardCharsets.UTF_8)); + md.update((byte) 0x00); + break; + case TYPE_HARDLINK: + md.update(norm(linkTarget).getBytes(StandardCharsets.UTF_8)); + md.update((byte) 0x00); + break; + default: + throw new UnhashableMemberException( + "Unhashable tar member type '" + type + "' for " + normalizedName); + } + return md.digest(); + } + + private static MessageDigest newSha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + + private static final Comparator UNSIGNED_LEX = new Comparator() { + @Override + public int compare(byte[] a, byte[] b) { + final int n = Math.min(a.length, b.length); + for (int i = 0; i < n; i++) { + final int ai = a[i] & 0xFF; + final int bi = b[i] & 0xFF; + if (ai != bi) { + return ai - bi; + } + } + return a.length - b.length; + } + }; + + private static String toHex(byte[] bytes) { + final char[] hexChars = "0123456789abcdef".toCharArray(); + final char[] out = new char[bytes.length * 2]; + for (int i = 0; i < bytes.length; i++) { + final int v = bytes[i] & 0xFF; + out[i * 2] = hexChars[v >>> 4]; + out[i * 2 + 1] = hexChars[v & 0x0F]; + } + return new String(out); + } +} diff --git a/controller/app/src/test/java/org/iiab/controller/deploy/data/RootfsIntegrityTest.java b/controller/app/src/test/java/org/iiab/controller/deploy/data/RootfsIntegrityTest.java new file mode 100644 index 0000000..9d84f3c --- /dev/null +++ b/controller/app/src/test/java/org/iiab/controller/deploy/data/RootfsIntegrityTest.java @@ -0,0 +1,89 @@ +/* + * ============================================================================ + * Name : RootfsIntegrityTest.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : End-to-end test of the dependency-free tar reader + integrity + * verifier against checked-in .tar.gz fixtures. The golden hashes + * were produced by the frozen reference tools/iiab_tree_hash.py. + * Fixtures cover pure ustar, GNU long-name ('L'), and pax ('x', + * incl. a non-ASCII name) — the formats Apache Commons Compress + * would have handled — proving our hand-rolled reader matches the + * build side byte-for-byte. Also covers MISMATCH, DECLARED_NONE + * (device backup) and ABSENT. None of these paths call android Log. + * ============================================================================ + */ +package org.iiab.controller.deploy.data; + +import static org.junit.Assert.assertEquals; + +import org.iiab.controller.deploy.data.RootfsIntegrity.Status; +import org.junit.Test; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; + +public class RootfsIntegrityTest { + + private static final String USTAR = "e892069fb8eb7d5eb199780cbb86a25bfe6a77fec013445139d092cf139a58ec"; + private static final String GNU = "b5439690ac990e24dd591165441f254db51bbb3311779281289f9227d0878ad7"; + private static final String PAX = "43dd453a891677e021ae6334f9b9d797e07e54d39fc653f21ce0e01f201df512"; + + /** Copy a classpath fixture to a temp file (verify() takes a path). */ + private static String materialize(String fixture) throws Exception { + File tmp = File.createTempFile("rootfs-fixture", ".tar.gz"); + tmp.deleteOnExit(); + try (InputStream in = RootfsIntegrityTest.class + .getResourceAsStream("/rootfs-fixtures/" + fixture); + OutputStream out = new FileOutputStream(tmp)) { + byte[] buf = new byte[8192]; + int r; + while ((r = in.read(buf)) != -1) { + out.write(buf, 0, r); + } + } + return tmp.getAbsolutePath(); + } + + private static RootfsIntegrity.Result verify(String fixture) throws Exception { + return RootfsIntegrity.verify(materialize(fixture)); + } + + @Test + public void plainUstarMatchesGolden() throws Exception { + RootfsIntegrity.Result r = verify("ustar.tar.gz"); + assertEquals(Status.MATCH, r.status); + assertEquals(USTAR, r.computedHash); + } + + @Test + public void gnuLongNameMatchesGolden() throws Exception { + RootfsIntegrity.Result r = verify("gnu.tar.gz"); + assertEquals(Status.MATCH, r.status); + assertEquals(GNU, r.computedHash); + } + + @Test + public void paxHeadersMatchGolden() throws Exception { + RootfsIntegrity.Result r = verify("pax.tar.gz"); + assertEquals(Status.MATCH, r.status); + assertEquals(PAX, r.computedHash); + } + + @Test + public void tamperedTreehashIsMismatch() throws Exception { + assertEquals(Status.MISMATCH, verify("mismatch.tar.gz").status); + } + + @Test + public void declaredNoneIsDeviceBackup() throws Exception { + assertEquals(Status.DECLARED_NONE, verify("none.tar.gz").status); + } + + @Test + public void noIntegrityMemberIsAbsent() throws Exception { + assertEquals(Status.ABSENT, verify("absent.tar.gz").status); + } +} diff --git a/controller/app/src/test/java/org/iiab/controller/deploy/domain/RootfsTreeHashTest.java b/controller/app/src/test/java/org/iiab/controller/deploy/domain/RootfsTreeHashTest.java new file mode 100644 index 0000000..71eec6d --- /dev/null +++ b/controller/app/src/test/java/org/iiab/controller/deploy/domain/RootfsTreeHashTest.java @@ -0,0 +1,117 @@ +/* + * ============================================================================ + * Name : RootfsTreeHashTest.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Byte-parity gate for RootfsTreeHash. GOLDEN is produced by the + * frozen reference tools/iiab_tree_hash.py over a fixed fixture of + * logical members. Passing this test is what lets us BLOCK on a + * hash mismatch with confidence (the Java verifier agrees with the + * build side byte-for-byte). + * ============================================================================ + */ +package org.iiab.controller.deploy.domain; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; + +import org.iiab.controller.deploy.domain.RootfsTreeHash.Member; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Random; + +public class RootfsTreeHashTest { + + /** python3 tools/iiab_tree_hash.py fixture.tar . */ + private static final String GOLDEN = + "e892069fb8eb7d5eb199780cbb86a25bfe6a77fec013445139d092cf139a58ec"; + + private static final String IDENTITY = "installed-rootfs/iiab/.iiab-rootfs.json"; + private static final String INTEGRITY = "installed-rootfs/iiab/.iiab-rootfs.integrity.json"; + + private static Member reg(final String name, final String content) { + final byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + return new Member() { + public String name() { return name; } + public char type() { return 'f'; } + public String linkTarget() { return null; } + public InputStream openContent() { return new ByteArrayInputStream(bytes); } + }; + } + + private static Member dir(final String name) { + return new Member() { + public String name() { return name; } + public char type() { return 'd'; } + public String linkTarget() { return null; } + public InputStream openContent() { return null; } + }; + } + + private static Member link(final char type, final String name, final String target) { + return new Member() { + public String name() { return name; } + public char type() { return type; } + public String linkTarget() { return target; } + public InputStream openContent() { return null; } + }; + } + + private static List fixture() { + List ms = new ArrayList<>(); + ms.add(reg(IDENTITY, "{\"schema\":1,\"kind\":\"iiab-rootfs\",\"arch\":\"arm64-v8a\"}")); + ms.add(dir("installed-rootfs/bin/")); + ms.add(reg("installed-rootfs/bin/hello", "hello world\n")); + ms.add(link('l', "installed-rootfs/bin/hello-link", "hello")); + ms.add(link('h', "installed-rootfs/bin/hello-hard", "installed-rootfs/bin/hello")); + ms.add(reg(INTEGRITY, "{\"schema\":1,\"algo\":\"iiab-tree-sha256-v1\",\"treehash\":\"PLACEHOLDER\"}")); + return ms; + } + + @Test + public void matchesReferenceGolden() throws Exception { + assertEquals(GOLDEN, RootfsTreeHash.compute(fixture(), INTEGRITY)); + } + + @Test + public void isOrderIndependent() throws Exception { + List ms = fixture(); + Collections.shuffle(ms, new Random(42)); + assertEquals(GOLDEN, RootfsTreeHash.compute(ms, INTEGRITY)); + } + + @Test + public void integrityMemberIsExcludedFromTheHash() throws Exception { + List ms = fixture(); + ms.set(ms.size() - 1, reg(INTEGRITY, "{\"totally\":\"different\"}")); + assertEquals(GOLDEN, RootfsTreeHash.compute(ms, INTEGRITY)); + } + + @Test + public void flippingOneContentByteChangesTheHash() throws Exception { + List ms = fixture(); + ms.set(2, reg("installed-rootfs/bin/hello", "hello WORLD\n")); + assertNotEquals(GOLDEN, RootfsTreeHash.compute(ms, INTEGRITY)); + } + + @Test(expected = RootfsTreeHash.UnhashableMemberException.class) + public void unhashableMemberTypeAborts() throws Exception { + List ms = new ArrayList<>(); + ms.add(link('c', "installed-rootfs/dev/null", null)); // 'c' = char device + RootfsTreeHash.compute(ms, ""); + } + + @Test + public void normMatchesReferenceRules() { + assertEquals("a", RootfsTreeHash.norm("./a//")); + assertEquals("x/y", RootfsTreeHash.norm("/x/y/")); + assertEquals("a/b", RootfsTreeHash.norm("a\\b")); + assertEquals("", RootfsTreeHash.norm("/")); + } +} diff --git a/controller/app/src/test/resources/rootfs-fixtures/absent.tar.gz b/controller/app/src/test/resources/rootfs-fixtures/absent.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..59aeafba88b74d1eadcba12061d4f55846eeceed GIT binary patch literal 200 zcmV;(05|_1iwFRsI6GfqG97g zL!!RVqSH(l&1CZFx@na$WifZww%aDqTCIacf8INbU1MwUjtZZsf%njRe4Y{k9@h;bs=;4w0!AH`2c;1(4-}nf)lXnIKI2k zQERc*Q5mXszYEAL7YuVaCz~~8-N>TQ%Cl9~Eb71*nFW4#eXjUdbyYe)A#8~m+umFH z_wM0t-PM6p0gf9wBXHAxzja;bOnD&nJlAVUrZ6EZQz|kgZ^S1PBJDhHi_)%R=4}#5 z9v$T0VEX>gOc@OPj?UDA^N;=CUjqmhJ1$56pGN;&7ezI!{zvm4`uR^qEHMAaU@NtI zTiHTgU>c(TcmDSZQ?ADG|GWHWf<=x?2hJh%|CjX7rB$cVpN67661XtN=5*%z`+*-^ z&Z=@b8_Mm6^*^BdtLBtPy?;S;?V>xw{eOJ_Z=?<6FCtJ{U;E8ngM0N~)&KMS=llMT zLcu8Z|6|a(3mbp*|B?BxDI2}Crn&#_B=|G`LwnfY@Bc*v=>JjJoBzsUS-s4E8>_Wf z=aTZ++W?u6u8_;Rn+0c_MQNOda-OE4rc5cG0>qp#SWwPX#v#kHC;|{7r3ug^U8Ix> g6(%B)O5g|?K@bE%5ClOG1VKjS3DI#9vj8Xn0C^1>3L>m(LcA{8ptR`9t4T$2GB+0{#p= z&Ht!vt)wyTdf&L1e?HBBJM;tcKZny*mygaHaf37<|6lZfZKK?K9iM(W30%rQPhtO@ z|KXOm$p7s9pUmE!`+xLORzXp_>Z>D9b3iUA3V9X~lEf(GuKA|(IgAhh0000000000 T0000009W@4;^l&P04M+ejxx6te3~Ez{_2rY`+!Z85Eg=-rzU6cPj# z5_P@{r@t3N#^Ff1ZiG^@@U1b;q7H~e7L3~KbuwDkMzeQ>P7=qL)#>p#T5? literal 0 HcmV?d00001 diff --git a/controller/app/src/test/resources/rootfs-fixtures/pax.tar.gz b/controller/app/src/test/resources/rootfs-fixtures/pax.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..573beca6b1021f031fd54fa49203977d3bdf8550 GIT binary patch literal 512 zcmV+b0{{IViwFRBI6GO#uH{-0k13z-nb68(Q3{TtJ^?zs9tng1}) ze@Tm1^t5dHt)|D?6u?K1w~%YR8Jk79P@976y9k^YVH`aJqGpiD$D zEYX}UT>msnvp4V5?yXVU_|vR$K=)sov%UAf@DB+6e~115(EkKftKX@mAg{~wzF+HNp6-VUGs zb`t!Y{~<4C{x7)1{r@67n*XZy#{HcC4&IogZxoYxvIVjtW1&&~I13hBYazK>mqEJ1 zgx#7lIp?A-H>EDhP$`TQnwNEzOI|am3^f#LMnTsS7>8Do5R%c7ZL zbWtZu^!r^vQ$h$i`A&0O+Yh>FY?=738>)WkoHonr@%p@TebaYs^b=_yIX~Qk>3w{7 zT#x%yFh!#{jb?Bg{@=%O^iMsIzNqa^lQmtCt!qoNCa;Q57DW4^4x8HVBv1C4CXW&P z8E%??&~|Oi6|QKZir9K(ztdzPyXNaf3El{irJa_5c2<}`q$c*m#IHXRTv3kI>+IJ`k$KrTszo} zcf zGHX;Rtf6_Xva~QJli>wZS*ew(Y?0Cx=Yp>CGR;|0g@qQGEigg?0000000000008`| MClhRB`T!^Z07(|RzyJUM literal 0 HcmV?d00001 From 3c97ca6c8f387622585a17438ea2940723356bdf Mon Sep 17 00:00:00 2001 From: "Luis Guzman (AppDevForAll)" Date: Wed, 24 Jun 2026 01:58:47 +0000 Subject: [PATCH 2/3] feat(controller): wire integrity (CORRUPT + OK_NO_CHECKSUM) into validator + import gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RootfsManifest.Identity gains 'origin' (cheap device-backup signal from the first tar header — no full pass for app-made backups). - RootfsArchiveValidator: Result.CORRUPT + OK_NO_CHECKSUM; new checkIntegrity overload. Import gate (checkIntegrity=true) recomputes the treehash for builder rootfs and fails closed on MISMATCH/ERROR; device backups (origin) and the restore path skip the full pass. Matrix: absent->OK_NO_MANIFEST(soft); origin/none->OK_NO_CHECKSUM; match->OK; mismatch->CORRUPT(block). - DeployFragment import gate: CORRUPT blocks+deletes like WRONG_ARCH; OK_NO_CHECKSUM proceeds with a transparency snackbar. - strings (en/es): install_error_corrupt, install_warn_no_checksum. --- .../org/iiab/controller/DeployFragment.java | 20 ++++++++-- .../deploy/data/RootfsArchiveValidator.java | 40 +++++++++++++++++-- .../deploy/data/RootfsManifest.java | 12 ++++-- .../app/src/main/res/values-es/strings.xml | 2 + .../app/src/main/res/values/strings.xml | 2 + 5 files changed, 65 insertions(+), 11 deletions(-) diff --git a/controller/app/src/main/java/org/iiab/controller/DeployFragment.java b/controller/app/src/main/java/org/iiab/controller/DeployFragment.java index 7796699..6a5cc13 100644 --- a/controller/app/src/main/java/org/iiab/controller/DeployFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/DeployFragment.java @@ -2284,11 +2284,18 @@ private void importBackupSafely(Uri sourceUri) { vr == org.iiab.controller.deploy.data.RootfsArchiveValidator.Result.OK; boolean okNoManifest = vr == org.iiab.controller.deploy.data.RootfsArchiveValidator.Result.OK_NO_MANIFEST; - if (!okValidated && !okNoManifest) { + boolean okNoChecksum = + vr == org.iiab.controller.deploy.data.RootfsArchiveValidator.Result.OK_NO_CHECKSUM; + if (!okValidated && !okNoManifest && !okNoChecksum) { if (destFile.exists()) destFile.delete(); - final int errMsg = (vr == org.iiab.controller.deploy.data.RootfsArchiveValidator.Result.WRONG_ARCH) - ? R.string.install_error_wrong_arch - : R.string.install_error_not_rootfs; + final int errMsg; + if (vr == org.iiab.controller.deploy.data.RootfsArchiveValidator.Result.WRONG_ARCH) { + errMsg = R.string.install_error_wrong_arch; + } else if (vr == org.iiab.controller.deploy.data.RootfsArchiveValidator.Result.CORRUPT) { + errMsg = R.string.install_error_corrupt; + } else { + errMsg = R.string.install_error_not_rootfs; + } if (getActivity() != null) { getActivity().runOnUiThread(() -> { isImporting = false; @@ -2306,6 +2313,11 @@ private void importBackupSafely(Uri sourceUri) { getActivity().runOnUiThread(() -> Snackbar.make(getView(), R.string.install_warn_manifest_missing, Snackbar.LENGTH_LONG).show()); } + // Transparency: an app-made (device) backup carries no integrity checksum. + if (okNoChecksum && getActivity() != null) { + getActivity().runOnUiThread(() -> + Snackbar.make(getView(), R.string.install_warn_no_checksum, Snackbar.LENGTH_LONG).show()); + } if (getActivity() != null) { getActivity().runOnUiThread(() -> { diff --git a/controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsArchiveValidator.java b/controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsArchiveValidator.java index 0aee4bf..19bb604 100644 --- a/controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsArchiveValidator.java +++ b/controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsArchiveValidator.java @@ -47,7 +47,7 @@ public final class RootfsArchiveValidator { private static final String TAG = "IIAB-RootfsValidator"; - public enum Result { OK, OK_NO_MANIFEST, NOT_A_ROOTFS, WRONG_ARCH, UNREADABLE } + public enum Result { OK, OK_NO_MANIFEST, OK_NO_CHECKSUM, NOT_A_ROOTFS, WRONG_ARCH, CORRUPT, UNREADABLE } private RootfsArchiveValidator() { // Static utility; not instantiable. @@ -62,7 +62,7 @@ public static Result validate(Context context, String archivePath) { if (entries.isEmpty()) { return Result.UNREADABLE; } - return validateWithEntries(context, archivePath, isGzip, tarBinary, entries); + return validateWithEntries(context, archivePath, isGzip, tarBinary, entries, true); } catch (Exception e) { Log.e(TAG, "Validation error", e); return Result.UNREADABLE; @@ -75,6 +75,19 @@ public static Result validate(Context context, String archivePath) { */ public static Result validateWithEntries(Context context, String archivePath, boolean isGzip, String tarBinary, List entries) { + // Restore re-uses the listing for the D11 guard; integrity was already + // checked at import time, so don't pay a second full pass here. + return validateWithEntries(context, archivePath, isGzip, tarBinary, entries, false); + } + + /** + * @param checkIntegrity when true (the import gate) and the rootfs is not an + * app-made backup, recompute the embedded iiab-tree-sha256-v1 treehash + * and fail closed ({@link Result#CORRUPT}) on a mismatch. + */ + public static Result validateWithEntries(Context context, String archivePath, + boolean isGzip, String tarBinary, + List entries, boolean checkIntegrity) { try { // Authoritative path: the build/app embeds an identity manifest // (installed-rootfs/iiab/.iiab-rootfs.json, packed first). See @@ -88,7 +101,28 @@ public static Result validateWithEntries(Context context, String archivePath, && !id.arch.equals(RootfsManifest.appAbiId())) { return Result.WRONG_ARCH; } - return Result.OK; // manifest-validated; no need to probe ELF + // Identity is authoritative for kind+arch. Now decide integrity. + if ("device-backup".equals(id.origin)) { + // App-made backup: no checksum by design (we don't turn the + // phone into a builder). Cheap signal from the first header. + return Result.OK_NO_CHECKSUM; + } + if (!checkIntegrity) { + return Result.OK; // restore: already verified at import + } + RootfsIntegrity.Result ir = RootfsIntegrity.verify(archivePath); + switch (ir.status) { + case MATCH: + case ABSENT: // builder rootfs without integrity yet (soft phase) + return Result.OK; + case DECLARED_NONE: + return Result.OK_NO_CHECKSUM; + case MISMATCH: + case ERROR: + default: + Log.w(TAG, "Integrity check failed (" + ir.status + ") for " + archivePath); + return Result.CORRUPT; + } } // Soft fallback (no manifest): legacy ELF/structure heuristic. We diff --git a/controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsManifest.java b/controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsManifest.java index 03ba232..6654ee9 100644 --- a/controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsManifest.java +++ b/controller/app/src/main/java/org/iiab/controller/deploy/data/RootfsManifest.java @@ -41,15 +41,18 @@ public static final class Identity { public final boolean present; public final String kind; public final String arch; + /** Producer hint; "device-backup" means an app-created backup with no checksum by design. */ + public final String origin; - private Identity(boolean present, String kind, String arch) { + private Identity(boolean present, String kind, String arch, String origin) { this.present = present; this.kind = kind; this.arch = arch; + this.origin = origin; } static Identity absent() { - return new Identity(false, null, null); + return new Identity(false, null, null, null); } } @@ -106,12 +109,13 @@ private static Identity parse(String jsonText) { JSONObject o = new JSONObject(jsonText); String kind = o.optString("kind", null); String arch = o.optString("arch", null); - return new Identity(true, kind, arch); + String origin = o.optString("origin", null); + return new Identity(true, kind, arch, origin); } catch (Exception e) { Log.w(TAG, "Identity manifest present but unparseable: " + e.getMessage()); // Present-but-broken: treat as present with no usable fields so the // caller's kind check fails closed. - return new Identity(true, null, null); + return new Identity(true, null, null, null); } } diff --git a/controller/app/src/main/res/values-es/strings.xml b/controller/app/src/main/res/values-es/strings.xml index fdc365e..94edddd 100644 --- a/controller/app/src/main/res/values-es/strings.xml +++ b/controller/app/src/main/res/values-es/strings.xml @@ -517,4 +517,6 @@ Este archivo no es un backup de rootfs IIAB válido, así que no se usó. Este backup es para otra arquitectura de CPU (32-bit vs 64-bit) y no puede usarse en esta app. Importado, pero este backup no tiene manifiesto IIAB, así que no se pudo verificar del todo. + Este backup falló la verificación de integridad (puede estar dañado o alterado), así que no se usó. + Importado. Este backup no tiene checksum de integridad porque fue creado en un dispositivo móvil. diff --git a/controller/app/src/main/res/values/strings.xml b/controller/app/src/main/res/values/strings.xml index 5fbe879..7b9859b 100644 --- a/controller/app/src/main/res/values/strings.xml +++ b/controller/app/src/main/res/values/strings.xml @@ -532,4 +532,6 @@ This file is not a valid IIAB rootfs backup, so it was not used. This backup is for a different CPU architecture (32-bit vs 64-bit) and cannot be used by this app. Imported, but this backup has no IIAB manifest, so it could not be fully verified. + This backup failed its integrity check (it may be corrupted or altered), so it was not used. + Imported. This backup has no integrity checksum because it was created on a mobile device. From a894e491c8aff728b5aa95b70d4018feece1552d Mon Sep 17 00:00:00 2001 From: "Luis Guzman (AppDevForAll)" Date: Wed, 24 Jun 2026 02:02:05 +0000 Subject: [PATCH 3/3] feat(controller): backup writer stamps a device-backup identity manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App-created backups now embed installed-rootfs/iiab/.iiab-rootfs.json declaring kind/arch + origin=device-backup (and builder=knowledgetogo-app). Staged in a temp tree and packed FIRST (a second tar -C) so the validator reads it from the first header — no full decompress, no java.nio, flag-agnostic. NO treehash is computed on the device (we don't make the phone a builder); origin=device-backup is the explicit no-checksum declaration the verifier maps to OK_NO_CHECKSUM. --- .../org/iiab/controller/DeployFragment.java | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/controller/app/src/main/java/org/iiab/controller/DeployFragment.java b/controller/app/src/main/java/org/iiab/controller/DeployFragment.java index 6a5cc13..634f41b 100644 --- a/controller/app/src/main/java/org/iiab/controller/DeployFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/DeployFragment.java @@ -1909,9 +1909,44 @@ private void bindBackupButtonLogic(MainActivity mainAct, File backupsDir, File i String tarBin = staticTar.exists() ? staticTar.getAbsolutePath() : "tar"; String gzipBin = staticGzip.exists() ? staticGzip.getAbsolutePath() : "gzip"; + // Stamp an identity manifest into the backup so a re-import is + // recognized (kind/arch) AND explicitly declares it carries NO + // integrity checksum (origin=device-backup) — we do NOT turn the + // phone into a builder. It is staged in a temp tree and packed + // FIRST (a second `-C`) so RootfsArchiveValidator reads it from + // the first tar header without decompressing the whole archive. + // See docs/ROOTFS_MANIFEST.md. + String manifestArg = null; + File mfStageRoot = new File(requireContext().getCacheDir(), "mfstage"); + try { + if (mfStageRoot.exists()) { + ProcessRunner.run(new String[]{"rm", "-rf", mfStageRoot.getAbsolutePath()}); + } + File iiabStage = new File(mfStageRoot, "installed-rootfs/iiab"); + if (iiabStage.mkdirs()) { + String appAbi = org.iiab.controller.deploy.data.RootfsManifest.appAbiId(); + String debArch = appAbi.contains("64") ? "arm64" : "armhf"; + String built = String.format(java.util.Locale.US, "%04d.%03d", year, dayOfYear); + String identityJson = "{\"schema\":1,\"kind\":\"iiab-rootfs\",\"arch\":\"" + + appAbi + "\",\"deb_arch\":\"" + debArch + "\",\"built\":\"" + + built + "\",\"builder\":\"knowledgetogo-app\",\"origin\":\"device-backup\"}"; + java.io.FileOutputStream mfo = + new java.io.FileOutputStream(new File(iiabStage, ".iiab-rootfs.json")); + mfo.write(identityJson.getBytes("UTF-8")); + mfo.close(); + manifestArg = "-C '" + mfStageRoot.getAbsolutePath() + + "' 'installed-rootfs/iiab/.iiab-rootfs.json' "; + } + } catch (Exception mfe) { + Log.w(TAG, "Could not stage identity manifest for backup: " + mfe.getMessage()); + manifestArg = null; + } + // D11: single-quote the interpolated paths so the backup pipe is robust // even if a path ever contains spaces/metacharacters (app-internal today). - String cmd = "'" + tarBin + "' -cf - -C '" + iiabRootDir.getAbsolutePath() + String cmd = "'" + tarBin + "' -cf - " + + (manifestArg != null ? manifestArg : "") + + "-C '" + iiabRootDir.getAbsolutePath() + "' installed-rootfs | '" + gzipBin + "' > '" + backupFile.getAbsolutePath() + "'"; // D12: ProcessRunner drains stderr so a large backup with tar warnings // cannot deadlock on a full pipe buffer.