mirror of
https://github.com/GayPizzaSpecifications/darwin-apk.git
synced 2025-08-03 21:41:31 +00:00
Initial implementation of APKINDEX, fetching, reading, parsing, & merging
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@ -3,7 +3,6 @@
|
||||
/Packages
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/configuration/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.swiftpm/
|
||||
.netrc
|
||||
Package.resolved
|
||||
|
@ -3,13 +3,24 @@ import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "darwin-apk",
|
||||
platforms: [
|
||||
.macOS(.v13),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.0"),
|
||||
.package(url: "https://github.com/tsolomko/SWCompression", from: "4.8.6"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "darwin-apk",
|
||||
dependencies: [
|
||||
.product(name: "SWCompression", package: "SWCompression"),
|
||||
],
|
||||
path: "Sources/apk"),
|
||||
.executableTarget(
|
||||
name: "dpk",
|
||||
dependencies: [
|
||||
"darwin-apk",
|
||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||
],
|
||||
path: "Sources/dpk-cli"
|
||||
|
31
Sources/apk/Index/ApkIndex.swift
Normal file
31
Sources/apk/Index/ApkIndex.swift
Normal file
@ -0,0 +1,31 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
struct ApkIndex {
|
||||
let packages: [ApkIndexPackage]
|
||||
}
|
||||
|
||||
extension ApkIndex {
|
||||
func first(name: String) -> ApkIndexPackage? {
|
||||
self.packages.first {
|
||||
$0.name == name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ApkIndex {
|
||||
static func merge<S: Sequence>(_ tables: S) -> Self where S.Element == Self {
|
||||
Self.init(packages: tables.flatMap(\.packages))
|
||||
}
|
||||
|
||||
static func merge(_ tables: Self...) -> ApkIndex {
|
||||
Self.init(packages: tables.flatMap(\.packages))
|
||||
}
|
||||
}
|
||||
|
||||
extension ApkIndex {
|
||||
init(raw: ApkRawIndex) throws {
|
||||
self.packages = try raw.packages.map {
|
||||
try ApkIndexPackage(raw: $0)
|
||||
}
|
||||
}
|
||||
}
|
26
Sources/apk/Index/ApkIndexDownloader.swift
Normal file
26
Sources/apk/Index/ApkIndexDownloader.swift
Normal file
@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import Foundation
|
||||
|
||||
struct ApkIndexDownloader {
|
||||
func downloadFile(remote remoteURL: URL, destination destLocalURL: URL) {
|
||||
let sem = DispatchSemaphore.init(value: 0)
|
||||
let downloadTask = URLSession.shared.downloadTask(with: remoteURL) { url, response, error in
|
||||
if let localURL = url {
|
||||
do {
|
||||
// Replace existing APKINDEX.tar.gz files
|
||||
if FileManager.default.fileExists(atPath: destLocalURL.path()) {
|
||||
try FileManager.default.removeItem(at: destLocalURL)
|
||||
}
|
||||
// Move temporary to the new location
|
||||
try FileManager.default.moveItem(at: localURL, to: destLocalURL)
|
||||
} catch {
|
||||
print("Download error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
sem.signal()
|
||||
}
|
||||
downloadTask.resume()
|
||||
sem.wait()
|
||||
}
|
||||
}
|
164
Sources/apk/Index/ApkIndexPackage.swift
Normal file
164
Sources/apk/Index/ApkIndexPackage.swift
Normal file
@ -0,0 +1,164 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import Foundation
|
||||
|
||||
struct ApkIndexPackage {
|
||||
let indexChecksum: String //TODO: Decode cus why not
|
||||
let name: String
|
||||
let version: String
|
||||
let architecture: String?
|
||||
let packageSize: UInt64
|
||||
let installedSize: UInt64
|
||||
let packageDescription: String
|
||||
let url: String
|
||||
let license: String
|
||||
let origin: String?
|
||||
let maintainer: String?
|
||||
let buildTime: Date?
|
||||
let commit: String?
|
||||
let providerPriority: UInt16?
|
||||
let dependencies: [String] //TODO: stuff
|
||||
let provides: [String] //TODO: stuff
|
||||
let installIf: [String] //TODO: stuff
|
||||
|
||||
var downloadFilename: String { "\(self.name)-\(version).apk" }
|
||||
|
||||
//TODO: Implementation
|
||||
//lazy var semanticVersion: (Int, Int, Int) = (0, 0, 0)
|
||||
}
|
||||
|
||||
extension ApkIndexPackage {
|
||||
init(raw rawEntry: ApkRawIndexEntry) throws(Self.ParseError) {
|
||||
// Required fields
|
||||
var indexChecksum: String? = nil
|
||||
var name: String? = nil
|
||||
var version: String? = nil
|
||||
var description: String? = nil
|
||||
var url: String? = nil
|
||||
var license: String? = nil
|
||||
var packageSize: UInt64? = nil
|
||||
var installedSize: UInt64? = nil
|
||||
|
||||
var dependencies = [String]()
|
||||
var provides = [String]()
|
||||
var installIf = [String]()
|
||||
|
||||
// Optional fields
|
||||
var architecture: String? = nil
|
||||
var origin: String? = nil
|
||||
var maintainer: String? = nil
|
||||
var buildTime: Date? = nil
|
||||
var commit: String? = nil
|
||||
var providerPriority: UInt16? = nil
|
||||
|
||||
// Read all the raw records for this entry
|
||||
for record in rawEntry.fields {
|
||||
switch record.key {
|
||||
case "P":
|
||||
name = record.value
|
||||
case "V":
|
||||
version = record.value
|
||||
case "T":
|
||||
description = record.value
|
||||
case "U":
|
||||
url = record.value
|
||||
case "L":
|
||||
license = record.value
|
||||
case "A":
|
||||
architecture = record.value
|
||||
case "D":
|
||||
dependencies = record.value.components(separatedBy: " ")
|
||||
case "C":
|
||||
indexChecksum = record.value // base64-encoded SHA1 hash prefixed with "Q1"
|
||||
case "S":
|
||||
guard let value = UInt64(record.value, radix: 10) else {
|
||||
throw .badValue(key: record.key)
|
||||
}
|
||||
packageSize = value
|
||||
case "I":
|
||||
guard let value = UInt64(record.value, radix: 10) else {
|
||||
throw .badValue(key: record.key)
|
||||
}
|
||||
installedSize = value
|
||||
case "p":
|
||||
provides = record.value.components(separatedBy: " ")
|
||||
case "i":
|
||||
installIf = record.value.components(separatedBy: " ")
|
||||
case "o":
|
||||
origin = record.value
|
||||
case "m":
|
||||
maintainer = record.value
|
||||
case "t":
|
||||
guard let timet = UInt64(record.value, radix: 10),
|
||||
let timetInterval = TimeInterval(exactly: timet) else {
|
||||
throw .badValue(key: record.key)
|
||||
}
|
||||
buildTime = Date(timeIntervalSince1970: timetInterval)
|
||||
case "c":
|
||||
commit = record.value
|
||||
case "k":
|
||||
guard let value = UInt64(record.value, radix: 10),
|
||||
(0..<UInt64(UInt16.max)).contains(value) else {
|
||||
throw .badValue(key: record.key)
|
||||
}
|
||||
providerPriority = UInt16(truncatingIfNeeded: value)
|
||||
case "F", "M", "R", "Z", "r", "q", "a", "s", "f":
|
||||
break // installed db entries
|
||||
default:
|
||||
// Safe to ignore
|
||||
guard record.key.isLowercase else {
|
||||
throw .badValue(key: record.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.indexChecksum = try indexChecksum.unwrap(or: Self.ParseError.required(key: "C"))
|
||||
self.name = try name.unwrap(or: Self.ParseError.required(key: "P"))
|
||||
self.version = try version.unwrap(or: Self.ParseError.required(key: "V"))
|
||||
self.packageDescription = try description.unwrap(or: Self.ParseError.required(key: "T"))
|
||||
self.url = try url.unwrap(or: Self.ParseError.required(key: "U"))
|
||||
self.license = try license.unwrap(or: Self.ParseError.required(key: "L"))
|
||||
self.packageSize = try packageSize.unwrap(or: Self.ParseError.required(key: "S"))
|
||||
self.installedSize = try installedSize.unwrap(or: Self.ParseError.required(key: "I"))
|
||||
|
||||
self.architecture = architecture
|
||||
self.origin = origin
|
||||
self.maintainer = maintainer
|
||||
self.buildTime = buildTime
|
||||
self.commit = commit
|
||||
self.providerPriority = providerPriority
|
||||
|
||||
self.dependencies = dependencies
|
||||
self.provides = provides
|
||||
self.installIf = installIf
|
||||
}
|
||||
|
||||
public enum ParseError: Error, LocalizedError {
|
||||
case badValue(key: Character)
|
||||
case unexpectedKey(key: Character)
|
||||
case required(key: Character)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .badValue(let key): "Bad value for key \"\(key)\""
|
||||
case .unexpectedKey(let key): "Unexpected key \"\(key)\""
|
||||
case .required(let key): "Missing required key \"\(key)\""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ApkIndexPackage: CustomStringConvertible {
|
||||
var description: String { "pkg(\(self.name))" }
|
||||
}
|
||||
|
||||
fileprivate extension Optional {
|
||||
func unwrap<E: Error>(or error: @autoclosure () -> E) throws(E) -> Wrapped {
|
||||
switch self {
|
||||
case .some(let v):
|
||||
return v
|
||||
case .none:
|
||||
throw error()
|
||||
}
|
||||
}
|
||||
}
|
100
Sources/apk/Index/ApkIndexUpdate.swift
Normal file
100
Sources/apk/Index/ApkIndexUpdate.swift
Normal file
@ -0,0 +1,100 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import Foundation
|
||||
import SWCompression
|
||||
import CryptoKit
|
||||
|
||||
public struct ApkIndexUpdater {
|
||||
var repositories: [String]
|
||||
var architectures: [String]
|
||||
|
||||
public init() {
|
||||
self.repositories = [
|
||||
"https://dl-cdn.alpinelinux.org/alpine/v3.21/main",
|
||||
"https://dl-cdn.alpinelinux.org/alpine/edge/community"
|
||||
]
|
||||
// other archs: "armhf", "armv7", "loongarch64", "ppc64le", "riscv64", "s390x", "x86"
|
||||
self.architectures = [ "aarch64", "x86_64" ]
|
||||
}
|
||||
|
||||
public func update() {
|
||||
let repositories = self.repositories.flatMap { repo in
|
||||
self.architectures.map { arch in
|
||||
Repository(name: repo, arch: arch)
|
||||
}
|
||||
}
|
||||
|
||||
let downloader = ApkIndexDownloader()
|
||||
for repo in repositories {
|
||||
let localIndex = URL(filePath: repo.localName)
|
||||
#if false
|
||||
let shouldDownload = true
|
||||
#else
|
||||
let shouldDownload = !FileManager.default.fileExists(atPath: localIndex.path())
|
||||
#endif
|
||||
if shouldDownload {
|
||||
print("Fetching index for \"\(repo.name)\"")
|
||||
downloader.downloadFile(remote: repo.url, destination: localIndex)
|
||||
}
|
||||
}
|
||||
|
||||
let index: ApkIndex
|
||||
do {
|
||||
let tables = try repositories.map { try readIndex(URL(filePath: $0.localName)) }
|
||||
index = ApkIndex.merge(tables)
|
||||
} catch {
|
||||
fatalError(error.localizedDescription)
|
||||
}
|
||||
|
||||
for package in index.packages {
|
||||
print(package)
|
||||
}
|
||||
}
|
||||
|
||||
private func readIndex(_ indexURL: URL) throws -> ApkIndex {
|
||||
let tarSignature: [TarReader.Entry]
|
||||
let tarRecords: [TarReader.Entry]
|
||||
|
||||
let tars = try GzipArchive.multiUnarchive( // Slow...
|
||||
archive: Data(contentsOf: indexURL))
|
||||
assert(tars.count >= 2)
|
||||
|
||||
var signatureStream = MemoryInputStream(buffer: tars[0].data)
|
||||
tarSignature = try TarReader.read(&signatureStream)
|
||||
var recordsStream = MemoryInputStream(buffer: tars[1].data)
|
||||
tarRecords = try TarReader.read(&recordsStream)
|
||||
|
||||
guard case .file(let signatureName, _) = tarSignature.first
|
||||
else { fatalError("Missing signature") }
|
||||
print(signatureName)
|
||||
guard let apkIndexFile = tarRecords.firstFile(name: "APKINDEX")
|
||||
else { fatalError("APKINDEX missing") }
|
||||
guard let description = tarRecords.firstFile(name: "DESCRIPTION")
|
||||
else { fatalError("DESCRIPTION missing") }
|
||||
|
||||
let reader = TextInputStream(binaryStream: MemoryInputStream(buffer: apkIndexFile))
|
||||
return try ApkIndex(raw:
|
||||
try ApkRawIndex(lines: reader.lines))
|
||||
}
|
||||
}
|
||||
|
||||
extension ApkIndexUpdater {
|
||||
struct Repository {
|
||||
let name: String
|
||||
let arch: String
|
||||
let discriminator: String
|
||||
|
||||
private static func resolveApkIndex(_ repo: String, _ arch: String)
|
||||
-> String { "\(repo)/\(arch)/APKINDEX.tar.gz" }
|
||||
var url: URL { URL(string: Self.resolveApkIndex(self.name, self.arch))! }
|
||||
var localName: String { "APKINDEX.\(discriminator).tar.gz" }
|
||||
|
||||
init(name repo: String, arch: String) {
|
||||
self.name = repo
|
||||
self.arch = arch
|
||||
|
||||
let urlSHA1Digest = Data(Insecure.SHA1.hash(data: Data(Self.resolveApkIndex(repo, arch).utf8)))
|
||||
self.discriminator = urlSHA1Digest.subdata(in: 0..<3).map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
}
|
77
Sources/apk/Index/ApkRawIndex.swift
Normal file
77
Sources/apk/Index/ApkRawIndex.swift
Normal file
@ -0,0 +1,77 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import Foundation
|
||||
|
||||
struct ApkRawIndex {
|
||||
let packages: [ApkRawIndexEntry]
|
||||
|
||||
init(lines: any Sequence<String>) throws {
|
||||
var packages = [ApkRawIndexEntry]()
|
||||
|
||||
var recordLines = [String]()
|
||||
recordLines.reserveCapacity(15)
|
||||
|
||||
for line in lines {
|
||||
if line.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
if !recordLines.isEmpty {
|
||||
packages.append(try .init(parsingEntryLines: recordLines))
|
||||
recordLines.removeAll(keepingCapacity: true)
|
||||
}
|
||||
} else {
|
||||
recordLines.append(line)
|
||||
}
|
||||
}
|
||||
if !recordLines.isEmpty {
|
||||
packages.append(try .init(parsingEntryLines: recordLines))
|
||||
}
|
||||
|
||||
self.packages = packages
|
||||
}
|
||||
}
|
||||
|
||||
struct ApkRawIndexEntry {
|
||||
let fields: [Record]
|
||||
|
||||
struct Record {
|
||||
let key: Character
|
||||
let value: String
|
||||
}
|
||||
}
|
||||
|
||||
extension ApkRawIndexEntry {
|
||||
init(parsingEntryLines lines: any Sequence<String>) throws {
|
||||
self.fields = try lines.map { line in
|
||||
guard let splitIdx = line.firstIndex(of: ":"),
|
||||
line.distance(from: line.startIndex, to: splitIdx) == 1 else {
|
||||
throw ApkRawIndexError.badPair
|
||||
}
|
||||
return Record(
|
||||
key: line.first!,
|
||||
value: String(line[line.index(after: splitIdx)...]))
|
||||
}
|
||||
}
|
||||
|
||||
func toMap() -> [Character: String] {
|
||||
Dictionary(uniqueKeysWithValues: self.fields.map { $0.pair })
|
||||
}
|
||||
|
||||
func lookup(_ key: Character) -> String? {
|
||||
fields.first(where: { $0.key == key })?.value
|
||||
}
|
||||
}
|
||||
|
||||
extension ApkRawIndexEntry.Record {
|
||||
var pair: (Character, String) {
|
||||
(self.key, self.value)
|
||||
}
|
||||
}
|
||||
|
||||
enum ApkRawIndexError: Error, LocalizedError {
|
||||
case badPair
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .badPair: "Malformed raw key-value pair"
|
||||
}
|
||||
}
|
||||
}
|
72
Sources/apk/Utility/FileInputStream.swift
Normal file
72
Sources/apk/Utility/FileInputStream.swift
Normal file
@ -0,0 +1,72 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct FileInputStream: InputStream {
|
||||
private var _hnd: FileHandle
|
||||
|
||||
public init(_ fileURL: URL) throws {
|
||||
self._hnd = try FileHandle(forReadingFrom: fileURL)
|
||||
}
|
||||
|
||||
public mutating func seek(_ whence: StreamWhence) throws(StreamError) {
|
||||
let applyOffset = { (position: UInt64, offset: Int) throws(StreamError) -> UInt64 in
|
||||
if offset < 0 {
|
||||
let (newPosition, overflow) = position.subtractingReportingOverflow(UInt64(-offset))
|
||||
if overflow { throw .seekRange }
|
||||
return newPosition
|
||||
} else {
|
||||
let (newPosition, overflow) = position.addingReportingOverflow(UInt64(offset))
|
||||
if overflow { throw .overflow }
|
||||
return newPosition
|
||||
}
|
||||
}
|
||||
|
||||
switch whence {
|
||||
case .set(let position):
|
||||
if position < 0 { throw .seekRange }
|
||||
do { try self._hnd.seek(toOffset: UInt64(truncatingIfNeeded: position)) }
|
||||
catch {
|
||||
throw .fileHandleError(error)
|
||||
}
|
||||
case .current(let offset):
|
||||
do { try self._hnd.seek(toOffset: try applyOffset(try self._hnd.offset(), offset)) }
|
||||
catch {
|
||||
if error is StreamError {
|
||||
throw error as! StreamError
|
||||
} else {
|
||||
throw .fileHandleError(error)
|
||||
}
|
||||
}
|
||||
case .end(let offset):
|
||||
do { try self._hnd.seek(toOffset: applyOffset(try self._hnd.seekToEnd(), offset)) }
|
||||
catch {
|
||||
if error is StreamError {
|
||||
throw error as! StreamError
|
||||
} else {
|
||||
throw .fileHandleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var tell: Int {
|
||||
get throws(StreamError) {
|
||||
let offset: UInt64
|
||||
do { offset = try self._hnd.offset() }
|
||||
catch {
|
||||
throw .fileHandleError(error)
|
||||
}
|
||||
if offset > Int.max { throw .overflow }
|
||||
return Int(truncatingIfNeeded: offset)
|
||||
}
|
||||
}
|
||||
|
||||
public mutating func read(_ count: Int) throws(StreamError) -> Data {
|
||||
do {
|
||||
return try self._hnd.read(upToCount: count) ?? Data()
|
||||
} catch {
|
||||
throw .fileHandleError(error)
|
||||
}
|
||||
}
|
||||
}
|
21
Sources/apk/Utility/InputStream.swift
Normal file
21
Sources/apk/Utility/InputStream.swift
Normal file
@ -0,0 +1,21 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol InputStream: Stream, IteratorProtocol {
|
||||
associatedtype Element = UInt8
|
||||
|
||||
mutating func read(_ count: Int) throws(StreamError) -> Data
|
||||
}
|
||||
|
||||
public extension InputStream {
|
||||
mutating func read(_ size: Int, items: Int) throws(StreamError) -> Data {
|
||||
try self.read(size * items)
|
||||
}
|
||||
}
|
||||
|
||||
public extension InputStream {
|
||||
mutating func next() -> UInt8? {
|
||||
try? self.read(1).first
|
||||
}
|
||||
}
|
61
Sources/apk/Utility/MemoryInputStream.swift
Normal file
61
Sources/apk/Utility/MemoryInputStream.swift
Normal file
@ -0,0 +1,61 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct MemoryInputStream: InputStream {
|
||||
private var _buf: [UInt8]! = nil
|
||||
private let _sli: ArraySlice<UInt8>
|
||||
private let _len: Int
|
||||
private var _idx = 0
|
||||
|
||||
public init(buffer: Data) {
|
||||
self._len = buffer.count
|
||||
self._buf = [UInt8](repeating: 0, count: self._len)
|
||||
self._buf.withUnsafeMutableBytes { _ = buffer.copyBytes(to: $0) }
|
||||
self._sli = self._buf[...]
|
||||
}
|
||||
|
||||
public init(view: ArraySlice<UInt8>) {
|
||||
self._sli = view
|
||||
self._len = view.count
|
||||
}
|
||||
|
||||
public mutating func seek(_ whence: StreamWhence) throws(StreamError) {
|
||||
let (position, overflow) = switch whence {
|
||||
case .set(let position): (position, false)
|
||||
case .current(let offset): self._idx.addingReportingOverflow(offset)
|
||||
case .end(let offset): self._len.addingReportingOverflow(offset)
|
||||
}
|
||||
if overflow {
|
||||
throw .overflow
|
||||
} else if position < 0 {
|
||||
throw .seekRange
|
||||
} else {
|
||||
self._idx = position
|
||||
}
|
||||
}
|
||||
|
||||
public var tell: Int {
|
||||
get throws(StreamError) {
|
||||
self._idx
|
||||
}
|
||||
}
|
||||
|
||||
public mutating func read(_ count: Int) throws(StreamError) -> Data {
|
||||
let beg = min(self._idx, self._len)
|
||||
let end = min(self._idx + count, self._len)
|
||||
let bytes = Data(self._sli[beg..<end])
|
||||
self._idx += beg.distance(to: end)
|
||||
return bytes
|
||||
}
|
||||
|
||||
public mutating func next() -> UInt8? {
|
||||
if self._idx < self._len {
|
||||
let byte = self._sli[self._idx]
|
||||
self._idx += 1
|
||||
return byte
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
30
Sources/apk/Utility/Stream.swift
Normal file
30
Sources/apk/Utility/Stream.swift
Normal file
@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol Stream {
|
||||
mutating func seek(_ whence: StreamWhence) throws(StreamError)
|
||||
var tell: Int { get throws(StreamError) }
|
||||
}
|
||||
|
||||
public enum StreamWhence {
|
||||
case set(_ position: Int)
|
||||
case current(_ offset: Int)
|
||||
case end(_ offset: Int)
|
||||
}
|
||||
|
||||
public enum StreamError: Error, LocalizedError {
|
||||
case unsupported
|
||||
case seekRange
|
||||
case overflow
|
||||
case fileHandleError(_ error: any Error)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .unsupported: "Unsupported operation"
|
||||
case .seekRange: "Seek out of range"
|
||||
case .overflow: "Stream position overflowed"
|
||||
case .fileHandleError(let error): "Error from file handle: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
121
Sources/apk/Utility/TarMemoryReader.swift
Normal file
121
Sources/apk/Utility/TarMemoryReader.swift
Normal file
@ -0,0 +1,121 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct TarReader {
|
||||
private static let tarBlockSize = 512
|
||||
private static let tarTypeOffset = 156
|
||||
private static let tarNameOffset = 0
|
||||
private static let tarNameSize = 100
|
||||
private static let tarSizeOffset = 124
|
||||
private static let tarSizeSize = 12
|
||||
|
||||
public enum Entry {
|
||||
case file(name: String, data: Data)
|
||||
case directory(name: String)
|
||||
}
|
||||
|
||||
public static func read<S: InputStream>(_ stream: inout S) throws -> [Entry] {
|
||||
var entries = [Entry]()
|
||||
|
||||
while true {
|
||||
let tarBlock = try stream.read(Self.tarBlockSize)
|
||||
if tarBlock.isEmpty { break }
|
||||
if tarBlock.count < Self.tarBlockSize { throw TarError.unexpectedEndOfStream }
|
||||
|
||||
let type = UnicodeScalar(tarBlock[Self.tarTypeOffset])
|
||||
switch type {
|
||||
case "0": // Regular file
|
||||
// Read metadata
|
||||
let name = try Self.readName(tarBlock)
|
||||
let size = try Self.readSize(tarBlock)
|
||||
|
||||
// Read file data
|
||||
var data = Data()
|
||||
var bytesRemaining = size, readAmount = 0
|
||||
while bytesRemaining > 0 {
|
||||
//FIXME: just read the whole thing at once tbh
|
||||
readAmount = min(bytesRemaining, Self.tarBlockSize)
|
||||
let block = try stream.read(readAmount)
|
||||
if block.count < readAmount { throw TarError.unexpectedEndOfStream }
|
||||
data += block
|
||||
bytesRemaining -= readAmount
|
||||
}
|
||||
entries.append(.file(name: name, data: data))
|
||||
|
||||
// Seek to next block
|
||||
let seekAmount = Self.tarBlockSize - readAmount
|
||||
if seekAmount > 0 {
|
||||
try stream.seek(.current(seekAmount))
|
||||
}
|
||||
case "5":
|
||||
// Directory
|
||||
let name = try Self.readName(tarBlock)
|
||||
entries.append(.directory(name: name))
|
||||
case "\0":
|
||||
// Null block, might also be a legacy regular file
|
||||
break
|
||||
case "x":
|
||||
// Extended header block
|
||||
try stream.seek(.current(Self.tarBlockSize))
|
||||
// Symlink, Reserved, Character, Block, FIFO, Reserved, Global, ignore all these
|
||||
case "1", "2", "3", "4", "6", "7", "g":
|
||||
let size = try self.readSize(tarBlock)
|
||||
let blockCount = (size - 1) / Self.tarBlockSize + 1 // Compute blocks to skip
|
||||
try stream.seek(.current(Self.tarBlockSize * blockCount))
|
||||
default: throw TarError.invalidType(type: type) // Not a TAR type
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
private static func readName(_ tar: Data, offset: Int = Self.tarNameOffset) throws (TarError) -> String {
|
||||
var nameSize = Self.tarNameSize
|
||||
for i in 0...Self.tarNameSize {
|
||||
if tar[offset + i] == 0 {
|
||||
nameSize = i
|
||||
break
|
||||
}
|
||||
}
|
||||
let data = tar.subdata(in: offset..<offset + nameSize)
|
||||
guard let name = String(data: data, encoding: .utf8) else { throw TarError.badNameField }
|
||||
return name
|
||||
}
|
||||
|
||||
private static func readSize(_ tar: Data, offset: Int = Self.tarSizeOffset) throws (TarError) -> Int {
|
||||
let sizeData = tar.subdata(in: offset..<offset + Self.tarSizeSize)
|
||||
let sizeEnd = sizeData.firstIndex(of: 0) ?? sizeData.endIndex // Find the null terminator
|
||||
guard
|
||||
let sizeString = String(data: sizeData[..<sizeEnd], encoding: .ascii),
|
||||
let result = Int(sizeString, radix: 0o10) else { throw TarError.badSizeField }
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
public enum TarError: Error, LocalizedError {
|
||||
case unexpectedEndOfStream
|
||||
case invalidType(type: UnicodeScalar)
|
||||
case badNameField, badSizeField
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .unexpectedEndOfStream: "Stream unexpectedly ended early"
|
||||
case .invalidType(let type): "Invalid block type \(type) found"
|
||||
case .badNameField: "Bad name field"
|
||||
case .badSizeField: "Bad size field"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension Array<TarReader.Entry> {
|
||||
func firstFile(name firstNameMatch: String) -> Data? {
|
||||
for entry in self {
|
||||
if case .file(let name, let data) = entry {
|
||||
if name == firstNameMatch {
|
||||
return data
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
85
Sources/apk/Utility/TextInputStream.swift
Normal file
85
Sources/apk/Utility/TextInputStream.swift
Normal file
@ -0,0 +1,85 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//FIXME: I don't like this, also SLOWWW
|
||||
struct TextInputStream<InStream: InputStream> where InStream.Element == UInt8 {
|
||||
private var _stream: InStream
|
||||
|
||||
public init(binaryStream: InStream) {
|
||||
_stream = binaryStream
|
||||
}
|
||||
|
||||
public var lines: LineSequence {
|
||||
LineSequence(_stream: self._stream)
|
||||
}
|
||||
|
||||
public struct LineSequence: Sequence {
|
||||
public typealias Element = String
|
||||
|
||||
fileprivate var _stream: InStream
|
||||
|
||||
public struct Iterator: IteratorProtocol where InStream.Element == UInt8 {
|
||||
public typealias Element = String
|
||||
|
||||
fileprivate init(stream: InStream) {
|
||||
self._stream = stream
|
||||
}
|
||||
|
||||
private var _stream: InStream
|
||||
private var _utf8Decoder = UTF8()
|
||||
private var _scalars = [Unicode.Scalar]()
|
||||
private var _lastChar: UnicodeScalar = "\0"
|
||||
private var _eof = false
|
||||
|
||||
private mutating func decodeScalarsLine() {
|
||||
Decode: while true {
|
||||
switch self._utf8Decoder.decode(&self._stream) {
|
||||
case .scalarValue(let value):
|
||||
if value == "\n" {
|
||||
if self._lastChar == "\n" { break }
|
||||
else { break Decode }
|
||||
} else if value == "\r" {
|
||||
break Decode
|
||||
}
|
||||
self._scalars.append(value)
|
||||
self._lastChar = value
|
||||
case .emptyInput:
|
||||
self._eof = true
|
||||
break Decode
|
||||
case .error:
|
||||
break Decode
|
||||
//FIXME: repair like the stdlib does
|
||||
//scalars.append(UTF8.encodedReplacementCharacter)
|
||||
//lastChar = UTF8.encodedReplacementCharacter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public mutating func next() -> String? {
|
||||
// Return early if we already hit the end of the stream
|
||||
guard !self._eof else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decode a line of scalars
|
||||
self.decodeScalarsLine()
|
||||
defer {
|
||||
self._scalars.removeAll(keepingCapacity: true)
|
||||
}
|
||||
|
||||
// Ignore the final empty newline
|
||||
guard !self._eof || !self._scalars.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert to string and return
|
||||
var string = String()
|
||||
string.unicodeScalars.append(contentsOf: self._scalars)
|
||||
return string
|
||||
}
|
||||
}
|
||||
|
||||
public func makeIterator() -> Iterator {
|
||||
Iterator(stream: self._stream)
|
||||
}
|
||||
}
|
||||
}
|
@ -8,68 +8,9 @@ struct DarwinApkCLI: ParsableCommand {
|
||||
commandName: "dpk",
|
||||
abstract: "Command-line interface for managing packages installed via darwin-apk.",
|
||||
subcommands: [
|
||||
Install.self,
|
||||
Remove.self,
|
||||
Update.self,
|
||||
Upgrade.self
|
||||
DpkInstallCommand.self,
|
||||
DpkRemoveCommand.self,
|
||||
DpkUpdateCommand.self,
|
||||
DpkUpgradeCommand.self
|
||||
])
|
||||
}
|
||||
|
||||
extension DarwinApkCLI {
|
||||
struct Install: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "add",
|
||||
abstract: "Install package(s) to the system.",
|
||||
aliases: [ "install", "i", "a" ])
|
||||
|
||||
@Argument(help: "One or more package names to install to the system.")
|
||||
var packages: [String]
|
||||
|
||||
func run() throws {
|
||||
print("installing \"\(packages.joined(separator: "\", \""))\"")
|
||||
}
|
||||
}
|
||||
|
||||
struct Remove: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "remove",
|
||||
abstract: "Remove specified package(s) from the system.",
|
||||
aliases: [ "uninstall", "del", "rem", "r" ])
|
||||
|
||||
@Argument(help: "One or more package names to uninstall from the system.")
|
||||
var packages: [String]
|
||||
|
||||
func run() throws {
|
||||
print("uninstalling \"\(packages.joined(separator: "\", \""))\"")
|
||||
}
|
||||
}
|
||||
|
||||
struct Update: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "update",
|
||||
abstract: "Update the system package repositories.",
|
||||
aliases: [ "u" ])
|
||||
|
||||
func run() throws {
|
||||
print("updating package repositories")
|
||||
}
|
||||
}
|
||||
|
||||
struct Upgrade: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "upgrade",
|
||||
abstract: "Upgrade installed packages.",
|
||||
aliases: [ "U" ])
|
||||
|
||||
@Argument(help: "Optionally specify packages to upgrade. Otherwise upgrade all packages installed on the system.")
|
||||
var packages: [String] = []
|
||||
|
||||
func run() throws {
|
||||
if packages.isEmpty {
|
||||
print("upgrading system")
|
||||
} else {
|
||||
print("upgrading invidual packages: \"\(packages.joined(separator: "\", \""))\"")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
17
Sources/dpk-cli/Subcommands/DpkInstallCommand.swift
Normal file
17
Sources/dpk-cli/Subcommands/DpkInstallCommand.swift
Normal file
@ -0,0 +1,17 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import ArgumentParser
|
||||
|
||||
struct DpkInstallCommand: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "add",
|
||||
abstract: "Install package(s) to the system.",
|
||||
aliases: [ "a", "install" ])
|
||||
|
||||
@Argument(help: "One or more package names to install to the system.")
|
||||
var packages: [String]
|
||||
|
||||
func run() throws {
|
||||
print("installing \"\(packages.joined(separator: "\", \""))\"")
|
||||
}
|
||||
}
|
17
Sources/dpk-cli/Subcommands/DpkRemoveCommand.swift
Normal file
17
Sources/dpk-cli/Subcommands/DpkRemoveCommand.swift
Normal file
@ -0,0 +1,17 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import ArgumentParser
|
||||
|
||||
struct DpkRemoveCommand: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "remove",
|
||||
abstract: "Remove specified package(s) from the system.",
|
||||
aliases: [ "r", "rem", "del", "uninstall" ])
|
||||
|
||||
@Argument(help: "One or more package(s) to uninstall from the system.")
|
||||
var packages: [String]
|
||||
|
||||
func run() throws {
|
||||
print("uninstalling \"\(packages.joined(separator: "\", \""))\"")
|
||||
}
|
||||
}
|
18
Sources/dpk-cli/Subcommands/DpkUpdateCommand.swift
Normal file
18
Sources/dpk-cli/Subcommands/DpkUpdateCommand.swift
Normal file
@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import Foundation
|
||||
import ArgumentParser
|
||||
import darwin_apk
|
||||
|
||||
struct DpkUpdateCommand: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "update",
|
||||
abstract: "Update the system package repositories.",
|
||||
aliases: [ "u" ])
|
||||
|
||||
func run() throws {
|
||||
print("Updating package repositories")
|
||||
var updater = ApkIndexUpdater()
|
||||
updater.update()
|
||||
}
|
||||
}
|
21
Sources/dpk-cli/Subcommands/DpkUpgradeCommand.swift
Normal file
21
Sources/dpk-cli/Subcommands/DpkUpgradeCommand.swift
Normal file
@ -0,0 +1,21 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import ArgumentParser
|
||||
|
||||
struct DpkUpgradeCommand: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "upgrade",
|
||||
abstract: "Upgrade installed packages.",
|
||||
aliases: [ "U" ])
|
||||
|
||||
@Argument(help: "Optionally specify packages to upgrade. Otherwise upgrade all packages installed on the system.")
|
||||
var packages: [String] = []
|
||||
|
||||
func run() throws {
|
||||
if packages.isEmpty {
|
||||
print("upgrading system")
|
||||
} else {
|
||||
print("upgrading invidual packages: \"\(packages.joined(separator: "\", \""))\"")
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user