// Copyright (c) 2026 Timofey Solomko // Licensed under MIT License // // See LICENSE for license information import Foundation import BitByteData /** A type that allows to iteratively read TAR entries from a container provided by a `FileHandle`. The `TarReader` may be helpful in reducing the peak memory usage on certain platforms. However, to achieve this either the `TarReader.process(_:)` function should be used or both the call to `TarReader.read()` and the processing of the returned entry should be wrapped inside the `autoreleasepool`. Since the `autoreleasepool` is available only on Darwin platforms, the memory reducing effect may be not as significant on non-Darwin platforms (such as Linux or Windows). The following code demonstrates an example usage of the `TarReader`: ```swift let handle: FileHandle = ... let reader = TarReader(fileHandle: handle) try reader.process { ... } ... try handle.close() ``` Note that closing the `FileHandle` remains the responsibility of the caller. */ public struct TarReader { private let handle: FileHandle private var lastGlobalExtendedHeader: TarExtendedHeader? private var lastLocalExtendedHeader: TarExtendedHeader? private var longLinkName: String? private var longName: String? /** Creates a new instance for reading TAR entries from the provided `fileHandle`. - Parameter fileHandle: A handle from which the entries will be read. Note that the `TarReader` does not close the `fileHandle` and this remains the responsibility of the caller. */ public init(fileHandle: FileHandle) { self.handle = fileHandle self.lastGlobalExtendedHeader = nil self.lastLocalExtendedHeader = nil self.longLinkName = nil self.longName = nil } /** Processes the next TAR entry by reading it from the container and calling the provided closure on the result. If the argument supplied to the closure is `nil` this indicates that the end of the input was reached. On Darwin platforms both the reading and the call to the closure are performed inside the `autoreleasepool` which allows to reduce the peak memory usage. - Throws: `DataError.truncated` if the input is truncated. `TarError` is thrown in case of malformed input. Errors thrown by `FileHandle` operations are also propagated. */ public mutating func process(_ transform: (TarEntry?) throws -> T) throws -> T { return try autoreleasepool { let entry = try read() return try transform(entry) } } /** Reads the next TAR entry from the container. On Darwin platforms it is recommended to wrap both the call to this function and the follow-up processing inside the `autoreleasepool` in order to reduce the peak memory usage. - Throws: `DataError.truncated` if the input is truncated. `TarError` is thrown in case of malformed input. Errors thrown by `FileHandle` operations are also propagated. - Returns: The next entry from the container or `nil` if the end of the input has been reached. */ public mutating func read() throws -> TarEntry? { let headerData = try getData(size: 512) if headerData.count == 0 { return nil } else if headerData == Data(count: 512) { // EOF marker case. let offset = try handle.offset() if try getData(size: 512) == Data(count: 512) { return nil } else { // In this case we have a zero-filled block immediately followed by a non-zero-filled block which do not // match the EOF marker signature. In practice, this indicates a malformed TAR container, since a // zero-filled block is not a valid TAR header (and in fact the end result is an error being thrown in // TarHeader initializer later down the line). try handle.seek(toOffset: offset) } } else if headerData.count < 512 { throw DataError.truncated } assert(headerData.count == 512) let header = try TarHeader(LittleEndianByteReader(data: headerData)) // Since we explicitly initialize the header from 512 bytes-long Data, we don't have to check that we processed // at most 512 bytes. // Check, just in case, since we use blockStartIndex = -1 when creating TAR containers. assert(header.blockStartIndex >= 0) let dataStartOffset = try handle.offset() let entryData = try getData(size: header.size) guard entryData.count == header.size else { throw DataError.truncated } if case .special(let specialEntryType) = header.type { switch specialEntryType { case .globalExtendedHeader: lastGlobalExtendedHeader = try TarExtendedHeader(entryData) case .sunExtendedHeader: fallthrough case .localExtendedHeader: lastLocalExtendedHeader = try TarExtendedHeader(entryData) case .longLinkName: longLinkName = LittleEndianByteReader(data: entryData).tarCString(maxLength: header.size) case .longName: longName = LittleEndianByteReader(data: entryData).tarCString(maxLength: header.size) } try handle.seek(toOffset: dataStartOffset + UInt64(truncatingIfNeeded: header.size.roundTo512())) return try read() } else { let info = TarEntryInfo(header, lastGlobalExtendedHeader, lastLocalExtendedHeader, longName, longLinkName) try handle.seek(toOffset: dataStartOffset + UInt64(truncatingIfNeeded: header.size.roundTo512())) lastLocalExtendedHeader = nil longName = nil longLinkName = nil if info.type == .directory { // For directories TarEntry.data is set to nil. var entry = TarEntry(info: info, data: nil) entry.info.size = 0 return entry } else { return TarEntry(info: info, data: entryData) } } } @inline(__always) private func getData(size: Int) throws -> Data { assert(size >= 0, "TarReader.getData(size:): negative size.") // The documentation for FileHandle.read(upToCount:) is a bit misleading. This method does "return the data // obtained by reading length bytes starting at the current file pointer" even if the requested amount is // larger than the available data. What is not clear is when the method returns nil. Apparently, there are // (at least) two cases when it happens: // - the file pointer is at the EOF regardless of the argument value, // - the argument is zero. // It is also unclear what happens when the argument is negative (it seems that it reads everything until // the EOF), but the assertion above takes care of this. In any case, instead of returning nil we return // empty data since both of these situations logically seem equivalent for our purposes. This also allows us // to eliminate additional guard-check for the size parameter. return try handle.read(upToCount: size) ?? Data() } } #if os(Linux) || os(Windows) || os(Android) || os(WASI) @discardableResult fileprivate func autoreleasepool(_ block: () throws -> T) rethrows -> T { return try block() } #endif