apksigner 1.0.0

APK signer and verifier CLI — a faithful Rust port of AOSP apksigner
//! Reproduces the JVM's uncaught-exception report for the failures that escape the tool.
//!
//! `ApkSignerTool.main` does not catch `ApkFormatException`, so in the real tool the exception
//! reaches the JVM, which prints `Exception in thread "main" ...` followed by the frames it
//! unwound, plus a `Caused by:` chain. The port has no JVM and no Java frames to unwind, so this
//! module rebuilds that report from a table of the frames Java's stack holds for each failure
//! shape.
//!
//! ## Scope
//!
//! Only shapes whose frames have been observed verbatim are rendered. Anything else keeps the
//! port's single `APK format error: ...` line, because inventing frames for an unobserved failure
//! would be worse than the shorter message.
//!
//! ## Line numbers
//!
//! The frames carry `File.java:line` coordinates, which are build metadata rather than behaviour:
//! the line number of a `throw` moves whenever the surrounding source changes. The numbers below
//! are those of the reference build the port is validated against (AOSP `platform/tools/apksig`
//! as shipped in Android build-tools). A reference built from a different revision of the same
//! source produces the same frames with different numbers, so this is the one place where the
//! port's output tracks a specific build rather than revision-stable behaviour.

/// Frames printed for a throwable in a rendered stack trace.
struct Throwable {
    class: &'static str,
    message: String,
    frames: &'static [&'static str],
    /// The `N` of a trailing `... N more`, or `0` when Java prints every frame.
    elided: usize,
    cause: Option<Box<Throwable>>,
}

impl Throwable {
    fn render(&self, header: &str, out: &mut String) {
        out.push_str(header);
        out.push_str(self.class);
        out.push_str(": ");
        out.push_str(&self.message);
        for frame in self.frames {
            out.push_str("\n\tat ");
            out.push_str(frame);
        }
        if self.elided != 0 {
            out.push_str("\n\t... ");
            out.push_str(&self.elided.to_string());
            out.push_str(" more");
        }
        if let Some(cause) = &self.cause {
            cause.render("\nCaused by: ", out);
        }
    }
}

const APK_FORMAT_EXCEPTION: &str = "com.android.apksig.apk.ApkFormatException";
const ZIP_FORMAT_EXCEPTION: &str = "com.android.apksig.zip.ZipFormatException";

/// `ApkVerifier.verify` unwinding through `ApkSignerTool.verify` / `main`.
const FRAMES_APK_VERIFIER_VERIFY: &[&str] = &[
    "com.android.apksig.ApkVerifier.verify(ApkVerifier.java:191)",
    "com.android.apksig.ApkVerifier.verify(ApkVerifier.java:164)",
    "com.android.apksigner.ApkSignerTool.verify(ApkSignerTool.java:587)",
    "com.android.apksigner.ApkSignerTool.main(ApkSignerTool.java:95)",
];

/// `ApkUtilsLite.findZipSections` unable to locate the End of Central Directory record.
const FRAMES_FIND_ZIP_SECTIONS_NO_EOCD: &[&str] = &[
    "com.android.apksig.apk.ApkUtilsLite.findZipSections(ApkUtilsLite.java:49)",
    "com.android.apksig.apk.ApkUtils.findZipSections(ApkUtils.java:60)",
    "com.android.apksig.ApkVerifier.verify(ApkVerifier.java:189)",
];

/// `ApkUtilsLite.findZipSections` rejecting a Central Directory that runs into the EoCD.
const FRAMES_FIND_ZIP_SECTIONS_OVERLAP: &[&str] = &[
    "com.android.apksig.apk.ApkUtilsLite.findZipSections(ApkUtilsLite.java:65)",
    "com.android.apksig.apk.ApkUtils.findZipSections(ApkUtils.java:60)",
    "com.android.apksig.ApkVerifier.verify(ApkVerifier.java:189)",
];

/// The v1 (JAR) verifier reading an entry it cannot decompress.
const FRAMES_V1_VERIFY_MANIFEST: &[&str] = &[
    "com.android.apksig.internal.apk.v1.V1SchemeVerifier$Signers.verify(V1SchemeVerifier.java:264)",
    "com.android.apksig.internal.apk.v1.V1SchemeVerifier$Signers.access$100(V1SchemeVerifier.java:210)",
    "com.android.apksig.internal.apk.v1.V1SchemeVerifier.verify(V1SchemeVerifier.java:132)",
    "com.android.apksig.ApkVerifier.verify(ApkVerifier.java:346)",
    "com.android.apksig.ApkVerifier.verify(ApkVerifier.java:164)",
    "com.android.apksigner.ApkSignerTool.verify(ApkSignerTool.java:587)",
    "com.android.apksigner.ApkSignerTool.main(ApkSignerTool.java:95)",
];

/// `LocalFileRecord` reading a record whose Local File Header and Central Directory disagree.
const FRAMES_LOCAL_FILE_RECORD: &[&str] = &[
    "com.android.apksig.internal.zip.LocalFileRecord.getRecord(LocalFileRecord.java:191)",
    "com.android.apksig.internal.zip.LocalFileRecord.outputUncompressedData(LocalFileRecord.java:427)",
    "com.android.apksig.internal.zip.LocalFileRecord.getUncompressedData(LocalFileRecord.java:457)",
    "com.android.apksig.internal.apk.v1.V1SchemeVerifier$Signers.verify(V1SchemeVerifier.java:262)",
];

/// `ApkSigner.getAndroidManifestFromApk` unable to find `AndroidManifest.xml`.
const FRAMES_GET_ANDROID_MANIFEST: &[&str] = &[
    "com.android.apksig.ApkSigner.getAndroidManifestFromApk(ApkSigner.java:970)",
    "com.android.apksig.ApkVerifier.getAndroidManifestFromApk(ApkVerifier.java:1225)",
    "com.android.apksig.ApkVerifier.verify(ApkVerifier.java:324)",
    "com.android.apksig.ApkVerifier.verify(ApkVerifier.java:164)",
    "com.android.apksigner.ApkSignerTool.verify(ApkSignerTool.java:587)",
    "com.android.apksigner.ApkSignerTool.main(ApkSignerTool.java:95)",
];

/// A formatter wraps its cause with `": "`, and the port's `ApkFormatException` flattens both into
/// one message, so this is the one marker that separates a throwable from its cause.
const ZIP_CAUSE_SEPARATOR: &str = ": ZIP format error: ";

const MISSING_ANDROID_MANIFEST: &str = "Missing AndroidManifest.xml";
const NOT_A_ZIP_ARCHIVE: &str = "Malformed APK: not a ZIP archive";
const MALFORMED_ZIP_ENTRY: &str = "Malformed ZIP entry: ";
const MANIFEST_ENTRY: &str = "META-INF/MANIFEST.MF";

/// Renders the report the JVM would print for `message`, the inner text of the
/// [`apksig::errors::ApkFormatException`] that escaped the tool.
///
/// Returns `None` for a failure whose frames have not been observed, so the caller can fall back
/// to the plain one-line message. The result has no trailing newline, matching what a caller
/// writing one line per `println!` produces.
pub fn render_apk_format_exception(message: &str) -> Option<String> {
    let mut out = String::new();
    let throwable = classify(message)?;
    throwable.render("Exception in thread \"main\" ", &mut out);
    Some(out)
}

fn classify(message: &str) -> Option<Throwable> {
    if message == MISSING_ANDROID_MANIFEST {
        return Some(Throwable {
            class: APK_FORMAT_EXCEPTION,
            message: message.to_string(),
            frames: FRAMES_GET_ANDROID_MANIFEST,
            elided: 0,
            cause: None,
        });
    }
    let (top, cause_message) = message.split_once(ZIP_CAUSE_SEPARATOR)?;

    if top == NOT_A_ZIP_ARCHIVE {
        // `ApkVerifier.verify` wraps the `ZipFormatException` thrown by `findZipSections`.
        let frames = if cause_message.starts_with("ZIP End of Central Directory record not found") {
            FRAMES_FIND_ZIP_SECTIONS_NO_EOCD
        } else if cause_message
            .starts_with("ZIP Central Directory overlaps with End of Central Directory.")
        {
            FRAMES_FIND_ZIP_SECTIONS_OVERLAP
        } else {
            return None;
        };
        return Some(Throwable {
            class: APK_FORMAT_EXCEPTION,
            message: top.to_string(),
            frames: FRAMES_APK_VERIFIER_VERIFY,
            elided: 0,
            cause: Some(Box::new(Throwable {
                class: ZIP_FORMAT_EXCEPTION,
                message: cause_message.to_string(),
                frames,
                // Java prints three of the ten frames the caused-by stack holds, because its tail
                // is the same frames the enclosing trace already printed.
                elided: 3,
                cause: None,
            })),
        });
    }

    // Any other entry fails at a different `V1SchemeVerifier` call site with a different pending
    // frame, and only the manifest entry has been observed, so it is the only one rendered.
    if top == format!("{MALFORMED_ZIP_ENTRY}{MANIFEST_ENTRY}")
        && cause_message.starts_with(
            "CRC-32 mismatch between Local File Header and Central Directory for entry ",
        )
    {
        return Some(Throwable {
            class: APK_FORMAT_EXCEPTION,
            message: top.to_string(),
            frames: FRAMES_V1_VERIFY_MANIFEST,
            elided: 0,
            cause: Some(Box::new(Throwable {
                class: ZIP_FORMAT_EXCEPTION,
                message: cause_message.to_string(),
                frames: FRAMES_LOCAL_FILE_RECORD,
                elided: 6,
                cause: None,
            })),
        });
    }

    None
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Every expectation below is the oracle's stderr, captured verbatim from an `apksigner
    /// verify --min-sdk-version 24` run against the corresponding corpus fixture.
    #[test]
    fn missing_android_manifest_matches_the_oracle() {
        let rendered = render_apk_format_exception("Missing AndroidManifest.xml").unwrap();
        assert_eq!(
            rendered,
            "Exception in thread \"main\" com.android.apksig.apk.ApkFormatException: Missing AndroidManifest.xml\n\
             \tat com.android.apksig.ApkSigner.getAndroidManifestFromApk(ApkSigner.java:970)\n\
             \tat com.android.apksig.ApkVerifier.getAndroidManifestFromApk(ApkVerifier.java:1225)\n\
             \tat com.android.apksig.ApkVerifier.verify(ApkVerifier.java:324)\n\
             \tat com.android.apksig.ApkVerifier.verify(ApkVerifier.java:164)\n\
             \tat com.android.apksigner.ApkSignerTool.verify(ApkSignerTool.java:587)\n\
             \tat com.android.apksigner.ApkSignerTool.main(ApkSignerTool.java:95)"
        );
    }

    #[test]
    fn missing_end_of_central_directory_matches_the_oracle() {
        let rendered = render_apk_format_exception(
            "Malformed APK: not a ZIP archive: ZIP format error: \
             ZIP End of Central Directory record not found",
        )
        .unwrap();
        assert_eq!(
            rendered,
            "Exception in thread \"main\" com.android.apksig.apk.ApkFormatException: Malformed APK: not a ZIP archive\n\
             \tat com.android.apksig.ApkVerifier.verify(ApkVerifier.java:191)\n\
             \tat com.android.apksig.ApkVerifier.verify(ApkVerifier.java:164)\n\
             \tat com.android.apksigner.ApkSignerTool.verify(ApkSignerTool.java:587)\n\
             \tat com.android.apksigner.ApkSignerTool.main(ApkSignerTool.java:95)\n\
             Caused by: com.android.apksig.zip.ZipFormatException: ZIP End of Central Directory record not found\n\
             \tat com.android.apksig.apk.ApkUtilsLite.findZipSections(ApkUtilsLite.java:49)\n\
             \tat com.android.apksig.apk.ApkUtils.findZipSections(ApkUtils.java:60)\n\
             \tat com.android.apksig.ApkVerifier.verify(ApkVerifier.java:189)\n\
             \t... 3 more"
        );
    }

    #[test]
    fn overlapping_central_directory_matches_the_oracle() {
        let rendered = render_apk_format_exception(
            "Malformed APK: not a ZIP archive: ZIP format error: ZIP Central Directory overlaps \
             with End of Central Directory. CD end: 4112, EoCD start: 4111",
        )
        .unwrap();
        assert_eq!(
            rendered,
            "Exception in thread \"main\" com.android.apksig.apk.ApkFormatException: Malformed APK: not a ZIP archive\n\
             \tat com.android.apksig.ApkVerifier.verify(ApkVerifier.java:191)\n\
             \tat com.android.apksig.ApkVerifier.verify(ApkVerifier.java:164)\n\
             \tat com.android.apksigner.ApkSignerTool.verify(ApkSignerTool.java:587)\n\
             \tat com.android.apksigner.ApkSignerTool.main(ApkSignerTool.java:95)\n\
             Caused by: com.android.apksig.zip.ZipFormatException: ZIP Central Directory overlaps \
             with End of Central Directory. CD end: 4112, EoCD start: 4111\n\
             \tat com.android.apksig.apk.ApkUtilsLite.findZipSections(ApkUtilsLite.java:65)\n\
             \tat com.android.apksig.apk.ApkUtils.findZipSections(ApkUtils.java:60)\n\
             \tat com.android.apksig.ApkVerifier.verify(ApkVerifier.java:189)\n\
             \t... 3 more"
        );
    }

    #[test]
    fn malformed_manifest_entry_matches_the_oracle() {
        let rendered = render_apk_format_exception(
            "Malformed ZIP entry: META-INF/MANIFEST.MF: ZIP format error: CRC-32 mismatch between \
             Local File Header and Central Directory for entry META-INF/MANIFEST.MF. \
             LFH: 3496857924, CD: 1634586948",
        )
        .unwrap();
        assert_eq!(
            rendered,
            "Exception in thread \"main\" com.android.apksig.apk.ApkFormatException: Malformed ZIP entry: META-INF/MANIFEST.MF\n\
             \tat com.android.apksig.internal.apk.v1.V1SchemeVerifier$Signers.verify(V1SchemeVerifier.java:264)\n\
             \tat com.android.apksig.internal.apk.v1.V1SchemeVerifier$Signers.access$100(V1SchemeVerifier.java:210)\n\
             \tat com.android.apksig.internal.apk.v1.V1SchemeVerifier.verify(V1SchemeVerifier.java:132)\n\
             \tat com.android.apksig.ApkVerifier.verify(ApkVerifier.java:346)\n\
             \tat com.android.apksig.ApkVerifier.verify(ApkVerifier.java:164)\n\
             \tat com.android.apksigner.ApkSignerTool.verify(ApkSignerTool.java:587)\n\
             \tat com.android.apksigner.ApkSignerTool.main(ApkSignerTool.java:95)\n\
             Caused by: com.android.apksig.zip.ZipFormatException: CRC-32 mismatch between Local File Header and Central Directory for entry META-INF/MANIFEST.MF. LFH: 3496857924, CD: 1634586948\n\
             \tat com.android.apksig.internal.zip.LocalFileRecord.getRecord(LocalFileRecord.java:191)\n\
             \tat com.android.apksig.internal.zip.LocalFileRecord.outputUncompressedData(LocalFileRecord.java:427)\n\
             \tat com.android.apksig.internal.zip.LocalFileRecord.getUncompressedData(LocalFileRecord.java:457)\n\
             \tat com.android.apksig.internal.apk.v1.V1SchemeVerifier$Signers.verify(V1SchemeVerifier.java:262)\n\
             \t... 6 more"
        );
    }

    #[test]
    fn unobserved_failures_fall_back_to_the_plain_message() {
        // An EoCD cause the port can produce but that has not been observed must not be given
        // invented frames.
        assert!(
            render_apk_format_exception(
                "Malformed APK: not a ZIP archive: ZIP format error: something else entirely"
            )
            .is_none()
        );
        // A different entry's CRC failure fails at a different V1SchemeVerifier call site.
        assert!(
            render_apk_format_exception(
                "Malformed ZIP entry: META-INF/CERT.SF: ZIP format error: CRC-32 mismatch between \
             Local File Header and Central Directory for entry META-INF/CERT.SF. LFH: 1, CD: 2"
            )
            .is_none()
        );
        // Messages the port produces that Java never reports as an uncaught exception.
        assert!(render_apk_format_exception("APK not provided").is_none());
        assert!(render_apk_format_exception("Failed to open APK /nope.apk").is_none());
    }
}