Files
2026-01-31 15:30:24 +08:00

301 lines
13 KiB
Swift

// Copyright (c) 2026 Timofey Solomko
// Licensed under MIT License
//
// See LICENSE for license information
import Foundation
import BitByteData
/// This type represents the low-level header structure of the TAR format.
struct TarHeader {
enum HeaderEntryType {
case normal(ContainerEntryType)
case special(SpecialEntryType)
}
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
}
// These fields are present in all formats.
let name: String
let permissions: Permissions?
let uid: Int?
let gid: Int?
let size: Int
private(set) var mtime: Date?
// - checksum
let type: HeaderEntryType
let linkName: String
// Ustar only
// - magic "ustar\000"
private(set) var uname: String?
private(set) var gname: String?
private(set) var deviceMajorNumber: Int?
private(set) var deviceMinorNumber: Int?
private(set) var prefix: String?
// These fields are present in gnu and star formats.
// - magic ("ustar \0" for [old] gnu)
private(set) var atime: Date?
private(set) var ctime: Date?
let format: TarContainer.Format
let blockStartIndex: Int
init(_ reader: LittleEndianByteReader) throws {
self.blockStartIndex = reader.offset
self.name = reader.tarCString(maxLength: 100)
if let posixAttributes = reader.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
}
self.uid = reader.tarInt(maxLength: 8)
self.gid = reader.tarInt(maxLength: 8)
guard let size = reader.tarInt(maxLength: 12)
else { throw TarError.wrongField }
self.size = size
if let mtime = reader.tarInt(maxLength: 12) {
self.mtime = Date(timeIntervalSince1970: TimeInterval(mtime))
}
// Checksum
guard let checksum = reader.tarInt(maxLength: 8)
else { throw TarError.wrongHeaderChecksum }
let currentIndex = reader.offset
reader.offset = blockStartIndex
var headerBytesForChecksum = reader.bytes(count: 512)
headerBytesForChecksum.replaceSubrange(148..<156, with: Array(repeating: 0x20, count: 8))
reader.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 }
let fileTypeIndicator = reader.byte()
if let specialEntryType = SpecialEntryType(rawValue: fileTypeIndicator) {
self.type = .special(specialEntryType)
} else {
self.type = .normal(ContainerEntryType(fileTypeIndicator))
}
self.linkName = reader.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 = reader.uint64()
if magic == 0x0020207261747375 || magic == 0x3030007261747375 || magic == 0x3030207261747375 {
self.uname = reader.tarCString(maxLength: 32)
self.gname = reader.tarCString(maxLength: 32)
self.deviceMajorNumber = reader.tarInt(maxLength: 8)
self.deviceMinorNumber = reader.tarInt(maxLength: 8)
if magic == 0x00_20_20_72_61_74_73_75 { // GNU format.
// GNU format is mostly 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.
if let atime = reader.tarInt(maxLength: 12) {
self.atime = Date(timeIntervalSince1970: TimeInterval(atime))
}
if let ctime = reader.tarInt(maxLength: 12) {
self.ctime = Date(timeIntervalSince1970: TimeInterval(ctime))
}
self.format = .gnu
} else {
self.prefix = reader.tarCString(maxLength: 155)
self.format = .ustar
}
} else {
self.format = .prePosix
}
}
init(specialName: String, specialType: SpecialEntryType, size: Int, uid: Int?, gid: Int?) {
self.name = specialName
self.type = .special(specialType)
self.size = size
self.permissions = Permissions(rawValue: 420)
self.uid = uid
self.gid = gid
self.mtime = Date()
self.linkName = ""
if specialType == .longName || specialType == .longLinkName {
self.format = .gnu
} else if specialType == .globalExtendedHeader || specialType == .localExtendedHeader {
self.format = .pax
} else {
self.format = .prePosix
}
// Unused if header was created using this initializer.
self.blockStartIndex = -1
}
init(_ info: TarEntryInfo) {
self.name = info.name
self.type = .normal(info.type)
self.size = info.size ?? 0
self.atime = info.accessTime
self.ctime = info.creationTime
self.mtime = info.modificationTime
self.permissions = info.permissions
self.uid = info.ownerID
self.gid = info.groupID
self.uname = info.ownerUserName
self.gname = info.ownerGroupName
self.deviceMajorNumber = info.deviceMajorNumber
self.deviceMinorNumber = info.deviceMinorNumber
self.linkName = info.linkName
self.format = .pax
// Unused if header was created using this initializer.
self.blockStartIndex = -1
}
/// This function overrides the value stored in the `format` property.
func generateContainerData(_ format: TarContainer.Format) -> 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, not ideal. Overall, it seems like using UTF-8 instead of ASCII is the most
// viable option.
var out = Data()
out.append(tarString: self.name, maxLength: 100)
out.append(tarInt: self.permissions?.rawValue.toInt(), maxLength: 8)
out.append(tarInt: self.uid, maxLength: 8)
out.append(tarInt: self.gid, maxLength: 8)
out.append(tarInt: self.size, maxLength: 12)
if let mtime = self.mtime?.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: UInt8
switch self.type {
case .normal(let entryType):
fileTypeIndicator = entryType.fileTypeIndicator
case .special(let specialType):
fileTypeIndicator = specialType.rawValue
}
out.append(fileTypeIndicator)
out.append(tarString: self.linkName, maxLength: 100)
// For prePosix format there is no additional fields.
// Magic
if format == .ustar || format == .pax {
out.append(contentsOf: [0x75, 0x73, 0x74, 0x61, 0x72, 0x00, 0x30, 0x30]) // "ustar\000"
} else if format == .gnu {
out.append(contentsOf: [0x75, 0x73, 0x74, 0x61, 0x72, 0x20, 0x20, 0x00]) // "ustar \0"
}
if format != .prePosix {
// Check in case other formats are added in the future.
assert(format == .ustar || format == .gnu || format == .pax)
// Both ustar, pax, and gnu formats contain the following four fields.
// In theory, user/group name is not guaranteed to have only ASCII characters, so the same disclaimer as for
// file name field applies here.
out.append(tarString: self.uname, maxLength: 32)
out.append(tarString: self.gname, maxLength: 32)
out.append(tarInt: self.deviceMajorNumber, maxLength: 8)
out.append(tarInt: self.deviceMinorNumber, maxLength: 8)
// ustar and pax formats contain prefix field.
if format == .ustar || format == .pax {
// Splitting the name property into the name and prefix fields.
let nameData = Data(self.name.utf8)
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.
// This way of finding the last slash works, since there is no other Unicode character that contains
// the 0x2F byte when encoded in UTF-8.
let lastPrefixSlashIndex = nameData.prefix(upTo: maxPrefixLength)
.range(of: Data([0x2f]), options: .backwards)?.lowerBound ?? -1
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 if format == .gnu {
// Gnu format contains atime and ctime instead of a prefix field.
if let atime = self.atime?.timeIntervalSince1970 {
out.append(tarInt: Int(atime), maxLength: 12)
} else {
out.append(tarInt: nil, maxLength: 12)
}
if let ctime = self.ctime?.timeIntervalSince1970 {
out.append(tarInt: Int(ctime), maxLength: 12)
} else {
out.append(tarInt: nil, maxLength: 12)
}
}
}
// 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)!)
assert(out.count == 512)
return out
}
}