mirror of
https://github.com/tsolomko/SWCompression.git
synced 2026-06-23 14:56:41 +00:00
301 lines
13 KiB
Swift
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
|
|
}
|
|
|
|
}
|