# frozen_string_literal: true class ApngInspector attr_reader :frames PNG_MAGIC_NUMBER = ["89504E470D0A1A0A"].pack('H*') def initialize(file_path) @file_path = file_path @corrupted = false @animated = false end #PNG file consists of 8-byte magic number, followed by arbitrary number of chunks #Each chunk has the following structure: #4-byte length (unsigned int, can be zero) #4-byte name (ASCII string consisting of letters A-z) #(length)-byte data #4-byte CRC # #Any data after chunk named IEND is irrelevant #APNG frame count is inside a chunk named acTL, in first 4 bytes of data. #This function calls associated block for each PNG chunk #parameters passed are |chunk_name, chunk_length, file_descriptor| #returns true if file is read succesfully from start to IEND, #or if 100 000 chunks are read; returns false otherwise. def each_chunk iend_reached = false File.open(@file_path, 'rb') do |file| #check if file is not PNG at all return false if file.read(8) != PNG_MAGIC_NUMBER chunks = 0 #We could be dealing with large number of chunks, #so the code should be optimized to create as few objects as possible. #All literal strings are frozen and read() function uses string buffer. chunkheader = +"" while file.read(8, chunkheader) #ensure that first 8 bytes from chunk were read properly if chunkheader == nil || chunkheader.bytesize < 8 return false end current_pos = file.tell chunk_len, chunk_name = chunkheader.unpack("Na4") return false if chunk_name =~ /[^A-Za-z]/ yield chunk_name, chunk_len, file #no need to read further if IEND is reached if chunk_name == "IEND" iend_reached = true break end #check if we processed too many chunks already #if we did, file is probably maliciously formed #fail gracefully without marking the file as corrupt chunks += 1 if chunks > 100000 iend_reached = true break end #jump to the next chunk - go forward by chunk length + 4 bytes CRC file.seek(current_pos+chunk_len+4, IO::SEEK_SET) end end return iend_reached end def inspect! actl_corrupted = false read_success = each_chunk do |name, len, file| if name == "acTL" framecount = parse_actl(len, file) if framecount < 1 actl_corrupted = true else @animated = true @frames = framecount end end end @corrupted = !read_success || actl_corrupted self end def corrupted? @corrupted end def animated? !@corrupted && @animated end private #return number of frames in acTL or -1 on failure def parse_actl(len, file) return -1 if len != 8 framedata = file.read(4) if framedata == nil || framedata.length != 4 return -1 end framedata.unpack1("N") end end