cdx-manager 0.3.3 → 0.4.0

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.
@@ -0,0 +1,134 @@
1
+ import base64
2
+ import hashlib
3
+ import hmac
4
+ import json
5
+ import os
6
+ from datetime import datetime, timezone
7
+
8
+ from .errors import CdxError
9
+
10
+
11
+ BUNDLE_SCHEMA_VERSION = 1
12
+ _SALT_BYTES = 16
13
+ _NONCE_BYTES = 16
14
+ _SCRYPT_N = 2 ** 14
15
+ _SCRYPT_R = 8
16
+ _SCRYPT_P = 1
17
+ _PBKDF2_ITERATIONS = 200000
18
+
19
+
20
+ def _now_iso():
21
+ return datetime.now(timezone.utc).astimezone().isoformat()
22
+
23
+
24
+ def _b64_encode(data):
25
+ return base64.b64encode(data).decode("ascii")
26
+
27
+
28
+ def _b64_decode(data):
29
+ try:
30
+ return base64.b64decode(data.encode("ascii"))
31
+ except (AttributeError, ValueError, UnicodeEncodeError) as error:
32
+ raise CdxError("Bundle contains invalid base64 data.") from error
33
+
34
+
35
+ def read_bundle_meta(data):
36
+ try:
37
+ wrapper = json.loads(data.decode("utf-8"))
38
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
39
+ raise CdxError("Invalid bundle format.") from error
40
+
41
+ if wrapper.get("schema_version") != BUNDLE_SCHEMA_VERSION:
42
+ raise CdxError("Unsupported bundle schema version.")
43
+ return wrapper
44
+
45
+
46
+ def _derive_keys(passphrase, salt):
47
+ if not passphrase:
48
+ raise CdxError("A non-empty passphrase is required for bundles that include auth data.")
49
+ if isinstance(passphrase, str):
50
+ passphrase = passphrase.encode("utf-8")
51
+ if hasattr(hashlib, "scrypt"):
52
+ key_material = hashlib.scrypt(
53
+ passphrase,
54
+ salt=salt,
55
+ n=_SCRYPT_N,
56
+ r=_SCRYPT_R,
57
+ p=_SCRYPT_P,
58
+ dklen=64,
59
+ )
60
+ else:
61
+ key_material = hashlib.pbkdf2_hmac(
62
+ "sha256",
63
+ passphrase,
64
+ salt,
65
+ _PBKDF2_ITERATIONS,
66
+ dklen=64,
67
+ )
68
+ return key_material[:32], key_material[32:]
69
+
70
+
71
+ def _xor_keystream(data, key, nonce):
72
+ output = bytearray()
73
+ counter = 0
74
+ while len(output) < len(data):
75
+ block = hashlib.sha256(key + nonce + counter.to_bytes(8, "big")).digest()
76
+ output.extend(block)
77
+ counter += 1
78
+ return bytes(a ^ b for a, b in zip(data, output[:len(data)]))
79
+
80
+
81
+ def encode_bundle(payload, include_auth=False, passphrase=None):
82
+ payload_bytes = json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
83
+ wrapper = {
84
+ "schema_version": BUNDLE_SCHEMA_VERSION,
85
+ "bundle_version": 1,
86
+ "created_at": _now_iso(),
87
+ "include_auth": bool(include_auth),
88
+ "encrypted": bool(include_auth),
89
+ "session_names": [item["name"] for item in payload.get("sessions", [])],
90
+ }
91
+ if include_auth:
92
+ salt = os.urandom(_SALT_BYTES)
93
+ nonce = os.urandom(_NONCE_BYTES)
94
+ enc_key, mac_key = _derive_keys(passphrase, salt)
95
+ ciphertext = _xor_keystream(payload_bytes, enc_key, nonce)
96
+ mac = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()
97
+ wrapper.update({
98
+ "salt": _b64_encode(salt),
99
+ "nonce": _b64_encode(nonce),
100
+ "hmac_sha256": _b64_encode(mac),
101
+ "payload": _b64_encode(ciphertext),
102
+ })
103
+ else:
104
+ wrapper["payload"] = _b64_encode(payload_bytes)
105
+ return json.dumps(wrapper, indent=2).encode("utf-8")
106
+
107
+
108
+ def decode_bundle(data, passphrase=None):
109
+ wrapper = read_bundle_meta(data)
110
+
111
+ encrypted = bool(wrapper.get("encrypted"))
112
+ payload_b64 = wrapper.get("payload")
113
+ if not isinstance(payload_b64, str):
114
+ raise CdxError("Bundle payload is missing.")
115
+
116
+ if encrypted:
117
+ salt = _b64_decode(wrapper.get("salt", ""))
118
+ nonce = _b64_decode(wrapper.get("nonce", ""))
119
+ expected_mac = _b64_decode(wrapper.get("hmac_sha256", ""))
120
+ ciphertext = _b64_decode(payload_b64)
121
+ enc_key, mac_key = _derive_keys(passphrase, salt)
122
+ actual_mac = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()
123
+ if not hmac.compare_digest(actual_mac, expected_mac):
124
+ raise CdxError("Invalid bundle passphrase or corrupted bundle.")
125
+ payload_bytes = _xor_keystream(ciphertext, enc_key, nonce)
126
+ else:
127
+ payload_bytes = _b64_decode(payload_b64)
128
+
129
+ try:
130
+ payload = json.loads(payload_bytes.decode("utf-8"))
131
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
132
+ raise CdxError("Bundle payload is corrupt.") from error
133
+
134
+ return {"meta": wrapper, "payload": payload}
package/src/cli.py CHANGED
@@ -1,14 +1,18 @@
1
1
  #!/usr/bin/env python3
2
2
 
3
+ import json
3
4
  import os
4
5
  import sys
5
6
 
6
7
  from .cli_commands import (
8
+ API_SCHEMA_VERSION,
7
9
  STATUS_USAGE,
8
10
  handle_add,
9
11
  handle_clean,
10
12
  handle_copy,
11
13
  handle_doctor,
14
+ handle_export,
15
+ handle_import,
12
16
  handle_launch,
13
17
  handle_login,
14
18
  handle_logout,
@@ -38,8 +42,9 @@ from .status_view import (
38
42
  _format_status_detail,
39
43
  _format_status_rows,
40
44
  )
45
+ from .update_check import check_for_update
41
46
 
42
- VERSION = "0.3.3"
47
+ VERSION = "0.4.0"
43
48
 
44
49
 
45
50
  # ---------------------------------------------------------------------------
@@ -52,21 +57,24 @@ def _print_help(use_color=False):
52
57
  "",
53
58
  _style("Usage:", "1", use_color),
54
59
  f" {_style('cdx', '36', use_color)}",
60
+ f" {_style('cdx --json', '36', use_color)}",
55
61
  f" {_style('cdx status [--json] [--refresh]', '36', use_color)}",
56
62
  f" {_style('cdx status --small|-s [--refresh]', '36', use_color)}",
57
63
  f" {_style('cdx status <name> [--json] [--refresh]', '36', use_color)}",
58
- f" {_style('cdx add [provider] <name>', '36', use_color)}",
59
- f" {_style('cdx cp <source> <dest>', '36', use_color)}",
60
- f" {_style('cdx ren <source> <dest>', '36', use_color)}",
61
- f" {_style('cdx login <name>', '36', use_color)}",
62
- f" {_style('cdx logout <name>', '36', use_color)}",
63
- f" {_style('cdx rmv <name> [--force]', '36', use_color)}",
64
- f" {_style('cdx clean [name]', '36', use_color)}",
64
+ f" {_style('cdx add [provider] <name> [--json]', '36', use_color)}",
65
+ f" {_style('cdx cp <source> <dest> [--json]', '36', use_color)}",
66
+ f" {_style('cdx ren <source> <dest> [--json]', '36', use_color)}",
67
+ f" {_style('cdx login <name> [--json]', '36', use_color)}",
68
+ f" {_style('cdx logout <name> [--json]', '36', use_color)}",
69
+ f" {_style('cdx rmv <name> [--force] [--json]', '36', use_color)}",
70
+ f" {_style('cdx clean [name] [--json]', '36', use_color)}",
71
+ f" {_style('cdx export <file> [--include-auth] [--sessions a,b] [--passphrase-env VAR] [--force] [--json]', '36', use_color)}",
72
+ f" {_style('cdx import <file> [--sessions a,b] [--passphrase-env VAR] [--force] [--json]', '36', use_color)}",
65
73
  f" {_style('cdx doctor [--json]', '36', use_color)}",
66
74
  f" {_style('cdx repair [--dry-run] [--force] [--json]', '36', use_color)}",
67
- f" {_style('cdx notify <name> --at-reset', '36', use_color)}",
68
- f" {_style('cdx notify --next-ready', '36', use_color)}",
69
- f" {_style('cdx <name>', '36', use_color)}",
75
+ f" {_style('cdx notify <name> --at-reset [--json]', '36', use_color)}",
76
+ f" {_style('cdx notify --next-ready [--json]', '36', use_color)}",
77
+ f" {_style('cdx <name> [--json]', '36', use_color)}",
70
78
  f" {_style('cdx --help', '36', use_color)}",
71
79
  f" {_style('cdx --version', '36', use_color)}",
72
80
  ])
@@ -76,6 +84,63 @@ def _print_version():
76
84
  return VERSION
77
85
 
78
86
 
87
+ def wants_json(argv):
88
+ return "--json" in argv
89
+
90
+
91
+ def format_json_error(error):
92
+ message = str(error)
93
+ code = "cdx_error"
94
+ if message.startswith("Usage:"):
95
+ code = "invalid_usage"
96
+ elif message.startswith("Unknown session:"):
97
+ code = "unknown_session"
98
+ elif message.startswith("Unknown command:"):
99
+ code = "unknown_command"
100
+ elif message.startswith("Session already exists:"):
101
+ code = "session_exists"
102
+ elif "requires an interactive terminal" in message or "requires confirmation" in message:
103
+ code = "interactive_terminal_required"
104
+ return json.dumps({
105
+ "schema_version": API_SCHEMA_VERSION,
106
+ "ok": False,
107
+ "error": {
108
+ "code": code,
109
+ "message": message,
110
+ "exit_code": error.exit_code,
111
+ },
112
+ }, indent=2)
113
+
114
+
115
+ def _get_update_notice(service, env, options):
116
+ checker = options.get("checkForUpdate") or check_for_update
117
+ return checker(
118
+ service["base_dir"],
119
+ VERSION,
120
+ env=env,
121
+ now_fn=options.get("now"),
122
+ )
123
+
124
+
125
+ def _update_warning_payload(notice):
126
+ if not notice:
127
+ return []
128
+ message = f"Update available: cdx-manager {notice['latest_version']} (current {VERSION})"
129
+ return [{
130
+ "code": "update_available",
131
+ "message": message,
132
+ "latest_version": notice["latest_version"],
133
+ "url": notice.get("url"),
134
+ }]
135
+
136
+
137
+ def _update_warning_text(notice):
138
+ if not notice:
139
+ return None
140
+ suffix = f" {notice['url']}" if notice.get("url") else ""
141
+ return f"Update available: cdx-manager {notice['latest_version']} (current {VERSION}).{suffix}"
142
+
143
+
79
144
  # ---------------------------------------------------------------------------
80
145
  # main()
81
146
  # ---------------------------------------------------------------------------
@@ -114,14 +179,24 @@ def main(argv, options=None):
114
179
  out(f"{_print_version()}\n")
115
180
  return 0
116
181
 
182
+ if argv == ["--json"]:
183
+ rows = service["format_list_rows"]()
184
+ notice = _get_update_notice(service, env, options)
185
+ out(f"{json.dumps(_list_json_payload(rows, notice=notice), indent=2)}\n")
186
+ return 0
187
+
117
188
  if not argv:
189
+ notice = _get_update_notice(service, env, options)
118
190
  out(f"{_format_sessions(service, use_color=use_color)}\n")
191
+ if notice:
192
+ out(f"{_style(_update_warning_text(notice), '33', use_color)}\n")
119
193
  return 0
120
194
 
121
195
  command, *rest = argv
122
196
  ctx = {
123
197
  "env": env,
124
198
  "options": options,
199
+ "raw_args": argv,
125
200
  "err": err,
126
201
  "out": out,
127
202
  "refresh_fn": refresh_fn,
@@ -130,6 +205,9 @@ def main(argv, options=None):
130
205
  "spawn": spawn,
131
206
  "spawn_sync": spawn_sync,
132
207
  "stdin_is_tty": stdin_is_tty,
208
+ "update_notice": _get_update_notice(service, env, options) if command not in (
209
+ "add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "notify", "status", "login", "logout", "export", "import", "help", "version"
210
+ ) else None,
133
211
  "use_color": use_color,
134
212
  }
135
213
 
@@ -148,6 +226,12 @@ def main(argv, options=None):
148
226
  if command == "clean":
149
227
  return handle_clean(rest, ctx)
150
228
 
229
+ if command == "export":
230
+ return handle_export(rest, ctx)
231
+
232
+ if command == "import":
233
+ return handle_import(rest, ctx)
234
+
151
235
  if command == "doctor":
152
236
  return handle_doctor(rest, ctx)
153
237
 
@@ -174,12 +258,23 @@ def main(argv, options=None):
174
258
  out(f"{_print_version()}\n")
175
259
  return 0
176
260
 
177
- if not rest:
261
+ if not rest or rest == ["--json"]:
178
262
  return handle_launch(command, ctx)
179
263
 
180
264
  raise CdxError(f"Unknown command: {command}. Use cdx --help.")
181
265
 
182
266
 
267
+ def _list_json_payload(rows, notice=None):
268
+ return {
269
+ "schema_version": API_SCHEMA_VERSION,
270
+ "ok": True,
271
+ "action": "list",
272
+ "message": "Listed known sessions",
273
+ "warnings": _update_warning_payload(notice),
274
+ "sessions": rows,
275
+ }
276
+
277
+
183
278
  def _enable_windows_ansi():
184
279
  if sys.platform != "win32":
185
280
  return
@@ -212,7 +307,10 @@ def cli_entry():
212
307
  try:
213
308
  raise SystemExit(main(sys.argv[1:]))
214
309
  except CdxError as error:
215
- sys.stderr.write(f"{format_error(error)}\n")
310
+ if wants_json(sys.argv[1:]):
311
+ sys.stderr.write(f"{format_json_error(error)}\n")
312
+ else:
313
+ sys.stderr.write(f"{format_error(error)}\n")
216
314
  raise SystemExit(error.exit_code)
217
315
 
218
316