envrcctl 0.2.2__tar.gz → 0.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- envrcctl-0.2.3/.gitignore +15 -0
- {envrcctl-0.2.2 → envrcctl-0.2.3}/PKG-INFO +10 -30
- {envrcctl-0.2.2 → envrcctl-0.2.3}/pyproject.toml +18 -7
- envrcctl-0.2.3/scripts/build_macos_auth_helper.sh +48 -0
- envrcctl-0.2.3/scripts/generate_completions.py +35 -0
- envrcctl-0.2.3/scripts/macos/envrcctl-macos-auth.swift +340 -0
- envrcctl-0.2.3/scripts/package_for_ship.sh +6 -0
- envrcctl-0.2.3/scripts/release_artifacts.py +286 -0
- envrcctl-0.2.3/tests/__init__.py +3 -0
- envrcctl-0.2.3/tests/conftest.py +15 -0
- envrcctl-0.2.3/tests/helpers/__init__.py +3 -0
- envrcctl-0.2.3/tests/helpers/cli_support.py +34 -0
- envrcctl-0.2.3/tests/test_audit.py +830 -0
- envrcctl-0.2.3/tests/test_audit_cli.py +364 -0
- envrcctl-0.2.3/tests/test_auth.py +181 -0
- envrcctl-0.2.3/tests/test_cli.py +81 -0
- envrcctl-0.2.3/tests/test_cli_doctor.py +213 -0
- envrcctl-0.2.3/tests/test_cli_errors.py +485 -0
- envrcctl-0.2.3/tests/test_cli_eval.py +84 -0
- envrcctl-0.2.3/tests/test_cli_exec.py +361 -0
- envrcctl-0.2.3/tests/test_cli_helpers.py +150 -0
- envrcctl-0.2.3/tests/test_cli_inject.py +222 -0
- envrcctl-0.2.3/tests/test_cli_migrate.py +50 -0
- envrcctl-0.2.3/tests/test_cli_secret_get.py +304 -0
- envrcctl-0.2.3/tests/test_cli_secret_list.py +66 -0
- envrcctl-0.2.3/tests/test_cli_secret_set_unset.py +138 -0
- envrcctl-0.2.3/tests/test_command_runner.py +51 -0
- envrcctl-0.2.3/tests/test_envrc.py +147 -0
- envrcctl-0.2.3/tests/test_keychain.py +609 -0
- envrcctl-0.2.3/tests/test_main.py +30 -0
- envrcctl-0.2.3/tests/test_managed_block.py +68 -0
- envrcctl-0.2.3/tests/test_secrets.py +186 -0
- envrcctl-0.2.3/tests/test_secretservice.py +91 -0
- envrcctl-0.2.3/tests/test_smoke.py +4 -0
- {envrcctl-0.2.2 → envrcctl-0.2.3}/LICENSE +0 -0
- {envrcctl-0.2.2 → envrcctl-0.2.3}/README.md +0 -0
- {envrcctl-0.2.2 → envrcctl-0.2.3}/completions/envrcctl.bash +0 -0
- {envrcctl-0.2.2 → envrcctl-0.2.3}/completions/envrcctl.fish +0 -0
- {envrcctl-0.2.2 → envrcctl-0.2.3}/completions/envrcctl.zsh +0 -0
- {envrcctl-0.2.2 → envrcctl-0.2.3}/src/envrcctl/__init__.py +0 -0
- {envrcctl-0.2.2 → envrcctl-0.2.3}/src/envrcctl/audit.py +0 -0
- {envrcctl-0.2.2 → envrcctl-0.2.3}/src/envrcctl/auth.py +0 -0
- {envrcctl-0.2.2 → envrcctl-0.2.3}/src/envrcctl/cli.py +0 -0
- {envrcctl-0.2.2 → envrcctl-0.2.3}/src/envrcctl/command_runner.py +0 -0
- {envrcctl-0.2.2 → envrcctl-0.2.3}/src/envrcctl/envrc.py +0 -0
- /envrcctl-0.2.2/src/envrcctl/envrcctl-macos-auth → /envrcctl-0.2.3/src/envrcctl/envrcctl-macos-auth.bak +0 -0
- {envrcctl-0.2.2 → envrcctl-0.2.3}/src/envrcctl/errors.py +0 -0
- {envrcctl-0.2.2 → envrcctl-0.2.3}/src/envrcctl/keychain.py +0 -0
- {envrcctl-0.2.2 → envrcctl-0.2.3}/src/envrcctl/main.py +0 -0
- {envrcctl-0.2.2 → envrcctl-0.2.3}/src/envrcctl/managed_block.py +0 -0
- {envrcctl-0.2.2 → envrcctl-0.2.3}/src/envrcctl/secrets.py +0 -0
- {envrcctl-0.2.2 → envrcctl-0.2.3}/src/envrcctl/secretservice.py +0 -0
|
@@ -1,39 +1,19 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: envrcctl
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Manage .envrc with managed blocks and OS-backed secrets.
|
|
5
|
-
Author: Rio Fujita
|
|
6
|
-
Author-email: Rio Fujita <rio_github@rio.st>
|
|
7
|
-
License: MIT License
|
|
8
|
-
|
|
9
|
-
Copyright (c) 2026 Rio Fujita
|
|
10
|
-
|
|
11
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
-
in the Software without restriction, including without limitation the rights
|
|
14
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
-
furnished to do so, subject to the following conditions:
|
|
17
|
-
|
|
18
|
-
The above copyright notice and this permission notice shall be included in all
|
|
19
|
-
copies or substantial portions of the Software.
|
|
20
|
-
|
|
21
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
-
SOFTWARE.
|
|
28
|
-
Requires-Dist: typer>=0.24.1
|
|
29
|
-
Requires-Dist: pytest>=9 ; extra == 'test'
|
|
30
|
-
Requires-Dist: pytest-cov>=7 ; extra == 'test'
|
|
31
|
-
Requires-Dist: bandit>=1.7.10 ; extra == 'test'
|
|
32
|
-
Requires-Python: >=3.14
|
|
33
5
|
Project-URL: Homepage, https://github.com/rioriost/envrcctl
|
|
34
6
|
Project-URL: Issues, https://github.com/rioriost/envrcctl/issues
|
|
35
7
|
Project-URL: Repository, https://github.com/rioriost/envrcctl
|
|
8
|
+
Author-email: Rio Fujita <rio_github@rio.st>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Python: >=3.14
|
|
12
|
+
Requires-Dist: typer>=0.24.1
|
|
36
13
|
Provides-Extra: test
|
|
14
|
+
Requires-Dist: bandit>=1.7.10; extra == 'test'
|
|
15
|
+
Requires-Dist: pytest-cov>=7; extra == 'test'
|
|
16
|
+
Requires-Dist: pytest>=9; extra == 'test'
|
|
37
17
|
Description-Content-Type: text/markdown
|
|
38
18
|
|
|
39
19
|
# envrcctl
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "envrcctl"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.3"
|
|
4
4
|
description = "Manage .envrc with managed blocks and OS-backed secrets."
|
|
5
5
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
6
|
-
license =
|
|
6
|
+
license = "MIT"
|
|
7
7
|
requires-python = ">=3.14"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name = "Rio Fujita", email = "rio_github@rio.st" },
|
|
@@ -12,10 +12,21 @@ dependencies = [
|
|
|
12
12
|
"typer>=0.24.1",
|
|
13
13
|
]
|
|
14
14
|
|
|
15
|
-
[tool.
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
[tool.hatch.build.targets.wheel]
|
|
16
|
+
packages = ["src/envrcctl"]
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.sdist]
|
|
19
|
+
include = [
|
|
20
|
+
"src/**",
|
|
21
|
+
"tests/**",
|
|
22
|
+
"scripts/**",
|
|
18
23
|
"completions/**",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"pyproject.toml",
|
|
27
|
+
]
|
|
28
|
+
exclude = [
|
|
29
|
+
"src/envrcctl/envrcctl-macos-auth",
|
|
19
30
|
]
|
|
20
31
|
|
|
21
32
|
[project.urls]
|
|
@@ -41,8 +52,8 @@ dev = [
|
|
|
41
52
|
]
|
|
42
53
|
|
|
43
54
|
[build-system]
|
|
44
|
-
requires = ["
|
|
45
|
-
build-backend = "
|
|
55
|
+
requires = ["hatchling>=1.25.0"]
|
|
56
|
+
build-backend = "hatchling.build"
|
|
46
57
|
|
|
47
58
|
[tool.pytest.ini_options]
|
|
48
59
|
testpaths = ["tests"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -eu
|
|
3
|
+
|
|
4
|
+
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
|
|
5
|
+
REPO_ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)"
|
|
6
|
+
SWIFT_SOURCE="${1:-$REPO_ROOT/scripts/macos/envrcctl-macos-auth.swift}"
|
|
7
|
+
OUTPUT_PATH="${2:-$REPO_ROOT/src/envrcctl/envrcctl-macos-auth}"
|
|
8
|
+
|
|
9
|
+
if [ "$(uname -s)" != "Darwin" ]; then
|
|
10
|
+
echo "This helper can only be built on macOS." >&2
|
|
11
|
+
exit 1
|
|
12
|
+
fi
|
|
13
|
+
|
|
14
|
+
if [ "$(uname -m)" != "arm64" ]; then
|
|
15
|
+
echo "This helper only supports Apple Silicon (arm64) macOS." >&2
|
|
16
|
+
exit 1
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
if ! command -v swiftc >/dev/null 2>&1; then
|
|
20
|
+
echo "swiftc not found. Install Xcode Command Line Tools first." >&2
|
|
21
|
+
exit 1
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
if [ ! -f "$SWIFT_SOURCE" ]; then
|
|
25
|
+
echo "Swift source not found: $SWIFT_SOURCE" >&2
|
|
26
|
+
echo "Pass the source path as the first argument or create scripts/macos/envrcctl-macos-auth.swift." >&2
|
|
27
|
+
exit 1
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
mkdir -p "$(dirname "$OUTPUT_PATH")"
|
|
31
|
+
|
|
32
|
+
echo "Building macOS auth helper for Apple Silicon (arm64)..."
|
|
33
|
+
echo " source: $SWIFT_SOURCE"
|
|
34
|
+
echo " output: $OUTPUT_PATH"
|
|
35
|
+
|
|
36
|
+
swiftc \
|
|
37
|
+
-O \
|
|
38
|
+
-framework LocalAuthentication \
|
|
39
|
+
-framework Security \
|
|
40
|
+
"$SWIFT_SOURCE" \
|
|
41
|
+
-o "$OUTPUT_PATH"
|
|
42
|
+
|
|
43
|
+
chmod 755 "$OUTPUT_PATH"
|
|
44
|
+
|
|
45
|
+
echo "Build complete: $OUTPUT_PATH"
|
|
46
|
+
echo
|
|
47
|
+
echo "You can override the helper path at runtime with:"
|
|
48
|
+
echo " ENVRCCTL_MACOS_AUTH_HELPER=$OUTPUT_PATH"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from click.shell_completion import get_completion_class
|
|
6
|
+
from typer.main import get_command
|
|
7
|
+
|
|
8
|
+
from envrcctl.cli import app
|
|
9
|
+
|
|
10
|
+
SHELLS = ("bash", "zsh", "fish")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main() -> None:
|
|
14
|
+
repo_root = Path(__file__).resolve().parents[1]
|
|
15
|
+
output_dir = repo_root / "completions"
|
|
16
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
|
|
18
|
+
command = get_command(app)
|
|
19
|
+
complete_var = "_ENVRCCTL_COMPLETE"
|
|
20
|
+
|
|
21
|
+
for shell in SHELLS:
|
|
22
|
+
comp_cls = get_completion_class(shell)
|
|
23
|
+
if comp_cls is None:
|
|
24
|
+
raise RuntimeError(f"Unsupported shell: {shell}")
|
|
25
|
+
comp = comp_cls(command, {}, "envrcctl", complete_var)
|
|
26
|
+
content = comp.source()
|
|
27
|
+
if not content.strip():
|
|
28
|
+
raise RuntimeError(f"Failed to generate {shell} completion.")
|
|
29
|
+
if not content.endswith("\n"):
|
|
30
|
+
content += "\n"
|
|
31
|
+
(output_dir / f"envrcctl.{shell}").write_text(content, encoding="utf-8")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
main()
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import LocalAuthentication
|
|
3
|
+
import Security
|
|
4
|
+
|
|
5
|
+
enum HelperError: Error, LocalizedError {
|
|
6
|
+
case invalidArguments(String)
|
|
7
|
+
case authenticationUnavailable(String)
|
|
8
|
+
case authenticationFailed(String)
|
|
9
|
+
case keychainFailure(String)
|
|
10
|
+
case decodeFailure
|
|
11
|
+
case inputReadFailure(String)
|
|
12
|
+
case outputEncodeFailure
|
|
13
|
+
|
|
14
|
+
var errorDescription: String? {
|
|
15
|
+
switch self {
|
|
16
|
+
case .invalidArguments(let message):
|
|
17
|
+
return message
|
|
18
|
+
case .authenticationUnavailable(let message):
|
|
19
|
+
return message
|
|
20
|
+
case .authenticationFailed(let message):
|
|
21
|
+
return message
|
|
22
|
+
case .keychainFailure(let message):
|
|
23
|
+
return message
|
|
24
|
+
case .decodeFailure:
|
|
25
|
+
return "Keychain item contains non-UTF-8 data."
|
|
26
|
+
case .inputReadFailure(let message):
|
|
27
|
+
return message
|
|
28
|
+
case .outputEncodeFailure:
|
|
29
|
+
return "Failed to encode JSON response."
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
struct Arguments {
|
|
35
|
+
let authorizeOnly: Bool
|
|
36
|
+
let service: String?
|
|
37
|
+
let account: String?
|
|
38
|
+
let inputJSONPath: String?
|
|
39
|
+
let reason: String
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
struct BulkRequest: Decodable {
|
|
43
|
+
let items: [BulkRequestItem]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
struct BulkRequestItem: Decodable {
|
|
47
|
+
let service: String
|
|
48
|
+
let account: String
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
struct BulkResponse: Encodable {
|
|
52
|
+
let items: [BulkResponseItem]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
struct BulkResponseItem: Encodable {
|
|
56
|
+
let service: String
|
|
57
|
+
let account: String
|
|
58
|
+
let value: String
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private func printErrorAndExit(_ error: Error) -> Never {
|
|
62
|
+
let message: String
|
|
63
|
+
if let helperError = error as? LocalizedError, let description = helperError.errorDescription {
|
|
64
|
+
message = description
|
|
65
|
+
} else {
|
|
66
|
+
message = "macOS authentication helper failed."
|
|
67
|
+
}
|
|
68
|
+
FileHandle.standardError.write(Data((message + "\n").utf8))
|
|
69
|
+
exit(1)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private func printHelpAndExit() -> Never {
|
|
73
|
+
let help = """
|
|
74
|
+
Usage:
|
|
75
|
+
envrcctl-macos-auth --authorize-only --reason <text>
|
|
76
|
+
envrcctl-macos-auth --service <service> --account <account> --reason <text>
|
|
77
|
+
envrcctl-macos-auth --input-json <path|- > --reason <text>
|
|
78
|
+
|
|
79
|
+
Options:
|
|
80
|
+
--authorize-only Require device owner authentication only.
|
|
81
|
+
--service Keychain service name.
|
|
82
|
+
--account Keychain account name.
|
|
83
|
+
--input-json JSON file path or '-' for stdin for bulk reads.
|
|
84
|
+
--reason Localized reason shown in the auth prompt.
|
|
85
|
+
--help Show this help.
|
|
86
|
+
|
|
87
|
+
Bulk JSON input:
|
|
88
|
+
{
|
|
89
|
+
"items": [
|
|
90
|
+
{ "service": "st.rio.envrcctl", "account": "openai:prod" },
|
|
91
|
+
{ "service": "st.rio.envrcctl", "account": "github:prod" }
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
"""
|
|
95
|
+
print(help)
|
|
96
|
+
exit(0)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private func parseArguments(_ argv: [String]) throws -> Arguments {
|
|
100
|
+
var authorizeOnly = false
|
|
101
|
+
var service: String?
|
|
102
|
+
var account: String?
|
|
103
|
+
var inputJSONPath: String?
|
|
104
|
+
var reason: String?
|
|
105
|
+
|
|
106
|
+
var index = 1
|
|
107
|
+
while index < argv.count {
|
|
108
|
+
let arg = argv[index]
|
|
109
|
+
switch arg {
|
|
110
|
+
case "--authorize-only":
|
|
111
|
+
authorizeOnly = true
|
|
112
|
+
index += 1
|
|
113
|
+
case "--service":
|
|
114
|
+
guard index + 1 < argv.count else {
|
|
115
|
+
throw HelperError.invalidArguments("Missing value for --service.")
|
|
116
|
+
}
|
|
117
|
+
service = argv[index + 1]
|
|
118
|
+
index += 2
|
|
119
|
+
case "--account":
|
|
120
|
+
guard index + 1 < argv.count else {
|
|
121
|
+
throw HelperError.invalidArguments("Missing value for --account.")
|
|
122
|
+
}
|
|
123
|
+
account = argv[index + 1]
|
|
124
|
+
index += 2
|
|
125
|
+
case "--input-json":
|
|
126
|
+
guard index + 1 < argv.count else {
|
|
127
|
+
throw HelperError.invalidArguments("Missing value for --input-json.")
|
|
128
|
+
}
|
|
129
|
+
inputJSONPath = argv[index + 1]
|
|
130
|
+
index += 2
|
|
131
|
+
case "--reason":
|
|
132
|
+
guard index + 1 < argv.count else {
|
|
133
|
+
throw HelperError.invalidArguments("Missing value for --reason.")
|
|
134
|
+
}
|
|
135
|
+
reason = argv[index + 1]
|
|
136
|
+
index += 2
|
|
137
|
+
case "--help", "-h":
|
|
138
|
+
printHelpAndExit()
|
|
139
|
+
default:
|
|
140
|
+
throw HelperError.invalidArguments("Unknown argument: \(arg)")
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
guard let reason, !reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
145
|
+
throw HelperError.invalidArguments("A non-empty --reason is required.")
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if authorizeOnly {
|
|
149
|
+
if service != nil || account != nil || inputJSONPath != nil {
|
|
150
|
+
throw HelperError.invalidArguments(
|
|
151
|
+
"--authorize-only cannot be combined with --service, --account, or --input-json."
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
return Arguments(
|
|
155
|
+
authorizeOnly: true,
|
|
156
|
+
service: nil,
|
|
157
|
+
account: nil,
|
|
158
|
+
inputJSONPath: nil,
|
|
159
|
+
reason: reason
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let hasSingle = service != nil || account != nil
|
|
164
|
+
let hasBulk = inputJSONPath != nil
|
|
165
|
+
|
|
166
|
+
if hasSingle && hasBulk {
|
|
167
|
+
throw HelperError.invalidArguments(
|
|
168
|
+
"--input-json cannot be combined with --service or --account."
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if hasBulk {
|
|
173
|
+
return Arguments(
|
|
174
|
+
authorizeOnly: false,
|
|
175
|
+
service: nil,
|
|
176
|
+
account: nil,
|
|
177
|
+
inputJSONPath: inputJSONPath,
|
|
178
|
+
reason: reason
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
guard let service, !service.isEmpty else {
|
|
183
|
+
throw HelperError.invalidArguments("--service is required.")
|
|
184
|
+
}
|
|
185
|
+
guard let account, !account.isEmpty else {
|
|
186
|
+
throw HelperError.invalidArguments("--account is required.")
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return Arguments(
|
|
190
|
+
authorizeOnly: false,
|
|
191
|
+
service: service,
|
|
192
|
+
account: account,
|
|
193
|
+
inputJSONPath: nil,
|
|
194
|
+
reason: reason
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private func authenticate(reason: String) throws -> LAContext {
|
|
199
|
+
let context = LAContext()
|
|
200
|
+
context.localizedCancelTitle = "Cancel"
|
|
201
|
+
|
|
202
|
+
var canEvaluateError: NSError?
|
|
203
|
+
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &canEvaluateError) else {
|
|
204
|
+
let message =
|
|
205
|
+
canEvaluateError?.localizedDescription
|
|
206
|
+
?? "Device owner authentication is unavailable."
|
|
207
|
+
throw HelperError.authenticationUnavailable(message)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
211
|
+
var authError: Error?
|
|
212
|
+
|
|
213
|
+
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, error in
|
|
214
|
+
if !success {
|
|
215
|
+
authError = error ?? HelperError.authenticationFailed("Authentication failed.")
|
|
216
|
+
}
|
|
217
|
+
semaphore.signal()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
semaphore.wait()
|
|
221
|
+
|
|
222
|
+
if let authError {
|
|
223
|
+
if let laError = authError as? LAError {
|
|
224
|
+
switch laError.code {
|
|
225
|
+
case .userCancel, .userFallback, .systemCancel, .appCancel:
|
|
226
|
+
throw HelperError.authenticationFailed("Authentication cancelled.")
|
|
227
|
+
default:
|
|
228
|
+
throw HelperError.authenticationFailed(laError.localizedDescription)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
throw HelperError.authenticationFailed(authError.localizedDescription)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return context
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private func readSecret(service: String, account: String, context: LAContext) throws -> String {
|
|
238
|
+
let query: [String: Any] = [
|
|
239
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
240
|
+
kSecAttrService as String: service,
|
|
241
|
+
kSecAttrAccount as String: account,
|
|
242
|
+
kSecReturnData as String: true,
|
|
243
|
+
kSecMatchLimit as String: kSecMatchLimitOne,
|
|
244
|
+
kSecUseAuthenticationContext as String: context,
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
var item: CFTypeRef?
|
|
248
|
+
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
|
249
|
+
|
|
250
|
+
guard status == errSecSuccess else {
|
|
251
|
+
let message = SecCopyErrorMessageString(status, nil) as String? ?? "Keychain read failed."
|
|
252
|
+
throw HelperError.keychainFailure(message)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
guard let data = item as? Data else {
|
|
256
|
+
throw HelperError.keychainFailure("Keychain returned an unexpected item type.")
|
|
257
|
+
}
|
|
258
|
+
guard let value = String(data: data, encoding: .utf8) else {
|
|
259
|
+
throw HelperError.decodeFailure
|
|
260
|
+
}
|
|
261
|
+
return value
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private func readBulkRequest(from path: String) throws -> BulkRequest {
|
|
265
|
+
let data: Data
|
|
266
|
+
if path == "-" {
|
|
267
|
+
data = FileHandle.standardInput.readDataToEndOfFile()
|
|
268
|
+
if data.isEmpty {
|
|
269
|
+
throw HelperError.inputReadFailure("No JSON input received on stdin.")
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
let url = URL(fileURLWithPath: path)
|
|
273
|
+
do {
|
|
274
|
+
data = try Data(contentsOf: url)
|
|
275
|
+
} catch {
|
|
276
|
+
throw HelperError.inputReadFailure("Failed to read JSON input: \(path)")
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
do {
|
|
281
|
+
let request = try JSONDecoder().decode(BulkRequest.self, from: data)
|
|
282
|
+
if request.items.isEmpty {
|
|
283
|
+
throw HelperError.invalidArguments("Bulk JSON input must include at least one item.")
|
|
284
|
+
}
|
|
285
|
+
for item in request.items {
|
|
286
|
+
if item.service.isEmpty || item.account.isEmpty {
|
|
287
|
+
throw HelperError.invalidArguments(
|
|
288
|
+
"Each bulk request item must include non-empty service and account values."
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return request
|
|
293
|
+
} catch let helperError as HelperError {
|
|
294
|
+
throw helperError
|
|
295
|
+
} catch {
|
|
296
|
+
throw HelperError.invalidArguments("Failed to decode bulk JSON input.")
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private func writeBulkResponse(_ response: BulkResponse) throws {
|
|
301
|
+
let encoder = JSONEncoder()
|
|
302
|
+
encoder.outputFormatting = [.sortedKeys]
|
|
303
|
+
guard let data = try? encoder.encode(response) else {
|
|
304
|
+
throw HelperError.outputEncodeFailure
|
|
305
|
+
}
|
|
306
|
+
FileHandle.standardOutput.write(data)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
do {
|
|
310
|
+
let args = try parseArguments(CommandLine.arguments)
|
|
311
|
+
let context = try authenticate(reason: args.reason)
|
|
312
|
+
|
|
313
|
+
if args.authorizeOnly {
|
|
314
|
+
exit(0)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if let inputJSONPath = args.inputJSONPath {
|
|
318
|
+
let request = try readBulkRequest(from: inputJSONPath)
|
|
319
|
+
let items = try request.items.map { item in
|
|
320
|
+
BulkResponseItem(
|
|
321
|
+
service: item.service,
|
|
322
|
+
account: item.account,
|
|
323
|
+
value: try readSecret(
|
|
324
|
+
service: item.service, account: item.account, context: context)
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
try writeBulkResponse(BulkResponse(items: items))
|
|
328
|
+
exit(0)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
guard let service = args.service, let account = args.account else {
|
|
332
|
+
throw HelperError.invalidArguments("Both --service and --account are required.")
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let secret = try readSecret(service: service, account: account, context: context)
|
|
336
|
+
FileHandle.standardOutput.write(Data(secret.utf8))
|
|
337
|
+
exit(0)
|
|
338
|
+
} catch {
|
|
339
|
+
printErrorAndExit(error)
|
|
340
|
+
}
|