When the file Command Is Wrong: JAR, APK, DOCX and Broken Files
On this page
Short answer: file reads a few fixed offsets near the start of a file. That is enough for ELF, Mach-O, PE, WebAssembly, SQLite, PNG and PDF, and both tools we tested got every one of those right. It breaks down for formats that are only defined by what is inside a ZIP (APK, IPA, a JAR not made by jar, a repacked DOCX), for signed blobs such as iOS provisioning profiles, and for damaged files, where file reports the header and has no way to know the rest is missing. Once xxd shows PK\x03\x04, list the entries. For anything that has to be right, open the file with a parser for its format.
The identify-by-magic-bytes guide covers the signature table and which tool opens which format. This one is about the cases where the answer was wrong or vague, based on 25 sample files and two identifiers: file 5.41 and this site’s file type identifier.
The test set
All 25 files were made on 2026-10-20 on macOS 27.0.1 or taken from this site’s own test fixtures, then copied to s01 … s25 so no extension gives anything away:
| Sample | What it is | How it was made |
|---|---|---|
| s01 | plain ZIP, one text file | zip 3.0 |
| s02 | JAR | javac --release 11 + jar cfe (Temurin 17.0.18) |
| s03 | APK | this site’s APK test fixture (test/apk/fixture.apk) |
| s04 | DOCX | textutil -convert docx |
| s05 | IPA | a synthetic iOS app zipped with zip -r (from the provisioning profile guide) |
| s06 | gzip, 54 bytes | gzip -9 of a 25-byte text file |
| s07 | ELF x86-64 | site fixture |
| s08 | Mach-O arm64 | clang -O2 |
| s09 | PE32+ x64 | site fixture |
| s10 | universal Mach-O | site fixture |
| s11 | Java class | the Hello.class from s02 |
| s12 | WebAssembly | site fixture (clang.wasm) |
| s13 | SQLite | sqlite3, one table |
| s14 | PNG 32 × 32 | site sample |
| s15 | cupsfilter of a text file |
|
| s16 | iOS provisioning profile, DER | openssl cms -sign -nodetach (synthetic) |
| s17 | the same, BER indefinite length | openssl cms -sign -stream |
| s18 | first half of s02 | head -c 373 |
| s19 | first 200 bytes of s14 | head -c 200 |
| s20 | s06 minus its last 8 bytes | head -c 46 |
| s21 | s02 unzipped and re-zipped with zip -r |
|
| s22 | s04 unzipped and re-zipped with zip -r . |
|
| s23 | valid gzip of hi\n, 23 bytes |
gzip -n |
| s24 | first half of a 43,771-byte gzip | seq 1 20000 | gzip -9, then head -c |
| s25 | s05 re-zipped with zip -X |
Versions: file-5.41 with /usr/share/file/magic as shipped with macOS, xxd from macOS, OpenSSL 4.0.3. The site tool’s code ran from this site’s repository under Node.js 22.22.3, the same identify() function the page’s Web Worker calls.
Treat a file you don’t recognize as untrusted: identify it, don’t run it. If it came from someone else’s system, check you are allowed to analyze it. See REA’s DISCLAIMER.md.
Where both tools agreed
For eleven samples there is nothing to discuss. ELF, Mach-O (thin and universal), PE32+, Java class, WebAssembly, SQLite, PNG, PDF, gzip and the plain ZIP got the same answer from both, with the same details: x86-64, arm64, PE32+ ... x86-64, version 55.0 (Java SE 11), 32 x 32, version 1.3. The CA FE BA BE collision between universal Mach-O and Java class was handled correctly by both; the next four bytes are 00 00 00 02 (two architectures) in s10 and 00 00 00 37 (class version 55) in s11.
Even here, file --mime-type is less consistent than the description line. It returned application/octet-stream for the WebAssembly module (whose description it got right) and application/x-java-applet for a plain class file with a main method. If you route uploads by MIME type, check what file --mime-type returns for each format you care about.
Where file was wrong or vague
APK and IPA come out as plain ZIP
$ file s03 s05
s03: Zip archive data, at least v0.0 to extract, compression method=deflate
s05: Zip archive data, at least v1.0 to extract, compression method=store
We then ran file over all 19 APK and AAB fixtures in this site’s repository: every one came back as Zip archive data. Its magic database has no APK rule. It does have an iOS rule, which we found by reading /usr/share/file/magic/archive:
# iOS App
>(26.s+30) leshort !0xcafe
>>26 string !\x8\0\0\0mimetype
>>>30 string Payload/
>>>>38 search/64 .app/ iOS App
The first entry’s name must be Payload/, and .app/ must appear within 64 bytes after offset 38. Our IPA’s first entry is Payload/, but Info-ZIP’s zip adds 28 bytes of extra fields (UT timestamps and ux owner IDs) to every local header, which pushes the second entry’s Payload/Demo.app/ beyond that window. Re-zipped with zip -X (no extra fields), the same app was recognized:
$ file s25
s25: iOS App Zip archive data, at least v1.0 to extract, compression method=store
$ file --mime-type s25
s25: application/x-ios-app
So whether file 5.41 calls an IPA an IPA depends on the zip tool that packed it.
A JAR that wasn’t made by jar
$ file s02 s21
s02: Java archive data (JAR)
s21: Zip archive data, at least v1.0 to extract, compression method=store
Both contain the same META-INF/MANIFEST.MF and Hello.class, and java -jar s21 printed hi. The JAR rule, >(26.s+30) leshort 0xcafe, looks for a 0xCAFE extra field ID on the first entry. The jar tool writes one there:
$ xxd -l 48 hello.jar
00000000: 504b 0304 1400 0808 0800 268f 545d 0000 PK........&.T]..
00000010: 0000 0000 0000 0000 0000 0900 0400 4d45 ..............ME
00000020: 5441 2d49 4e46 2ffe ca00 0003 0050 4b07 TA-INF/......PK.
fe ca 00 00 right after META-INF/ is that marker. Gradle, Maven and zip do not have to write it, so file often gives no answer for a valid JAR.
A repacked DOCX loses its name and its MIME type
$ file s04 s22
s04: Microsoft Word 2007+
s22: Microsoft OOXML
$ file --mime-type s04 s22
s04: application/vnd.openxmlformats-officedocument.wordprocessingml.document
s22: application/octet-stream
The rule in /usr/share/file/magic/msooxml checks that the first entry is [Content_Types].xml, _rels/.rels or docProps, then looks only at the names of the third and fourth entries for word/, xl/ or ppt/. textutil writes [Content_Types].xml, _rels/.rels, then word/_rels/document.xml.rels, so the third entry matches. Our zip -r . copy also starts with [Content_Types].xml, but it adds directory entries and puts docProps/ next; word/ is entry number 8. The description falls back to “Microsoft OOXML”, and the MIME type to application/octet-stream.
A signed iOS profile is “data”
$ file s16 s17
s16: data
s17: data
The bytes are 30 82 0e af 06 09 2a 86 48 86 f7 0d 01 07 02: a DER sequence followed by the signedData object identifier. file 5.41 has no rule for a bare CMS structure. The provisioning profile guide shows how to decode it.
Half a JAR is still a JAR
$ file s18
s18: Java archive data (JAR)
$ unzip -l s18
End-of-central-directory signature not found. Either this file is not
a zipfile, ...
file reads the first local header, and that header survived. The central directory that every unzip tool needs is in the half that is gone. Same with the PNG cut to 200 bytes: file s19 printed PNG image data, 32 x 32, 8-bit/color RGBA, non-interlaced, identical to the full image, because the dimensions are in the first chunk.
“truncated” on a valid gzip, nothing on a broken one
This one surprised us most. Two gzip files:
$ gzip -t s23 && echo OK
OK
$ gzip -t s24
gzip: s24: unexpected end of file
$ file s23 s24
s23: gzip compressed data, from Unix, truncated
s24: gzip compressed data, last modified: Tue Oct 20 10:02:02 2026, max compression, from Unix, original size modulo 2^32 3994873712
The valid file is reported as truncated. The broken one is not, and file printed an “original size” of almost 4 GB for what was a 108,894-byte input. The rule in /usr/share/file/magic/compress explains both:
>>-0 offset >48
>>>-4 ulelong x \b, original size modulo 2^32 %u
>>-0 offset <48 \b, truncated
“truncated” means the file is shorter than 48 bytes, and the size is the last four bytes of the file, whatever they are. Our 23-byte gzip of hi\n is complete but short. The cut file’s last four bytes are compressed data. Our first cut-off sample, s20, happened to be 46 bytes, so file said “truncated” there too, which is correct only by accident. Use gzip -t (or xz -t, zstd -t) to test a compressed file.
Where the site’s identifier was wrong or vague
The file type identifier works differently for ZIP: it reads the central directory at the end of the file and names the format from the entry list (AndroidManifest.xml with classes.dex or resources.arsc for an APK, Payload/*.app/ for an IPA, META-INF/MANIFEST.MF or .class files for a JAR, word/ for DOCX). That is why it named s03, s05, s21, s22 and s25 correctly where file did not. In our first run it also named the 14 APK fixtures apk and 3 of the 5 AAB fixtures aab, all at high confidence; the other two AABs are below. Zip packers don’t change the central directory layout, so the extra fields that broke file’s IPA rule made no difference.
It had its own failures in that first run on 2026-10-20. We fixed them the same day and ran the affected cases again against the fixed identify(), again under Node.js 22.22.3. The profiles, the gzip files and the IPA for the second run were rebuilt with the same commands as the first set (openssl cms, gzip -9, zip -r), so their sizes differ by a few bytes; the AABs, the PNG and the DOCX are the same inputs. Both results are given below.
The provisioning profiles. In the first run, s16 came back as DER-encoded ASN.1 (certificate, key, PKCS#7 / #12) at medium confidence, evidence one byte (30). True, but generic. s17, the BER form starting 30 80, came back as Unknown binary data, low confidence, with the note that schema-less formats such as Protocol Buffers have no magic number. That hint pointed the wrong way: the file has a signedData OID at offset 4. The iOS profile viewer on the same site read both files without trouble, but the identifier did not send you there.
The identifier now parses the CMS structure in both encodings, with the same DER reader the profile viewer uses (it handles BER indefinite lengths), and if the signed content is a plist with ProvisionedDevices, ProvisionsAllDevices, TeamIdentifier or Entitlements keys, it calls the file a profile and links to the viewer. On the second run:
s16: iOS provisioning profile (CMS-signed plist) high encoding DER, 1 certificate
s17: iOS provisioning profile (CMS-signed plist) high encoding BER, indefinite length, 1 certificate
The protobuf hint is gone for both. A CMS signature over plain text, also made with openssl cms -sign, now comes back as CMS / PKCS#7 SignedData, high, with the content type 1.2.840.113549.1.7.1. A 30 80 file that does not parse as CMS is still Unknown binary data.
Two AABs called JAR. In the first run, sig-bundle.aab and sig-bundle-tampered.aab were reported as jar, high. Listing them shows why: they hold META-INF/MANIFEST.MF, a JAR signature, base/manifest/AndroidManifest.xml and base/dex/classes.dex, but no BundleConfig.pb, which the identifier required for an AAB. They are hand-made test files for signature checks; an AAB from bundletool or Gradle always has BundleConfig.pb. Still, a signed bundle missing that file was called a JAR, not “AAB, incomplete”.
Now a module manifest such as base/manifest/AndroidManifest.xml together with a module directory (base/dex/, res/, lib/, assets/ or root/) is enough for aab. Without BundleConfig.pb the result stays at medium and says so. On the second run both files came back as aab, medium, with flags: no BundleConfig.pb; the other three AAB fixtures are still aab, high. A ZIP that would be a JAR but contains an AndroidManifest.xml anywhere no longer gets high confidence either.
Damaged files. For the half JAR, the identifier dropped to ZIP archive, medium, with a note that the central directory could not be read and a hint that a META-INF/ entry was seen. That is more honest than file’s confident “JAR”. In the first run, the cut PNG (s19) and the cut gzip files (s20, s24) got the same high-confidence PNG image and gzip as the intact files, because nothing looked at the end of the file.
Now a PNG has to end with its IEND chunk, and a gzip file of up to 8 MiB is inflated once inside the worker with the browser’s DecompressionStream, output thrown away, to see whether the stream reaches its end. Second run:
s19 (200 of 939 bytes): PNG image medium note: does not end the way this format ends
s20 (44 of 52 bytes): gzip medium same note
s24 (21,889 of 43,779 bytes): gzip medium same note
s14, s06, s23 and the full 43,779-byte gzip s24 was cut from: high, no note
The same note appears when something is appended after the end, which some files do on purpose, so treat it as “check this” rather than “broken”.
Re-running the gzip cases turned up one more miss. A 46-byte gzip from Node.js’s zlib.gzipSync came back as Unknown binary data: zlib (1.3.1 in this Node.js build) writes 19 in the OS byte on macOS, and the identifier accepted only 0 to 13, the values RFC 1952 lists. It also refused 255, “unknown”, which Python 3.14.7’s gzip.compress writes. Both are now accepted, shown as macOS and unknown.
MIME types for Office and IPA. In the first run it reported application/zip for DOCX and IPA, where file gave the specific OOXML type for the intact DOCX and application/x-ios-app for s25. The format name was right; the MIME field was the container’s. On the second run, s04 came back with application/vnd.openxmlformats-officedocument.wordprocessingml.document and the IPA with application/x-ios-app, the same value file gave for s25. XLSX and PPTX get their OOXML types the same way.
What to do with an unknown file
-
Look at the first 16 bytes with
xxd -l 16. That gives the container, and for executables, images and databases usually the final answer. -
For
PK\x03\x04, list the entries withunzip -lorzipinfo -1, or drop the file on the identifier, which does the same from the central directory. Don’t trustfileto tell APK, IPA, JAR or Office apart; its answer depends on how the archive was packed. -
For
30 82or30 80, think signed or encoded data: a certificate, a PKCS#12 file, a CMS signature, a provisioning profile.openssl asn1parsenames the outer object identifier, whichfiledid not do for s16 and the site identifier did only after the fix above:$ openssl asn1parse -inform DER -in s16 | head -3 0:d=0 hl=4 l=3759 cons: SEQUENCE 4:d=1 hl=2 l= 9 prim: OBJECT :pkcs7-signedData 15:d=1 hl=4 l=3744 cons: cont [ 0 ] -
Before trusting a compressed or archived file, test it:
gzip -t,unzip -t,xz -t.filedoes not check integrity. The site identifier now inflates gzip files up to 8 MiB to the end, which also checks the stored CRC-32, and checks that a PNG ends withIEND, without checking the CRC of each PNG chunk. Other formats get no integrity check. -
Open it with a parser for its format. A header only tells you what the file is supposed to be. A parser fails when the rest of the file is missing.
REA and ZIP subtypes
REA, the open-source project by morluto, has the opposite approach in its artifact inventory. In the published [email protected] package, dist/domain/zipPackageFormat.js checks the ZIP signature from the bytes and then takes the package type from the file name only: .ipa, .apk, .msix, .appx, .hap, .hsp, else zip. The source comment says it is “deliberately extension-only”. We read this from the npm pack tarball and did not run it. With REA, give a package its proper extension before you run inspect-artifact. With extensionless files like ours, identify them first with the steps above.
Limits of this test
25 files is a small set chosen to probe ZIP subtypes and damage. It is not a coverage benchmark. We tested file 5.41, the version macOS ships; newer releases of file may have APK or CMS rules we did not see. And our IPA and profiles are synthetic: real ones exported by Xcode may be packed differently, which, as s05 and s25 showed, is exactly what changes file’s answer.
Next steps
- Signature tables and which tool opens which format: Identify a File Type by Its Magic Bytes.
- A file turned out to be an APK or AAB: Unpack an APK or AAB.
- It is a provisioning profile: What’s Inside a .mobileprovision.
- It is a JAR or class file and you need the Java version: the Java class version checker.