Skip to content

tar: Add support for archiving directories #74

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 123 additions & 19 deletions Sources/Tar/tar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ import struct Foundation.Data
// relatively straightforward; reading an arbitrary tar file is more
// complicated because the reader must be prepared to handle all variants.

// Tar archives consist of 512-byte blocks, either containing member headers
// or file data. Blocks shorter than 512 bytes are padded with zeros.
let blockSize = 512

/// Returns the number of padding bytes to be appended to a file.
/// Each file in a tar archive must be padded to a multiple of the 512 byte block size.
/// - Parameter len: The length of the archive member.
/// - Returns: The number of zero bytes to append as padding.
func padding(_ len: Int) -> Int {
(blockSize - len % blockSize) % blockSize
}

enum TarError: Error, Equatable {
case invalidName(String)
}
Expand Down Expand Up @@ -134,7 +146,7 @@ func checksum(header: [UInt8]) -> Int {
// The checksum calculation can't overflow (maximum possible value 776) so we can use
// unchecked arithmetic.

precondition(header.count == 512)
precondition(header.count == blockSize)
return header.reduce(0) { $0 &+ Int($1) }
}

Expand Down Expand Up @@ -182,7 +194,7 @@ public enum MemberType: String {

// maybe limited string, octal6 and octal11 should be separate types

/// Represents a single tar archive member
/// Represents a single tar archive member header
public struct TarHeader {
/// Member file name when unpacked
var name: String
Expand Down Expand Up @@ -232,7 +244,7 @@ public struct TarHeader {
/// Filename prefix - prepended to name
var prefix: String = ""

init(
public init(
name: String,
mode: Int = 0o555,
uid: Int = 0,
Expand Down Expand Up @@ -273,10 +285,7 @@ public struct TarHeader {
}

extension TarHeader {
/// Creates a tar header for a single file
/// - Parameters:
/// - hdr: The header structure of the file
/// - Returns: A tar header representing the file
/// The serialized byte representation of the header.
var bytes: [UInt8] {
// A file entry consists of a file header followed by the
// contents of the file. The header includes information such as
Expand All @@ -285,7 +294,7 @@ extension TarHeader {
//
// The file data is padded with nulls to a multiple of 512 bytes.

var bytes = [UInt8](repeating: 0, count: 512)
var bytes = [UInt8](repeating: 0, count: blockSize)

// Construct a POSIX ustar header for the file
bytes.writeString(self.name, inField: Field.name, withTermination: .null)
Expand All @@ -312,16 +321,6 @@ extension TarHeader {
}
}

let blockSize = 512

/// Returns the number of padding bytes to be appended to a file.
/// Each file in a tar archive must be padded to a multiple of the 512 byte block size.
/// - Parameter len: The length of the archive member.
/// - Returns: The number of zero bytes to append as padding.
func padding(_ len: Int) -> Int {
(blockSize - len % blockSize) % blockSize
}

/// Creates a tar archive containing a single file
/// - Parameters:
/// - bytes: The file's body data
Expand All @@ -339,7 +338,7 @@ public func tar(_ bytes: [UInt8], filename: String = "app") throws -> [UInt8] {
archive.append(contentsOf: padding)

// Append the end of file marker
let marker = [UInt8](repeating: 0, count: 2 * 512)
let marker = [UInt8](repeating: 0, count: 2 * blockSize)
archive.append(contentsOf: marker)
return archive
}
Expand All @@ -353,3 +352,108 @@ public func tar(_ bytes: [UInt8], filename: String = "app") throws -> [UInt8] {
public func tar(_ data: Data, filename: String) throws -> [UInt8] {
try tar([UInt8](data), filename: filename)
}

/// Represents a tar archive
public struct Archive {
/// The files, directories and other members of the archive
var members: [ArchiveMember]

/// Creates an empty Archive
public init() {
members = []
}

/// Appends a member to the archive
/// Parameters:
/// - member: The member to append
public mutating func append(_ member: ArchiveMember) {
self.members.append(member)
}

/// Returns a new archive made by appending a member to the receiver
/// Parameters:
/// - member: The member to append
/// Returns: A new archive made by appending `member` to the receiver.
public func appending(_ member: ArchiveMember) -> Self {
var ret = self
ret.members += [member]
return ret
}

/// The serialized byte representation of the archive, including padding and end-of-archive marker.
public var bytes: [UInt8] {
var ret: [UInt8] = []
for member in members {
ret.append(contentsOf: member.bytes)
}

// Append the end of file marker
let marker = [UInt8](repeating: 0, count: 2 * blockSize)
ret.append(contentsOf: marker)

return ret
}
}

/// Represents a member of a tar archive
public struct ArchiveMember {
/// Member header containing metadata about the member
var header: TarHeader

/// File content
var contents: [UInt8]

/// Creates a new ArchiveMember
/// Parameters:
/// - header: Member header containing metadata about the member
/// - data: File content
public init(
header: TarHeader,
data: [UInt8] = []
) {
self.header = header
self.contents = data
}

/// The serialized byte representation of the member, including padding.
public var bytes: [UInt8] {
let padding = [UInt8](repeating: 0, count: padding(contents.count))
return header.bytes + self.contents + padding
}
}

extension Archive {
/// Adds a new file member at the end of the archive
/// parameters:
/// - name: File name
/// - prefix: Path prefix
/// - data: File contents
public mutating func appendFile(name: String, prefix: String = "", data: [UInt8]) throws {
try append(.init(header: .init(name: name, size: data.count, prefix: prefix), data: data))
}

/// Adds a new file member at the end of the archive
/// parameters:
/// - name: File name
/// - prefix: Path prefix
/// - data: File contents
public func appendingFile(name: String, prefix: String = "", data: [UInt8]) throws -> Self {
try appending(.init(header: .init(name: name, size: data.count, prefix: prefix), data: data))
}

/// Adds a new directory member at the end of the archive
/// parameters:
/// - name: Directory name
/// - prefix: Path prefix
public mutating func appendDirectory(name: String, prefix: String = "") throws {
try append(.init(header: .init(name: name, typeflag: .DIRTYPE, prefix: prefix)))
}

/// Adds a new directory member at the end of the archive
/// parameters:
/// - name: Directory name
/// - prefix: Path prefix
public func appendingDirectory(name: String, prefix: String = "") throws -> Self {
try self.appending(.init(header: .init(name: name, typeflag: .DIRTYPE, prefix: prefix)))
}
}
10 changes: 6 additions & 4 deletions Tests/TarTests/TarInteropTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
//
//===----------------------------------------------------------------------===//

import Foundation
import class Foundation.Pipe
import class Foundation.Process

import Testing
@testable import Tar

Expand Down Expand Up @@ -43,7 +45,7 @@ import Testing
@Test func testSingle4BFile() async throws {
let data = "test"
let result = try tar([UInt8](data.utf8), filename: "filename")
#expect(result.count == headerLen + blocksize + trailerLen)
#expect(result.count == headerSize + blockSize + trailerSize)

let output = try await tarListContents(result)
#expect(output == "-r-xr-xr-x 0 0 0 4 Jan 1 1970 filename")
Expand All @@ -60,7 +62,7 @@ import Testing

let data = ""
let result = try tar([UInt8](data.utf8), filename: "filename")
#expect(result.count == headerLen + trailerLen)
#expect(result.count == headerSize + trailerSize)

let output = try await tarListContents(result)
#expect(output == "-r-xr-xr-x 0 0 0 0 Jan 1 1970 filename")
Expand All @@ -72,7 +74,7 @@ import Testing
hdr.append(contentsOf: try TarHeader(name: "filename1", size: 0).bytes)

// No file data, no padding, no end of file marker
#expect(hdr.count == headerLen)
#expect(hdr.count == headerSize)

// bsdtar tolerates the lack of end of file marker
let output = try await tarListContents(hdr)
Expand Down
Loading