Files
SWCompression/Sources/TAR/TarEntryInfo.swift

499 lines
21 KiB
Swift

// Copyright (c) 2021 Timofey Solomko
// Licensed under MIT License
//
// See LICENSE for license information
import Foundation
import BitByteData
/// Provides access to information about an entry from the TAR container.
public struct TarEntryInfo: ContainerEntryInfo {
enum SpecialEntryType: UInt8 {
case longName = 76
case longLinkName = 75
case globalExtendedHeader = 103
case localExtendedHeader = 120
// Sun were the first to use extended headers. Their headers are mostly compatible with PAX ones, but differ in
// the typeflag used ("X" instead of "x").
case sunExtendedHeader = 88
}
// MARK: ContainerEntryInfo
/**
Entry's name.
Depending on the particular format of the container, different container's structures are used
to set this property, in the following preference order:
1. Local PAX extended header "path" property.
2. Global PAX extended header "path" property.
3. GNU format type "L" (LongName) entry.
4. Default TAR header.
- Note: When new TAR container is created, if `name` cannot be encoded with ASCII or its ASCII byte-representation
is longer than 100 bytes then a PAX extended header will be created to represent this value correctly.
- Note: When creating new TAR container, `name` is always encoded with UTF-8 in basic TAR header.
*/
public var name: String
/**
Entry's data size.
- Note: This property cannot be directly modified. Instead it is updated automatically to be equal to its parent
`entry.data.count`.
- Note: When new TAR container is created, if `size` is bigger than 8589934591 then a PAX extended header will be
created to represent this value correctly. Also, base-256 encoding will be used to encode this value in basic TAR
header.
*/
public internal(set) var size: Int?
public let type: ContainerEntryType
/**
Entry's last access time (only available for PAX format; `nil` otherwise).
- Note: When new TAR container is created, if `accessTime` is not `nil` then a PAX extended header will be created
to store this property.
*/
public var accessTime: Date?
/**
Entry's creation time (only available for PAX format; `nil` otherwise).
- Note: When new TAR container is created, if `creationTime` is not `nil` then a PAX extended header will be
created to store this property.
*/
public var creationTime: Date?
/**
Entry's last modification time.
- Note: When new TAR container is created, if `modificationTime` is bigger than 8589934591 then a PAX extended
header will be created to represent this value correctly. Also, base-256 encoding will be used to encode this value
in basic TAR header.
*/
public var modificationTime: Date?
public var permissions: Permissions?
// MARK: TAR specific
/// Entry's compression method. Always `.copy` for entries of TAR containers.
public let compressionMethod = CompressionMethod.copy
/**
ID of entry's owner.
- Note: When new TAR container is created, if `ownerID` is bigger than 2097151 then a PAX extended header will be
created to represent this value correctly. Also, base-256 encoding will be used to encode this value in basic TAR
header.
*/
public var ownerID: Int?
/**
ID of the group of entry's owner.
- Note: When new TAR container is created, if `groupID` is bigger than 2097151 then a PAX extended header will be
created to represent this value correctly. Also, base-256 encoding will be used to encode this value in basic TAR
header.
*/
public var groupID: Int?
/**
User name of entry's owner.
- Note: When new TAR container is created, if `ownerUserName` cannot be encoded with ASCII or its ASCII
byte-representation is longer than 32 bytes then a PAX extended header will be created to represent this value
correctly.
- Note: When creating new TAR container, `ownerUserName` is always encoded with UTF-8 in ustar header.
*/
public var ownerUserName: String?
/**
Name of the group of entry's owner.
- Note: When new TAR container is created, if `ownerGroupName` cannot be encoded with ASCII or its ASCII
byte-representation is longer than 32 bytes then a PAX extended header will be created to represent this value
correctly.
- Note: When creating new TAR container, `ownerGroupName` is always encoded with UTF-8 in ustar header.
*/
public var ownerGroupName: String?
/**
Device major number (used when entry is either block or character special file).
- Note: When new TAR container is created, if `deviceMajorNumber` is bigger than 2097151 then base-256 encoding
will be used to encode this value in ustar header.
*/
public var deviceMajorNumber: Int?
/**
Device minor number (used when entry is either block or character special file).
- Note: When new TAR container is created, if `deviceMajorNumber` is bigger than 2097151 then base-256 encoding
will be used to encode this value in ustar header.
*/
public var deviceMinorNumber: Int?
/**
Name of the character set used to encode entry's data (only available for PAX format; `nil` otherwise).
- Note: When new TAR container is created, if `charset` is not `nil` then a PAX extended header will be created to
store this property.
*/
public var charset: String?
/**
Entry's comment (only available for PAX format; `nil` otherwise).
- Note: When new TAR container is created, if `comment` is not `nil` then a PAX extended header will be created to
store this property.
*/
public var comment: String?
/**
Path to a linked file for symbolic link entry.
Depending on the particular format of the container, different container's structures are used
to set this property, in the following preference order:
1. Local PAX extended header "linkpath" property.
2. Global PAX extended header "linkpath" property.
3. GNU format type "K" (LongLink) entry.
4. Default TAR header.
- Note: When new TAR container is created, if `linkName` cannot be encoded with ASCII or its ASCII
byte-representation is longer than 100 bytes then a PAX extended header will be created to represent this value
correctly.
- Note: When creating new TAR container, `linkName` is always encoded with UTF-8 in basic TAR header.
*/
public var linkName: String
/**
All custom (unknown) records from global and local PAX extended headers. `nil`, if there were no headers.
- Note: When new TAR container is created, if `unknownExtendedHeaderRecords` is not `nil` then a *local* PAX
extended header will be created to store this property.
*/
public var unknownExtendedHeaderRecords: [String: String]?
var specialEntryType: SpecialEntryType?
let format: TarContainer.Format
let blockStartIndex: Int
/**
Initializes the entry's info with its name and type.
- Note: Entry's type cannot be modified after initialization.
- Parameter name: Entry's name.
- Parameter type: Entry's type.
*/
public init(name: String, type: ContainerEntryType) {
self.name = name
self.type = type
self.linkName = ""
// These properties are only used when entry is loaded from the container.
self.format = .pax
self.blockStartIndex = 0
}
init(_ byteReader: LittleEndianByteReader, _ global: TarExtendedHeader?, _ local: TarExtendedHeader?,
_ longName: String?, _ longLinkName: String?) throws {
self.blockStartIndex = byteReader.offset
// File name
var name = byteReader.tarCString(maxLength: 100)
// General notes for all the properties processing below:
// 1. There might be a corresponding field in either global or local extended PAX header.
// 2. We still need to read general TAR fields so we can't eliminate auxiliary local let-variables.
// 3. `tarInt` returning `nil` corresponds to either field being unused and filled with NULLs or non-UTF-8
// string describing number which means that either this field or container in general is corrupted.
// Corruption of the container should be detected by checksum comparison, so we decided to ignore them here;
// the alternative, which was used in previous versions, is to throw an error.
if let posixAttributes = byteReader.tarInt(maxLength: 8) {
// Sometimes file mode field also contains unix type, so we need to filter it out.
self.permissions = Permissions(rawValue: UInt32(truncatingIfNeeded: posixAttributes) & 0xFFF)
} else {
self.permissions = nil
}
let ownerAccountID = byteReader.tarInt(maxLength: 8)
self.ownerID = (local?.uid ?? global?.uid) ?? ownerAccountID
let groupAccountID = byteReader.tarInt(maxLength: 8)
self.groupID = (local?.gid ?? global?.gid) ?? groupAccountID
guard let fileSize = byteReader.tarInt(maxLength: 12)
else { throw TarError.wrongField }
self.size = (local?.size ?? global?.size) ?? fileSize
let mtime = byteReader.tarInt(maxLength: 12)
if let paxMtime = local?.mtime ?? global?.mtime {
self.modificationTime = Date(timeIntervalSince1970: paxMtime)
} else if let mtime = mtime {
self.modificationTime = Date(timeIntervalSince1970: TimeInterval(mtime))
}
// Checksum
guard let checksum = byteReader.tarInt(maxLength: 8)
else { throw TarError.wrongHeaderChecksum }
let currentIndex = byteReader.offset
byteReader.offset = blockStartIndex
var headerBytesForChecksum = byteReader.bytes(count: 512)
headerBytesForChecksum.replaceSubrange(148..<156, with: Array(repeating: 0x20, count: 8))
byteReader.offset = currentIndex
// Some implementations treat bytes as signed integers, but some don't.
// So we check both cases, equality in one of them will pass the checksum test.
let unsignedOurChecksum = headerBytesForChecksum.reduce(0 as UInt) { $0 + UInt(truncatingIfNeeded: $1) }
let signedOurChecksum = headerBytesForChecksum.reduce(0 as Int) { $0 + $1.toInt() }
guard unsignedOurChecksum == UInt(truncatingIfNeeded: checksum) || signedOurChecksum == checksum
else { throw TarError.wrongHeaderChecksum }
// File type
let fileTypeIndicator = byteReader.byte()
self.specialEntryType = SpecialEntryType(rawValue: fileTypeIndicator)
self.type = ContainerEntryType(fileTypeIndicator)
// Linked file name
let linkName = byteReader.tarCString(maxLength: 100)
// There are two different formats utilizing this section of TAR header: GNU format and POSIX (aka "ustar";
// PAX containers can also be considered as POSIX). They differ in the value of magic field as well as what
// comes after deviceMinorNumber field. While "ustar" format may contain prefix for file name, GNU format
// uses this place for storing atime/ctime and fields related to sparse-files. In practice, these fields are
// rarely used by GNU tar and only present if "incremental backups" options were used. Thus, GNU format TAR
// container can often be incorrectly considered as having prefix field containing only NULLs.
let magic = byteReader.uint64()
var gnuAtime: Int?
var gnuCtime: Int?
if magic == 0x0020207261747375 || magic == 0x3030007261747375 || magic == 0x3030207261747375 {
let uname = byteReader.tarCString(maxLength: 32)
self.ownerUserName = (local?.uname ?? global?.uname) ?? uname
let gname = byteReader.tarCString(maxLength: 32)
self.ownerGroupName = (local?.gname ?? global?.gname) ?? gname
self.deviceMajorNumber = byteReader.tarInt(maxLength: 8)
self.deviceMinorNumber = byteReader.tarInt(maxLength: 8)
if magic == 0x00_20_20_72_61_74_73_75 { // GNU format.
// GNU format mostly is identical to POSIX format and in the common situations can be considered as
// having prefix containing only NULLs. However, in the case of incremental backups produced by GNU tar
// this part of the TAR header is used for storing a lot of different properties. For now, we are only
// reading atime and ctime.
gnuAtime = byteReader.tarInt(maxLength: 12)
gnuCtime = byteReader.tarInt(maxLength: 12)
} else {
let prefix = byteReader.tarCString(maxLength: 155)
if prefix != "" {
if prefix.last == "/" {
name = prefix + name
} else {
name = prefix + "/" + name
}
}
}
} else {
self.ownerUserName = local?.uname ?? global?.uname
self.ownerGroupName = local?.gname ?? global?.gname
}
if local != nil || global != nil {
self.format = .pax
} else if magic == 0x0020207261747375 || longName != nil || longLinkName != nil {
self.format = .gnu
} else if magic == 0x3030007261747375 || magic == 0x3030207261747375 {
self.format = .ustar
} else {
self.format = .prePosix
}
// Set `name` and `linkName` to values from PAX or GNU format if possible.
self.name = ((local?.path ?? global?.path) ?? longName) ?? name
self.linkName = ((local?.linkpath ?? global?.linkpath) ?? longLinkName) ?? linkName
// Set additional properties from PAX extended headers.
if let atime = local?.atime ?? global?.atime {
self.accessTime = Date(timeIntervalSince1970: atime)
} else if let gnuAtime = gnuAtime {
self.accessTime = Date(timeIntervalSince1970: TimeInterval(gnuAtime))
}
if let ctime = local?.ctime ?? global?.ctime {
self.creationTime = Date(timeIntervalSince1970: ctime)
} else if let gnuCtime = gnuCtime {
self.creationTime = Date(timeIntervalSince1970: TimeInterval(gnuCtime))
}
self.charset = local?.charset ?? global?.charset
self.comment = local?.comment ?? global?.comment
if let localUnknownRecords = local?.unknownRecords {
if let globalUnknownRecords = global?.unknownRecords {
self.unknownExtendedHeaderRecords = globalUnknownRecords.merging(localUnknownRecords) { $1 }
} else {
self.unknownExtendedHeaderRecords = localUnknownRecords
}
} else {
self.unknownExtendedHeaderRecords = global?.unknownRecords
}
}
func generateContainerData() throws -> Data {
// It is not possible to encode non-english characters with ASCII (expectedly), so we are using UTF-8.
// While this contradicts format specification, in case of ustar and basic TAR format our other options in
// situation when it is not possible to encode with ASCII are:
// - crash with fatalError, etc.
// - throw an error.
// - ignore the problem, and just write NULLs.
// The last option is, obviously, is not ideal. Overall, it seems like using UTF-8 instead of ASCII is the most
// viable option.
var out = Data()
try out.append(tarString: self.name, maxLength: 100)
out.append(tarInt: self.permissions?.rawValue.toInt(), maxLength: 8)
out.append(tarInt: self.ownerID, maxLength: 8)
out.append(tarInt: self.groupID, maxLength: 8)
out.append(tarInt: self.size, maxLength: 12)
if let mtime = self.modificationTime?.timeIntervalSince1970 {
out.append(tarInt: Int(mtime), maxLength: 12)
} else {
out.append(tarInt: nil, maxLength: 12)
}
// Checksum is calculated based on the complete header with spaces instead of checksum.
out.append(contentsOf: Array(repeating: 0x20, count: 8))
let fileTypeIndicator = self.specialEntryType?.rawValue ?? self.type.fileTypeIndicator
out.append(fileTypeIndicator)
try out.append(tarString: self.linkName, maxLength: 100)
out.append(contentsOf: [0x75, 0x73, 0x74, 0x61, 0x72, 0x00, 0x30, 0x30]) // "ustar\000"
// In theory, user/group name is not guaranteed to have only ASCII characters, so the same disclaimer as for
// file name field applies here.
try out.append(tarString: self.ownerUserName, maxLength: 32)
try out.append(tarString: self.ownerGroupName, maxLength: 32)
out.append(tarInt: self.deviceMajorNumber, maxLength: 8)
out.append(tarInt: self.deviceMinorNumber, maxLength: 8)
guard let nameData = self.name.data(using: .utf8)
else { throw TarCreateError.utf8NonEncodable }
if nameData.count > 100 {
var maxPrefixLength = nameData.count
if maxPrefixLength > 156 {
// We can set actual maximum possible length of prefix equal to 156 and not 155, because it may
// include trailing slash which will be removed during splitting.
maxPrefixLength = 156
} else if nameData[maxPrefixLength - 1] == 0x2F {
// Skip trailing slash.
maxPrefixLength -= 1
}
// Looking for the last slash in the potential prefix. -1 if not found.
// It determines the end of the actual prefix and the beginning of the updated name field.
#if (swift(>=4.1.50) || (swift(>=3.4) && !swift(>=4.0))) || !os(Linux)
let lastPrefixSlashIndex = nameData.prefix(upTo: maxPrefixLength)
.range(of: Data([0x2f]), options: .backwards)?.lowerBound ?? -1
#else
// TODO: This is a workaround for runtime crash in `Data.prefix(upTo:).range(of:options:)` on Linux with
// Swift 4.1. It seems like it is fixed in 4.2 and master snapshots, so it will be removed when Swift
// 5.0 is released.
var lastPrefixSlashIndex = -1
for i in stride(from: maxPrefixLength - 1, through: 0, by: -1) {
if nameData[i] == 0x2f {
lastPrefixSlashIndex = i
break
}
}
#endif
let updatedNameLength = nameData.count - lastPrefixSlashIndex - 1
let prefixLength = lastPrefixSlashIndex
if lastPrefixSlashIndex <= 0 || updatedNameLength > 100 || updatedNameLength == 0 || prefixLength > 155 {
// Unsplittable name.
out.append(Data(count: 155))
} else {
// Add prefix data to output.
out.append(nameData.prefix(upTo: lastPrefixSlashIndex))
// Update name field data in output.
var newNameData = nameData.suffix(from: lastPrefixSlashIndex + 1)
newNameData.append(Data(count: 100 - newNameData.count))
out.replaceSubrange(0..<100, with: newNameData)
}
} else {
out.append(Data(count: 155)) // Empty prefix
}
// Checksum calculation.
// First, we pad header data to 512 bytes.
out.append(Data(count: 512 - out.count))
let checksum = out.reduce(0 as Int) { $0 + $1.toInt() }
let checksumString = String(format: "%06o", checksum).appending("\0 ")
out.replaceSubrange(148..<156, with: checksumString.data(using: .ascii)!)
return out
}
}
fileprivate extension Data {
/// This should work in the same way as `String.padding(toLength: length, withPad: "\0", startingAt: 0)`.
@inline(__always)
private func zeroPad(_ length: Int) -> Data {
var out = length < self.count ? self.prefix(upTo: length) : self
out.append(Data(count: length - out.count))
return out
}
mutating func append(tarInt value: Int?, maxLength: Int) {
guard var value = value else {
// No value; fill field with NULLs.
self.append(Data(count: maxLength))
return
}
let maxOctalValue = (1 << (maxLength * 3)) - 1
guard value > maxOctalValue || value < 0 else {
// Normal octal encoding.
self.append(String(value, radix: 8).data(using: .utf8)!.zeroPad(maxLength))
return
}
// Base-256 encoding.
var buffer = Array(repeating: 0 as UInt8, count: maxLength)
for i in stride(from: maxLength - 1, to: 0, by: -1) {
buffer[i] = UInt8(truncatingIfNeeded: value & 0xFF)
value >>= 8
}
buffer[0] |= 0x80 // Highest bit indicates base-256 encoding.
self.append(Data(buffer))
}
mutating func append(tarString string: String?, maxLength: Int) throws {
guard let string = string else {
// No value; fill field with NULLs.
self.append(Data(count: maxLength))
return
}
guard let stringData = string.data(using: .utf8)?.zeroPad(maxLength)
else { throw TarCreateError.utf8NonEncodable }
self.append(stringData)
}
}