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.length < 8 return false end current_pos = file.tell chunk_len, chunk_name = chunkheader.unpack("Na4".freeze) 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".freeze 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 > 100_000 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 iend_reached end def inspect! actl_corrupted = false read_success = each_chunk do |name, len, file| if name == 'acTL'.freeze 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".freeze) end end