Compare commits

..
1 Commits
Author SHA1 Message Date
ek-docker-images cf4ea12b36 Deployed 5287b59 with MkDocs version: 1.6.1 2026-05-12 21:45:50 +00:00
380 changed files with 76620 additions and 13799 deletions
-18
View File
@@ -1,18 +0,0 @@
#!/bin/sh
set -e
export VERSION="${CIRRUS_TAG:-0}"
mkdir -p .ci/pkg/
cp .build/arm64-apple-macosx/release/tart .ci/pkg/tart
cp Resources/embedded.provisionprofile .ci/pkg/embedded.provisionprofile
cp Resources/AppIcon.png .ci/pkg/AppIcon.png
cp Resources/Info.plist .ci/pkg/Info.plist
pkgbuild --root .ci/pkg/ --identifier com.github.cirruslabs.tart --version $VERSION \
--scripts .ci/pkg/scripts \
--install-location "/Library/Application Support/Tart" \
--sign "Developer ID Installer: Cirrus Labs, Inc. (9M2P8L4D89)" \
"./.ci/Tart-$VERSION.pkg"
xcrun notarytool submit "./.ci/Tart-$VERSION.pkg" --keychain-profile "notarytool" --wait
xcrun stapler staple "./.ci/Tart-$VERSION.pkg"
-15
View File
@@ -1,15 +0,0 @@
#!/bin/sh
set -e
# fix structure
mkdir -p "$2/tart.app/Contents/MacOS" "$2/tart.app/Resources"
mv "$2/tart" "$2/tart.app/Contents/MacOS/tart"
mv "$2/embedded.provisionprofile" "$2/tart.app/Contents/embedded.provisionprofile"
mv "$2/AppIcon.png" "$2/tart.app/Resources/AppIcon.png"
mv "$2/Info.plist" "$2/tart.app/Contents/Info.plist"
echo "#!/bin/sh" > /usr/local/bin/tart
echo "exec '$2/tart.app/Contents/MacOS/tart' \"\$@\"" >> /usr/local/bin/tart
chmod +x /usr/local/bin/tart
-5
View File
@@ -1,5 +0,0 @@
#!/bin/sh
TMPFILE=$(mktemp)
envsubst < Sources/tart/CI/CI.swift > $TMPFILE
mv $TMPFILE Sources/tart/CI/CI.swift
-163
View File
@@ -1,163 +0,0 @@
use_compute_credits: true
env:
XCODE_TAG: latest
task:
name: Test on Sonoma
alias: test
persistent_worker:
labels:
name: dev-mini
resources:
tart-vms: 1
test_script:
- swift test
integration_test_script:
# Build Tart
- swift build
- codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
- export PATH=$(pwd)/.build/arm64-apple-macosx/debug:$PATH
# Run integration tests
- cd integration-tests
- HOMEBREW_NO_AUTO_UPDATE=1 brew install virtualenv
- virtualenv venv
- source venv/bin/activate
- pip install -r requirements.txt
- pytest --verbose --junit-xml=pytest-junit.xml
pytest_junit_result_artifacts:
path: "integration-tests/pytest-junit.xml"
format: junit
task:
name: Markdown Lint
only_if: $CIRRUS_BRANCH != 'gh-pages' && changesInclude('**.md')
container:
image: node:latest
install_script: npm install -g markdownlint-cli
lint_script: markdownlint --config=docs/.markdownlint.yml docs/
task:
name: Lint
alias: lint
macos_instance:
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
lint_script:
- swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
always:
swiftformat_report_artifacts:
path: swiftformat.json
format: swiftformat
task:
only_if: $CIRRUS_TAG == ''
env:
matrix:
BUILD_ARCH: arm64
BUILD_ARCH: x86_64
name: Build ($BUILD_ARCH)
alias: build
macos_instance:
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
build_script: swift build --arch $BUILD_ARCH --product tart
sign_script: codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/$BUILD_ARCH-apple-macosx/debug/tart
binary_artifacts:
path: .build/$BUILD_ARCH-apple-macosx/debug/tart
task:
only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin')
name: Release (Dry Run)
depends_on:
- lint
- build
macos_instance:
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
env:
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
setup_script:
- cd $HOME
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
- security create-keychain -p password101 build.keychain
- security default-keychain -s build.keychain
- security unlock-keychain -p password101 build.keychain
- security import certificate.p12 -k build.keychain -P password101 -T /usr/bin/codesign -T /usr/bin/pkgbuild
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
install_script:
- brew install go goreleaser/tap/goreleaser-pro
- brew install mitchellh/gon/gon
info_script:
- security find-identity -v
- xcodebuild -version
- swift -version
goreleaser_script: goreleaser release --skip-publish --snapshot --clean
always:
dist_artifacts:
path: "dist/*"
task:
name: Release
only_if: $CIRRUS_TAG != ''
depends_on:
- lint
- test
- build
macos_instance:
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
env:
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
SENTRY_ORG: cirrus-labs
SENTRY_PROJECT: persistent-workers
SENTRY_AUTH_TOKEN: ENCRYPTED[!c16a5cf7da5f856b4bc2f21fe8cb7aa2a6c981f851c094ed4d3025fd02ea59a58a86cee8b193a69a1fc20fa217e56ac3!]
setup_script:
- cd $HOME
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
- security create-keychain -p password101 build.keychain
- security default-keychain -s build.keychain
- security unlock-keychain -p password101 build.keychain
- security import certificate.p12 -k build.keychain -P password101 -T /usr/bin/codesign -T /usr/bin/pkgbuild
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
install_script:
- brew install go goreleaser/tap/goreleaser-pro getsentry/tools/sentry-cli
- brew install mitchellh/gon/gon
info_script:
- security find-identity -v
- xcodebuild -version
- swift -version
release_script: goreleaser
upload_sentry_debug_files_script:
- cd .build/arm64-apple-macosx/release/
# Generate and upload symbols
- dsymutil tart
- sentry-cli debug-files upload tart.dSYM/
- SENTRY_PROJECT=tart sentry-cli debug-files upload tart.dSYM/
# Bundle and upload sources
- sentry-cli debug-files bundle-sources tart.dSYM
- sentry-cli debug-files upload tart.src.zip
- SENTRY_PROJECT=tart sentry-cli debug-files upload tart.src.zip
create_sentry_release_script:
- export SENTRY_RELEASE="tart@$CIRRUS_TAG"
- sentry-cli releases new $SENTRY_RELEASE
- sentry-cli releases set-commits $SENTRY_RELEASE --auto
- sentry-cli releases finalize $SENTRY_RELEASE
task:
name: Deploy Documentation
only_if: $CIRRUS_BRANCH == 'main'
container:
image: ghcr.io/cirruslabs/mkdocs-material-insiders:latest
registry_config: ENCRYPTED[!cf1a0f25325aa75bad3ce6ebc890bc53eb0044c02efa70d8cefb83ba9766275a994b4831706c52630a0692b2fa9cfb9e!]
env:
DEPLOY_TOKEN: ENCRYPTED[!45ed45666558902ed1c2400add734ec063103bec31841847e8c8764802fca229bfa6d85c690e16ad159e047574b48793!]
deploy_script:
- git config --global user.name "Cirrus CI"
- git config --global user.name "hello@cirruslabs.org"
- git remote set-url origin https://$DEPLOY_TOKEN@github.com/cirruslabs/tart/
- mkdocs --verbose gh-deploy --force --remote-branch gh-pages
-6
View File
@@ -1,6 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 2
insert_final_newline = true
-1
View File
@@ -1 +0,0 @@
* @edigaryev @fkorotkov
-1
View File
@@ -1 +0,0 @@
github: [cirruslabs]
-18
View File
@@ -1,18 +0,0 @@
# Xcode's user settings
xcuserdata/
tart.xcodeproj/
# SwiftPM
.swiftpm/
# AppCode
.idea/
# Swift
.build/
# GoReleaser
dist/
# mkdocs
.cache
-57
View File
@@ -1,57 +0,0 @@
project_name: tart
before:
hooks:
- .ci/set-version.sh
- swift build --arch x86_64 -c release --product tart
- swift build --arch arm64 -c release --product tart
- gon gon.hcl
builds:
- id: tart
builder: prebuilt
goamd64: [v1]
goos:
- darwin
goarch:
- arm64
- amd64
binary: tart.app/Contents/MacOS/tart
prebuilt:
path: '.build/{{- if eq .Arch "arm64" }}arm64{{- else }}x86_64{{ end }}-apple-macosx/release/tart'
archives:
- name_template: "{{ .ProjectName }}-{{ .Arch }}"
files:
- src: Resources/embedded.provisionprofile
dst: tart.app/Contents
strip_parent: true
- src: Resources/Info.plist
dst: tart.app/Contents
strip_parent: true
- src: Resources/AppIcon.png
dst: tart.app/Contents/Resources
strip_parent: true
- LICENSE
release:
prerelease: auto
brews:
- name: tart
repository:
owner: cirruslabs
name: homebrew-cli
caveats: See the GitHub repository for more information
homepage: https://github.com/cirruslabs/tart
license: "Fair Source"
description: Run macOS and Linux VMs on Apple Hardware
skip_upload: auto
dependencies:
- "cirruslabs/cli/softnet"
install: |
libexec.install Dir["*"]
bin.write_exec_script "#{libexec}/tart.app/Contents/MacOS/tart"
generate_completions_from_executable(libexec/"tart.app/Contents/MacOS/tart", "--generate-completion-script")
custom_block: |
depends_on :macos => :ventura
View File
-5
View File
@@ -1,5 +0,0 @@
--disable all
--enable indent
--indent 2
--exclude Sources/tart/OCI/Reference/Generated
--swiftversion 5.7
+1619
View File
File diff suppressed because it is too large Load Diff
View File
-40
View File
@@ -1,40 +0,0 @@
# Contributing to Tart
Table of Contents
-----------------
- [How to Build](#how-to-build)
- [How to Create an Issue/Enhancement](#how-to-create-an-issueenhancement)
- [Style Guidelines](#style-guidelines)
- [Pull Requests](#Pull-Requests)
## How to Build
1. Fork the repository to your own GitHub account
2. Clone the forked repository to your local machine
3. If using Xcode, use from Xcode 15 or newer
4. Run ./scripts/run-signed.sh from the root of your repository
```bash
./scripts/run-signed.sh list
```
## How to Create an Issue/Enhancement
1. Go to the [Issue page](https://github.com/cirruslabs/tart/issues) of the repository
2. Click on the "New Issue" button
3. Provide a descriptive title and detailed description of the issue or enhancement you're suggesting
4. Submit the issue
## Style Guidelines
1. Code should follow camel case
2. Code should follow [SwiftFormat](https://github.com/nicklockwood/SwiftFormat#swift-package-manager-plugin) guidelines. You can auto-format the code by running the following command:
```bash
swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore .
```
## Pull Requests
1. Provide a detailed description of the changes you made in the pull request
2. Wait for pull request to be reviewed
3. Make adjustments if necessary
-45
View File
@@ -1,45 +0,0 @@
Fair Source License, version 0.9
Copyright (C) 2023 Cirrus Labs, Inc.
Licensor: Cirrus Labs, Inc.
Software: Tart
Use Limitation: 100 users. User is defined as a single core of a central processing unit (CPU) used by the product.
The Use Limitation does not apply to CPUs installed in devices used by a single individual.
License Grant. Licensor hereby grants to each recipient of the
Software ("you") a non-exclusive, non-transferable, royalty-free and
fully-paid-up license, under all of the Licensor's copyright and
patent rights, to use, copy, distribute, prepare derivative works of,
publicly perform and display the Software, subject to the Use
Limitation and the conditions set forth below.
Use Limitation. The license granted above allows use by up to the
number of users per entity set forth above (the "Use Limitation"). For
determining the number of users, "you" includes all affiliates,
meaning legal entities controlling, controlled by, or under common
control with you. If you exceed the Use Limitation, your use is
subject to payment of Licensor's then-current list price for licenses.
Conditions. Redistribution in source code or other forms must include
a copy of this license document to be provided in a reasonable
manner. Any redistribution of the Software is only allowed subject to
this license.
Trademarks. This license does not grant you any right in the
trademarks, service marks, brand names or logos of Licensor.
DISCLAIMER. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OR
CONDITION, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. LICENSORS HEREBY DISCLAIM ALL LIABILITY, WHETHER IN
AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE.
Termination. If you violate the terms of this license, your rights
will terminate automatically and will not be reinstated without the
prior written consent of Licensor. Any such termination will not
affect the right of others who may have received copies of the
Software from you.
-159
View File
@@ -1,159 +0,0 @@
{
"originHash" : "6d48639bc0ea02002de0b4f38fe3fce0ddc9d174f2e56180c2ffcbedb7391ef8",
"pins" : [
{
"identity" : "antlr4",
"kind" : "remoteSourceControl",
"location" : "https://github.com/antlr/antlr4",
"state" : {
"branch" : "dev",
"revision" : "2703a8516c0fb7fe92db6b9c40e0113f577646d2"
}
},
{
"identity" : "dynamic",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mhdhejazi/Dynamic",
"state" : {
"branch" : "master",
"revision" : "772883073d044bc754d401cabb6574624eb3778f"
}
},
{
"identity" : "semaphore",
"kind" : "remoteSourceControl",
"location" : "https://github.com/groue/Semaphore",
"state" : {
"revision" : "f1c4a0acabeb591068dea6cffdd39660b86dec28",
"version" : "0.0.8"
}
},
{
"identity" : "sentry-cocoa",
"kind" : "remoteSourceControl",
"location" : "https://github.com/getsentry/sentry-cocoa",
"state" : {
"revision" : "ef4fec9dfb8dd5027b09a4a5c9362feafd118e1a",
"version" : "8.24.0"
}
},
{
"identity" : "swift-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-algorithms",
"state" : {
"revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42",
"version" : "1.2.0"
}
},
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser",
"state" : {
"revision" : "46989693916f56d1186bd59ac15124caef896560",
"version" : "1.3.1"
}
},
{
"identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms",
"state" : {
"branch" : "main",
"revision" : "f05e450f0b909c0e80670a47516c4b9700b9e5da"
}
},
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "cd142fd2f64be2100422d658e7411e39489da985",
"version" : "1.2.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "f504716c27d2e5d4144fa4794b12129301d17729",
"version" : "1.0.3"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "e97a6fcb1ab07462881ac165fdbb37f067e205d5",
"version" : "1.5.4"
}
},
{
"identity" : "swift-numerics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-numerics",
"state" : {
"revision" : "0a5bc04095a675662cf24757cc0640aa2204253b",
"version" : "1.0.2"
}
},
{
"identity" : "swift-retry",
"kind" : "remoteSourceControl",
"location" : "https://github.com/fumoboy007/swift-retry",
"state" : {
"revision" : "9f133487ffc2ab4539688c29efe57bb1ba31d7b0",
"version" : "0.2.3"
}
},
{
"identity" : "swift-sysctl",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sersoft-gmbh/swift-sysctl.git",
"state" : {
"revision" : "a91be36de6803ebe48f678699dfd0694c2200d2f",
"version" : "1.8.0"
}
},
{
"identity" : "swiftdate",
"kind" : "remoteSourceControl",
"location" : "https://github.com/malcommac/SwiftDate",
"state" : {
"revision" : "5d943224c3bb173e6ecf27295611615eba90c80e",
"version" : "7.0.0"
}
},
{
"identity" : "swiftformat",
"kind" : "remoteSourceControl",
"location" : "https://github.com/nicklockwood/SwiftFormat",
"state" : {
"revision" : "9df3b01f477163b33d5e63c5e2e5b9f946a49c56",
"version" : "0.53.6"
}
},
{
"identity" : "swiftradix",
"kind" : "remoteSourceControl",
"location" : "https://github.com/orchetect/SwiftRadix",
"state" : {
"revision" : "a52c37a4c213403f7377ae77b4c68451bcab8330",
"version" : "1.3.1"
}
},
{
"identity" : "texttable",
"kind" : "remoteSourceControl",
"location" : "https://github.com/cfilipov/TextTable",
"state" : {
"branch" : "master",
"revision" : "e03289289155b4e7aa565e32862f9cb42140596a"
}
}
],
"version" : 3
}
-53
View File
@@ -1,53 +0,0 @@
// swift-tools-version:5.10
import PackageDescription
let package = Package(
name: "Tart",
platforms: [
.macOS(.v13)
],
products: [
.executable(name: "tart", targets: ["tart"])
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.1"),
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
.package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0"),
.package(url: "https://github.com/apple/swift-async-algorithms", branch: "main"),
.package(url: "https://github.com/malcommac/SwiftDate", from: "7.0.0"),
.package(url: "https://github.com/antlr/antlr4", branch: "dev"),
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.2.0")),
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.53.6"),
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.24.0"),
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
.package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.8.0"),
.package(url: "https://github.com/orchetect/SwiftRadix", from: "1.3.1"),
.package(url: "https://github.com/groue/Semaphore", from: "0.0.8"),
.package(url: "https://github.com/fumoboy007/swift-retry", from: "0.2.3"),
],
targets: [
.executableTarget(name: "tart", dependencies: [
.product(name: "Algorithms", package: "swift-algorithms"),
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Dynamic", package: "Dynamic"),
.product(name: "SwiftDate", package: "SwiftDate"),
.product(name: "Antlr4Static", package: "Antlr4"),
.product(name: "Atomics", package: "swift-atomics"),
.product(name: "Sentry", package: "sentry-cocoa"),
.product(name: "TextTable", package: "TextTable"),
.product(name: "Sysctl", package: "swift-sysctl"),
.product(name: "SwiftRadix", package: "SwiftRadix"),
.product(name: "Semaphore", package: "Semaphore"),
.product(name: "DMRetry", package: "swift-retry"),
], exclude: [
"OCI/Reference/Makefile",
"OCI/Reference/Reference.g4",
"OCI/Reference/Generated/Reference.interp",
"OCI/Reference/Generated/Reference.tokens",
"OCI/Reference/Generated/ReferenceLexer.interp",
"OCI/Reference/Generated/ReferenceLexer.tokens",
]),
.testTarget(name: "TartTests", dependencies: ["tart"])
]
)
-71
View File
@@ -1,71 +0,0 @@
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartSocial.png"/>
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines (VMs) on Apple Silicon.
Built by CI engineers for your automation needs. Here are some highlights of Tart:
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/20382844?baseline=20382722).
* Push/Pull virtual machines from any OCI-compatible container registry.
* Use Tart Packer Plugin to automate VM creation.
* Easily integrates with any CI system.
Tart powers [Cirrus Runners](https://cirrus-runners.app/)
service — a drop-in replacement for the standard GitHub-hosted runners, offering 2-3 times better performance for a fraction of the price.
<p align="center">
<a href="https://tart.run/integrations/github-actions/?utm_source=github&utm_medium=referral" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/CirrusRunnersForGHA.png" height="65"/>
</a>
</p>
Many companies are using Tart in their internal setups. Here are a few of them:
<p align="center">
<a href="https://ahrefs.com/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/ahrefs.png" height="65"/>
</a>
<a href="https://krisp.ai/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
</a>
<a href="https://mullvad.net/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Mullvad.png" height="65"/>
</a>
<a href="https://shape.dk/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/shape.png" height="65"/>
</a>
<a href="https://suran.com/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Suran.png" height="65"/>
</a>
<a href="https://symflower.com/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Symflower.png" height="65"/>
</a>
<a href="https://transloadit.com/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Transloadit.png" height="65"/>
</a>
<a href="https://uphold.com/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Uphold.png" height="65"/>
</a>
<a href="https://www.pitsdatarecovery.net/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/PITSGlobalDataRecoveryServices.png" height="65"/>
</a>
</p>
**Note:** If your company or project is using Tart please consider [adding yourself to the list above](/Resources/Users/HowToAddYourself.md).
<p align="center">
<a href="https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws?utm_source=github&utm_medium=referral" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/AWSMarkeplaceLogo.png" height="90"/>
</a>
</p>
## Usage
Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura) or later (will download a 25 GB image):
```bash
brew install cirruslabs/cli/tart
tart clone ghcr.io/cirruslabs/macos-sonoma-base:latest sonoma-base
tart run sonoma-base
```
Please check the [official documentation](https://tart.run) for more information and/or feel free to use [discussions](https://github.com/cirruslabs/tart/discussions)
for remaining questions.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

-23
View File
@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>tart</string>
<key>CFBundleIdentifier</key>
<string>org.cirruslabs.tart</string>
<key>CFBundleExecutable</key>
<string>tart</string>
<key>LSBackgroundOnly</key>
<string>1</string>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon.png</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 589 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

-4
View File
@@ -1,4 +0,0 @@
If you'd like to highlight your use of Tart, please create a `456px` by `130px` logo and create a PR
that adds it to `README.md` in alphabetical order. Don't forget to include a small description of your usage pattern.
You can refer to `Background.png` as a base for your logo.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.virtualization</key>
<true/>
<key>com.apple.security.get-task-allow</key>
<true/>
</dict>
</plist>
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.virtualization</key>
<true/>
<key>com.apple.vm.networking</key>
<true/>
</dict>
</plist>
-17
View File
@@ -1,17 +0,0 @@
struct CI {
private static let rawVersion = "${CIRRUS_TAG}"
static var version: String {
rawVersion.expanded() ? rawVersion : "SNAPSHOT"
}
static var release: String? {
rawVersion.expanded() ? "tart@\(rawVersion)" : nil
}
}
private extension String {
func expanded() -> Bool {
!isEmpty && !starts(with: "$")
}
}
-81
View File
@@ -1,81 +0,0 @@
import ArgumentParser
import Foundation
import SystemConfiguration
struct Clone: AsyncParsableCommand {
static var configuration = CommandConfiguration(
abstract: "Clone a VM",
discussion: """
Creates a local virtual machine by cloning either a remote or another local virtual machine.
Due to copy-on-write magic in Apple File System a cloned VM won't actually claim all the space right away.
Only changes to a cloned disk will be written and claim new space. By default, Tart checks available capacity
in Tart's home directory and checks if there is enough space for the worst possible scenario: when the whole disk
will be modified.
This behaviour can be disabled by setting TART_NO_AUTO_PRUNE environment variable. This might be helpful
for use cases when the original image is very big and a workload is known to only modify a fraction of the cloned disk.
"""
)
@Argument(help: "source VM name", completion: .custom(completeMachines))
var sourceName: String
@Argument(help: "new VM name")
var newName: String
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
var insecure: Bool = false
@Option(help: "network concurrency to use when pulling a remote VM from the OCI-compatible registry")
var concurrency: UInt = 4
func validate() throws {
if newName.contains("/") {
throw ValidationError("<new-name> should be a local name")
}
if concurrency < 1 {
throw ValidationError("network concurrency cannot be less than 1")
}
}
func run() async throws {
let ociStorage = VMStorageOCI()
let localStorage = VMStorageLocal()
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
// Pull the VM in case it's OCI-based and doesn't exist locally yet
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency)
}
let sourceVM = try VMStorageHelper.open(sourceName)
let tmpVMDir = try VMDirectory.temporary()
// Lock the temporary VM directory to prevent it's garbage collection
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
try tmpVMDirLock.lock()
try await withTaskCancellationHandler(operation: {
// Acquire a global lock
let lock = try FileLock(lockURL: Config().tartHomeDir)
try lock.lock()
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
&& sourceVM.state() != .Suspended
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
try localStorage.move(newName, from: tmpVMDir)
try lock.unlock()
// APFS is doing copy-on-write so the above cloning operation (just copying files on disk)
// is not actually claiming new space until the VM is started and it writes something to disk.
// So once we clone the VM let's try to claim a little bit of space for the VM to run.
try Prune.reclaimIfNeeded(UInt64(sourceVM.allocatedSizeBytes()), sourceVM)
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
})
}
}
-74
View File
@@ -1,74 +0,0 @@
import ArgumentParser
import Dispatch
import Foundation
import SwiftUI
import Virtualization
struct Create: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Create a VM")
@Argument(help: "VM name")
var name: String
@Option(help: ArgumentHelp("create a macOS VM using path to the IPSW file or URL (or \"latest\", to fetch the latest supported IPSW automatically)", valueName: "path"))
var fromIPSW: String?
@Flag(help: "create a Linux VM")
var linux: Bool = false
@Option(help: ArgumentHelp("Disk size in Gb"))
var diskSize: UInt16 = 50
func validate() throws {
if fromIPSW == nil && !linux {
throw ValidationError("Please specify either a --from-ipsw or --linux option!")
}
#if arch(x86_64)
if fromIPSW != nil {
throw ValidationError("Only Linux VMs are supported on Intel!")
}
#endif
}
func run() async throws {
let tmpVMDir = try VMDirectory.temporary()
// Lock the temporary VM directory to prevent it's garbage collection
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
try tmpVMDirLock.lock()
try await withTaskCancellationHandler(operation: {
#if arch(arm64)
if let fromIPSW = fromIPSW {
let ipswURL: URL
if fromIPSW == "latest" {
defaultLogger.appendNewLine("Looking up the latest supported IPSW...")
let image = try await withCheckedThrowingContinuation { continuation in
VZMacOSRestoreImage.fetchLatestSupported() { result in
continuation.resume(with: result)
}
}
ipswURL = image.url
} else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
ipswURL = URL(string: fromIPSW)!
} else {
ipswURL = URL(fileURLWithPath: NSString(string: fromIPSW).expandingTildeInPath)
}
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
}
#endif
if linux {
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
}
try VMStorageLocal().move(name, from: tmpVMDir)
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
})
}
}
-16
View File
@@ -1,16 +0,0 @@
import ArgumentParser
import Dispatch
import SwiftUI
struct Delete: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Delete a VM")
@Argument(help: "VM name", completion: .custom(completeMachines))
var name: [String]
func run() async throws {
for it in name {
try VMStorageHelper.delete(it)
}
}
}
-44
View File
@@ -1,44 +0,0 @@
import ArgumentParser
import Foundation
struct Export: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Export VM to a compressed .tvm file")
@Argument(help: "Source VM name.", completion: .custom(completeMachines))
var name: String
@Argument(help: "Path to the destination file.")
var path: String?
func run() async throws {
let correctedPath: String
if let path = path {
correctedPath = path
} else {
correctedPath = "\(name).tvm"
if FileManager.default.fileExists(atPath: correctedPath) {
while true {
if userWantsOverwrite(correctedPath) {
break
} else {
return
}
}
}
}
print("exporting...")
try VMStorageHelper.open(name).exportToArchive(path: correctedPath)
}
func userWantsOverwrite(_ filename: String) -> Bool {
print("file \(filename) already exists, are you sure you want to overwrite it? (yes, [no])? ", terminator: "")
let answer = readLine()!
return answer == "yes"
}
}
-22
View File
@@ -1,22 +0,0 @@
import ArgumentParser
import Foundation
import SystemConfiguration
struct FQN: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Get a fully-qualified VM name", shouldDisplay: false)
@Argument(help: "VM name", completion: .custom(completeMachines))
var name: String
func run() async throws {
if var remoteName = try? RemoteName(name) {
let digest = try VMStorageOCI().digest(remoteName)
remoteName.reference = Reference(digest: digest)
print(remoteName)
} else {
print(name)
}
}
}
-32
View File
@@ -1,32 +0,0 @@
import ArgumentParser
import Foundation
fileprivate struct VMInfo: Encodable {
let OS: OS
let CPU: Int
let Memory: UInt64
let Disk: Int
let Size: String
let Display: String
let Running: Bool
let State: String
}
struct Get: AsyncParsableCommand {
static var configuration = CommandConfiguration(commandName: "get", abstract: "Get a VM's configuration")
@Argument(help: "VM name.", completion: .custom(completeLocalMachines))
var name: String
@Option(help: "Output format: text or json")
var format: Format = .text
func run() async throws {
let vmDir = try VMStorageLocal().open(name)
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue)
print(format.renderSingle(info))
}
}
-75
View File
@@ -1,75 +0,0 @@
import ArgumentParser
import Foundation
import Network
import SystemConfiguration
import Sentry
enum IPResolutionStrategy: String, ExpressibleByArgument, CaseIterable {
case dhcp, arp
private(set) static var allValueStrings: [String] = Format.allCases.map { "\($0)"}
}
struct IP: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Get VM's IP address")
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
var name: String
@Option(help: "Number of seconds to wait for a potential VM booting")
var wait: UInt16 = 0
@Option(help: ArgumentHelp("Strategy for resolving IP address: dhcp or arp",
discussion: """
By default, Tart is looking up and parsing DHCP lease file to determine the IP of the VM.\n
This method is fast and the most reliable but only returns local IP adresses.\n
Alternatively, Tart can call external `arp` executable and parse it's output.\n
In case of enabled Bridged Networking this method will return VM's IP address on the network interface used for Bridged Networking.\n
Note that `arp` strategy won't work for VMs using `--net-softnet`.
"""))
var resolver: IPResolutionStrategy = .dhcp
func run() async throws {
let vmDir = try VMStorageLocal().open(name)
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
guard let ip = try await IP.resolveIP(vmMACAddress, resolutionStrategy: resolver, secondsToWait: wait) else {
var message = "no IP address found"
if try !vmDir.running() {
message += ", is your VM running?"
}
if (vmConfig.os == .linux && resolver == .arp) {
message += " (not all Linux distributions are compatible with the ARP resolver)"
}
throw RuntimeError.NoIPAddressFound(message)
}
print(ip)
}
static public func resolveIP(_ vmMACAddress: MACAddress, resolutionStrategy: IPResolutionStrategy = .dhcp, secondsToWait: UInt16 = 0) async throws -> IPv4Address? {
let waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)!
repeat {
switch resolutionStrategy {
case .arp:
if let ip = try ARPCache().ResolveMACAddress(macAddress: vmMACAddress) {
return ip
}
case .dhcp:
if let leases = try Leases(), let ip = leases.ResolveMACAddress(macAddress: vmMACAddress) {
return ip
}
}
// wait a second
try await Task.sleep(nanoseconds: 1_000_000_000)
} while Date.now < waitUntil
return nil
}
}
-51
View File
@@ -1,51 +0,0 @@
import ArgumentParser
import Foundation
struct Import: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Import VM from a compressed .tvm file")
@Argument(help: "Path to a file created with \"tart export\".")
var path: String
@Argument(help: "Destination VM name.")
var name: String
func validate() throws {
if name.contains("/") {
throw ValidationError("<name> should be a local name")
}
}
func run() async throws {
let localStorage = VMStorageLocal()
// Create a temporary VM directory to which we will load the export file
let tmpVMDir = try VMDirectory.temporary()
// Lock the temporary VM directory to prevent it's garbage collection
// while we're running
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
try tmpVMDirLock.lock()
// Populate the temporary VM directory with the export file contents
print("importing...")
try tmpVMDir.importFromArchive(path: path)
try await withTaskCancellationHandler(operation: {
// Acquire a global lock
let lock = try FileLock(lockURL: Config().tartHomeDir)
try lock.lock()
// Re-generate the VM's MAC address importing it will result in address collision
if try localStorage.hasVMsWithMACAddress(macAddress: tmpVMDir.macAddress()) {
try tmpVMDir.regenerateMACAddress()
}
try localStorage.move(name, from: tmpVMDir)
try lock.unlock()
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
})
}
}
-63
View File
@@ -1,63 +0,0 @@
import ArgumentParser
import Dispatch
import SwiftUI
fileprivate struct VMInfo: Encodable {
let Source: String
let Name: String
let Disk: Int
let Size: Int
let Running: Bool
let State: String
}
struct List: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "List created VMs")
@Option(help: ArgumentHelp("Only display VMs from the specified source (e.g. --source local, --source oci)."))
var source: String?
@Option(help: "Output format: text or json")
var format: Format = .text
@Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names."))
var quiet: Bool = false
func validate() throws {
guard let source = source else {
return
}
if !["local", "oci"].contains(source) {
throw ValidationError("'\(source)' is not a valid <source>")
}
}
func run() async throws {
var infos: [VMInfo] = []
if source == nil || source == "local" {
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
try VMInfo(Source: "local", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), Running: vmDir.running(), State: vmDir.state().rawValue)
})
}
if source == nil || source == "oci" {
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
try VMInfo(Source: "oci", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), Running: vmDir.running(), State: vmDir.state().rawValue)
})
}
if (quiet) {
for info in infos {
print(info.Name)
}
} else {
print(format.renderList(infos))
}
}
private func sortedInfos(_ infos: [VMInfo]) -> [VMInfo] {
infos.sorted(by: { left, right in left.Name < right.Name })
}
}
-79
View File
@@ -1,79 +0,0 @@
import ArgumentParser
import Dispatch
import SwiftUI
struct Login: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Login to a registry")
@Argument(help: "host")
var host: String
@Option(help: "username")
var username: String?
@Flag(help: "password-stdin")
var passwordStdin: Bool = false
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
var insecure: Bool = false
@Flag(help: "skip validation of the registry's credentials before logging-in")
var noValidate: Bool = false
func validate() throws {
let usernameProvided = username != nil
let passwordProvided = passwordStdin
if usernameProvided != passwordProvided {
throw ValidationError("both --username and --password-stdin are required")
}
}
func run() async throws {
var user: String
var password: String
if let username = username {
user = username
let passwordData = FileHandle.standardInput.readDataToEndOfFile()
password = String(decoding: passwordData, as: UTF8.self)
// Support "echo $PASSWORD | tart login --username $USERNAME --password-stdin $REGISTRY"
password.trimSuffix { c in c.isNewline }
} else {
(user, password) = try StdinCredentials.retrieve()
}
let credentialsProvider = DictionaryCredentialsProvider([
host: (user, password)
])
if !noValidate {
do {
let registry = try Registry(host: host, namespace: "", insecure: insecure,
credentialsProviders: [credentialsProvider])
try await registry.ping()
} catch {
throw RuntimeError.InvalidCredentials("invalid credentials: \(error)")
}
}
try KeychainCredentialsProvider().store(host: host, user: user, password: password)
}
}
fileprivate class DictionaryCredentialsProvider: CredentialsProvider {
var credentials: Dictionary<String, (String, String)>
init(_ credentials: Dictionary<String, (String, String)>) {
self.credentials = credentials
}
func retrieve(host: String) throws -> (String, String)? {
credentials[host]
}
func store(host: String, user: String, password: String) throws {
credentials[host] = (user, password)
}
}
-14
View File
@@ -1,14 +0,0 @@
import ArgumentParser
import Dispatch
import SwiftUI
struct Logout: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Logout from a registry")
@Argument(help: "host")
var host: String
func run() async throws {
try KeychainCredentialsProvider().remove(host: host)
}
}
-189
View File
@@ -1,189 +0,0 @@
import ArgumentParser
import Dispatch
import Sentry
import SwiftUI
import SwiftDate
struct Prune: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches or local VMs")
@Option(help: ArgumentHelp("Entries to remove: \"caches\" targets OCI and IPSW caches and \"vms\" targets local VMs."))
var entries: String = "caches"
@Option(help: ArgumentHelp("Remove entries that were last accessed more than n days ago",
discussion: "For example, --older-than=7 will remove entries that weren't accessed by Tart in the last 7 days.",
valueName: "n"))
var olderThan: UInt?
@Option(help: .hidden)
var cacheBudget: UInt?
@Option(help: ArgumentHelp("Remove the least recently used entries that do not fit the specified space size budget n, expressed in gigabytes",
discussion: "For example, --space-budget=50 will effectively shrink all entries to a total size of 50 gigabytes.",
valueName: "n"))
var spaceBudget: UInt?
@Flag(help: .hidden)
var gc: Bool = false
mutating func validate() throws {
// --cache-budget deprecation logic
if let cacheBudget = cacheBudget {
fputs("--cache-budget is deprecated, please use --space-budget\n", stderr)
if spaceBudget != nil {
throw ValidationError("--cache-budget is deprecated, please use --space-budget")
}
spaceBudget = cacheBudget
}
if olderThan == nil && spaceBudget == nil && !gc {
throw ValidationError("at least one pruning criteria must be specified")
}
}
func run() async throws {
if gc {
try VMStorageOCI().gc()
}
// Build a list of prunable storages that we're going to prune based on user's request
let prunableStorages: [PrunableStorage]
switch entries {
case "caches":
prunableStorages = [VMStorageOCI(), try IPSWCache()]
case "vms":
prunableStorages = [VMStorageLocal()]
default:
throw ValidationError("unsupported --entries value, please specify either \"caches\" or \"vms\"")
}
// Clean up cache entries based on last accessed date
if let olderThan = olderThan {
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval
let olderThanDate = Date() - olderThanInterval
try Prune.pruneOlderThan(prunableStorages: prunableStorages, olderThanDate: olderThanDate)
}
// Clean up cache entries based on imposed cache size limit and entry's last accessed date
if let spaceBudget = spaceBudget {
try Prune.pruneSpaceBudget(prunableStorages: prunableStorages, spaceBudgetBytes: UInt64(spaceBudget) * 1024 * 1024 * 1024)
}
}
static func pruneOlderThan(prunableStorages: [PrunableStorage], olderThanDate: Date) throws {
let prunables: [Prunable] = try prunableStorages.flatMap { try $0.prunables() }
try prunables.filter { try $0.accessDate() <= olderThanDate }.forEach { try $0.delete() }
}
static func pruneSpaceBudget(prunableStorages: [PrunableStorage], spaceBudgetBytes: UInt64) throws {
let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() > $1.accessDate() }
var spaceBudgetBytes = spaceBudgetBytes
var prunablesToDelete: [Prunable] = []
for prunable in prunables {
let prunableSizeBytes = UInt64(try prunable.allocatedSizeBytes())
if prunableSizeBytes <= spaceBudgetBytes {
// Don't mark for deletion as
// there's a budget available
spaceBudgetBytes -= prunableSizeBytes
} else {
// Mark for deletion
prunablesToDelete.append(prunable)
}
}
try prunablesToDelete.forEach { try $0.delete() }
}
static func reclaimIfNeeded(_ requiredBytes: UInt64, _ initiator: Prunable? = nil) throws {
if ProcessInfo.processInfo.environment.keys.contains("TART_NO_AUTO_PRUNE") {
return
}
SentrySDK.configureScope { scope in
scope.setContext(value: ["requiredBytes": requiredBytes], key: "Prune")
}
// Figure out how much disk space is available
let attrs = try Config().tartCacheDir.resourceValues(forKeys: [
.volumeAvailableCapacityKey,
.volumeAvailableCapacityForImportantUsageKey
])
let volumeAvailableCapacityCalculated = max(
UInt64(attrs.volumeAvailableCapacity!),
UInt64(attrs.volumeAvailableCapacityForImportantUsage!)
)
SentrySDK.configureScope { scope in
scope.setContext(value: [
"volumeAvailableCapacity": attrs.volumeAvailableCapacity!,
"volumeAvailableCapacityForImportantUsage": attrs.volumeAvailableCapacityForImportantUsage!,
"volumeAvailableCapacityCalculated": volumeAvailableCapacityCalculated
], key: "Prune")
}
if volumeAvailableCapacityCalculated <= 0 {
SentrySDK.capture(message: "Zero volume capacity reported") { scope in
scope.setLevel(.warning)
}
return
}
// Now that we know how much free space is left,
// check if we even need to reclaim anything
if requiredBytes < volumeAvailableCapacityCalculated {
return
}
try Prune.reclaimIfPossible(requiredBytes - volumeAvailableCapacityCalculated, initiator)
}
private static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws {
let transaction = SentrySDK.startTransaction(name: "Pruning cache", operation: "prune", bindToScope: true)
defer { transaction.finish() }
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() < $1.accessDate() }
// Does it even make sense to start?
let cacheUsedBytes = try prunables.map { try $0.allocatedSizeBytes() }.reduce(0, +)
if cacheUsedBytes < reclaimBytes {
return
}
var cacheReclaimedBytes: Int = 0
var it = prunables.makeIterator()
while cacheReclaimedBytes <= reclaimBytes {
guard let prunable = it.next() else {
break
}
if prunable.url == initiator?.url.resolvingSymlinksInPath() {
// do not prune the initiator
continue
}
try SentrySDK.span?.setData(value: prunable.allocatedSizeBytes(), key: prunable.url.path)
cacheReclaimedBytes += try prunable.allocatedSizeBytes()
try prunable.delete()
}
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
}
}
-48
View File
@@ -1,48 +0,0 @@
import ArgumentParser
import Dispatch
import SwiftUI
struct Pull: AsyncParsableCommand {
static var configuration = CommandConfiguration(
abstract: "Pull a VM from a registry",
discussion: """
Pulls a virtual machine from a remote OCI-compatible registry. Supports authorization via Keychain (see "tart login --help"),
Docker credential helpers defined in ~/.docker/config.json or via TART_REGISTRY_USERNAME/TART_REGISTRY_PASSWORD environment variables.
By default, Tart checks available capacity in Tart's home directory and tries to reclaim minimum possible storage for the remote image to fit via "tart prune".
This behaviour can be disabled by setting TART_NO_AUTO_PRUNE environment variable.
"""
)
@Argument(help: "remote VM name")
var remoteName: String
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
var insecure: Bool = false
@Option(help: "network concurrency to use when pulling a remote VM from the OCI-compatible registry")
var concurrency: UInt = 4
func validate() throws {
if concurrency < 1 {
throw ValidationError("network concurrency cannot be less than 1")
}
}
func run() async throws {
// Be more liberal when accepting local image as argument,
// see https://github.com/cirruslabs/tart/issues/36
if VMStorageLocal().exists(remoteName) {
print("\"\(remoteName)\" is a local image, nothing to pull here!")
return
}
let remoteName = try RemoteName(remoteName)
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
defaultLogger.appendNewLine("pulling \(remoteName)...")
try await VMStorageOCI().pull(remoteName, registry: registry, concurrency: concurrency)
}
}
-122
View File
@@ -1,122 +0,0 @@
import ArgumentParser
import Dispatch
import Foundation
import Compression
struct Push: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Push a VM to a registry")
@Argument(help: "local or remote VM name", completion: .custom(completeMachines))
var localName: String
@Argument(help: "remote VM name(s)")
var remoteNames: [String]
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
var insecure: Bool = false
@Option(help: ArgumentHelp("chunk size in MB if registry supports chunked uploads",
discussion: """
By default monolithic method is used for uploading blobs to the registry but some registries support a more efficient chunked method.
For example, AWS Elastic Container Registry supports only chunks larger than 5MB but GitHub Container Registry supports only chunks smaller than 4MB. Google Container Registry on the other hand doesn't support chunked uploads at all.
Please refer to the documentation of your particular registry in order to see if this option is suitable for you and what's the recommended chunk size.
"""))
var chunkSize: Int = 0
@Option(help: .hidden)
var diskFormat: String = "v2"
@Flag(help: ArgumentHelp("cache pushed images locally",
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
var populateCache: Bool = false
func run() async throws {
let ociStorage = VMStorageOCI()
let localVMDir = try VMStorageHelper.open(localName)
// Parse remote names supplied by the user
let remoteNames = try remoteNames.map{
try RemoteName($0)
}
// Group remote names by registry
struct RegistryIdentifier: Hashable, Equatable {
var host: String
var namespace: String
}
let registryGroups = Dictionary(grouping: remoteNames, by: {
RegistryIdentifier(host: $0.host, namespace: $0.namespace)
})
// Push VM
for (registryIdentifier, remoteNamesForRegistry) in registryGroups {
let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace,
insecure: insecure)
defaultLogger.appendNewLine("pushing \(localName) to "
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
let references = remoteNamesForRegistry.map{ $0.reference.value }
let pushedRemoteName: RemoteName
// If we're pushing a local OCI VM, check if points to an already existing registry manifest
// and if so, only upload manifests (without config, disk and NVRAM) to the user-specified references
if let remoteName = try? RemoteName(localName) {
pushedRemoteName = try await lightweightPushToRegistry(
registry: registry,
remoteName: remoteName,
references: references
)
} else {
pushedRemoteName = try await localVMDir.pushToRegistry(
registry: registry,
references: references,
chunkSizeMb: chunkSize,
diskFormat: diskFormat
)
// Populate the local cache (if requested)
if populateCache {
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
}
}
// link the rest remote names
if populateCache {
for remoteName in remoteNamesForRegistry {
try ociStorage.link(from: remoteName, to: pushedRemoteName)
}
}
}
}
func lightweightPushToRegistry(registry: Registry, remoteName: RemoteName, references: [String]) async throws -> RemoteName {
// Is the local OCI VM already present in the registry?
let digest = try VMStorageOCI().digest(remoteName)
let (remoteManifest, _) = try await registry.pullManifest(reference: digest)
// Overwrite registry's references with the retrieved manifest
for reference in references {
defaultLogger.appendNewLine("pushing manifest for \(reference)...")
_ = try await registry.pushManifest(reference: reference, manifest: remoteManifest)
}
return RemoteName(host: registry.host!, namespace: registry.namespace,
reference: Reference(digest: digest))
}
}
extension Collection where Element == RemoteName {
func referenceNames() -> String {
let references = self.map{ $0.reference.fullyQualified }
switch count {
case 0: return "∅"
case 1: return references.first!
default: return "{" + references.joined(separator: ",") + "}"
}
}
}
-32
View File
@@ -1,32 +0,0 @@
import ArgumentParser
import Foundation
struct Rename: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Rename a VM")
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
var name: String
@Argument(help: "new VM name")
var newName: String
func validate() throws {
if newName.contains("/") {
throw ValidationError("<new-name> should be a local name")
}
}
func run() async throws {
let localStorage = VMStorageLocal()
if !localStorage.exists(name) {
throw ValidationError("failed to rename a non-existent VM: \(name)")
}
if localStorage.exists(newName) {
throw ValidationError("failed to rename VM \(name), target VM \(newName) already exists, delete it first!")
}
try localStorage.rename(name, newName)
}
}
-839
View File
@@ -1,839 +0,0 @@
import ArgumentParser
import Cocoa
import Darwin
import Dispatch
import SwiftUI
import Virtualization
import Sentry
var vm: VM?
struct IPNotFound: Error {
}
struct Run: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Run a VM")
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
var name: String
@Flag(help: ArgumentHelp(
"Don't open a UI window.",
discussion: "Useful for integrating Tart VMs into other tools.\nUse `tart ip` in order to get an IP for SSHing or VNCing into the VM."))
var noGraphics: Bool = false
@Flag(help: ArgumentHelp(
"Open serial console in /dev/ttySXX",
discussion: "Useful for debugging Linux Kernel."))
var serial: Bool = false
@Option(help: ArgumentHelp(
"Attach an externally created serial console",
discussion: "Alternative to `--serial` flag for programmatic integrations."
))
var serialPath: String?
@Flag(help: ArgumentHelp("Force open a UI window, even when VNC is enabled.", visibility: .private))
var graphics: Bool = false
@Flag(help: "Disable audio pass-through to host.")
var noAudio: Bool = false
#if arch(arm64)
@Flag(help: "Boot into recovery mode")
#endif
var recovery: Bool = false
#if arch(arm64)
@Flag(help: ArgumentHelp(
"Use screen sharing instead of the built-in UI.",
discussion: "Useful since Screen Sharing supports copy/paste, drag and drop, etc.\n"
+ "Note that Remote Login option should be enabled inside the VM."))
#endif
var vnc: Bool = false
#if arch(arm64)
@Flag(help: ArgumentHelp(
"Use Virtualization.Framework's VNC server instead of the built-in UI.",
discussion: "Useful since this type of VNC is available in recovery mode and in macOS installation.\n"
+ "Note that this feature is experimental and there may be bugs present when using VNC."))
#endif
var vncExperimental: Bool = false
@Option(help: ArgumentHelp("""
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\" --disk=\"/dev/disk0\" --disk=\"nbd://localhost:10809/myDisk\")
""", discussion: """
Can be either a disk image file, a block device like a local SSD on AWS EC2 Mac instances or a Network Block Device (NBD).
Learn how to create a disk image using Disk Utility here:
https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
To work with block devices 'tart' binary must be executed as root which affects locating Tart VMs.
To workaround this issue pass TART_HOME explicitly:
sudo TART_HOME="$HOME/.tart" tart run sonoma --disk=/dev/disk0
""", valueName: "path[:ro]"))
var disk: [String] = []
#if arch(arm64)
@Option(name: [.customLong("rosetta")], help: ArgumentHelp(
"Attaches a Rosetta share to the guest Linux VM with a specific tag (e.g. --rosetta=\"rosetta\")",
discussion: """
Requires host to be macOS 13.0 (Ventura) with Rosetta installed. The latter can be done
by running "softwareupdate --install-rosetta" (without quotes) in the Terminal.app.
Note that you also have to configure Rosetta in the guest Linux VM by following the
steps from "Mount the Shared Directory and Register Rosetta" section here:
https://developer.apple.com/documentation/virtualization/running_intel_binaries_in_linux_vms_with_rosetta#3978496
""",
valueName: "tag"
))
#endif
var rosettaTag: String?
@Option(help: ArgumentHelp("Additional directory shares with an optional read-only and mount tag options (e.g. --dir=\"~/src/build\" or --dir=\"~/src/sources:ro\")", discussion: """
Requires host to be macOS 13.0 (Ventura) or newer. macOS guests must be running macOS 13.0 (Ventura) or newer too.
Options are comma-separated and are as follows:
* ro — mount this directory share in read-only mode instead of the default read-write (e.g. --dir=\"~/src/sources:ro\")
* tag=<TAG> — by default, the \"com.apple.virtio-fs.automount\" mount tag is used for all directory shares. On macOS, this causes the directories to be automatically mounted to "/Volumes/My Shared Files" directory. On Linux, you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point".
Mount tag can be overridden by appending tag property to the directory share (e.g. --dir=\"~/src/build:tag=build\" or --dir=\"~/src/build:ro,tag=build\"). Then it can be mounted via "mount_virtiofs build ~/build" inside guest macOS and "mount -t virtiofs build ~/build" inside guest Linux.
In case of passing multiple directories per mount tag it is required to prefix them with names e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\". These names will be used as directory names under the mounting point inside guests. For the example above it will be "/Volumes/My Shared Files/build" and "/Volumes/My Shared Files/sources" respectively.
""", valueName: "[name:]path[:options]"))
var dir: [String] = []
@Option(help: ArgumentHelp("""
Use bridged networking instead of the default shared (NAT) networking \n(e.g. --net-bridged=en0 or --net-bridged=\"Wi-Fi\")
""", discussion: """
Specify "list" as an interface name (--net-bridged=list) to list the available bridged interfaces.
""", valueName: "interface name"))
var netBridged: [String] = []
@Flag(help: ArgumentHelp("Use software networking instead of the default shared (NAT) networking",
discussion: "Learn how to configure Softnet for use with Tart here: https://github.com/cirruslabs/softnet"))
var netSoftnet: Bool = false
@Option(help: ArgumentHelp("Comma-separated list of CIDRs to allow the traffic to when using Softnet isolation\n(e.g. --net-softnet-allow=192.168.0.0/24)", valueName: "comma-separated CIDRs"))
var netSoftnetAllow: String?
@Flag(help: ArgumentHelp("Restrict network access to the host-only network"))
var netHost: Bool = false
#if arch(arm64)
@Flag(help: ArgumentHelp("Disables audio and entropy devices and switches to only Mac-specific input devices.", discussion: "Useful for running a VM that can be suspended via \"tart suspend\"."))
#endif
var suspendable: Bool = false
#if arch(arm64)
@Flag(help: ArgumentHelp("Whether system hot keys should be sent to the guest instead of the host",
discussion: "If enabled then system hot keys like Cmd+Tab will be sent to the guest instead of the host."))
#endif
var captureSystemKeys: Bool = false
mutating func validate() throws {
if vnc && vncExperimental {
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
}
// check that not more than one network option is specified
var netFlags = 0
if netBridged.count > 0 { netFlags += 1 }
if netSoftnet { netFlags += 1 }
if netHost { netFlags += 1 }
if netFlags > 1 {
throw ValidationError("--net-bridged, --net-softnet and --net-host are mutually exclusive")
}
if graphics && noGraphics {
throw ValidationError("--graphics and --no-graphics are mutually exclusive")
}
if (noGraphics || vnc || vncExperimental) && captureSystemKeys {
throw ValidationError("--captures-system-keys can only be used with the default VM view")
}
let localStorage = VMStorageLocal()
let vmDir = try localStorage.open(name)
if try vmDir.state() == .Suspended {
suspendable = true
}
if suspendable {
if dir.count > 0 {
throw ValidationError("Suspending VMs with shared directories is not supported")
}
}
for disk in disk {
if disk.hasSuffix("-amd64.iso") {
throw ValidationError("Seems you have a disk targeting x86 architecture (hence amd64 in the name). Please use an 'arm64' version of the disk.")
}
}
}
@MainActor
func run() async throws {
let localStorage = VMStorageLocal()
let vmDir = try localStorage.open(name)
let storageLock = try FileLock(lockURL: Config().tartHomeDir)
if try vmDir.state() == .Suspended {
try storageLock.lock() // lock before checking
let needToGenerateNewMac = try localStorage.list().contains {
// check if there is a running VM with the same MAC but different name
try $1.running() && $1.macAddress() == vmDir.macAddress() && $1.name != vmDir.name
}
if needToGenerateNewMac {
print("There is already a running VM with the same MAC address!")
print("Resetting VM to assign a new MAC address...")
try vmDir.regenerateMACAddress()
}
}
if (netSoftnet || netHost) && isInteractiveSession() {
try Softnet.configureSUIDBitIfNeeded()
}
let additionalDiskAttachments = try additionalDiskAttachments()
var serialPorts: [VZSerialPortConfiguration] = []
if serial {
let tty_fd = createPTY()
if (tty_fd < 0) {
throw RuntimeError.VMConfigurationError("Failed to create PTY")
}
let tty_read = FileHandle.init(fileDescriptor: tty_fd)
let tty_write = FileHandle.init(fileDescriptor: tty_fd)
serialPorts.append(createSerialPortConfiguration(tty_read, tty_write))
} else if serialPath != nil {
let tty_read = FileHandle.init(forReadingAtPath: serialPath!)
let tty_write = FileHandle.init(forWritingAtPath: serialPath!)
if (tty_read == nil || tty_write == nil) {
throw RuntimeError.VMConfigurationError("Failed to open PTY")
}
serialPorts.append(createSerialPortConfiguration(tty_read!, tty_write!))
}
vm = try VM(
vmDir: vmDir,
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
additionalStorageDevices: additionalDiskAttachments,
directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
serialPorts: serialPorts,
suspendable: suspendable,
audio: !noAudio
)
let vncImpl: VNC? = try {
if vnc {
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
return ScreenSharingVNC(vmConfig: vmConfig)
} else if vncExperimental {
return FullFledgedVNC(virtualMachine: vm!.virtualMachine)
} else {
return nil
}
}()
// Lock the VM
//
// More specifically, lock the "config.json", because we can't lock
// directories with fcntl(2)-based locking and we better not interfere
// with the VM's disk and NVRAM, because they are opened (and even seem
// to be locked) directly by the Virtualization.Framework's process.
//
// Note that due to "completely stupid semantics"[1] of the fcntl-based
// file locking, we need to acquire the lock after we read the VM's
// configuration file, otherwise we will loose the lock.
//
// [1]: https://man.openbsd.org/fcntl
let lock = try vmDir.lock()
if try !lock.trylock() {
throw RuntimeError.VMAlreadyRunning("VM \"\(name)\" is already running!")
}
// now VM state will return "running" so we can unlock
try storageLock.unlock()
let task = Task {
do {
var resume = false
#if arch(arm64)
if #available(macOS 14, *) {
if FileManager.default.fileExists(atPath: vmDir.stateURL.path) {
print("restoring VM state from a snapshot...")
try await vm!.virtualMachine.restoreMachineStateFrom(url: vmDir.stateURL)
try FileManager.default.removeItem(at: vmDir.stateURL)
resume = true
print("resuming VM...")
}
}
#endif
try await vm!.start(recovery: recovery, resume: resume)
if let vncImpl = vncImpl {
let vncURL = try await vncImpl.waitForURL()
if noGraphics || ProcessInfo.processInfo.environment["CI"] != nil {
print("VNC server is running at \(vncURL)")
} else {
print("Opening \(vncURL)...")
NSWorkspace.shared.open(vncURL)
}
}
try await vm!.run()
if let vncImpl = vncImpl {
try vncImpl.stop()
}
Foundation.exit(0)
} catch {
// Capture the error into Sentry
SentrySDK.capture(error: error)
SentrySDK.flush(timeout: 2.seconds.timeInterval)
fputs("\(error)\n", stderr)
Foundation.exit(1)
}
}
// "tart stop" support
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
sigintSrc.setEventHandler {
task.cancel()
}
sigintSrc.activate()
// "tart suspend" / UI window closing support
signal(SIGUSR1, SIG_IGN)
let sigusr1Src = DispatchSource.makeSignalSource(signal: SIGUSR1)
sigusr1Src.setEventHandler {
Task {
do {
#if arch(arm64)
if #available(macOS 14, *) {
try vm!.configuration.validateSaveRestoreSupport()
print("pausing VM to take a snapshot...")
try await vm!.virtualMachine.pause()
print("creating a snapshot...")
try await vm!.virtualMachine.saveMachineStateTo(url: vmDir.stateURL)
print("snapshot created successfully! shutting down the VM...")
task.cancel()
} else {
print(RuntimeError.SuspendFailed("this functionality is only supported on macOS 14 (Sonoma) or newer"))
Foundation.exit(1)
}
#endif
} catch (let e) {
print(RuntimeError.SuspendFailed(e.localizedDescription))
Foundation.exit(1)
}
}
}
sigusr1Src.activate()
let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics
if noGraphics || useVNCWithoutGraphics {
// enter the main even loop, without bringing up any UI,
// and just wait for the VM to exit.
NSApplication.shared.run()
} else {
runUI(suspendable, captureSystemKeys)
}
}
private func createSerialPortConfiguration(_ tty_read: FileHandle, _ tty_write: FileHandle) -> VZVirtioConsoleDeviceSerialPortConfiguration {
let serialPortConfiguration = VZVirtioConsoleDeviceSerialPortConfiguration()
let serialPortAttachment = VZFileHandleSerialPortAttachment(
fileHandleForReading: tty_read,
fileHandleForWriting: tty_write)
serialPortConfiguration.attachment = serialPortAttachment
return serialPortConfiguration
}
func isInteractiveSession() -> Bool {
isatty(STDOUT_FILENO) == 1
}
func userSpecifiedNetwork(vmDir: VMDirectory) throws -> Network? {
if netSoftnet {
let config = try VMConfig.init(fromURL: vmDir.configURL)
var extraArguments: [String] = []
if let netSoftnetAllow = netSoftnetAllow {
extraArguments += ["--allow", netSoftnetAllow]
}
return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: extraArguments)
}
if netHost {
let config = try VMConfig.init(fromURL: vmDir.configURL)
return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: ["--vm-net-type", "host"])
}
if netBridged.count > 0 {
func findBridgedInterface(_ name: String) throws -> VZBridgedNetworkInterface {
let interface = VZBridgedNetworkInterface.networkInterfaces.first { interface in
interface.identifier == name || interface.localizedDisplayName == name
}
if (interface == nil) {
throw ValidationError("no bridge interfaces matched \"\(netBridged)\", "
+ "available interfaces: \(bridgeInterfaces())")
}
return interface!
}
return NetworkBridged(interfaces: try netBridged.map { try findBridgedInterface($0) })
}
return nil
}
func bridgeInterfaces() -> [String] {
VZBridgedNetworkInterface.networkInterfaces.map { interface in
var bridgeDescription = interface.identifier
if let localizedDisplayName = interface.localizedDisplayName {
bridgeDescription += " (or \"\(localizedDisplayName)\")"
}
return bridgeDescription
}
}
func additionalDiskAttachments() throws -> [VZStorageDeviceConfiguration] {
var result: [VZStorageDeviceConfiguration] = []
let readOnlySuffix = ":ro"
let expandedDiskPaths = disk.map { NSString(string:$0).expandingTildeInPath }
for rawDisk in expandedDiskPaths {
let diskReadOnly = rawDisk.hasSuffix(readOnlySuffix)
let diskPath = diskReadOnly ? String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count)) : rawDisk
if (diskPath.starts(with: "nbd://")) {
guard #available(macOS 14, *) else {
throw UnsupportedOSError("attaching Network Block Devices", "are")
}
guard let nbdURL = URL(string: diskPath) else {
throw RuntimeError.VMConfigurationError("invalid NBD URL: \(diskPath)")
}
let nbdAttachment = try VZNetworkBlockDeviceStorageDeviceAttachment(
url: nbdURL,
timeout: 30,
isForcedReadOnly: diskReadOnly,
synchronizationMode: VZDiskSynchronizationMode.none
)
result.append(VZVirtioBlockDeviceConfiguration(attachment: nbdAttachment))
continue
}
let diskURL = URL(fileURLWithPath: diskPath)
// check if `diskPath` is a block device or a directory
if pathHasMode(diskPath, mode: S_IFBLK) || pathHasMode(diskPath, mode: S_IFDIR) {
print("Using block device\n")
guard #available(macOS 14, *) else {
throw UnsupportedOSError("attaching block devices", "are")
}
let fileHandle = FileHandle(forUpdatingAtPath: diskPath)
guard fileHandle != nil else {
if ProcessInfo.processInfo.userName != "root" {
throw RuntimeError.VMConfigurationError("need to run as root to work with block devices")
}
throw RuntimeError.VMConfigurationError("block device \(diskURL.url.path) seems to be already in use, unmount it first via 'diskutil unmount'")
}
let attachment = try VZDiskBlockDeviceStorageDeviceAttachment(fileHandle: fileHandle!, readOnly: diskReadOnly, synchronizationMode: .full)
result.append(VZVirtioBlockDeviceConfiguration(attachment: attachment))
} else {
// Error out if the disk is locked by the host (e.g. it was mounted in Finder),
// see https://github.com/cirruslabs/tart/issues/323 for more details.
if try !diskReadOnly && !FileLock(lockURL: diskURL).trylock() {
throw RuntimeError.DiskAlreadyInUse("disk \(diskURL.url.path) seems to be already in use, unmount it first in Finder")
}
let diskImageAttachment = try VZDiskImageStorageDeviceAttachment(
url: diskURL,
readOnly: diskReadOnly
)
result.append(VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment))
}
}
return result
}
func directoryShares() throws -> [VZDirectorySharingDeviceConfiguration] {
if dir.isEmpty {
return []
}
guard #available(macOS 13, *) else {
throw UnsupportedOSError("directory sharing", "is")
}
var allDirectoryShares: [DirectoryShare] = []
for rawDir in dir {
allDirectoryShares.append(try DirectoryShare(parseFrom: rawDir))
}
return try Dictionary(grouping: allDirectoryShares, by: {$0.mountTag}).map { mountTag, directoryShares in
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: mountTag)
var allNamedShares = true
for directoryShare in directoryShares {
if directoryShare.name == nil {
allNamedShares = false
}
}
if directoryShares.count == 1 && directoryShares.first!.name == nil {
let directoryShare = directoryShares.first!
let singleDirectoryShare = VZSingleDirectoryShare(directory: try directoryShare.createConfiguration())
sharingDevice.share = singleDirectoryShare
} else if !allNamedShares {
throw ValidationError("invalid --dir syntax: for multiple directory shares each one of them should be named")
} else {
var directories: [String : VZSharedDirectory] = Dictionary()
try directoryShares.forEach { directories[$0.name!] = try $0.createConfiguration() }
sharingDevice.share = VZMultipleDirectoryShare(directories: directories)
}
return sharingDevice
}
}
private func rosettaDirectoryShare() throws -> [VZDirectorySharingDeviceConfiguration] {
guard let rosettaTag = rosettaTag else {
return []
}
#if arch(arm64)
guard #available(macOS 13, *) else {
throw UnsupportedOSError("Rosetta directory share", "is")
}
switch VZLinuxRosettaDirectoryShare.availability {
case .notInstalled:
throw UnsupportedOSError("Rosetta directory share", "is", "that have Rosetta installed")
case .notSupported:
throw UnsupportedOSError("Rosetta directory share", "is", "running Apple silicon")
default:
break
}
try VZVirtioFileSystemDeviceConfiguration.validateTag(rosettaTag)
let device = VZVirtioFileSystemDeviceConfiguration(tag: rosettaTag)
device.share = try VZLinuxRosettaDirectoryShare()
return [device]
#elseif arch(x86_64)
// there is no Rosetta on Intel
return []
#endif
}
private func runUI(_ suspendable: Bool, _ captureSystemKeys: Bool) {
let nsApp = NSApplication.shared
nsApp.setActivationPolicy(.regular)
nsApp.activate(ignoringOtherApps: true)
struct MainApp: App {
static var disappearSignal: Int32 = SIGINT
static var capturesSystemKeys: Bool = false
@NSApplicationDelegateAdaptor private var appDelegate: MinimalMenuAppDelegate
var body: some Scene {
WindowGroup(vm!.name) {
Group {
VMView(vm: vm!, capturesSystemKeys: MainApp.capturesSystemKeys).onAppear {
NSWindow.allowsAutomaticWindowTabbing = false
}.onDisappear {
let ret = kill(getpid(), MainApp.disappearSignal)
if ret != 0 {
// Fallback to the old termination method that doesn't
// propagate the cancellation to Task's in case graceful
// termination via kill(2) is not successful
NSApplication.shared.terminate(self)
}
}
}.frame(
minWidth: CGFloat(vm!.config.display.width),
idealWidth: CGFloat(vm!.config.display.width),
maxWidth: .infinity,
minHeight: CGFloat(vm!.config.display.height),
idealHeight: CGFloat(vm!.config.display.height),
maxHeight: .infinity
)
}.commands {
// Remove some standard menu options
CommandGroup(replacing: .help, addition: {})
CommandGroup(replacing: .newItem, addition: {})
CommandGroup(replacing: .pasteboard, addition: {})
CommandGroup(replacing: .textEditing, addition: {})
CommandGroup(replacing: .undoRedo, addition: {})
CommandGroup(replacing: .windowSize, addition: {})
// Replace some standard menu options
CommandGroup(replacing: .appInfo) { AboutTart(config: vm!.config) }
CommandMenu("Control") {
Button("Start") {
Task { try await vm!.virtualMachine.start() }
}
Button("Stop") {
Task { try await vm!.virtualMachine.stop() }
}
Button("Request Stop") {
Task { try vm!.virtualMachine.requestStop() }
}
if #available(macOS 14, *) {
Button("Suspend") {
kill(getpid(), SIGUSR1)
}
}
}
}
}
}
MainApp.disappearSignal = suspendable ? SIGUSR1 : SIGINT
MainApp.capturesSystemKeys = captureSystemKeys
MainApp.main()
}
}
// The only way to fully remove Edit menu item.
class MinimalMenuAppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
let indexOfEditMenu = 2
func applicationDidFinishLaunching(_ : Notification) {
NSApplication.shared.mainMenu?.removeItem(at: indexOfEditMenu)
}
}
struct AboutTart: View {
var credits: NSAttributedString
init(config: VMConfig) {
let mutableAttrStr = NSMutableAttributedString()
let style = NSMutableParagraphStyle()
style.alignment = NSTextAlignment.center
let attrCenter: [NSAttributedString.Key : Any] = [
.paragraphStyle: style,
]
mutableAttrStr.append(NSAttributedString(string: "CPU: \(config.cpuCount) cores\n", attributes: attrCenter))
mutableAttrStr.append(NSAttributedString(string: "Memory: \(config.memorySize / 1024 / 1024) MB\n", attributes: attrCenter))
mutableAttrStr.append(NSAttributedString(string: "Display: \(config.display.description)\n", attributes: attrCenter))
mutableAttrStr.append(NSAttributedString(string: "https://github.com/cirruslabs/tart", attributes: [
.paragraphStyle: style,
.link : "https://github.com/cirruslabs/tart"
]))
credits = mutableAttrStr
}
var body: some View {
Button("About Tart") {
NSApplication.shared.orderFrontStandardAboutPanel(options: [
NSApplication.AboutPanelOptionKey.applicationIcon: NSApplication.shared.applicationIconImage as Any,
NSApplication.AboutPanelOptionKey.applicationName: "Tart",
NSApplication.AboutPanelOptionKey.applicationVersion: CI.version,
NSApplication.AboutPanelOptionKey.credits: credits,
])
}
}
}
struct VMView: NSViewRepresentable {
typealias NSViewType = VZVirtualMachineView
@ObservedObject var vm: VM
var capturesSystemKeys: Bool
func makeNSView(context: Context) -> NSViewType {
let machineView = VZVirtualMachineView()
machineView.capturesSystemKeys = capturesSystemKeys
// Enable automatic display reconfiguration
// for guests that support it
//
// This is disabled for Linux because of poor HiDPI
// support, which manifests in fonts being too small
if #available(macOS 14.0, *), vm.config.os != .linux {
machineView.automaticallyReconfiguresDisplay = true
}
return machineView
}
func updateNSView(_ nsView: NSViewType, context: Context) {
nsView.virtualMachine = vm.virtualMachine
}
}
struct DirectoryShare {
let name: String?
let path: URL
let readOnly: Bool
let mountTag: String
init(parseFrom: String) throws {
var parseFrom = parseFrom
// Consume options
(self.readOnly, self.mountTag, parseFrom) = Self.parseOptions(parseFrom)
// Special case for URLs
if parseFrom.hasPrefix("http:") || parseFrom.hasPrefix("https:") {
self.name = nil
self.path = URL(string: parseFrom)!
return
}
let arguments = parseFrom.split(separator: ":", maxSplits: 1)
if arguments.count == 2 {
self.name = String(arguments[0])
self.path = String(arguments[1]).toRemoteOrLocalURL()
} else {
self.name = nil
self.path = String(arguments[0]).toRemoteOrLocalURL()
}
}
static func parseOptions(_ parseFrom: String) -> (Bool, String, String) {
var arguments = parseFrom.split(separator: ":")
let options = arguments.last!.split(separator: ",")
var readOnly: Bool = false
var mountTag: String = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
var found: Bool = false
for option in options {
switch true {
case option == "ro":
readOnly = true
found = true
case option.hasPrefix("tag="):
mountTag = String(option.dropFirst(4))
found = true
default:
continue
}
}
if found {
arguments.removeLast()
}
return (readOnly, mountTag, arguments.joined(separator: ":"))
}
func createConfiguration() throws -> VZSharedDirectory {
if (path.isFileURL) {
return VZSharedDirectory(url: path, readOnly: readOnly)
}
let urlCache = URLCache(memoryCapacity: 0, diskCapacity: 1 * 1024 * 1024 * 1024)
let archiveRequest = URLRequest(url: path, cachePolicy: .returnCacheDataElseLoad)
var response: CachedURLResponse? = urlCache.cachedResponse(for: archiveRequest)
if (response == nil || response?.data.isEmpty == true) {
print("Downloading \(path)...")
// download and unarchive remote directories if needed here
// use old school API to prevent deadlocks since we are running via MainActor
let downloadSemaphore = DispatchSemaphore(value: 0)
Task {
do {
let (archiveData, archiveResponse) = try await URLSession.shared.data(for: archiveRequest)
if archiveData.isEmpty {
print("Remote archive is empty!")
} else {
urlCache.storeCachedResponse(CachedURLResponse(response: archiveResponse, data: archiveData, storagePolicy: .allowed), for: archiveRequest)
print("Cached for future invocations!")
}
} catch {
print("Download failed: \(error)")
}
downloadSemaphore.signal()
}
downloadSemaphore.wait()
response = urlCache.cachedResponse(for: archiveRequest)
} else {
print("Using cached archive for \(path)...")
}
if (response == nil) {
throw ValidationError("Failed to fetch a remote archive!")
}
let temporaryLocation = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString + ".volume")
try FileManager.default.createDirectory(atPath: temporaryLocation.path, withIntermediateDirectories: true)
let lock = try FileLock(lockURL: temporaryLocation)
try lock.lock()
guard let executableURL = resolveBinaryPath("tar") else {
throw ValidationError("tar not found in PATH")
}
let process = Process.init()
process.executableURL = executableURL
process.currentDirectoryURL = temporaryLocation
process.arguments = ["-xz"]
let inPipe = Pipe()
process.standardInput = inPipe
process.launch()
inPipe.fileHandleForWriting.write(response!.data)
try inPipe.fileHandleForWriting.close()
process.waitUntilExit()
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
throw ValidationError("Unarchiving failed!")
}
print("Unarchived into a temporary directory!")
return VZSharedDirectory(url: temporaryLocation, readOnly: readOnly)
}
}
extension String {
func toRemoteOrLocalURL() -> URL {
if (starts(with: "https://") || starts(with: "https://")) {
URL(string: self)!
} else {
URL(fileURLWithPath: NSString(string: self).expandingTildeInPath)
}
}
}
func pathHasMode(_ path: String, mode: mode_t) -> Bool {
var st = stat()
let statRes = stat(path, &st)
guard statRes != -1 else {
return false
}
return (Int32(st.st_mode) & Int32(mode)) == Int32(mode)
}
-72
View File
@@ -1,72 +0,0 @@
import ArgumentParser
import Foundation
struct Set: AsyncParsableCommand {
static var configuration = CommandConfiguration(commandName: "set", abstract: "Modify VM's configuration")
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
var name: String
@Option(help: "Number of VM CPUs")
var cpu: UInt16?
@Option(help: "VM memory size in megabytes")
var memory: UInt64?
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
var display: VMDisplayConfig?
@Option(help: ArgumentHelp("Resize the VMs disk to the specified size in GB (note that the disk size can only be increased to avoid losing data)",
discussion: """
Disk resizing works on most cloud-ready Linux distributions out-of-the box (e.g. Ubuntu Cloud Images
have the \"cloud-initramfs-growroot\" package installed that runs on boot) and on the rest of the
distributions by running the \"growpart\" or \"resize2fs\" commands.
For macOS, however, things are a bit more complicated: you need to remove the recovery partition
first and then run various \"diskutil\" commands, see Tart's packer plugin source code for more
details[1].
[1]: https://github.com/cirruslabs/packer-plugin-tart/blob/main/builder/tart/step_disk_resize.go
"""))
var diskSize: UInt16?
func run() async throws {
let vmDir = try VMStorageLocal().open(name)
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
if let cpu = cpu {
try vmConfig.setCPU(cpuCount: Int(cpu))
}
if let memory = memory {
try vmConfig.setMemory(memorySize: memory * 1024 * 1024)
}
if let display = display {
if (display.width > 0) {
vmConfig.display.width = display.width
}
if (display.height > 0) {
vmConfig.display.height = display.height
}
}
try vmConfig.save(toURL: vmDir.configURL)
if diskSize != nil {
try vmDir.resizeDisk(diskSize!)
}
}
}
extension VMDisplayConfig: ExpressibleByArgument {
public init(argument: String) {
let parts = argument.components(separatedBy: "x").map {
Int($0) ?? 0
}
self = VMDisplayConfig(
width: parts[safe: 0] ?? 0,
height: parts[safe: 1] ?? 0
)
}
}
-75
View File
@@ -1,75 +0,0 @@
import ArgumentParser
import Foundation
import System
import SwiftDate
struct Stop: AsyncParsableCommand {
static var configuration = CommandConfiguration(commandName: "stop", abstract: "Stop a VM")
@Argument(help: "VM name", completion: .custom(completeRunningMachines))
var name: String
@Option(name: [.short, .long], help: "Seconds to wait for graceful termination before forcefully terminating the VM")
var timeout: UInt64 = 30
func run() async throws {
let vmDir = try VMStorageLocal().open(name)
switch try vmDir.state() {
case .Suspended:
try stopSuspended(vmDir)
case .Running:
try await stopRunning(vmDir)
case .Stopped:
throw RuntimeError.VMNotRunning(name)
}
}
func stopSuspended(_ vmDir: VMDirectory) throws {
try? FileManager.default.removeItem(at: vmDir.stateURL)
}
func stopRunning(_ vmDir: VMDirectory) async throws {
let lock = try vmDir.lock()
// Find the VM's PID
var pid = try lock.pid()
if pid == 0 {
throw RuntimeError.VMNotRunning(name)
}
// Try to gracefully terminate the VM
//
// Note that we don't check the return code here
// to provide a clean exit from "tart stop" in cases
// when the VM is already shutting down and we hit
// a race condition.
//
// We check the return code in the kill(2) below, though,
// because it's a less common scenario and it would be
// nice to know for the user that we've tried all methods
// and failed to shutdown the VM.
kill(pid, SIGINT)
// Ensure that the VM has terminated
var gracefulWaitDuration = Measurement(value: Double(timeout), unit: UnitDuration.seconds)
let gracefulTickDuration = Measurement(value: Double(100), unit: UnitDuration.milliseconds)
while gracefulWaitDuration.value > 0 {
pid = try lock.pid()
if pid == 0 {
return
}
try await Task.sleep(nanoseconds: UInt64(gracefulTickDuration.converted(to: .nanoseconds).value))
gracefulWaitDuration = gracefulWaitDuration - gracefulTickDuration
}
// Seems that VM is still running, proceed with forceful termination
let ret = kill(pid, SIGKILL)
if ret != 0 {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.VMTerminationFailed("failed to forcefully terminate the VM \"\(name)\": \(details)")
}
}
}
-28
View File
@@ -1,28 +0,0 @@
import ArgumentParser
import Foundation
import System
import SwiftDate
struct Suspend: AsyncParsableCommand {
static var configuration = CommandConfiguration(commandName: "suspend", abstract: "Suspend a VM")
@Argument(help: "VM name", completion: .custom(completeRunningMachines))
var name: String
func run() async throws {
let vmDir = try VMStorageLocal().open(name)
let lock = try vmDir.lock()
// Find the VM's PID
let pid = try lock.pid()
if pid == 0 {
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
}
// Tell the "tart run" process to suspend the VM
let ret = kill(pid, SIGUSR1)
if ret != 0 {
throw RuntimeError.SuspendFailed("failed to send SIGUSR1 signal to the \"tart run\" process running VM \"\(name)\"")
}
}
}
-52
View File
@@ -1,52 +0,0 @@
import Foundation
struct Config {
let tartHomeDir: URL
let tartCacheDir: URL
let tartTmpDir: URL
init() throws {
var tartHomeDir: URL
if let customTartHome = ProcessInfo.processInfo.environment["TART_HOME"] {
tartHomeDir = URL(fileURLWithPath: customTartHome)
} else {
tartHomeDir = FileManager.default
.homeDirectoryForCurrentUser
.appendingPathComponent(".tart", isDirectory: true)
}
self.tartHomeDir = tartHomeDir
tartCacheDir = tartHomeDir.appendingPathComponent("cache", isDirectory: true)
try FileManager.default.createDirectory(at: tartCacheDir, withIntermediateDirectories: true)
tartTmpDir = tartHomeDir.appendingPathComponent("tmp", isDirectory: true)
try FileManager.default.createDirectory(at: tartTmpDir, withIntermediateDirectories: true)
}
func gc() throws {
for entry in try FileManager.default.contentsOfDirectory(at: tartTmpDir,
includingPropertiesForKeys: [], options: []) {
let lock = try FileLock(lockURL: entry)
if try !lock.trylock() {
continue
}
try FileManager.default.removeItem(at: entry)
try lock.unlock()
}
}
static func jsonEncoder() -> JSONEncoder {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
return encoder
}
static func jsonDecoder() -> JSONDecoder {
JSONDecoder()
}
}
@@ -1,10 +0,0 @@
import Foundation
enum CredentialsProviderError: Error {
case Failed(message: String)
}
protocol CredentialsProvider {
func retrieve(host: String) throws -> (String, String)?
func store(host: String, user: String, password: String) throws
}
@@ -1,113 +0,0 @@
import Foundation
class DockerConfigCredentialsProvider: CredentialsProvider {
func retrieve(host: String) throws -> (String, String)? {
let dockerConfigURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json")
if !FileManager.default.fileExists(atPath: dockerConfigURL.path) {
return nil
}
let config = try JSONDecoder().decode(DockerConfig.self, from: Data(contentsOf: dockerConfigURL))
if let credentialsFromAuth = config.auths?[host]?.decodeCredentials() {
return credentialsFromAuth
}
if let helperProgram = try config.findCredHelper(host: host) {
return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host)
}
return nil
}
private func executeHelper(binaryName: String, host: String) throws -> (String, String)? {
guard let executableURL = resolveBinaryPath(binaryName) else {
throw CredentialsProviderError.Failed(message: "\(binaryName) not found in PATH")
}
let process = Process.init()
process.executableURL = executableURL
process.arguments = ["get"]
let outPipe = Pipe()
let inPipe = Pipe()
process.standardOutput = outPipe
process.standardError = outPipe
process.standardInput = inPipe
process.launch()
inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
inPipe.fileHandleForWriting.closeFile()
process.waitUntilExit()
let outputData = try outPipe.fileHandleForReading.readToEnd()
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
if let outputData = outputData {
print(String(decoding: outputData, as: UTF8.self))
}
throw CredentialsProviderError.Failed(message: "Docker helper failed!")
}
if outputData == nil || outputData?.count == 0 {
throw CredentialsProviderError.Failed(message: "Docker helper output is empty!")
}
let getOutput = try JSONDecoder().decode(DockerGetOutput.self, from: outputData!)
return (getOutput.Username, getOutput.Secret)
}
func store(host: String, user: String, password: String) throws {
throw CredentialsProviderError.Failed(message: "Docker helpers don't support storing!")
}
}
struct DockerConfig: Codable {
var auths: Dictionary<String, DockerAuthConfig>? = Dictionary()
var credHelpers: Dictionary<String, String>? = Dictionary()
func findCredHelper(host: String) throws -> String? {
// Tart supports wildcards in credHelpers
// Similar to what is requested from Docker: https://github.com/docker/cli/issues/2928
guard let credHelpers else {
return nil
}
for (hostPattern, helperProgram) in credHelpers {
if (hostPattern == host) {
return helperProgram
}
let compiledPattern = try? Regex(hostPattern)
if (try compiledPattern?.wholeMatch(in: host) != nil) {
return helperProgram
}
}
return nil
}
}
struct DockerAuthConfig: Codable {
var auth: String? = nil
func decodeCredentials() -> (String, String)? {
// auth is a base64("username:password")
guard let authBase64 = auth else {
return nil
}
guard let data = Data(base64Encoded: authBase64) else {
return nil
}
guard let components = String(data: data, encoding: .utf8)?.components(separatedBy: ":") else {
return nil
}
if components.count != 2 {
return nil
}
return (components[0], components[1])
}
}
struct DockerGetOutput: Codable {
var Username: String
var Secret: String
}
@@ -1,20 +0,0 @@
import Foundation
class EnvironmentCredentialsProvider: CredentialsProvider {
func retrieve(host: String) throws -> (String, String)? {
if let tartRegistryHostname = ProcessInfo.processInfo.environment["TART_REGISTRY_HOSTNAME"],
tartRegistryHostname != host {
return nil
}
let username = ProcessInfo.processInfo.environment["TART_REGISTRY_USERNAME"]
let password = ProcessInfo.processInfo.environment["TART_REGISTRY_PASSWORD"]
if let username = username, let password = password {
return (username, password)
}
return nil
}
func store(host: String, user: String, password: String) throws {
}
}
@@ -1,88 +0,0 @@
import Foundation
class KeychainCredentialsProvider: CredentialsProvider {
func retrieve(host: String) throws -> (String, String)? {
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
kSecAttrServer as String: host,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnAttributes as String: true,
kSecReturnData as String: true,
kSecAttrLabel as String: "Tart Credentials",
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status != errSecSuccess {
if status == errSecItemNotFound {
return nil
}
throw CredentialsProviderError.Failed(message: "Keychain returned unsuccessful status \(status)")
}
guard let item = item as? [String: Any],
let user = item[kSecAttrAccount as String] as? String,
let passwordData = item[kSecValueData as String] as? Data,
let password = String(data: passwordData, encoding: .utf8)
else {
throw CredentialsProviderError.Failed(message: "Keychain item has unexpected format")
}
return (user, password)
}
func store(host: String, user: String, password: String) throws {
let passwordData = password.data(using: .utf8)
let key: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
kSecAttrServer as String: host,
kSecAttrLabel as String: "Tart Credentials",
]
let value: [String: Any] = [kSecAttrAccount as String: user,
kSecValueData as String: passwordData as Any,
]
let status = SecItemCopyMatching(key as CFDictionary, nil)
switch status {
case errSecItemNotFound:
let status = SecItemAdd(key.merging(value) { (current, _) in current } as CFDictionary, nil)
if status != errSecSuccess {
throw CredentialsProviderError.Failed(message: "Keychain failed to add item: \(status.explanation())")
}
case errSecSuccess:
let status = SecItemUpdate(key as CFDictionary, value as CFDictionary)
if status != errSecSuccess {
throw CredentialsProviderError.Failed(message: "Keychain failed to update item: \(status.explanation())")
}
default:
throw CredentialsProviderError.Failed(message: "Keychain failed to find item: \(status.explanation())")
}
}
func remove(host: String) throws {
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrServer as String: host,
kSecAttrLabel as String: "Tart Credentials",
]
let status = SecItemDelete(query as CFDictionary)
switch status {
case errSecSuccess:
return
case errSecItemNotFound:
return
default:
throw CredentialsProviderError.Failed(message: "Failed to remove Keychain item(s): \(status.explanation())")
}
}
}
extension OSStatus {
func explanation() -> CFString {
SecCopyErrorMessageString(self, nil) ?? "Unknown status code \(self)." as CFString
}
}
@@ -1,31 +0,0 @@
import Foundation
enum StdinCredentialsError: Error {
case CredentialRequired(which: String)
case CredentialTooLong(message: String)
}
class StdinCredentials {
static func retrieve() throws -> (String, String) {
let user = try readStdinCredential(name: "username", prompt: "User: ", isSensitive: false)
let password = try readStdinCredential(name: "password", prompt: "Password: ", isSensitive: true)
return (user, password)
}
private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 1024, isSensitive: Bool) throws -> String {
var buf = [CChar](repeating: 0, count: maxCharacters + 1 /* sentinel */ + 1 /* NUL */)
guard let rawCredential = readpassphrase(prompt, &buf, buf.count, isSensitive ? RPP_ECHO_OFF : RPP_ECHO_ON) else {
throw StdinCredentialsError.CredentialRequired(which: name)
}
let credential = String(cString: rawCredential).trimmingCharacters(in: .newlines)
if credential.count > maxCharacters {
throw StdinCredentialsError.CredentialTooLong(
message: "\(name) should contain no more than \(maxCharacters) characters")
}
return credential
}
}
-37
View File
@@ -1,37 +0,0 @@
import Foundation
import Sysctl
class DeviceInfo {
private static var osMemoized: String? = nil
private static var modelMemoized: String? = nil
static var os: String {
if let os = osMemoized {
return os
}
osMemoized = getOS()
return osMemoized!
}
static var model: String {
if let model = modelMemoized {
return model
}
modelMemoized = getModel()
return modelMemoized!
}
private static func getOS() -> String {
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
return "macOS \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
}
private static func getModel() -> String {
return SystemControl().hardware.model
}
}
-80
View File
@@ -1,80 +0,0 @@
import Foundation
import AsyncAlgorithms
fileprivate let urlSession = createURLSession()
class DownloadDelegate: NSObject, URLSessionTaskDelegate {
let progress: Progress
init(_ progress: Progress) throws {
self.progress = progress
}
func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) {
self.progress.addChild(task.progress, withPendingUnitCount: self.progress.totalUnitCount)
}
}
class Fetcher {
static func fetch(_ request: URLRequest, viaFile: Bool = false, progress: Progress? = nil) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
let delegate = progress != nil ? try DownloadDelegate(progress!) : nil
if viaFile {
return try await fetchViaFile(request, delegate: delegate)
}
return try await fetchViaMemory(request, delegate: delegate)
}
private static func fetchViaMemory(_ request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
let dataCh = AsyncThrowingChannel<Data, Error>()
let (data, response) = try await urlSession.data(for: request, delegate: delegate)
Task {
await dataCh.send(data)
dataCh.finish()
}
return (dataCh, response as! HTTPURLResponse)
}
private static func fetchViaFile(_ request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
let dataCh = AsyncThrowingChannel<Data, Error>()
let (fileURL, response) = try await urlSession.download(for: request, delegate: delegate)
// Acquire a handle to the downloaded file and then remove it.
//
// This keeps a working reference to that file, yet we don't
// have to deal with the cleanup any more.
let mappedFile = try Data(contentsOf: fileURL, options: [.alwaysMapped])
try FileManager.default.removeItem(at: fileURL)
Task {
for chunk in (0 ..< mappedFile.count).chunks(ofCount: 64 * 1024 * 1024) {
await dataCh.send(mappedFile.subdata(in: chunk))
}
dataCh.finish()
}
return (dataCh, response as! HTTPURLResponse)
}
}
fileprivate func createURLSession() -> URLSession {
let config = URLSessionConfiguration.default
// Harbor expects a CSRF token to be present if the HTTP client
// carries a session cookie between its requests[1] and fails if
// it was not present[2].
//
// To fix that, we disable the automatic cookies carry in URLSession.
//
// [1]: https://github.com/goharbor/harbor/blob/a4c577f9ec4f18396207a5e686433a6ba203d4ef/src/server/middleware/csrf/csrf.go#L78
// [2]: https://github.com/cirruslabs/tart/issues/295
config.httpShouldSetCookies = false
return URLSession(configuration: config)
}
-48
View File
@@ -1,48 +0,0 @@
import Foundation
import System
enum FileLockError: Error, Equatable {
case Failed(_ message: String)
case AlreadyLocked
}
class FileLock {
let url: URL
let fd: Int32
init(lockURL: URL) throws {
url = lockURL
fd = open(lockURL.path, 0)
}
deinit {
close(fd)
}
func trylock() throws -> Bool {
try flockWrapper(LOCK_EX | LOCK_NB)
}
func lock() throws {
_ = try flockWrapper(LOCK_EX)
}
func unlock() throws {
_ = try flockWrapper(LOCK_UN)
}
func flockWrapper(_ operation: Int32) throws -> Bool {
let ret = flock(fd, operation)
if ret != 0 {
let details = Errno(rawValue: CInt(errno))
if (operation & LOCK_NB) != 0 && details == .wouldBlock {
return false
}
throw FileLockError.Failed("failed to lock \(url): \(details)")
}
return true
}
}
-47
View File
@@ -1,47 +0,0 @@
import ArgumentParser
import Foundation
import TextTable
enum Format: String, ExpressibleByArgument, CaseIterable {
case text, json
private(set) static var allValueStrings: [String] = Format.allCases.map { "\($0)"}
func renderSingle<T>(_ data: T) -> String where T: Encodable {
switch self {
case .text:
return renderList([data])
case .json:
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
return try! encoder.encode(data).asText()
}
}
func renderList<T>(_ data: Array<T>) -> String where T: Encodable {
switch self {
case .text:
if (data.count == 0) {
return ""
}
let table = TextTable<T> { (item: T) in
let mirroredObject = Mirror(reflecting: item)
return mirroredObject.children.enumerated()
.filter {(_, element) in
// Deprecate the "Running" field: only make it available
// from JSON for backwards-compatibility
element.label! != "Running"
}
.map { (_, element) in
let fieldName = element.label!
return Column(title: fieldName, value: element.value)
}
}
return table.string(for: data, style: Style.plain)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
case .json:
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
return try! encoder.encode(data).asText()
}
}
}
-20
View File
@@ -1,20 +0,0 @@
import Foundation
import Virtualization
class IPSWCache: PrunableStorage {
let baseURL: URL
init() throws {
baseURL = try Config().tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true)
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
}
func locationFor(fileName: String) -> URL {
baseURL.appendingPathComponent(fileName, isDirectory: false)
}
func prunables() throws -> [Prunable] {
try FileManager.default.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: nil)
.filter { $0.lastPathComponent.hasSuffix(".ipsw")}
}
}
-46
View File
@@ -1,46 +0,0 @@
import Foundation
public protocol Logger {
func appendNewLine(_ line: String) -> Void
func updateLastLine(_ line: String) -> Void
}
var defaultLogger: Logger = {
if ProcessInfo.processInfo.environment["CI"] != nil {
return SimpleConsoleLogger()
} else {
return InteractiveConsoleLogger()
}
}()
public class InteractiveConsoleLogger: Logger {
private let eraseCursorDown = "\u{001B}[J" // clear entire line
private let moveUp = "\u{001B}[1A" // move one line up
private let moveBeginningOfLine = "\r" //
public init() {
}
public func appendNewLine(_ line: String) {
print(line, terminator: "\n")
}
public func updateLastLine(_ line: String) {
print(moveUp, moveBeginningOfLine, eraseCursorDown, line, separator: "", terminator: "\n")
}
}
public class SimpleConsoleLogger: Logger {
public init() {
}
public func appendNewLine(_ line: String) {
print(line, terminator: "\n")
}
public func updateLastLine(_ line: String) {
print(line, terminator: "\n")
}
}
@@ -1,26 +0,0 @@
import Foundation
public class ProgressObserver: NSObject {
@objc var progressToObserve: Progress
var observation: NSKeyValueObservation?
var lastTimeUpdated = Date.now
public init(_ progress: Progress) {
progressToObserve = progress
}
func log(_ renderer: Logger) {
renderer.appendNewLine(ProgressObserver.lineToRender(progressToObserve))
observation = observe(\.progressToObserve.fractionCompleted) { progress, _ in
let currentTime = Date.now
if self.progressToObserve.isFinished || currentTime.timeIntervalSince(self.lastTimeUpdated) >= 1.0 {
self.lastTimeUpdated = currentTime
renderer.updateLastLine(ProgressObserver.lineToRender(self.progressToObserve))
}
}
}
private static func lineToRender(_ progress: Progress) -> String {
String(Int(100 * progress.fractionCompleted)) + "%"
}
}
@@ -1,17 +0,0 @@
import Foundation
public class URLSessionLogger: NSObject, URLSessionTaskDelegate {
let renderer: Logger
public init(_ renderer: Logger) {
self.renderer = renderer
}
public func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
renderer.updateLastLine(URLSessionLogger.lineToRender(task.progress))
}
private static func lineToRender(_ progress: Progress) -> String {
String(100 * progress.completedUnitCount / progress.totalUnitCount) + "%"
}
}
@@ -1,121 +0,0 @@
import Foundation
import Network
import Virtualization
struct ARPCommandFailedError: Error, CustomStringConvertible {
var terminationReason: Process.TerminationReason
var terminationStatus: Int32
var description: String {
var reason: String
switch terminationReason {
case .exit:
reason = "exit code \(terminationStatus)"
case .uncaughtSignal:
reason = "uncaught signal"
default:
reason = "unknown reason"
}
return "arp command failed: \(reason)"
}
}
struct ARPCommandYieldedInvalidOutputError: Error, CustomStringConvertible {
var explanation: String
var description: String {
"arp command yielded invalid output: \(explanation)"
}
}
struct ARPCacheInternalError: Error, CustomStringConvertible {
var explanation: String
var description: String {
"ARPCache internal error: \(explanation)"
}
}
struct ARPCache {
let arpCommandOutput: Data
init() throws {
let process = Process.init()
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
process.arguments = ["-an"]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
process.standardInput = FileHandle.nullDevice
try process.run()
process.waitUntilExit()
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
throw ARPCommandFailedError(
terminationReason: process.terminationReason,
terminationStatus: process.terminationStatus)
}
guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else {
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
}
self.arpCommandOutput = arpCommandOutput
}
func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
let lines = String(decoding: arpCommandOutput, as: UTF8.self)
.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: "\n")
// Based on https://opensource.apple.com/source/network_cmds/network_cmds-606.40.2/arp.tproj/arp.c.auto.html
let regex = try NSRegularExpression(pattern: #"^.* \((?<ip>.*)\) at (?<mac>.*) on (?<interface>.*) .*$"#)
for line in lines {
let nsLineRange = NSRange(line.startIndex..<line.endIndex, in: line)
guard let match = regex.firstMatch(in: line, range: nsLineRange) else {
throw ARPCommandYieldedInvalidOutputError(explanation: "unparseable entry \"\(line)\"")
}
let rawIP = try match.getCaptureGroup(name: "ip", for: line)
guard let ip = IPv4Address(rawIP) else {
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse IPv4 address \(rawIP)")
}
let rawMAC = try match.getCaptureGroup(name: "mac", for: line)
if rawMAC == "(incomplete)" {
continue
}
guard let mac = MACAddress(fromString: rawMAC) else {
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse MAC address \(rawMAC)")
}
if macAddress == mac {
return ip
}
}
return nil
}
}
extension NSTextCheckingResult {
func getCaptureGroup(name: String, for string: String) throws -> String {
let nsRange = self.range(withName: name)
if nsRange.location == NSNotFound {
throw ARPCacheInternalError(explanation: "attempted to retrieve non-existent named capture group \(name)")
}
guard let range = Range.init(nsRange, in: string) else {
throw ARPCacheInternalError(explanation: "failed to convert NSRange to Range")
}
return String(string[range])
}
}
@@ -1,42 +0,0 @@
import Foundation
import Network
import SwiftRadix
struct Lease {
var mac: MACAddress
var ip: IPv4Address
var expiresAt: Date
init?(fromRawLease: [String : String]) {
// Retrieve the required fields
guard let hwAddress = fromRawLease["hw_address"] else { return nil }
guard let ipAddress = fromRawLease["ip_address"] else { return nil }
guard let lease = fromRawLease["lease"] else { return nil }
// Parse MAC address
let hwAddressSplits = hwAddress.split(separator: ",")
if hwAddressSplits.count != 2 {
return nil
}
if let hwAddressProto = Int(hwAddressSplits[0]), hwAddressProto != ARPHRD_ETHER {
return nil
}
guard let mac = MACAddress(fromString: String(hwAddressSplits[1])) else {
return nil
}
// Parse IP address
guard let ip = IPv4Address(ipAddress) else {
return nil
}
// Parse expiration timestamp
guard let leaseTimestamp = lease.hex?.value else {
return nil
}
self.ip = ip
self.mac = mac
self.expiresAt = Date(timeIntervalSince1970: TimeInterval(leaseTimestamp))
}
}
@@ -1,118 +0,0 @@
import Foundation
import Network
enum LeasesError: Error {
case UnexpectedFormat(name: String = "unexpected DHCPD leases file format", message: String, line: Int)
case Truncated(name: String = "truncated DHCPD leases file")
var description: String {
switch self {
case .UnexpectedFormat(name: let name, message: let message, line: let line):
return "\(name) on line \(line): \(message)"
case .Truncated(name: let name):
return "\(name)"
}
}
}
class Leases {
private let leases: [MACAddress : Lease]
convenience init?() throws {
try self.init(URL(fileURLWithPath: "/var/db/dhcpd_leases"))
}
convenience init?(_ fromURL: URL) throws {
do {
let urlContents = try String(contentsOf: fromURL, encoding: .utf8)
try self.init(urlContents)
} catch {
if error.isFileNotFound() {
return nil
}
throw error
}
}
init(_ fromString: String) throws {
let leases = try Self.retrieveRawLeases(fromString).compactMap({ rawLease in
Lease(fromRawLease: rawLease)
}).filter({ lease in
lease.expiresAt.isInFuture
}).map({ lease in
(lease.mac, lease)
})
self.leases = Dictionary(leases) { (left, right) in
// When duplicate lease is found, prefer a newer lease over the older one
(left.expiresAt > right.expiresAt) ? left : right
}
}
/// Parse leases from the host cache similarly to the PLCache_read() function found in Apple's Open Source releases.
///
/// [1]: https://github.com/apple-opensource/bootp/blob/master/bootplib/NICache.c#L285-L391
private static func retrieveRawLeases(_ dhcpdLeasesContents: String) throws -> [[String : String]] {
var rawLeases: [[String : String]] = Array()
enum State {
case Nowhere
case Start
case Body
case End
}
var state = State.Nowhere
var currentRawLease: [String : String] = Dictionary()
for (lineNumber, line) in dhcpdLeasesContents.split(separator: "\n").enumerated().map({ ($0 + 1, $1) }) {
if line == "{" {
// Handle lease block start
if state != .Nowhere && state != .End {
throw LeasesError.UnexpectedFormat(message: "unexpected lease block start ({)", line: lineNumber)
}
state = .Start
} else if line == "}" {
// Handle lease block end
if state != .Body {
throw LeasesError.UnexpectedFormat(message: "unexpected lease block end (})", line: lineNumber)
}
rawLeases.append(currentRawLease)
currentRawLease = Dictionary()
state = .End
} else {
// Handle lease block contents
let lineWithoutTabs = String(line.drop { $0 == " " || $0 == "\t"})
if lineWithoutTabs.isEmpty {
continue
}
let splits = lineWithoutTabs.split(separator: "=", maxSplits: 1)
if splits.count != 2 {
throw LeasesError.UnexpectedFormat(message: "key-value pair with only a key", line: lineNumber)
}
let (key, value) = (String(splits[0]), String(splits[1]))
currentRawLease[key] = value
state = .Body
}
}
if state == .Start || state == .Body {
throw LeasesError.Truncated()
}
return rawLeases
}
func ResolveMACAddress(macAddress: MACAddress) -> IPv4Address? {
leases[macAddress]?.ip
}
}
@@ -1,21 +0,0 @@
import Foundation
struct MACAddress: Equatable, Hashable, CustomStringConvertible {
var mac: [UInt8] = Array(repeating: 0, count: 6)
init?(fromString: String) {
let components = fromString.components(separatedBy: ":")
if components.count != 6 {
return nil
}
for (index, component) in components.enumerated() {
mac[index] = UInt8(component, radix: 16)!
}
}
var description: String {
String(format: "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
}
}
-8
View File
@@ -1,8 +0,0 @@
import Virtualization
import Semaphore
protocol Network {
func attachments() -> [VZNetworkDeviceAttachment]
func run(_ sema: AsyncSemaphore) throws
func stop() async throws
}
-23
View File
@@ -1,23 +0,0 @@
import Foundation
import Semaphore
import Virtualization
class NetworkBridged: Network {
let interfaces: [VZBridgedNetworkInterface]
init(interfaces: [VZBridgedNetworkInterface]) {
self.interfaces = interfaces
}
func attachments() -> [VZNetworkDeviceAttachment] {
interfaces.map { VZBridgedNetworkDeviceAttachment(interface: $0) }
}
func run(_ sema: AsyncSemaphore) throws {
// no-op, only used for Softnet
}
func stop() async throws {
// no-op, only used for Softnet
}
}
-17
View File
@@ -1,17 +0,0 @@
import Foundation
import Semaphore
import Virtualization
class NetworkShared: Network {
func attachments() -> [VZNetworkDeviceAttachment] {
[VZNATNetworkDeviceAttachment()]
}
func run(_ sema: AsyncSemaphore) throws {
// no-op, only used for Softnet
}
func stop() async throws {
// no-op, only used for Softnet
}
}
-159
View File
@@ -1,159 +0,0 @@
import Atomics
import Foundation
import Semaphore
import System
import Virtualization
enum SoftnetError: Error {
case InitializationFailed(why: String)
case RuntimeFailed(why: String)
}
class Softnet: Network {
private let process = Process()
private var monitorTask: Task<Void, Error>? = nil
private let monitorTaskFinished = ManagedAtomic<Bool>(false)
let vmFD: Int32
init(vmMACAddress: String, extraArguments: [String] = []) throws {
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
let ret = socketpair(AF_UNIX, SOCK_DGRAM, 0, fds)
if ret != 0 {
throw SoftnetError.InitializationFailed(why: "socketpair() failed with exit code \(ret)")
}
vmFD = fds[0]
let softnetFD = fds[1]
try setSocketBuffers(vmFD, 1 * 1024 * 1024);
try setSocketBuffers(softnetFD, 1 * 1024 * 1024);
process.executableURL = try Self.softnetExecutableURL()
process.arguments = ["--vm-fd", String(STDIN_FILENO), "--vm-mac-address", vmMACAddress] + extraArguments
process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false)
}
static func softnetExecutableURL() throws -> URL {
let binaryName = "softnet"
guard let executableURL = resolveBinaryPath(binaryName) else {
throw SoftnetError.InitializationFailed(why: "\(binaryName) not found in PATH")
}
return executableURL
}
func run(_ sema: AsyncSemaphore) throws {
try process.run()
monitorTask = Task {
// Wait for the Softnet to finish
process.waitUntilExit()
// Signal to the caller that the Softnet has finished
sema.signal()
// Signal to ourselves that the Softnet has finished
monitorTaskFinished.store(true, ordering: .sequentiallyConsistent)
}
}
func stop() async throws {
if monitorTaskFinished.load(ordering: .sequentiallyConsistent) {
// Consume the monitor task's value to ensure the task has finished
_ = try await monitorTask?.value
throw SoftnetError.RuntimeFailed(why: "Softnet process terminated prematurely")
} else {
process.interrupt()
// Consume the monitor task's value to ensure the task has finished
_ = try await monitorTask?.value
}
}
private func setSocketBuffers(_ fd: Int32, _ sizeBytes: Int) throws {
let option_len = socklen_t(MemoryLayout<Int>.size)
// The system expects the value of SO_RCVBUF to be at least double the value of SO_SNDBUF,
// and for optimal performance, the recommended value of SO_RCVBUF is four times the value of SO_SNDBUF.
// See: https://developer.apple.com/documentation/virtualization/vzfilehandlenetworkdeviceattachment/3969266-maximumtransmissionunit
var receiveBufferSize = 4 * sizeBytes
var ret = setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &receiveBufferSize, option_len)
if ret != 0 {
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_RCVBUF) returned \(ret)")
}
var sendBufferSize = sizeBytes
ret = setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &sendBufferSize, option_len)
if ret != 0 {
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_SNDBUF) returned \(ret)")
}
}
func attachments() -> [VZNetworkDeviceAttachment] {
let fh = FileHandle.init(fileDescriptor: vmFD)
return [VZFileHandleNetworkDeviceAttachment(fileHandle: fh)]
}
static func configureSUIDBitIfNeeded() throws {
// Obtain the Softnet executable path
//
// It's important to use resolvingSymlinksInPath() here, because otherwise
// we will get something like "/opt/homebrew/bin/softnet" instead of
// "/opt/homebrew/Cellar/softnet/0.6.2/bin/softnet"
let softnetExecutablePath = try Softnet.softnetExecutableURL().resolvingSymlinksInPath().path
// Check if the SUID bit is already configured
let info = try FileManager.default.attributesOfItem(atPath: softnetExecutablePath) as NSDictionary
if info.fileOwnerAccountID() == 0 && (info.filePosixPermissions() & Int(S_ISUID)) != 0 {
return
}
// Check if the passwordless Sudo is already configured for Softnet
let sudoBinaryName = "sudo"
guard let sudoExecutableURL = resolveBinaryPath(sudoBinaryName) else {
throw SoftnetError.InitializationFailed(why: "\(sudoBinaryName) not found in PATH")
}
var process = Process()
process.executableURL = sudoExecutableURL
process.arguments = ["--non-interactive", "softnet", "--help"]
process.standardInput = nil
process.standardOutput = nil
process.standardError = nil
try process.run()
process.waitUntilExit()
if process.terminationStatus == 0 {
return
}
// Configure the SUID bit by spawning the Sudo process in interactive mode
// and asking the user for password required to run chown & chmod
fputs("Softnet requires a Sudo password to set the SUID bit on the Softnet executable, please enter it below.\n",
stderr)
process = try Process.run(sudoExecutableURL, arguments: [
"sh",
"-c",
"chown root \(softnetExecutablePath) && chmod u+s \(softnetExecutablePath)",
])
// Set TTY's foreground process group to that of the Sudo process,
// otherwise it will get stopped by a SIGTTIN once user input arrives
if tcsetpgrp(STDIN_FILENO, process.processIdentifier) == -1 {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.SoftnetFailed("tcsetpgrp(2) failed: \(details)")
}
process.waitUntilExit()
if process.terminationStatus != 0 {
throw RuntimeError.SoftnetFailed("failed to configure SUID bit on Softnet executable with Sudo")
}
}
}
-21
View File
@@ -1,21 +0,0 @@
import Foundation
protocol Authentication {
func header() -> (String, String)
func isValid() -> Bool
}
struct BasicAuthentication: Authentication {
let user: String
let password: String
func header() -> (String, String) {
let creds = Data("\(user):\(password)".utf8).base64EncodedString()
return ("Authorization", "Basic \(creds)")
}
func isValid() -> Bool {
true
}
}
@@ -1,25 +0,0 @@
import Foundation
actor AuthenticationKeeper {
var authentication: Authentication? = nil
func set(_ authentication: Authentication) {
self.authentication = authentication
}
func header() -> (String, String)? {
if let authentication = authentication {
// Do not suggest any headers if the
// authentication token has expired
if !authentication.isValid() {
return nil
}
return authentication.header()
}
// Do not suggest any headers if the
// authentication token is not set
return nil
}
}
-63
View File
@@ -1,63 +0,0 @@
import Foundation
import CryptoKit
enum DigestError: Error {
case InvalidOffset
case InvalidSize
}
class Digest {
var hash: SHA256 = SHA256()
func update(_ data: Data) {
hash.update(data: data)
}
func finalize() -> String {
hash.finalize().hexdigest()
}
static func hash(_ data: Data) -> String {
SHA256.hash(data: data).hexdigest()
}
static func hash(_ url: URL) throws -> String {
hash(try Data(contentsOf: url))
}
static func hash(_ url: URL, offset: UInt64, size: UInt64) throws -> String {
// Sanity check
let fhSanity = try FileHandle(forReadingFrom: url)
try fhSanity.seekToEnd()
let fileSize = try fhSanity.offset()
try fhSanity.close()
if offset > fileSize {
throw DigestError.InvalidOffset
}
if (offset + size) > fileSize {
throw DigestError.InvalidSize
}
// Read a chunk of size ``size`` at offset ``offset``
// and calculate it's digest
let fh = try FileHandle(forReadingFrom: url)
defer { try! fh.close() }
try fh.seek(toOffset: offset)
let data = try fh.read(upToCount: Int(size))!
return hash(data)
}
}
extension SHA256.Digest {
func hexdigest() -> String {
"sha256:" + self.map {
String(format: "%02x", $0)
}
.joined()
}
}
-6
View File
@@ -1,6 +0,0 @@
import Foundation
protocol Disk {
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, progress: Progress) async throws -> [OCIManifestLayer]
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress) async throws
}
-75
View File
@@ -1,75 +0,0 @@
import Foundation
import Compression
class DiskV1: Disk {
private static let bufferSizeBytes = 4 * 1024 * 1024
private static let layerLimitBytes = 500 * 1000 * 1000
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, progress: Progress) async throws -> [OCIManifestLayer] {
var pushedLayers: [OCIManifestLayer] = []
// Open the disk file
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
var mappedDiskReadOffset = 0
// Compress the disk file as a single stream
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { (length: Int) -> Data? in
// Determine the size of the next chunk
let bytesRead = min(length, mappedDisk.count - mappedDiskReadOffset)
// Read the next uncompressed chunk
let data = mappedDisk.subdata(in: mappedDiskReadOffset ..< mappedDiskReadOffset + bytesRead)
// Advance the offset
mappedDiskReadOffset += bytesRead
// Provide the uncompressed chunk to the compressing filter
return data
}
// Cut the compressed stream into layers, each equal exactly ``Self.layerLimitBytes`` bytes,
// except for the last one, which may be smaller
while let compressedData = try compressingFilter.readData(ofLength: Self.layerLimitBytes) {
let layerDigest = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb)
pushedLayers.append(OCIManifestLayer(
mediaType: diskV1MediaType,
size: compressedData.count,
digest: layerDigest
))
// Update progress using an absolute value
progress.completedUnitCount = Int64(mappedDiskReadOffset)
}
return pushedLayers
}
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress) async throws {
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
throw OCIError.FailedToCreateVmFile
}
// Open the disk file
let disk = try FileHandle(forWritingTo: diskURL)
defer { try! disk.close() }
// Decompress the layers onto the disk in a single stream
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
if let data = data {
disk.write(data)
}
}
for diskLayer in diskLayers {
try await registry.pullBlob(diskLayer.digest) { data in
try filter.write(data)
// Update the progress
progress.completedUnitCount += Int64(data.count)
}
}
try filter.finalize()
}
}
-188
View File
@@ -1,188 +0,0 @@
import Foundation
import Compression
class DiskV2: Disk {
private static let bufferSizeBytes = 4 * 1024 * 1024
private static let layerLimitBytes = 500 * 1000 * 1000
private static let holeGranularityBytes = 64 * 1024
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, progress: Progress) async throws -> [OCIManifestLayer] {
var pushedLayers: [OCIManifestLayer] = []
// Open the disk file
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
// Compress the disk file as multiple individually decompressible streams,
// each equal ``Self.layerLimitBytes`` bytes or slightly larger due to the
// internal compressor's buffer
var offset: UInt64 = 0
while let (compressedData, uncompressedSize, uncompressedDigest) = try compressNextLayerOfLimitBytesOrMore(mappedDisk: mappedDisk, offset: offset) {
offset += uncompressedSize
let layerDigest = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb)
pushedLayers.append(OCIManifestLayer(
mediaType: diskV2MediaType,
size: compressedData.count,
digest: layerDigest,
uncompressedSize: uncompressedSize,
uncompressedContentDigest: uncompressedDigest
))
// Update progress using a relative value
progress.completedUnitCount += Int64(uncompressedSize)
}
return pushedLayers
}
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress) async throws {
// Support resumable pulls
let pullResumed = FileManager.default.fileExists(atPath: diskURL.path)
if !pullResumed && !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
throw OCIError.FailedToCreateVmFile
}
// Calculate the uncompressed disk size
var uncompressedDiskSize: UInt64 = 0
for layer in diskLayers {
guard let uncompressedLayerSize = layer.uncompressedSize() else {
throw OCIError.LayerIsMissingUncompressedSizeAnnotation
}
uncompressedDiskSize += uncompressedLayerSize
}
// Truncate the target disk file so that it will be able
// to accomodate the uncompressed disk size
let disk = try FileHandle(forWritingTo: diskURL)
try disk.truncate(atOffset: uncompressedDiskSize)
try disk.close()
// Concurrently fetch and decompress layers
try await withThrowingTaskGroup(of: Void.self) { group in
var globalDiskWritingOffset: UInt64 = 0
for (index, diskLayer) in diskLayers.enumerated() {
// Respect the concurrency limit
if index >= concurrency {
try await group.next()
}
// Retrieve layer annotations
guard let uncompressedLayerSize = diskLayer.uncompressedSize() else {
throw OCIError.LayerIsMissingUncompressedSizeAnnotation
}
guard let uncompressedLayerContentDigest = diskLayer.uncompressedContentDigest() else {
throw OCIError.LayerIsMissingUncompressedDigestAnnotation
}
// Capture the current disk writing offset
let diskWritingOffset = globalDiskWritingOffset
// Launch a fetching and decompression task
group.addTask {
// No need to fetch and decompress anything if we've already done so
if try pullResumed && Digest.hash(diskURL, offset: diskWritingOffset, size: uncompressedLayerSize) == uncompressedLayerContentDigest {
// Update the progress
progress.completedUnitCount += Int64(diskLayer.size)
return
}
// Open the disk file
let disk = try FileHandle(forWritingTo: diskURL)
// A zero chunk for faster than byte-by-byte comparisons
//
// Assumes that the other Data(...) is equal in size, but it's fine to get a false-negative
// on the last block since it costs only 64 KiB of excess data per 500 MB layer.
//
// Some simple benchmarks ("sync && sudo purge" command was used to negate the disk caching effects):
// +--------------------------------------+---------------------------------------------------+
// | Operation | time(1) result |
// +--------------------------------------+---------------------------------------------------+
// | Data(...) == zeroChunk | 2.16s user 11.71s system 73% cpu 18.928 total |
// | Data(...).contains(where: {$0 != 0}) | 603.68s user 12.97s system 99% cpu 10:22.85 total |
// +--------------------------------------+---------------------------------------------------+
let zeroChunk = Data(count: holeGranularityBytes)
var diskWritingOffset = diskWritingOffset
// Pull and decompress a single layer into the specific offset on disk
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
guard let data = data else {
return
}
for chunk in data.chunks(ofCount: holeGranularityBytes) {
// Only write chunks that are not zero
if chunk != zeroChunk {
try disk.seek(toOffset: diskWritingOffset)
disk.write(chunk)
}
diskWritingOffset += UInt64(chunk.count)
}
}
try await registry.pullBlob(diskLayer.digest) { data in
try filter.write(data)
// Update the progress
progress.completedUnitCount += Int64(data.count)
}
try filter.finalize()
try disk.close()
}
globalDiskWritingOffset += uncompressedLayerSize
}
}
}
private static func compressNextLayerOfLimitBytesOrMore(mappedDisk: Data, offset: UInt64) throws -> (Data, UInt64, String)? {
var compressedData = Data()
var bytesRead: UInt64 = 0
let digest = Digest()
// Create a compressing filter that we will terminate upon
// reaching ``Self.layerLimitBytes`` of compressed data
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: bufferSizeBytes) { (length: Int) -> Data? in
if compressedData.count >= Self.layerLimitBytes {
return nil
}
let readFromByte = Int(offset + bytesRead)
let numBytesToRead = min(mappedDisk.count - readFromByte, bufferSizeBytes)
if numBytesToRead == 0 {
return nil
}
let uncompressedChunk = mappedDisk.subdata(in: readFromByte ..< (readFromByte + numBytesToRead))
bytesRead += UInt64(uncompressedChunk.count)
digest.update(uncompressedChunk)
return uncompressedChunk
}
// Retrieve compressed data chunks, but normally no more than ``Self.layerLimitBytes`` bytes
while let compressedChunk = try compressingFilter.readData(ofLength: Self.bufferSizeBytes) {
compressedData.append(compressedChunk)
}
// Nothing was read this time from the disk,
// signal that to the consumer
if bytesRead == 0 {
return nil
}
return (compressedData, bytesRead, digest.finalize())
}
}
-121
View File
@@ -1,121 +0,0 @@
import Foundation
// OCI manifest and OCI config media types
let ociManifestMediaType = "application/vnd.oci.image.manifest.v1+json"
let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
// Layer media types
let configMediaType = "application/vnd.cirruslabs.tart.config.v1"
let diskV1MediaType = "application/vnd.cirruslabs.tart.disk.v1"
let diskV2MediaType = "application/vnd.cirruslabs.tart.disk.v2"
let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1"
// Manifest annotations
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
// Layer annotations
let uncompressedSizeAnnotation = "org.cirruslabs.tart.uncompressed-size"
let uncompressedContentDigestAnnotation = "org.cirruslabs.tart.uncompressed-content-digest"
struct OCIManifest: Codable, Equatable {
var schemaVersion: Int = 2
var mediaType: String = ociManifestMediaType
var config: OCIManifestConfig
var layers: [OCIManifestLayer] = Array()
var annotations: Dictionary<String, String>?
init(config: OCIManifestConfig, layers: [OCIManifestLayer], uncompressedDiskSize: UInt64? = nil, uploadDate: Date? = nil) {
self.config = config
self.layers = layers
var annotations: [String: String] = [:]
if let uncompressedDiskSize = uncompressedDiskSize {
annotations[uncompressedDiskSizeAnnotation] = String(uncompressedDiskSize)
}
if let uploadDate = uploadDate {
annotations[uploadTimeAnnotation] = uploadDate.toISO()
}
self.annotations = annotations
}
init(fromJSON: Data) throws {
self = try Config.jsonDecoder().decode(Self.self, from: fromJSON)
}
func toJSON() throws -> Data {
try Config.jsonEncoder().encode(self)
}
func digest() throws -> String {
try Digest.hash(toJSON())
}
func uncompressedDiskSize() -> UInt64? {
guard let value = annotations?[uncompressedDiskSizeAnnotation] else {
return nil
}
return UInt64(value)
}
}
struct OCIConfig: Codable {
var architecture: Architecture = .arm64
var os: OS = .darwin
func toJSON() throws -> Data {
try Config.jsonEncoder().encode(self)
}
}
struct OCIManifestConfig: Codable, Equatable {
var mediaType: String = ociConfigMediaType
var size: Int
var digest: String
}
struct OCIManifestLayer: Codable, Equatable {
var mediaType: String
var size: Int
var digest: String
var annotations: Dictionary<String, String>?
init(mediaType: String, size: Int, digest: String, uncompressedSize: UInt64? = nil, uncompressedContentDigest: String? = nil) {
self.mediaType = mediaType
self.size = size
self.digest = digest
var annotations: [String: String] = [:]
if let uncompressedSize = uncompressedSize {
annotations[uncompressedSizeAnnotation] = String(uncompressedSize)
}
if let uncompressedContentDigest = uncompressedContentDigest {
annotations[uncompressedContentDigestAnnotation] = uncompressedContentDigest
}
self.annotations = annotations
}
func uncompressedSize() -> UInt64? {
guard let value = annotations?[uncompressedSizeAnnotation] else {
return nil
}
return UInt64(value)
}
func uncompressedContentDigest() -> String? {
annotations?[uncompressedContentDigestAnnotation]
}
}
struct Descriptor: Equatable {
var size: Int
var digest: String
}
@@ -1,37 +0,0 @@
token literal names:
null
':'
'/'
'.'
'-'
'@'
'_'
null
null
token symbolic names:
null
null
null
null
null
null
null
DIGIT
LETTER
rule names:
root
host
port
host_component
namespace
namespace_component
reference
tag
separator
name
atn:
[4, 1, 8, 95, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 1, 0, 1, 0, 1, 0, 3, 0, 24, 8, 0, 1, 0, 1, 0, 1, 0, 3, 0, 29, 8, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 5, 1, 36, 8, 1, 10, 1, 12, 1, 39, 9, 1, 1, 2, 4, 2, 42, 8, 2, 11, 2, 12, 2, 43, 1, 3, 1, 3, 1, 3, 5, 3, 49, 8, 3, 10, 3, 12, 3, 52, 9, 3, 1, 4, 1, 4, 1, 4, 5, 4, 57, 8, 4, 10, 4, 12, 4, 60, 9, 4, 1, 5, 1, 5, 3, 5, 64, 8, 5, 4, 5, 66, 8, 5, 11, 5, 12, 5, 67, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 3, 6, 77, 8, 6, 1, 7, 1, 7, 1, 7, 1, 7, 5, 7, 83, 8, 7, 10, 7, 12, 7, 86, 9, 7, 1, 8, 1, 8, 1, 9, 4, 9, 91, 8, 9, 11, 9, 12, 9, 92, 1, 9, 0, 0, 10, 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 0, 2, 2, 0, 3, 4, 6, 6, 1, 0, 7, 8, 95, 0, 20, 1, 0, 0, 0, 2, 32, 1, 0, 0, 0, 4, 41, 1, 0, 0, 0, 6, 45, 1, 0, 0, 0, 8, 53, 1, 0, 0, 0, 10, 65, 1, 0, 0, 0, 12, 76, 1, 0, 0, 0, 14, 78, 1, 0, 0, 0, 16, 87, 1, 0, 0, 0, 18, 90, 1, 0, 0, 0, 20, 23, 3, 2, 1, 0, 21, 22, 5, 1, 0, 0, 22, 24, 3, 4, 2, 0, 23, 21, 1, 0, 0, 0, 23, 24, 1, 0, 0, 0, 24, 25, 1, 0, 0, 0, 25, 26, 5, 2, 0, 0, 26, 28, 3, 8, 4, 0, 27, 29, 3, 12, 6, 0, 28, 27, 1, 0, 0, 0, 28, 29, 1, 0, 0, 0, 29, 30, 1, 0, 0, 0, 30, 31, 5, 0, 0, 1, 31, 1, 1, 0, 0, 0, 32, 37, 3, 6, 3, 0, 33, 34, 5, 3, 0, 0, 34, 36, 3, 6, 3, 0, 35, 33, 1, 0, 0, 0, 36, 39, 1, 0, 0, 0, 37, 35, 1, 0, 0, 0, 37, 38, 1, 0, 0, 0, 38, 3, 1, 0, 0, 0, 39, 37, 1, 0, 0, 0, 40, 42, 5, 7, 0, 0, 41, 40, 1, 0, 0, 0, 42, 43, 1, 0, 0, 0, 43, 41, 1, 0, 0, 0, 43, 44, 1, 0, 0, 0, 44, 5, 1, 0, 0, 0, 45, 50, 3, 18, 9, 0, 46, 47, 5, 4, 0, 0, 47, 49, 3, 18, 9, 0, 48, 46, 1, 0, 0, 0, 49, 52, 1, 0, 0, 0, 50, 48, 1, 0, 0, 0, 50, 51, 1, 0, 0, 0, 51, 7, 1, 0, 0, 0, 52, 50, 1, 0, 0, 0, 53, 58, 3, 10, 5, 0, 54, 55, 5, 2, 0, 0, 55, 57, 3, 10, 5, 0, 56, 54, 1, 0, 0, 0, 57, 60, 1, 0, 0, 0, 58, 56, 1, 0, 0, 0, 58, 59, 1, 0, 0, 0, 59, 9, 1, 0, 0, 0, 60, 58, 1, 0, 0, 0, 61, 63, 3, 18, 9, 0, 62, 64, 3, 16, 8, 0, 63, 62, 1, 0, 0, 0, 63, 64, 1, 0, 0, 0, 64, 66, 1, 0, 0, 0, 65, 61, 1, 0, 0, 0, 66, 67, 1, 0, 0, 0, 67, 65, 1, 0, 0, 0, 67, 68, 1, 0, 0, 0, 68, 11, 1, 0, 0, 0, 69, 70, 5, 1, 0, 0, 70, 77, 3, 14, 7, 0, 71, 72, 5, 5, 0, 0, 72, 73, 3, 18, 9, 0, 73, 74, 5, 1, 0, 0, 74, 75, 3, 18, 9, 0, 75, 77, 1, 0, 0, 0, 76, 69, 1, 0, 0, 0, 76, 71, 1, 0, 0, 0, 77, 13, 1, 0, 0, 0, 78, 84, 3, 18, 9, 0, 79, 80, 3, 16, 8, 0, 80, 81, 3, 18, 9, 0, 81, 83, 1, 0, 0, 0, 82, 79, 1, 0, 0, 0, 83, 86, 1, 0, 0, 0, 84, 82, 1, 0, 0, 0, 84, 85, 1, 0, 0, 0, 85, 15, 1, 0, 0, 0, 86, 84, 1, 0, 0, 0, 87, 88, 7, 0, 0, 0, 88, 17, 1, 0, 0, 0, 89, 91, 7, 1, 0, 0, 90, 89, 1, 0, 0, 0, 91, 92, 1, 0, 0, 0, 92, 90, 1, 0, 0, 0, 92, 93, 1, 0, 0, 0, 93, 19, 1, 0, 0, 0, 11, 23, 28, 37, 43, 50, 58, 63, 67, 76, 84, 92]
@@ -1,14 +0,0 @@
T__0=1
T__1=2
T__2=3
T__3=4
T__4=5
T__5=6
DIGIT=7
LETTER=8
':'=1
'/'=2
'.'=3
'-'=4
'@'=5
'_'=6
@@ -1,167 +0,0 @@
// Generated from java-escape by ANTLR 4.11.1
import Antlr4
/**
* This class provides an empty implementation of {@link ReferenceListener},
* which can be extended to create a listener which only needs to handle a subset
* of the available methods.
*/
open class ReferenceBaseListener: ReferenceListener {
public init() { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func enterRoot(_ ctx: ReferenceParser.RootContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func exitRoot(_ ctx: ReferenceParser.RootContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func enterHost(_ ctx: ReferenceParser.HostContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func exitHost(_ ctx: ReferenceParser.HostContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func enterPort(_ ctx: ReferenceParser.PortContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func exitPort(_ ctx: ReferenceParser.PortContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func enterHost_component(_ ctx: ReferenceParser.Host_componentContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func exitHost_component(_ ctx: ReferenceParser.Host_componentContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func enterNamespace(_ ctx: ReferenceParser.NamespaceContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func exitNamespace(_ ctx: ReferenceParser.NamespaceContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func enterNamespace_component(_ ctx: ReferenceParser.Namespace_componentContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func exitNamespace_component(_ ctx: ReferenceParser.Namespace_componentContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func enterReference(_ ctx: ReferenceParser.ReferenceContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func exitReference(_ ctx: ReferenceParser.ReferenceContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func enterTag(_ ctx: ReferenceParser.TagContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func exitTag(_ ctx: ReferenceParser.TagContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func enterSeparator(_ ctx: ReferenceParser.SeparatorContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func exitSeparator(_ ctx: ReferenceParser.SeparatorContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func enterName(_ ctx: ReferenceParser.NameContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func exitName(_ ctx: ReferenceParser.NameContext) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func enterEveryRule(_ ctx: ParserRuleContext) throws { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func exitEveryRule(_ ctx: ParserRuleContext) throws { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func visitTerminal(_ node: TerminalNode) { }
/**
* {@inheritDoc}
*
* <p>The default implementation does nothing.</p>
*/
open func visitErrorNode(_ node: ErrorNode) { }
}
@@ -1,41 +0,0 @@
token literal names:
null
':'
'/'
'.'
'-'
'@'
'_'
null
null
token symbolic names:
null
null
null
null
null
null
null
DIGIT
LETTER
rule names:
T__0
T__1
T__2
T__3
T__4
T__5
DIGIT
LETTER
channel names:
DEFAULT_TOKEN_CHANNEL
HIDDEN
mode names:
DEFAULT_MODE
atn:
[4, 0, 8, 33, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 1, 0, 1, 0, 1, 1, 1, 1, 1, 2, 1, 2, 1, 3, 1, 3, 1, 4, 1, 4, 1, 5, 1, 5, 1, 6, 1, 6, 1, 7, 1, 7, 0, 0, 8, 1, 1, 3, 2, 5, 3, 7, 4, 9, 5, 11, 6, 13, 7, 15, 8, 1, 0, 2, 1, 0, 48, 57, 2, 0, 65, 90, 97, 122, 32, 0, 1, 1, 0, 0, 0, 0, 3, 1, 0, 0, 0, 0, 5, 1, 0, 0, 0, 0, 7, 1, 0, 0, 0, 0, 9, 1, 0, 0, 0, 0, 11, 1, 0, 0, 0, 0, 13, 1, 0, 0, 0, 0, 15, 1, 0, 0, 0, 1, 17, 1, 0, 0, 0, 3, 19, 1, 0, 0, 0, 5, 21, 1, 0, 0, 0, 7, 23, 1, 0, 0, 0, 9, 25, 1, 0, 0, 0, 11, 27, 1, 0, 0, 0, 13, 29, 1, 0, 0, 0, 15, 31, 1, 0, 0, 0, 17, 18, 5, 58, 0, 0, 18, 2, 1, 0, 0, 0, 19, 20, 5, 47, 0, 0, 20, 4, 1, 0, 0, 0, 21, 22, 5, 46, 0, 0, 22, 6, 1, 0, 0, 0, 23, 24, 5, 45, 0, 0, 24, 8, 1, 0, 0, 0, 25, 26, 5, 64, 0, 0, 26, 10, 1, 0, 0, 0, 27, 28, 5, 95, 0, 0, 28, 12, 1, 0, 0, 0, 29, 30, 7, 0, 0, 0, 30, 14, 1, 0, 0, 0, 31, 32, 7, 1, 0, 0, 32, 16, 1, 0, 0, 0, 1, 0, 0]
@@ -1,90 +0,0 @@
// Generated from java-escape by ANTLR 4.11.1
import Antlr4
open class ReferenceLexer: Lexer {
internal static var _decisionToDFA: [DFA] = {
var decisionToDFA = [DFA]()
let length = ReferenceLexer._ATN.getNumberOfDecisions()
for i in 0..<length {
decisionToDFA.append(DFA(ReferenceLexer._ATN.getDecisionState(i)!, i))
}
return decisionToDFA
}()
internal static let _sharedContextCache = PredictionContextCache()
public
static let T__0=1, T__1=2, T__2=3, T__3=4, T__4=5, T__5=6, DIGIT=7, LETTER=8
public
static let channelNames: [String] = [
"DEFAULT_TOKEN_CHANNEL", "HIDDEN"
]
public
static let modeNames: [String] = [
"DEFAULT_MODE"
]
public
static let ruleNames: [String] = [
"T__0", "T__1", "T__2", "T__3", "T__4", "T__5", "DIGIT", "LETTER"
]
private static let _LITERAL_NAMES: [String?] = [
nil, "':'", "'/'", "'.'", "'-'", "'@'", "'_'"
]
private static let _SYMBOLIC_NAMES: [String?] = [
nil, nil, nil, nil, nil, nil, nil, "DIGIT", "LETTER"
]
public
static let VOCABULARY = Vocabulary(_LITERAL_NAMES, _SYMBOLIC_NAMES)
override open
func getVocabulary() -> Vocabulary {
return ReferenceLexer.VOCABULARY
}
public
required init(_ input: CharStream) {
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
super.init(input)
_interp = LexerATNSimulator(self, ReferenceLexer._ATN, ReferenceLexer._decisionToDFA, ReferenceLexer._sharedContextCache)
}
override open
func getGrammarFileName() -> String { return "Reference.g4" }
override open
func getRuleNames() -> [String] { return ReferenceLexer.ruleNames }
override open
func getSerializedATN() -> [Int] { return ReferenceLexer._serializedATN }
override open
func getChannelNames() -> [String] { return ReferenceLexer.channelNames }
override open
func getModeNames() -> [String] { return ReferenceLexer.modeNames }
override open
func getATN() -> ATN { return ReferenceLexer._ATN }
static let _serializedATN:[Int] = [
4,0,8,33,6,-1,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,6,
2,7,7,7,1,0,1,0,1,1,1,1,1,2,1,2,1,3,1,3,1,4,1,4,1,5,1,5,1,6,1,6,1,7,1,
7,0,0,8,1,1,3,2,5,3,7,4,9,5,11,6,13,7,15,8,1,0,2,1,0,48,57,2,0,65,90,97,
122,32,0,1,1,0,0,0,0,3,1,0,0,0,0,5,1,0,0,0,0,7,1,0,0,0,0,9,1,0,0,0,0,11,
1,0,0,0,0,13,1,0,0,0,0,15,1,0,0,0,1,17,1,0,0,0,3,19,1,0,0,0,5,21,1,0,0,
0,7,23,1,0,0,0,9,25,1,0,0,0,11,27,1,0,0,0,13,29,1,0,0,0,15,31,1,0,0,0,
17,18,5,58,0,0,18,2,1,0,0,0,19,20,5,47,0,0,20,4,1,0,0,0,21,22,5,46,0,0,
22,6,1,0,0,0,23,24,5,45,0,0,24,8,1,0,0,0,25,26,5,64,0,0,26,10,1,0,0,0,
27,28,5,95,0,0,28,12,1,0,0,0,29,30,7,0,0,0,30,14,1,0,0,0,31,32,7,1,0,0,
32,16,1,0,0,0,1,0,0
]
public
static let _ATN: ATN = try! ATNDeserializer().deserialize(_serializedATN)
}
@@ -1,14 +0,0 @@
T__0=1
T__1=2
T__2=3
T__3=4
T__4=5
T__5=6
DIGIT=7
LETTER=8
':'=1
'/'=2
'.'=3
'-'=4
'@'=5
'_'=6
@@ -1,129 +0,0 @@
// Generated from java-escape by ANTLR 4.11.1
import Antlr4
/**
* This interface defines a complete listener for a parse tree produced by
* {@link ReferenceParser}.
*/
public protocol ReferenceListener: ParseTreeListener {
/**
* Enter a parse tree produced by {@link ReferenceParser#root}.
- Parameters:
- ctx: the parse tree
*/
func enterRoot(_ ctx: ReferenceParser.RootContext)
/**
* Exit a parse tree produced by {@link ReferenceParser#root}.
- Parameters:
- ctx: the parse tree
*/
func exitRoot(_ ctx: ReferenceParser.RootContext)
/**
* Enter a parse tree produced by {@link ReferenceParser#host}.
- Parameters:
- ctx: the parse tree
*/
func enterHost(_ ctx: ReferenceParser.HostContext)
/**
* Exit a parse tree produced by {@link ReferenceParser#host}.
- Parameters:
- ctx: the parse tree
*/
func exitHost(_ ctx: ReferenceParser.HostContext)
/**
* Enter a parse tree produced by {@link ReferenceParser#port}.
- Parameters:
- ctx: the parse tree
*/
func enterPort(_ ctx: ReferenceParser.PortContext)
/**
* Exit a parse tree produced by {@link ReferenceParser#port}.
- Parameters:
- ctx: the parse tree
*/
func exitPort(_ ctx: ReferenceParser.PortContext)
/**
* Enter a parse tree produced by {@link ReferenceParser#host_component}.
- Parameters:
- ctx: the parse tree
*/
func enterHost_component(_ ctx: ReferenceParser.Host_componentContext)
/**
* Exit a parse tree produced by {@link ReferenceParser#host_component}.
- Parameters:
- ctx: the parse tree
*/
func exitHost_component(_ ctx: ReferenceParser.Host_componentContext)
/**
* Enter a parse tree produced by {@link ReferenceParser#namespace}.
- Parameters:
- ctx: the parse tree
*/
func enterNamespace(_ ctx: ReferenceParser.NamespaceContext)
/**
* Exit a parse tree produced by {@link ReferenceParser#namespace}.
- Parameters:
- ctx: the parse tree
*/
func exitNamespace(_ ctx: ReferenceParser.NamespaceContext)
/**
* Enter a parse tree produced by {@link ReferenceParser#namespace_component}.
- Parameters:
- ctx: the parse tree
*/
func enterNamespace_component(_ ctx: ReferenceParser.Namespace_componentContext)
/**
* Exit a parse tree produced by {@link ReferenceParser#namespace_component}.
- Parameters:
- ctx: the parse tree
*/
func exitNamespace_component(_ ctx: ReferenceParser.Namespace_componentContext)
/**
* Enter a parse tree produced by {@link ReferenceParser#reference}.
- Parameters:
- ctx: the parse tree
*/
func enterReference(_ ctx: ReferenceParser.ReferenceContext)
/**
* Exit a parse tree produced by {@link ReferenceParser#reference}.
- Parameters:
- ctx: the parse tree
*/
func exitReference(_ ctx: ReferenceParser.ReferenceContext)
/**
* Enter a parse tree produced by {@link ReferenceParser#tag}.
- Parameters:
- ctx: the parse tree
*/
func enterTag(_ ctx: ReferenceParser.TagContext)
/**
* Exit a parse tree produced by {@link ReferenceParser#tag}.
- Parameters:
- ctx: the parse tree
*/
func exitTag(_ ctx: ReferenceParser.TagContext)
/**
* Enter a parse tree produced by {@link ReferenceParser#separator}.
- Parameters:
- ctx: the parse tree
*/
func enterSeparator(_ ctx: ReferenceParser.SeparatorContext)
/**
* Exit a parse tree produced by {@link ReferenceParser#separator}.
- Parameters:
- ctx: the parse tree
*/
func exitSeparator(_ ctx: ReferenceParser.SeparatorContext)
/**
* Enter a parse tree produced by {@link ReferenceParser#name}.
- Parameters:
- ctx: the parse tree
*/
func enterName(_ ctx: ReferenceParser.NameContext)
/**
* Exit a parse tree produced by {@link ReferenceParser#name}.
- Parameters:
- ctx: the parse tree
*/
func exitName(_ ctx: ReferenceParser.NameContext)
}
@@ -1,799 +0,0 @@
// Generated from java-escape by ANTLR 4.11.1
import Antlr4
open class ReferenceParser: Parser {
internal static var _decisionToDFA: [DFA] = {
var decisionToDFA = [DFA]()
let length = ReferenceParser._ATN.getNumberOfDecisions()
for i in 0..<length {
decisionToDFA.append(DFA(ReferenceParser._ATN.getDecisionState(i)!, i))
}
return decisionToDFA
}()
internal static let _sharedContextCache = PredictionContextCache()
public
enum Tokens: Int {
case EOF = -1, T__0 = 1, T__1 = 2, T__2 = 3, T__3 = 4, T__4 = 5, T__5 = 6,
DIGIT = 7, LETTER = 8
}
public
static let RULE_root = 0, RULE_host = 1, RULE_port = 2, RULE_host_component = 3,
RULE_namespace = 4, RULE_namespace_component = 5, RULE_reference = 6,
RULE_tag = 7, RULE_separator = 8, RULE_name = 9
public
static let ruleNames: [String] = [
"root", "host", "port", "host_component", "namespace", "namespace_component",
"reference", "tag", "separator", "name"
]
private static let _LITERAL_NAMES: [String?] = [
nil, "':'", "'/'", "'.'", "'-'", "'@'", "'_'"
]
private static let _SYMBOLIC_NAMES: [String?] = [
nil, nil, nil, nil, nil, nil, nil, "DIGIT", "LETTER"
]
public
static let VOCABULARY = Vocabulary(_LITERAL_NAMES, _SYMBOLIC_NAMES)
override open
func getGrammarFileName() -> String { return "java-escape" }
override open
func getRuleNames() -> [String] { return ReferenceParser.ruleNames }
override open
func getSerializedATN() -> [Int] { return ReferenceParser._serializedATN }
override open
func getATN() -> ATN { return ReferenceParser._ATN }
override open
func getVocabulary() -> Vocabulary {
return ReferenceParser.VOCABULARY
}
override public
init(_ input:TokenStream) throws {
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
try super.init(input)
_interp = ParserATNSimulator(self,ReferenceParser._ATN,ReferenceParser._decisionToDFA, ReferenceParser._sharedContextCache)
}
public class RootContext: ParserRuleContext {
open
func host() -> HostContext? {
return getRuleContext(HostContext.self, 0)
}
open
func namespace() -> NamespaceContext? {
return getRuleContext(NamespaceContext.self, 0)
}
open
func EOF() -> TerminalNode? {
return getToken(ReferenceParser.Tokens.EOF.rawValue, 0)
}
open
func port() -> PortContext? {
return getRuleContext(PortContext.self, 0)
}
open
func reference() -> ReferenceContext? {
return getRuleContext(ReferenceContext.self, 0)
}
override open
func getRuleIndex() -> Int {
return ReferenceParser.RULE_root
}
override open
func enterRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.enterRoot(self)
}
}
override open
func exitRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.exitRoot(self)
}
}
}
@discardableResult
open func root() throws -> RootContext {
var _localctx: RootContext
_localctx = RootContext(_ctx, getState())
try enterRule(_localctx, 0, ReferenceParser.RULE_root)
var _la: Int = 0
defer {
try! exitRule()
}
do {
try enterOuterAlt(_localctx, 1)
setState(20)
try host()
setState(23)
try _errHandler.sync(self)
_la = try _input.LA(1)
if (_la == ReferenceParser.Tokens.T__0.rawValue) {
setState(21)
try match(ReferenceParser.Tokens.T__0.rawValue)
setState(22)
try port()
}
setState(25)
try match(ReferenceParser.Tokens.T__1.rawValue)
setState(26)
try namespace()
setState(28)
try _errHandler.sync(self)
_la = try _input.LA(1)
if (_la == ReferenceParser.Tokens.T__0.rawValue || _la == ReferenceParser.Tokens.T__4.rawValue) {
setState(27)
try reference()
}
setState(30)
try match(ReferenceParser.Tokens.EOF.rawValue)
}
catch ANTLRException.recognition(let re) {
_localctx.exception = re
_errHandler.reportError(self, re)
try _errHandler.recover(self, re)
}
return _localctx
}
public class HostContext: ParserRuleContext {
open
func host_component() -> [Host_componentContext] {
return getRuleContexts(Host_componentContext.self)
}
open
func host_component(_ i: Int) -> Host_componentContext? {
return getRuleContext(Host_componentContext.self, i)
}
override open
func getRuleIndex() -> Int {
return ReferenceParser.RULE_host
}
override open
func enterRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.enterHost(self)
}
}
override open
func exitRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.exitHost(self)
}
}
}
@discardableResult
open func host() throws -> HostContext {
var _localctx: HostContext
_localctx = HostContext(_ctx, getState())
try enterRule(_localctx, 2, ReferenceParser.RULE_host)
var _la: Int = 0
defer {
try! exitRule()
}
do {
try enterOuterAlt(_localctx, 1)
setState(32)
try host_component()
setState(37)
try _errHandler.sync(self)
_la = try _input.LA(1)
while (_la == ReferenceParser.Tokens.T__2.rawValue) {
setState(33)
try match(ReferenceParser.Tokens.T__2.rawValue)
setState(34)
try host_component()
setState(39)
try _errHandler.sync(self)
_la = try _input.LA(1)
}
}
catch ANTLRException.recognition(let re) {
_localctx.exception = re
_errHandler.reportError(self, re)
try _errHandler.recover(self, re)
}
return _localctx
}
public class PortContext: ParserRuleContext {
open
func DIGIT() -> [TerminalNode] {
return getTokens(ReferenceParser.Tokens.DIGIT.rawValue)
}
open
func DIGIT(_ i:Int) -> TerminalNode? {
return getToken(ReferenceParser.Tokens.DIGIT.rawValue, i)
}
override open
func getRuleIndex() -> Int {
return ReferenceParser.RULE_port
}
override open
func enterRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.enterPort(self)
}
}
override open
func exitRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.exitPort(self)
}
}
}
@discardableResult
open func port() throws -> PortContext {
var _localctx: PortContext
_localctx = PortContext(_ctx, getState())
try enterRule(_localctx, 4, ReferenceParser.RULE_port)
var _la: Int = 0
defer {
try! exitRule()
}
do {
try enterOuterAlt(_localctx, 1)
setState(41)
try _errHandler.sync(self)
_la = try _input.LA(1)
repeat {
setState(40)
try match(ReferenceParser.Tokens.DIGIT.rawValue)
setState(43);
try _errHandler.sync(self)
_la = try _input.LA(1)
} while (_la == ReferenceParser.Tokens.DIGIT.rawValue)
}
catch ANTLRException.recognition(let re) {
_localctx.exception = re
_errHandler.reportError(self, re)
try _errHandler.recover(self, re)
}
return _localctx
}
public class Host_componentContext: ParserRuleContext {
open
func name() -> [NameContext] {
return getRuleContexts(NameContext.self)
}
open
func name(_ i: Int) -> NameContext? {
return getRuleContext(NameContext.self, i)
}
override open
func getRuleIndex() -> Int {
return ReferenceParser.RULE_host_component
}
override open
func enterRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.enterHost_component(self)
}
}
override open
func exitRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.exitHost_component(self)
}
}
}
@discardableResult
open func host_component() throws -> Host_componentContext {
var _localctx: Host_componentContext
_localctx = Host_componentContext(_ctx, getState())
try enterRule(_localctx, 6, ReferenceParser.RULE_host_component)
var _la: Int = 0
defer {
try! exitRule()
}
do {
try enterOuterAlt(_localctx, 1)
setState(45)
try name()
setState(50)
try _errHandler.sync(self)
_la = try _input.LA(1)
while (_la == ReferenceParser.Tokens.T__3.rawValue) {
setState(46)
try match(ReferenceParser.Tokens.T__3.rawValue)
setState(47)
try name()
setState(52)
try _errHandler.sync(self)
_la = try _input.LA(1)
}
}
catch ANTLRException.recognition(let re) {
_localctx.exception = re
_errHandler.reportError(self, re)
try _errHandler.recover(self, re)
}
return _localctx
}
public class NamespaceContext: ParserRuleContext {
open
func namespace_component() -> [Namespace_componentContext] {
return getRuleContexts(Namespace_componentContext.self)
}
open
func namespace_component(_ i: Int) -> Namespace_componentContext? {
return getRuleContext(Namespace_componentContext.self, i)
}
override open
func getRuleIndex() -> Int {
return ReferenceParser.RULE_namespace
}
override open
func enterRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.enterNamespace(self)
}
}
override open
func exitRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.exitNamespace(self)
}
}
}
@discardableResult
open func namespace() throws -> NamespaceContext {
var _localctx: NamespaceContext
_localctx = NamespaceContext(_ctx, getState())
try enterRule(_localctx, 8, ReferenceParser.RULE_namespace)
var _la: Int = 0
defer {
try! exitRule()
}
do {
try enterOuterAlt(_localctx, 1)
setState(53)
try namespace_component()
setState(58)
try _errHandler.sync(self)
_la = try _input.LA(1)
while (_la == ReferenceParser.Tokens.T__1.rawValue) {
setState(54)
try match(ReferenceParser.Tokens.T__1.rawValue)
setState(55)
try namespace_component()
setState(60)
try _errHandler.sync(self)
_la = try _input.LA(1)
}
}
catch ANTLRException.recognition(let re) {
_localctx.exception = re
_errHandler.reportError(self, re)
try _errHandler.recover(self, re)
}
return _localctx
}
public class Namespace_componentContext: ParserRuleContext {
open
func name() -> [NameContext] {
return getRuleContexts(NameContext.self)
}
open
func name(_ i: Int) -> NameContext? {
return getRuleContext(NameContext.self, i)
}
open
func separator() -> [SeparatorContext] {
return getRuleContexts(SeparatorContext.self)
}
open
func separator(_ i: Int) -> SeparatorContext? {
return getRuleContext(SeparatorContext.self, i)
}
override open
func getRuleIndex() -> Int {
return ReferenceParser.RULE_namespace_component
}
override open
func enterRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.enterNamespace_component(self)
}
}
override open
func exitRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.exitNamespace_component(self)
}
}
}
@discardableResult
open func namespace_component() throws -> Namespace_componentContext {
var _localctx: Namespace_componentContext
_localctx = Namespace_componentContext(_ctx, getState())
try enterRule(_localctx, 10, ReferenceParser.RULE_namespace_component)
var _la: Int = 0
defer {
try! exitRule()
}
do {
try enterOuterAlt(_localctx, 1)
setState(65)
try _errHandler.sync(self)
_la = try _input.LA(1)
repeat {
setState(61)
try name()
setState(63)
try _errHandler.sync(self)
_la = try _input.LA(1)
if ((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0) {
setState(62)
try separator()
}
setState(67);
try _errHandler.sync(self)
_la = try _input.LA(1)
} while (_la == ReferenceParser.Tokens.DIGIT.rawValue || _la == ReferenceParser.Tokens.LETTER.rawValue)
}
catch ANTLRException.recognition(let re) {
_localctx.exception = re
_errHandler.reportError(self, re)
try _errHandler.recover(self, re)
}
return _localctx
}
public class ReferenceContext: ParserRuleContext {
open
func tag() -> TagContext? {
return getRuleContext(TagContext.self, 0)
}
open
func name() -> [NameContext] {
return getRuleContexts(NameContext.self)
}
open
func name(_ i: Int) -> NameContext? {
return getRuleContext(NameContext.self, i)
}
override open
func getRuleIndex() -> Int {
return ReferenceParser.RULE_reference
}
override open
func enterRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.enterReference(self)
}
}
override open
func exitRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.exitReference(self)
}
}
}
@discardableResult
open func reference() throws -> ReferenceContext {
var _localctx: ReferenceContext
_localctx = ReferenceContext(_ctx, getState())
try enterRule(_localctx, 12, ReferenceParser.RULE_reference)
defer {
try! exitRule()
}
do {
setState(76)
try _errHandler.sync(self)
switch (ReferenceParser.Tokens(rawValue: try _input.LA(1))!) {
case .T__0:
try enterOuterAlt(_localctx, 1)
setState(69)
try match(ReferenceParser.Tokens.T__0.rawValue)
setState(70)
try tag()
break
case .T__4:
try enterOuterAlt(_localctx, 2)
setState(71)
try match(ReferenceParser.Tokens.T__4.rawValue)
setState(72)
try name()
setState(73)
try match(ReferenceParser.Tokens.T__0.rawValue)
setState(74)
try name()
break
default:
throw ANTLRException.recognition(e: NoViableAltException(self))
}
}
catch ANTLRException.recognition(let re) {
_localctx.exception = re
_errHandler.reportError(self, re)
try _errHandler.recover(self, re)
}
return _localctx
}
public class TagContext: ParserRuleContext {
open
func name() -> [NameContext] {
return getRuleContexts(NameContext.self)
}
open
func name(_ i: Int) -> NameContext? {
return getRuleContext(NameContext.self, i)
}
open
func separator() -> [SeparatorContext] {
return getRuleContexts(SeparatorContext.self)
}
open
func separator(_ i: Int) -> SeparatorContext? {
return getRuleContext(SeparatorContext.self, i)
}
override open
func getRuleIndex() -> Int {
return ReferenceParser.RULE_tag
}
override open
func enterRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.enterTag(self)
}
}
override open
func exitRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.exitTag(self)
}
}
}
@discardableResult
open func tag() throws -> TagContext {
var _localctx: TagContext
_localctx = TagContext(_ctx, getState())
try enterRule(_localctx, 14, ReferenceParser.RULE_tag)
var _la: Int = 0
defer {
try! exitRule()
}
do {
try enterOuterAlt(_localctx, 1)
setState(78)
try name()
setState(84)
try _errHandler.sync(self)
_la = try _input.LA(1)
while ((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0) {
setState(79)
try separator()
setState(80)
try name()
setState(86)
try _errHandler.sync(self)
_la = try _input.LA(1)
}
}
catch ANTLRException.recognition(let re) {
_localctx.exception = re
_errHandler.reportError(self, re)
try _errHandler.recover(self, re)
}
return _localctx
}
public class SeparatorContext: ParserRuleContext {
override open
func getRuleIndex() -> Int {
return ReferenceParser.RULE_separator
}
override open
func enterRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.enterSeparator(self)
}
}
override open
func exitRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.exitSeparator(self)
}
}
}
@discardableResult
open func separator() throws -> SeparatorContext {
var _localctx: SeparatorContext
_localctx = SeparatorContext(_ctx, getState())
try enterRule(_localctx, 16, ReferenceParser.RULE_separator)
var _la: Int = 0
defer {
try! exitRule()
}
do {
try enterOuterAlt(_localctx, 1)
setState(87)
_la = try _input.LA(1)
if (!((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0)) {
try _errHandler.recoverInline(self)
}
else {
_errHandler.reportMatch(self)
try consume()
}
}
catch ANTLRException.recognition(let re) {
_localctx.exception = re
_errHandler.reportError(self, re)
try _errHandler.recover(self, re)
}
return _localctx
}
public class NameContext: ParserRuleContext {
open
func LETTER() -> [TerminalNode] {
return getTokens(ReferenceParser.Tokens.LETTER.rawValue)
}
open
func LETTER(_ i:Int) -> TerminalNode? {
return getToken(ReferenceParser.Tokens.LETTER.rawValue, i)
}
open
func DIGIT() -> [TerminalNode] {
return getTokens(ReferenceParser.Tokens.DIGIT.rawValue)
}
open
func DIGIT(_ i:Int) -> TerminalNode? {
return getToken(ReferenceParser.Tokens.DIGIT.rawValue, i)
}
override open
func getRuleIndex() -> Int {
return ReferenceParser.RULE_name
}
override open
func enterRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.enterName(self)
}
}
override open
func exitRule(_ listener: ParseTreeListener) {
if let listener = listener as? ReferenceListener {
listener.exitName(self)
}
}
}
@discardableResult
open func name() throws -> NameContext {
var _localctx: NameContext
_localctx = NameContext(_ctx, getState())
try enterRule(_localctx, 18, ReferenceParser.RULE_name)
var _la: Int = 0
defer {
try! exitRule()
}
do {
var _alt:Int
try enterOuterAlt(_localctx, 1)
setState(90);
try _errHandler.sync(self)
_alt = 1;
repeat {
switch (_alt) {
case 1:
setState(89)
_la = try _input.LA(1)
if (!(_la == ReferenceParser.Tokens.DIGIT.rawValue || _la == ReferenceParser.Tokens.LETTER.rawValue)) {
try _errHandler.recoverInline(self)
}
else {
_errHandler.reportMatch(self)
try consume()
}
break
default:
throw ANTLRException.recognition(e: NoViableAltException(self))
}
setState(92);
try _errHandler.sync(self)
_alt = try getInterpreter().adaptivePredict(_input,10,_ctx)
} while (_alt != 2 && _alt != ATN.INVALID_ALT_NUMBER)
}
catch ANTLRException.recognition(let re) {
_localctx.exception = re
_errHandler.reportError(self, re)
try _errHandler.recover(self, re)
}
return _localctx
}
static let _serializedATN:[Int] = [
4,1,8,95,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,6,2,7,7,
7,2,8,7,8,2,9,7,9,1,0,1,0,1,0,3,0,24,8,0,1,0,1,0,1,0,3,0,29,8,0,1,0,1,
0,1,1,1,1,1,1,5,1,36,8,1,10,1,12,1,39,9,1,1,2,4,2,42,8,2,11,2,12,2,43,
1,3,1,3,1,3,5,3,49,8,3,10,3,12,3,52,9,3,1,4,1,4,1,4,5,4,57,8,4,10,4,12,
4,60,9,4,1,5,1,5,3,5,64,8,5,4,5,66,8,5,11,5,12,5,67,1,6,1,6,1,6,1,6,1,
6,1,6,1,6,3,6,77,8,6,1,7,1,7,1,7,1,7,5,7,83,8,7,10,7,12,7,86,9,7,1,8,1,
8,1,9,4,9,91,8,9,11,9,12,9,92,1,9,0,0,10,0,2,4,6,8,10,12,14,16,18,0,2,
2,0,3,4,6,6,1,0,7,8,95,0,20,1,0,0,0,2,32,1,0,0,0,4,41,1,0,0,0,6,45,1,0,
0,0,8,53,1,0,0,0,10,65,1,0,0,0,12,76,1,0,0,0,14,78,1,0,0,0,16,87,1,0,0,
0,18,90,1,0,0,0,20,23,3,2,1,0,21,22,5,1,0,0,22,24,3,4,2,0,23,21,1,0,0,
0,23,24,1,0,0,0,24,25,1,0,0,0,25,26,5,2,0,0,26,28,3,8,4,0,27,29,3,12,6,
0,28,27,1,0,0,0,28,29,1,0,0,0,29,30,1,0,0,0,30,31,5,0,0,1,31,1,1,0,0,0,
32,37,3,6,3,0,33,34,5,3,0,0,34,36,3,6,3,0,35,33,1,0,0,0,36,39,1,0,0,0,
37,35,1,0,0,0,37,38,1,0,0,0,38,3,1,0,0,0,39,37,1,0,0,0,40,42,5,7,0,0,41,
40,1,0,0,0,42,43,1,0,0,0,43,41,1,0,0,0,43,44,1,0,0,0,44,5,1,0,0,0,45,50,
3,18,9,0,46,47,5,4,0,0,47,49,3,18,9,0,48,46,1,0,0,0,49,52,1,0,0,0,50,48,
1,0,0,0,50,51,1,0,0,0,51,7,1,0,0,0,52,50,1,0,0,0,53,58,3,10,5,0,54,55,
5,2,0,0,55,57,3,10,5,0,56,54,1,0,0,0,57,60,1,0,0,0,58,56,1,0,0,0,58,59,
1,0,0,0,59,9,1,0,0,0,60,58,1,0,0,0,61,63,3,18,9,0,62,64,3,16,8,0,63,62,
1,0,0,0,63,64,1,0,0,0,64,66,1,0,0,0,65,61,1,0,0,0,66,67,1,0,0,0,67,65,
1,0,0,0,67,68,1,0,0,0,68,11,1,0,0,0,69,70,5,1,0,0,70,77,3,14,7,0,71,72,
5,5,0,0,72,73,3,18,9,0,73,74,5,1,0,0,74,75,3,18,9,0,75,77,1,0,0,0,76,69,
1,0,0,0,76,71,1,0,0,0,77,13,1,0,0,0,78,84,3,18,9,0,79,80,3,16,8,0,80,81,
3,18,9,0,81,83,1,0,0,0,82,79,1,0,0,0,83,86,1,0,0,0,84,82,1,0,0,0,84,85,
1,0,0,0,85,15,1,0,0,0,86,84,1,0,0,0,87,88,7,0,0,0,88,17,1,0,0,0,89,91,
7,1,0,0,90,89,1,0,0,0,91,92,1,0,0,0,92,90,1,0,0,0,92,93,1,0,0,0,93,19,
1,0,0,0,11,23,28,37,43,50,58,63,67,76,84,92
]
public
static let _ATN = try! ATNDeserializer().deserialize(_serializedATN)
}
-5
View File
@@ -1,5 +0,0 @@
all: clean
antlr -o Generated -Dlanguage=Swift Reference.g4
clean:
rm -rf Generated
-14
View File
@@ -1,14 +0,0 @@
grammar Reference;
root: host (':' port)? '/' namespace reference? EOF;
host: host_component ('.' host_component)*;
port: DIGIT+;
host_component: name ('-' name)*;
namespace: namespace_component ('/' namespace_component)*;
namespace_component: (name separator?)+;
reference: (':' tag) | ('@' name ':' name);
tag: name (separator name)*;
separator: '.' | '-' | '_';
name: (LETTER | DIGIT)+;
DIGIT: [0-9];
LETTER: [A-Za-z];
-403
View File
@@ -1,403 +0,0 @@
import Foundation
import Algorithms
import AsyncAlgorithms
enum RegistryError: Error {
case UnexpectedHTTPStatusCode(when: String, code: Int, details: String = "")
case MissingLocationHeader
case AuthFailed(why: String, details: String = "")
case MalformedHeader(why: String)
}
enum HTTPMethod: String {
case GET = "GET"
case POST = "POST"
case PUT = "PUT"
case PATCH = "PATCH"
}
enum HTTPCode: Int {
case Ok = 200
case Created = 201
case Accepted = 202
case Unauthorized = 401
}
extension Data {
func asText() -> String {
String(decoding: self, as: UTF8.self)
}
}
extension AsyncThrowingChannel<Data, Error> {
func asData() async throws -> Data {
var result = Data()
for try await chunk in self {
result += chunk
}
return result
}
}
struct TokenResponse: Decodable, Authentication {
var token: String?
var accessToken: String?
var expiresIn: Int?
var issuedAt: Date?
static func parse(fromData: Data) throws -> Self {
let decoder = Config.jsonDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = [.withInternetDateTime]
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
return dateFormatter.date(from: dateString) ?? Date()
}
var response = try decoder.decode(TokenResponse.self, from: fromData)
response.issuedAt = response.issuedAt ?? Date()
guard response.token != nil || response.accessToken != nil else {
throw DecodingError.keyNotFound(CodingKeys.token, .init(codingPath: [], debugDescription: "Missing token or access_token. One must be present."))
}
return response
}
var tokenExpiresAt: Date {
get {
// Tokens can expire and expire_in field is used to determine when:
//
// >The duration in seconds since the token was issued that it will remain valid.
// >When omitted, this defaults to 60 seconds. For compatibility with older clients,
// >a token should never be returned with less than 60 seconds to live.
//
// [1]: https://docs.docker.com/registry/spec/auth/token/#requesting-a-token
(issuedAt ?? Date()) + TimeInterval(expiresIn ?? 60)
}
}
func header() -> (String, String) {
return ("Authorization", "Bearer \(token ?? accessToken ?? "")")
}
func isValid() -> Bool {
Date() < tokenExpiresAt
}
}
class Registry {
private let baseURL: URL
let namespace: String
let credentialsProviders: [CredentialsProvider]
let authenticationKeeper = AuthenticationKeeper()
var host: String? {
guard let host = baseURL.host else { return nil }
if let port = baseURL.port {
return "\(host):\(port)"
}
return host
}
init(urlComponents: URLComponents,
namespace: String,
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
) throws {
baseURL = urlComponents.url!
self.namespace = namespace
self.credentialsProviders = credentialsProviders
}
convenience init(
host: String,
namespace: String,
insecure: Bool = false,
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
) throws {
let proto = insecure ? "http" : "https"
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
try self.init(urlComponents: baseURLComponents, namespace: namespace, credentialsProviders: credentialsProviders)
}
func ping() async throws {
let (_, response) = try await dataRequest(.GET, endpointURL("/v2/"))
if response.statusCode != HTTPCode.Ok.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "doing ping", code: response.statusCode)
}
}
func pushManifest(reference: String, manifest: OCIManifest) async throws -> String {
let manifestJSON = try manifest.toJSON()
let (data, response) = try await dataRequest(.PUT, endpointURL("\(namespace)/manifests/\(reference)"),
headers: ["Content-Type": manifest.mediaType],
body: manifestJSON)
if response.statusCode != HTTPCode.Created.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing manifest", code: response.statusCode,
details: data.asText())
}
return Digest.hash(manifestJSON)
}
public func pullManifest(reference: String) async throws -> (OCIManifest, Data) {
let (data, response) = try await dataRequest(.GET, endpointURL("\(namespace)/manifests/\(reference)"),
headers: ["Accept": ociManifestMediaType])
if response.statusCode != HTTPCode.Ok.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling manifest", code: response.statusCode,
details: data.asText())
}
let manifest = try OCIManifest(fromJSON: data)
return (manifest, data)
}
private func uploadLocationFromResponse(_ response: HTTPURLResponse) throws -> URLComponents {
guard let uploadLocationRaw = response.value(forHTTPHeaderField: "Location") else {
throw RegistryError.MissingLocationHeader
}
guard let uploadLocation = URL(string: uploadLocationRaw) else {
throw RegistryError.MalformedHeader(why: "Location header contains invalid URL: \"\(uploadLocationRaw)\"")
}
return URLComponents(url: uploadLocation.absolutize(baseURL), resolvingAgainstBaseURL: true)!
}
public func pushBlob(fromData: Data, chunkSizeMb: Int = 0) async throws -> String {
// Initiate a blob upload
let (data, postResponse) = try await dataRequest(.POST, endpointURL("\(namespace)/blobs/uploads/"),
headers: ["Content-Length": "0"])
if postResponse.statusCode != HTTPCode.Accepted.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (POST)", code: postResponse.statusCode,
details: data.asText())
}
// Figure out where to upload the blob
var uploadLocation = try uploadLocationFromResponse(postResponse)
let digest = Digest.hash(fromData)
if chunkSizeMb == 0 {
// monolithic upload
let (data, response) = try await dataRequest(
.PUT,
uploadLocation,
headers: [
"Content-Type": "application/octet-stream",
],
parameters: ["digest": digest],
body: fromData
)
if response.statusCode != HTTPCode.Created.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT) to \(uploadLocation)",
code: response.statusCode, details: data.asText())
}
return digest
}
// chunked upload
var uploadedBytes = 0
let chunks = fromData.chunks(ofCount: chunkSizeMb == 0 ? fromData.count : chunkSizeMb * 1_000_000)
for (index, chunk) in chunks.enumerated() {
let lastChunk = index == (chunks.count - 1)
let (data, response) = try await dataRequest(
lastChunk ? .PUT : .PATCH,
uploadLocation,
headers: [
"Content-Type": "application/octet-stream",
"Content-Range": "\(uploadedBytes)-\(uploadedBytes + chunk.count - 1)",
],
parameters: lastChunk ? ["digest": digest] : [:],
body: chunk
)
// always accept both statuses since AWS ECR is not following specification
if response.statusCode != HTTPCode.Created.rawValue && response.statusCode != HTTPCode.Accepted.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "streaming blob to \(uploadLocation)",
code: response.statusCode, details: data.asText())
}
uploadedBytes += chunk.count
// Update location for the next chunk
uploadLocation = try uploadLocationFromResponse(response)
}
return digest
}
public func pullBlob(_ digest: String, handler: (Data) async throws -> Void) async throws {
let (channel, response) = try await channelRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"), viaFile: true)
if response.statusCode != HTTPCode.Ok.rawValue {
let body = try await channel.asData().asText()
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling blob", code: response.statusCode,
details: body)
}
for try await part in channel {
try Task.checkCancellation()
try await handler(part)
}
}
private func endpointURL(_ endpoint: String) -> URLComponents {
let url = URL(string: endpoint, relativeTo: baseURL)!
return URLComponents(url: url, resolvingAgainstBaseURL: true)!
}
private func dataRequest(
_ method: HTTPMethod,
_ urlComponents: URLComponents,
headers: Dictionary<String, String> = Dictionary(),
parameters: Dictionary<String, String> = Dictionary(),
body: Data? = nil,
doAuth: Bool = true
) async throws -> (Data, HTTPURLResponse) {
let (channel, response) = try await channelRequest(method, urlComponents,
headers: headers, parameters: parameters, body: body, doAuth: doAuth)
return (try await channel.asData(), response)
}
private func channelRequest(
_ method: HTTPMethod,
_ urlComponents: URLComponents,
headers: Dictionary<String, String> = Dictionary(),
parameters: Dictionary<String, String> = Dictionary(),
body: Data? = nil,
doAuth: Bool = true,
viaFile: Bool = false
) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
var urlComponents = urlComponents
if urlComponents.queryItems == nil && !parameters.isEmpty {
urlComponents.queryItems = []
}
urlComponents.queryItems?.append(contentsOf: parameters.map { key, value -> URLQueryItem in
URLQueryItem(name: key, value: value)
})
var request = URLRequest(url: urlComponents.url!)
request.httpMethod = method.rawValue
for (key, value) in headers {
request.addValue(value, forHTTPHeaderField: key)
}
if let body = body {
request.addValue("\(body.count)", forHTTPHeaderField: "Content-Length")
request.httpBody = body
}
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
_ = try await channel.asData()
try await auth(response: response)
(channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
}
return (channel, response)
}
private func auth(response: HTTPURLResponse) async throws {
// Process WWW-Authenticate header
guard let wwwAuthenticateRaw = response.value(forHTTPHeaderField: "WWW-Authenticate") else {
throw RegistryError.AuthFailed(why: "got HTTP 401, but WWW-Authenticate header is missing")
}
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw)
if wwwAuthenticate.scheme.lowercased() == "basic" {
if let (user, password) = try lookupCredentials() {
await authenticationKeeper.set(BasicAuthentication(user: user, password: password))
}
return
}
if wwwAuthenticate.scheme.lowercased() != "bearer" {
throw RegistryError.AuthFailed(why: "WWW-Authenticate header's authentication scheme "
+ "\"\(wwwAuthenticate.scheme)\" is unsupported, expected \"Bearer\" scheme")
}
guard let realm = wwwAuthenticate.kvs["realm"] else {
throw RegistryError.AuthFailed(why: "WWW-Authenticate header is missing a \"realm\" directive")
}
// Request a token
guard var authenticateURL = URLComponents(string: realm) else {
throw RegistryError.AuthFailed(why: "WWW-Authenticate header's realm directive "
+ "\"\(realm)\" doesn't look like URL")
}
// Token Authentication Specification[1]:
//
// >To respond to this challenge, the client will need to make a GET request
// >[...] using the service and scope values from the WWW-Authenticate header.
//
// [1]: https://docs.docker.com/registry/spec/auth/token/
authenticateURL.queryItems = ["scope", "service"].compactMap { key in
if let value = wwwAuthenticate.kvs[key] {
return URLQueryItem(name: key, value: value)
} else {
return nil
}
}
var headers: Dictionary<String, String> = Dictionary()
if let (user, password) = try lookupCredentials() {
let encodedCredentials = "\(user):\(password)".data(using: .utf8)?.base64EncodedString()
headers["Authorization"] = "Basic \(encodedCredentials!)"
}
let (data, response) = try await dataRequest(.GET, authenticateURL, headers: headers, doAuth: false)
if response.statusCode != HTTPCode.Ok.rawValue {
throw RegistryError.AuthFailed(why: "received unexpected HTTP status code \(response.statusCode) "
+ "while retrieving an authentication token", details: data.asText())
}
await authenticationKeeper.set(try TokenResponse.parse(fromData: data))
}
private func lookupCredentials() throws -> (String, String)? {
var host = baseURL.host!
if let port = baseURL.port {
host += ":\(port)"
}
for provider in credentialsProviders {
if let (user, password) = try provider.retrieve(host: host) {
return (user, password)
}
}
return nil
}
private func authAwareRequest(request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
var request = request
if let (name, value) = await authenticationKeeper.header() {
request.addValue(value, forHTTPHeaderField: name)
}
request.setValue("Tart/\(CI.version) (\(DeviceInfo.os); \(DeviceInfo.model))",
forHTTPHeaderField: "User-Agent")
return try await Fetcher.fetch(request, viaFile: viaFile)
}
}
-150
View File
@@ -1,150 +0,0 @@
import Foundation
import Antlr4
struct Reference: Comparable, Hashable, CustomStringConvertible {
enum ReferenceType: Comparable {
case Tag
case Digest
}
let type: ReferenceType
let value: String
var fullyQualified: String {
get {
switch type {
case .Tag:
return ":" + value
case .Digest:
return "@" + value
}
}
}
init(tag: String) {
type = .Tag
value = tag
}
init(digest: String) {
type = .Digest
value = digest
}
static func <(lhs: Reference, rhs: Reference) -> Bool {
if lhs.type != rhs.type {
return lhs.type < rhs.type
} else {
return lhs.value < rhs.value
}
}
var description: String {
get {
fullyQualified
}
}
}
class ReferenceCollector: ReferenceBaseListener {
var host: String? = nil
var port: String? = nil
var namespace: String? = nil
var reference: String? = nil
override func exitHost(_ ctx: ReferenceParser.HostContext) {
host = ctx.getText()
}
override func exitPort(_ ctx: ReferenceParser.PortContext) {
port = ctx.getText()
}
override func exitNamespace(_ ctx: ReferenceParser.NamespaceContext) {
namespace = ctx.getText()
}
override func exitReference(_ ctx: ReferenceParser.ReferenceContext) {
reference = ctx.getText()
}
}
class ErrorCollector: BaseErrorListener {
var error: String? = nil
override func syntaxError<T>(_ recognizer: Recognizer<T>, _ offendingSymbol: AnyObject?, _ line: Int, _ charPositionInLine: Int, _ msg: String, _ e: AnyObject?) {
if error == nil {
error = "\(msg) (character \(charPositionInLine + 1))"
}
}
}
struct RemoteName: Comparable, Hashable, CustomStringConvertible {
var host: String
var namespace: String
var reference: Reference
init(host: String, namespace: String, reference: Reference) {
self.host = host
self.namespace = namespace
self.reference = reference
}
init(_ name: String) throws {
let errorCollector = ErrorCollector()
let inputStream = ANTLRInputStream(Array(name.unicodeScalars), name.count)
let lexer = ReferenceLexer(inputStream)
lexer.removeErrorListeners()
lexer.addErrorListener(errorCollector)
let tokenStream = CommonTokenStream(lexer)
let parser = try ReferenceParser(tokenStream)
parser.removeErrorListeners()
parser.addErrorListener(errorCollector)
let referenceCollector = ReferenceCollector()
try ParseTreeWalker().walk(referenceCollector, try parser.root())
if let error = errorCollector.error {
throw RuntimeError.FailedToParseRemoteName("\(error)")
}
host = referenceCollector.host!
if let port = referenceCollector.port {
host += ":" + port
}
namespace = referenceCollector.namespace!
if let reference = referenceCollector.reference {
if reference.starts(with: "@sha256:") {
self.reference = Reference(digest: String(reference.dropFirst(1)))
} else if reference.starts(with: ":") {
self.reference = Reference(tag: String(reference.dropFirst(1)))
} else {
throw RuntimeError.FailedToParseRemoteName("unknown reference format")
}
} else {
self.reference = Reference(tag: "latest")
}
}
static func <(lhs: RemoteName, rhs: RemoteName) -> Bool {
if lhs.host != rhs.host {
return lhs.host < rhs.host
} else if lhs.namespace != rhs.namespace {
return lhs.namespace < rhs.namespace
} else {
return lhs.reference < rhs.reference
}
}
var description: String {
"\(host)/\(namespace)\(reference.fullyQualified)"
}
}
extension Array where Self.Element == ClosedRange<UInt8> {
func asCharacterSet() -> CharacterSet {
let characters = self.joined().map { String(UnicodeScalar($0)) }.joined()
return CharacterSet(charactersIn: characters)
}
}
-7
View File
@@ -1,7 +0,0 @@
import Foundation
extension URL {
func absolutize(_ baseURL: URL) -> Self {
URL(string: absoluteString, relativeTo: baseURL)!
}
}
-63
View File
@@ -1,63 +0,0 @@
import Foundation
// WWW-Authenticate header parser based on details from RFCs[1][2]
///
// [1]: https://www.rfc-editor.org/rfc/rfc2617#section-3.2.1
// [2]: https://www.rfc-editor.org/rfc/rfc6750#section-3
class WWWAuthenticate {
var scheme: String
var kvs: Dictionary<String, String> = Dictionary()
init(rawHeaderValue: String) throws {
let splits = rawHeaderValue.split(separator: " ", maxSplits: 1)
if splits.count == 2 {
scheme = String(splits[0])
} else {
throw RegistryError.MalformedHeader(why: "WWW-Authenticate header should consist of two parts: "
+ "scheme and directives")
}
let rawDirectives = contextAwareCommaSplit(rawDirectives: String(splits[1]))
try rawDirectives.forEach { sequence in
let parts = sequence.split(separator: "=", maxSplits: 1)
if parts.count != 2 {
throw RegistryError.MalformedHeader(why: "Each WWW-Authenticate header directive should be in the form of "
+ "key=value or key=\"value\"")
}
let key = String(parts[0])
var value = String(parts[1])
value = value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
kvs[key] = value
}
}
private func contextAwareCommaSplit(rawDirectives: String) -> Array<String> {
var result: Array<String> = Array()
var inQuotation: Bool = false
var accumulator: Array<Character> = Array()
for ch in rawDirectives {
if ch == "," && !inQuotation {
result.append(String(accumulator))
accumulator.removeAll()
continue
}
accumulator.append(ch)
if ch == "\"" {
inQuotation.toggle()
}
}
if !accumulator.isEmpty {
result.append(String(accumulator))
}
return result
}
}
-57
View File
@@ -1,57 +0,0 @@
import Foundation
import System
class PIDLock {
let url: URL
let fd: Int32
init(lockURL: URL) throws {
url = lockURL
fd = open(lockURL.path, O_RDWR)
if fd == -1 {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.PIDLockFailed("failed to open lock file \(url): \(details)")
}
}
deinit {
close(fd)
}
func trylock() throws -> Bool {
let (locked, _) = try lockWrapper(F_SETLK, F_WRLCK, "failed to lock \(url)")
return locked
}
func lock() throws {
_ = try lockWrapper(F_SETLKW, F_WRLCK, "failed to lock \(url)")
}
func unlock() throws {
_ = try lockWrapper(F_SETLK, F_UNLCK, "failed to unlock \(url)")
}
func pid() throws -> pid_t {
let (_, result) = try lockWrapper(F_GETLK, F_RDLCK, "failed to get lock \(url) status")
return result.l_pid
}
func lockWrapper(_ operation: Int32, _ type: Int32, _ message: String) throws -> (Bool, flock) {
var result = flock(l_start: 0, l_len: 0, l_pid: 0, l_type: Int16(type), l_whence: Int16(SEEK_SET))
let ret = fcntl(fd, operation, &result)
if ret != 0 {
if operation == F_SETLK && errno == EAGAIN {
return (false, result)
}
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.PIDLockFailed("\(message): \(details)")
}
return (true, result)
}
}
@@ -1,13 +0,0 @@
import Foundation
struct PassphraseGenerator: Sequence {
func makeIterator() -> PassphraseIterator {
PassphraseIterator()
}
}
struct PassphraseIterator: IteratorProtocol {
mutating func next() -> String? {
passphrases[Int(arc4random_uniform(UInt32(passphrases.count)))]
}
}
File diff suppressed because it is too large Load Diff
-14
View File
@@ -1,14 +0,0 @@
import Foundation
enum Architecture: String, Codable {
case arm64
case amd64
}
func CurrentArchitecture() -> Architecture {
#if arch(arm64)
return .arm64
#elseif arch(x86_64)
return .amd64
#endif
}
-136
View File
@@ -1,136 +0,0 @@
import Virtualization
struct UnsupportedHostOSError: Error, CustomStringConvertible {
var description: String {
"error: host macOS version is outdated to run this virtual machine"
}
}
#if arch(arm64)
struct Darwin: PlatformSuspendable {
var ecid: VZMacMachineIdentifier
var hardwareModel: VZMacHardwareModel
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
self.ecid = ecid
self.hardwareModel = hardwareModel
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let encodedECID = try container.decode(String.self, forKey: .ecid)
guard let data = Data.init(base64Encoded: encodedECID) else {
throw DecodingError.dataCorruptedError(forKey: .ecid,
in: container,
debugDescription: "failed to initialize Data using the provided value")
}
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
throw DecodingError.dataCorruptedError(forKey: .ecid,
in: container,
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
}
self.ecid = ecid
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
}
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
}
self.hardwareModel = hardwareModel
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
}
func os() -> OS {
.darwin
}
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
VZMacOSBootLoader()
}
func platform(nvramURL: URL) throws -> VZPlatformConfiguration {
let result = VZMacPlatformConfiguration()
result.machineIdentifier = ecid
result.auxiliaryStorage = VZMacAuxiliaryStorage(url: nvramURL)
if !hardwareModel.isSupported {
// At the moment support of M1 chip is not yet dropped in any macOS version
// This mean that host software is not supporting this hardware model and should be updated
throw UnsupportedHostOSError()
}
result.hardwareModel = hardwareModel
return result
}
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
let result = VZMacGraphicsDeviceConfiguration()
if let hostMainScreen = NSScreen.main {
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
result.displays = [
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
]
return result
}
result.displays = [
VZMacGraphicsDisplayConfiguration(
widthInPixels: vmConfig.display.width,
heightInPixels: vmConfig.display.height,
// A reasonable guess according to Apple's documentation[1]
// [1]: https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
pixelsPerInch: 72
)
]
return result
}
func keyboards() -> [VZKeyboardConfiguration] {
if #available(macOS 14, *) {
// Mac keyboard is only supported by guests starting with macOS Ventura
return [VZUSBKeyboardConfiguration(), VZMacKeyboardConfiguration()]
} else {
return [VZUSBKeyboardConfiguration()]
}
}
func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
if #available(macOS 14, *) {
return [VZMacKeyboardConfiguration()]
} else {
// fallback to the regular configuration
return keyboards()
}
}
func pointingDevices() -> [VZPointingDeviceConfiguration] {
// Trackpad is only supported by guests starting with macOS Ventura
[VZUSBScreenCoordinatePointingDeviceConfiguration(), VZMacTrackpadConfiguration()]
}
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
if #available(macOS 14, *) {
return [VZMacTrackpadConfiguration()]
} else {
// fallback to the regular configuration
return pointingDevices()
}
}
}
#endif
-41
View File
@@ -1,41 +0,0 @@
import Virtualization
@available(macOS 13, *)
struct Linux: Platform {
func os() -> OS {
.linux
}
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
let result = VZEFIBootLoader()
result.variableStore = VZEFIVariableStore(url: nvramURL)
return result
}
func platform(nvramURL: URL) throws -> VZPlatformConfiguration {
VZGenericPlatformConfiguration()
}
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
let result = VZVirtioGraphicsDeviceConfiguration()
result.scanouts = [
VZVirtioGraphicsScanoutConfiguration(
widthInPixels: vmConfig.display.width,
heightInPixels: vmConfig.display.height
)
]
return result
}
func keyboards() -> [VZKeyboardConfiguration] {
[VZUSBKeyboardConfiguration()]
}
func pointingDevices() -> [VZPointingDeviceConfiguration] {
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
}
}

Some files were not shown because too many files have changed in this diff Show More