From 55bdfa710a6e879d5514f3a59cdf450ba3083415 Mon Sep 17 00:00:00 2001 From: madtisa Date: Mon, 12 Aug 2024 12:12:32 +0300 Subject: [PATCH] (fix) Search `acTL` only among chunk types Byte sequence `acTL` can be encountered in other parts of PNG, that are not chunk type (e.g. in chunk data or crc), so we should check only chunk types to avoid false positive detection. --- src/images/staticWithMetadata.png | Bin 0 -> 2020 bytes src/index.test.js | 44 +++++++++++++------ src/index.ts | 69 ++++++++++++++++++++---------- 3 files changed, 78 insertions(+), 35 deletions(-) create mode 100644 src/images/staticWithMetadata.png diff --git a/src/images/staticWithMetadata.png b/src/images/staticWithMetadata.png new file mode 100644 index 0000000000000000000000000000000000000000..9b5194506d3404620bc053d6b4af1f0d410f02d1 GIT binary patch literal 2020 zcmbVN4NMbf7(SRK3S*m@I)65E90?4xy=y77oRlA16qEwPP7pHWdUu7B>)m;GrNw0m zs3;3#YUZ3w#h=0aAx>f>I)^{7p$?HPN@C2Gpd$V*>fD0pkbP~TL+4^r zyw6y2 zOxVbaG#snKlq7~>ur5}m)~QL2&NdU)U>XgI=}~ozQmw}^4X%xXhd&5NCsH;%%QR;= z8F(@vMY1g5DC%@NRn90CFBYQe*w|PU)1VrS5+IaPDJK&yB`1ZBFqmkG6d6Hgcn)?m z5?0EsenW~f_@ zfEz`cka^L<^X$k@WsZ~#YqTmg{BAbGQM^-%7!^R92$?n@VAr%tOs`aHENTpoj=?eg z3@i%AFfY{1Q;ecQ&8X;ijavHf+-TW@uHmof*CteNTUK*h`_!!;zpk3 zMUWU&7v-tKY{pYLNhUawPBj@2pjpK*6t1JJYC?m>Dq~13skBCGqm@=$v{tFp>8u2) zi;2+_UKJqT#FGy9&W^-W|1&;AWWdoP*q7!3CjjV{8JVSr4?_|w53g9p?Mj>_xI&;# zNzpdK!OBQHL0Zz{P`Axl20%S4ZxlL;%i}aWg^_>8c>e*&@;yH!X(}%I|-3aDg;~bVvl-*$Rf}W8cZ=1K}Xbkb)nBn z_H<^{Xc&QmdE;LCCLMV2%}SaB)FPOKz2CIdK#+fQswv5m)AqxrB<_6TgvyUrvFn-N z+m9sHWID=C3zK&H=lyiJd1X#tKyLu;m)Gha(NS_LAY!lOLc@f}lm7LQhb}cNEs1Zv z9d~|SOjTjn{haDQ?rs=3-rsfh%ckf%ugKe+oA0zaN<#bAte@Dk^~zdB+n(;y zS>@?%>#D2k0u#rmgq5F07@PF34$ciqw5;e(kF-R0-7<$jwFSvnKf?z%LOZ{`K1nyU zZ}Y@EGZpI^2W~Z?7lI!u9c6UPmPc8u$<`AtH-X~ zzsi { t.false(check(dir + '/images/static.jpg')) }) +test('returns false on static PNG with `acTL` text in metadata', (t) => { + t.false(check(dir + '/images/staticWithMetadata.png')) +}) + test('returns true when IDAT follows acTL', (t) => { t.true( isApng( new Uint8Array([ // PNG header 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - // acTL + // Chunk length: 0 + 0x00, 0x00, 0x00, 0x00, + // Chunk type: acTL 0x61, 0x63, 0x54, 0x4c, - // IDAT + // Chunk CRC + 0x00, 0x00, 0x00, 0x00, + // Chunk length: 0 + 0x00, 0x00, 0x00, 0x00, + // Chunk type: IDAT 0x49, 0x44, 0x41, 0x54, ]), ), ) }) -test('returns false when IDAT precedes acTL', (t) => { +test('returns false when acTL is not a chunk type', (t) => { t.false( isApng( new Uint8Array([ // PNG header 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - // IDAT - 0x49, 0x44, 0x41, 0x54, - // acTL + // Chunk length: 4 + 0x00, 0x00, 0x00, 0x04, + // Chunk type: any + 0x00, 0x00, 0x00, 0x01, + // Chunk data: acTL 0x61, 0x63, 0x54, 0x4c, ]), ), ) }) -test('returns false when missing IDAT', (t) => { +test('returns false when IDAT precedes acTL', (t) => { t.false( isApng( new Uint8Array([ // PNG header 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - // acTL + // Chunk length: 0 + 0x00, 0x00, 0x00, 0x00, + // Chunk type: IDAT + 0x49, 0x44, 0x41, 0x54, + // Chunk CRC + 0x00, 0x00, 0x00, 0x00, + // Chunk length: 0 + 0x00, 0x00, 0x00, 0x00, + // Chunk type: acTL 0x61, 0x63, 0x54, 0x4c, ]), ), @@ -72,10 +92,10 @@ test('chunks should be found when preceded by a partial of themselves', (t) => { new Uint8Array([ // PNG header 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - // a acTL - 0x61, 0x61, 0x63, 0x54, 0x4c, - // I IDAT - 0x49, 0x49, 0x44, 0x41, 0x54, + // Chunk length: a + 0x00, 0x00, 0x00, 0x61, + // Chunk type: acTL + 0x61, 0x63, 0x54, 0x4c, ]), ), ) diff --git a/src/index.ts b/src/index.ts index 1cf86b3..6c58e4e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,34 @@ +function convertToInt(bytes: Uint8Array) { + return bytes.reduce((value, byte) => (value << 8) + byte) +} + +function isEqual(first, second) { + return ( + first[0] === second[0] && + first[1] === second[1] && + first[2] === second[2] && + first[3] === second[3] + ) +} + const encoder = new TextEncoder() -const sequences = { - animationControlChunk: encoder.encode('acTL'), - imageDataChunk: encoder.encode('IDAT'), +const chunkTypes = { + animationControl: encoder.encode('acTL'), + imageData: encoder.encode('IDAT'), +} + +/** + * @see http://www.libpng.org/pub/png/spec/1.2/PNG-Structure.html + */ +const headerSizes = { + /** Number of bytes reserved for PNG signature */ + SIGNATURE: 8, + /** Number of bytes reserved for chunk type */ + LENGTH: 4, + /** Number of bytes reserved for chunk type */ + TYPE: 4, + /** Number of bytes reserved for chunk CRC */ + CRC: 4, } export default function isApng(buffer: Buffer | Uint8Array): boolean { @@ -33,32 +60,28 @@ export default function isApng(buffer: Buffer | Uint8Array): boolean { // APNGs have an animation control chunk (acTL) preceding any IDAT(s). // See: https://en.wikipedia.org/wiki/APNG#File_format - buffer = buffer.subarray(8) + buffer = buffer.subarray(headerSizes.SIGNATURE) - let firstIndex = 0 - let secondIndex = 0 - for (let i = 0; i < buffer.length; i++) { - if (buffer[i] !== sequences.animationControlChunk[firstIndex]) { - firstIndex = 0 - } + const minBufferSize = headerSizes.LENGTH + headerSizes.TYPE + while (buffer.length >= minBufferSize) { + const chunkLength = convertToInt(buffer.subarray(0, headerSizes.LENGTH)) + const chunkType = buffer.subarray( + headerSizes.LENGTH, + headerSizes.LENGTH + headerSizes.TYPE, + ) - if (buffer[i] === sequences.animationControlChunk[firstIndex]) { - firstIndex++ - if (firstIndex === sequences.animationControlChunk.length) { - return true - } + if (isEqual(chunkType, chunkTypes.animationControl)) { + return true } - if (buffer[i] !== sequences.imageDataChunk[secondIndex]) { - secondIndex = 0 + if (isEqual(chunkType, chunkTypes.imageData)) { + return false } - if (buffer[i] === sequences.imageDataChunk[secondIndex]) { - secondIndex++ - if (secondIndex === sequences.imageDataChunk.length) { - return false - } - } + const nextChunkPosition = + headerSizes.LENGTH + headerSizes.TYPE + chunkLength + headerSizes.CRC + + buffer = buffer.subarray(nextChunkPosition) } return false