ossnap 0.1.0__py3-none-any.whl

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.
ossnap/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
ossnap/cli.py ADDED
@@ -0,0 +1,541 @@
1
+ import datetime
2
+ import shutil
3
+ import sys
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ import click
8
+ import questionary
9
+
10
+ from . import config, crypto, git, github, install, repos, ssh, ui
11
+ from .exceptions import (
12
+ ConfigNotFoundError,
13
+ DecryptionError,
14
+ GhAuthError,
15
+ GitError,
16
+ NetworkError,
17
+ )
18
+
19
+
20
+ class AliasedGroup(click.Group):
21
+ ALIASES = {"i": "init", "s": "snapshot", "p": "pull", "l": "list"}
22
+ _REVERSE = {v: k for k, v in ALIASES.items()}
23
+
24
+ def get_command(self, ctx, cmd_name):
25
+ return super().get_command(ctx, self.ALIASES.get(cmd_name, cmd_name))
26
+
27
+ def format_commands(self, ctx, formatter):
28
+ rows = []
29
+ for name in self.list_commands(ctx):
30
+ cmd = self.get_command(ctx, name)
31
+ if cmd is None or cmd.hidden:
32
+ continue
33
+ alias = self._REVERSE.get(name, "")
34
+ label = f"{name} ({alias})" if alias else name
35
+ rows.append((label, cmd.get_short_help_str(limit=formatter.width)))
36
+ if rows:
37
+ with formatter.section("Commands"):
38
+ formatter.write_dl(rows)
39
+
40
+
41
+ @click.group(cls=AliasedGroup, context_settings={"help_option_names": ["-h", "--help"]})
42
+ def main():
43
+ """ossnap — snapshot and restore your macOS dev environment."""
44
+ pass
45
+
46
+
47
+ HELP_OPTS = {"help_option_names": ["-h", "--help"]}
48
+
49
+
50
+ @main.command(context_settings=HELP_OPTS)
51
+ def init():
52
+ """Interactive setup wizard."""
53
+ ui.banner("init")
54
+
55
+ # Load existing config as defaults (if re-running init)
56
+ try:
57
+ existing_cfg = config.load_config()
58
+ ui.info("Existing config found — pre-filling values.")
59
+ except ConfigNotFoundError:
60
+ existing_cfg = {}
61
+
62
+ # 1. Check gh CLI
63
+ if not install.ensure_gh():
64
+ sys.exit(1)
65
+
66
+ # 2. Authenticate
67
+ with ui.status_spinner("Checking GitHub authentication..."):
68
+ username = github.check_authenticated()
69
+
70
+ if username:
71
+ ui.success(f"Logged in as: {username}")
72
+ else:
73
+ try:
74
+ github.login()
75
+ with ui.status_spinner("Verifying authentication..."):
76
+ username = github.check_authenticated() or ""
77
+ ui.success(f"Logged in as: {username}")
78
+ except (GhAuthError, NetworkError) as e:
79
+ ui.error(f"Authentication failed: {e}")
80
+ sys.exit(1)
81
+
82
+ # 3. Select or create snapshot repo
83
+ ui.header("Snapshot Repository")
84
+ try:
85
+ with ui.status_spinner("Fetching your repositories..."):
86
+ repo_list = github.list_repos()
87
+ except NetworkError as e:
88
+ ui.error(f"Failed to list repos: {e}")
89
+ sys.exit(1)
90
+
91
+ repo_map = {r["name"]: r["url"] for r in repo_list}
92
+ CREATE_NEW = "+ Create new private repo"
93
+ # Pre-select existing repo from config
94
+ existing_url = existing_cfg.get("github_repo_url", "")
95
+ existing_repo_name = existing_url.rstrip("/").split("/")[-1] if existing_url else None
96
+ repo_default = existing_repo_name if existing_repo_name in repo_map else CREATE_NEW
97
+ selected = questionary.select(
98
+ "Snapshot repository:",
99
+ choices=[CREATE_NEW] + list(repo_map.keys()),
100
+ default=repo_default,
101
+ ).ask()
102
+ if selected is None:
103
+ sys.exit(0)
104
+
105
+ if selected == CREATE_NEW:
106
+ repo_name = questionary.text(
107
+ "Repository name:", default="macos_setup"
108
+ ).ask()
109
+ if not repo_name or not repo_name.strip():
110
+ sys.exit(0)
111
+ try:
112
+ with ui.status_spinner(f"Creating {repo_name.strip()}..."):
113
+ repo_url = github.create_private_repo(repo_name.strip())
114
+ ui.success(f"Created: {repo_url}")
115
+ except NetworkError as e:
116
+ ui.error(f"Failed to create repo: {e}")
117
+ sys.exit(1)
118
+ else:
119
+ repo_url = repo_map[selected]
120
+ ui.success(f"Using: {repo_url}")
121
+
122
+ # 4. SSH directory
123
+ ui.header("SSH Directory")
124
+ existing_ssh = existing_cfg.get("ssh_dir", "~/.ssh")
125
+ existing_ssh = existing_ssh.replace(str(Path.home()), "~")
126
+ ssh_dir_input = questionary.path(
127
+ "SSH directory:",
128
+ default=existing_ssh,
129
+ only_directories=True,
130
+ ).ask()
131
+ if ssh_dir_input is None:
132
+ sys.exit(0)
133
+ ssh_dir = str(Path(ssh_dir_input).expanduser())
134
+ ui.success(f"SSH dir: {ssh_dir}")
135
+
136
+ # 5. Scan directories
137
+ ui.header("Repo Scan Directories")
138
+ home = Path.home()
139
+ common_names = ["Documents", "Projects", "Developer", "Desktop", "workspace", "code", "repos", "work", "dev"]
140
+ default_names = {"Documents", "Projects"}
141
+ existing_scan = {str(Path(d).expanduser()) for d in existing_cfg.get("scan_dirs", [])}
142
+ dir_choices = [
143
+ questionary.Choice(
144
+ str(home / name),
145
+ checked=(str(home / name) in existing_scan if existing_scan else name in default_names),
146
+ )
147
+ for name in common_names
148
+ if (home / name).exists()
149
+ ]
150
+ # Add custom dirs from existing config that aren't in common list
151
+ for d in existing_scan:
152
+ if not any(str(home / n) == d for n in common_names) and Path(d).exists():
153
+ dir_choices.append(questionary.Choice(d, checked=True))
154
+ scan_dirs = questionary.checkbox(
155
+ "Select directories to scan for git repos:",
156
+ choices=dir_choices,
157
+ instruction="(space: select, a: all, i: invert)",
158
+ ).ask()
159
+ if scan_dirs is None:
160
+ sys.exit(0)
161
+
162
+ while True:
163
+ d = questionary.path(
164
+ "Add custom directory (leave blank to finish):",
165
+ default="",
166
+ only_directories=True,
167
+ ).ask()
168
+ if not d or not d.strip():
169
+ break
170
+ expanded = str(Path(d).expanduser())
171
+ if expanded not in scan_dirs:
172
+ scan_dirs.append(expanded)
173
+
174
+ if not scan_dirs:
175
+ ui.warn("No scan directories configured. You can edit ~/.ossnap/config.json later.")
176
+ else:
177
+ ui.success(f"Will scan: {', '.join(scan_dirs)}")
178
+
179
+ # 6. Env file patterns
180
+ ui.header("Env File Patterns")
181
+ default_patterns = {".env", ".env.local", ".env.development", ".env.production"}
182
+ existing_patterns = set(existing_cfg.get("env_patterns", [])) or default_patterns
183
+ known = [".env", ".env.local", ".env.development", ".env.production", ".env.staging", ".env.test"]
184
+ all_choices = [
185
+ questionary.Choice(p, checked=(p in existing_patterns))
186
+ for p in known
187
+ ]
188
+ # Add custom patterns from existing config not in known list
189
+ for p in existing_patterns:
190
+ if p not in known:
191
+ all_choices.append(questionary.Choice(p, checked=True))
192
+ env_patterns = questionary.checkbox(
193
+ "Select env file patterns to snapshot:",
194
+ choices=all_choices,
195
+ instruction="(space: select, a: all, i: invert)",
196
+ ).ask()
197
+
198
+ if env_patterns is None:
199
+ sys.exit(0)
200
+
201
+ custom = questionary.text(
202
+ "Additional patterns? (comma-separated, leave blank to skip):", default=""
203
+ ).ask()
204
+ if custom:
205
+ extras = [p.strip() for p in custom.split(",") if p.strip()]
206
+ env_patterns.extend(e for e in extras if e not in env_patterns)
207
+
208
+ ui.success(f"Env patterns: {', '.join(env_patterns)}")
209
+
210
+ # 7. Encryption password
211
+ ui.header("Encryption")
212
+ existing_pw = crypto.get_password_if_exists()
213
+ if existing_pw:
214
+ change = questionary.confirm("Encryption password already set. Change it?", default=False).ask()
215
+ if not change:
216
+ pw = existing_pw
217
+ ui.success("Keeping existing password.")
218
+ else:
219
+ existing_pw = None
220
+
221
+ if not existing_pw:
222
+ ui.info("Set a password to encrypt your SSH keys and .env files.")
223
+ while True:
224
+ pw = questionary.password("Password:").ask()
225
+ if not pw:
226
+ ui.error("Password cannot be empty.")
227
+ continue
228
+ pw2 = questionary.password("Confirm password:").ask()
229
+ if pw != pw2:
230
+ ui.error("Passwords do not match. Try again.")
231
+ continue
232
+ break
233
+ crypto.set_password(pw)
234
+ ui.success("Password saved to macOS Keychain")
235
+
236
+ # 8. Save config
237
+ cfg = config.default_config()
238
+ cfg["github_repo_url"] = repo_url
239
+ cfg["ssh_dir"] = ssh_dir_input.replace(str(Path.home()), "~")
240
+ cfg["scan_dirs"] = [str(d).replace(str(Path.home()), "~") for d in scan_dirs]
241
+ cfg["env_patterns"] = env_patterns
242
+ config.save_config(cfg)
243
+ ui.success("Config saved to ~/.ossnap/config.json")
244
+
245
+ # 9. Verify connection
246
+ ui.header("Verifying connection")
247
+ with tempfile.TemporaryDirectory() as tmpdir:
248
+ tmp = Path(tmpdir) / "verify"
249
+ try:
250
+ with ui.status_spinner("Connecting to snapshot repo..."):
251
+ git.clone_or_pull(repo_url, tmp)
252
+ ui.success("Connection verified")
253
+ except GitError as e:
254
+ ui.warn(f"Could not verify connection: {e}")
255
+ ui.info("You can still snapshot/pull later.")
256
+
257
+ # 10. Preview what will be backed up
258
+ ui.header("Preview")
259
+ ssh_result = ssh.scan_ssh(Path(ssh_dir).expanduser())
260
+ with ui.status_spinner("Scanning repos..."):
261
+ repo_list = repos.collect_repos(
262
+ [str(Path(d).expanduser()) for d in scan_dirs],
263
+ cfg.get("exclude_dirs", config.DEFAULT_CONFIG["exclude_dirs"]),
264
+ )
265
+ repo_results = [
266
+ (entry["path"], repos.scan_envs(Path.home() / entry["path"], env_patterns))
267
+ for entry in repo_list
268
+ ]
269
+ ui.print_snapshot_tree(repo_results, ssh_result)
270
+
271
+ ui.header("Done! Run `ossnap snapshot` to create your first snapshot.")
272
+
273
+
274
+ @main.command(name="list", context_settings=HELP_OPTS)
275
+ def list_snapshots():
276
+ """List all snapshots."""
277
+ ui.banner("list")
278
+
279
+ try:
280
+ cfg = config.load_config()
281
+ except ConfigNotFoundError as e:
282
+ ui.error(str(e))
283
+ sys.exit(1)
284
+
285
+ with tempfile.TemporaryDirectory() as tmpdir:
286
+ tmp = Path(tmpdir) / "snapshot_repo"
287
+ try:
288
+ with ui.status_spinner("Fetching snapshot history..."):
289
+ git.clone_or_pull(cfg["github_repo_url"], tmp)
290
+ except GitError as e:
291
+ ui.error(f"Failed to access snapshot repo: {e}")
292
+ sys.exit(1)
293
+
294
+ commits = git.list_commits(tmp, limit=50)
295
+
296
+ if not commits:
297
+ ui.warn("No snapshots found.")
298
+ return
299
+
300
+ ui.print_table(
301
+ title="Snapshot History",
302
+ headers=["#", "Commit", "Date", "Name"],
303
+ rows=[
304
+ (
305
+ str(i + 1),
306
+ c["short"],
307
+ c["date"],
308
+ c["message"],
309
+ )
310
+ for i, c in enumerate(commits)
311
+ ],
312
+ )
313
+
314
+
315
+ @main.command(context_settings=HELP_OPTS)
316
+ @click.option("-n", "--name", default=None, help="Custom snapshot name.")
317
+ def snapshot(name: str | None):
318
+ """Save a snapshot of your environment to GitHub."""
319
+ ui.banner("snapshot")
320
+
321
+ try:
322
+ cfg = config.load_config()
323
+ except ConfigNotFoundError as e:
324
+ ui.error(str(e))
325
+ sys.exit(1)
326
+
327
+ try:
328
+ password = crypto.get_or_create_password()
329
+ except Exception as e:
330
+ ui.error(f"Could not get encryption password: {e}")
331
+ sys.exit(1)
332
+
333
+ repo_url = cfg["github_repo_url"]
334
+ ssh_dir = Path(cfg["ssh_dir"]).expanduser()
335
+ scan_dirs = cfg["scan_dirs"]
336
+ env_patterns = cfg.get("env_patterns", [".env", ".env.local"])
337
+ exclude_dirs = cfg.get("exclude_dirs", [])
338
+
339
+ with tempfile.TemporaryDirectory() as tmpdir:
340
+ tmp = Path(tmpdir) / "snapshot_repo"
341
+ try:
342
+ with ui.status_spinner("Cloning snapshot repo..."):
343
+ git.clone_or_pull(repo_url, tmp)
344
+ except GitError as e:
345
+ ui.error(f"Failed to access snapshot repo: {e}")
346
+ sys.exit(1)
347
+
348
+ # SSH
349
+ ssh_result = {}
350
+ try:
351
+ with ui.status_spinner("Backing up SSH..."):
352
+ ssh_result = ssh.snapshot_ssh(tmp, ssh_dir, password)
353
+ except Exception as e:
354
+ ui.warn(f"SSH snapshot failed: {e}")
355
+
356
+ # Repos
357
+ with ui.status_spinner("Discovering git repos..."):
358
+ repo_list = repos.collect_repos(scan_dirs, exclude_dirs)
359
+
360
+ # Clear entire repos/ dir so stale entries don't accumulate
361
+ repos_dir = tmp / "repos"
362
+ if repos_dir.exists():
363
+ shutil.rmtree(repos_dir)
364
+
365
+ repos.write_repos_json(repo_list, tmp)
366
+
367
+ env_base = tmp / "repos" / "envs"
368
+ snapshot_results = []
369
+ for entry in repo_list:
370
+ repo_path = Path.home() / entry["path"]
371
+ env_files = repos.snapshot_envs(repo_path, env_base, password, env_patterns)
372
+ snapshot_results.append((entry["path"], env_files))
373
+
374
+ ui.print_snapshot_tree(snapshot_results, ssh_result)
375
+
376
+ # Meta
377
+ import json, platform
378
+ meta = {
379
+ "tool_version": "0.1.0",
380
+ "snapshot_date": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
381
+ "hostname": platform.uname().node,
382
+ }
383
+ (tmp / "meta.json").write_text(json.dumps(meta, indent=2))
384
+
385
+ # Commit + push
386
+ now_str = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
387
+ msg = name.strip() if name else f"Snapshot at {now_str}"
388
+ try:
389
+ with ui.status_spinner("Saving snapshot..."):
390
+ changed = git.git_add_commit_push(tmp, msg)
391
+ if changed:
392
+ ui.success(f"Snapshot complete — {msg}")
393
+ else:
394
+ ui.info("Nothing changed since last snapshot.")
395
+ except GitError as e:
396
+ ui.error(f"Snapshot failed: {e}")
397
+ sys.exit(1)
398
+
399
+
400
+ @main.command(context_settings=HELP_OPTS)
401
+ @click.option("--ssh-dir", "ssh_dir_override", default=None, metavar="DIR",
402
+ help="Directory to restore SSH keys into.")
403
+ @click.option("--repos-dir", "repos_dir_override", default=None, metavar="DIR",
404
+ help="Base directory to clone repos into.")
405
+ def pull(ssh_dir_override: str | None, repos_dir_override: str | None):
406
+ """Restore your environment from GitHub."""
407
+ ui.banner("pull")
408
+
409
+ try:
410
+ cfg = config.load_config()
411
+ except ConfigNotFoundError as e:
412
+ ui.error(str(e))
413
+ sys.exit(1)
414
+
415
+ try:
416
+ password = crypto.get_or_create_password()
417
+ except Exception as e:
418
+ ui.error(f"Could not get encryption password: {e}")
419
+ sys.exit(1)
420
+
421
+ repo_url = cfg["github_repo_url"]
422
+ ssh_dir = Path(ssh_dir_override).expanduser() if ssh_dir_override else Path(cfg.get("ssh_dir", "~/.ssh")).expanduser()
423
+ repos_base = Path(repos_dir_override).expanduser() if repos_dir_override else None
424
+
425
+ with tempfile.TemporaryDirectory() as tmpdir:
426
+ tmp = Path(tmpdir) / "snapshot_repo"
427
+ try:
428
+ with ui.status_spinner("Pulling from GitHub..."):
429
+ git.clone_or_pull(repo_url, tmp)
430
+ except GitError as e:
431
+ ui.error(f"Failed to access snapshot repo: {e}")
432
+ sys.exit(1)
433
+
434
+ # Version selection
435
+ commits = git.list_commits(tmp)
436
+ if len(commits) > 1:
437
+ ui.header("Version")
438
+ def _fmt(c: dict, suffix: str = "") -> str:
439
+ return f"{c['short']} {c['date']} {c['message']}{suffix}"
440
+
441
+ LATEST = _fmt(commits[0], " (latest)")
442
+ choices = [LATEST] + [_fmt(c) for c in commits[1:]]
443
+ selected_version = questionary.select(
444
+ "Select snapshot to restore:",
445
+ choices=choices,
446
+ ).ask()
447
+ if selected_version is None:
448
+ sys.exit(0)
449
+ if selected_version != LATEST:
450
+ idx = choices.index(selected_version)
451
+ git.checkout_commit(tmp, commits[idx]["hash"])
452
+
453
+ # SSH — let user select what to restore
454
+ ssh_items = ssh.list_snapshot_items(tmp)
455
+ if ssh_items:
456
+ ui.header("SSH")
457
+ ui.info("Existing files will be skipped automatically.")
458
+ selected_ssh = questionary.checkbox(
459
+ "Select SSH items to restore:",
460
+ choices=[questionary.Choice(item, checked=True) for item in ssh_items],
461
+ instruction="(space: select, a: all, i: invert)",
462
+ ).ask()
463
+ if selected_ssh is None:
464
+ sys.exit(0)
465
+ ssh_selection = set(selected_ssh) if selected_ssh else None
466
+ else:
467
+ ssh_selection = None
468
+
469
+ if ssh_selection:
470
+ try:
471
+ ssh.restore_ssh(tmp, ssh_dir, password, ssh_selection)
472
+ except DecryptionError:
473
+ ui.error("Wrong encryption password. Aborting SSH restore.")
474
+ sys.exit(1)
475
+ except Exception as e:
476
+ ui.warn(f"SSH restore failed: {e}")
477
+
478
+ env_base = tmp / "repos" / "envs"
479
+
480
+ # Repos — select which to clone
481
+ repo_list = repos.read_repos_json(tmp)
482
+ selected_repo_paths: set[str] = set()
483
+ if repo_list:
484
+ ui.header("Repos")
485
+ selected_repos = questionary.checkbox(
486
+ "Select repos to clone:",
487
+ choices=[questionary.Choice(e["path"], checked=True) for e in repo_list],
488
+ instruction="(space: select, a: all, i: invert)",
489
+ ).ask()
490
+ if selected_repos is None:
491
+ sys.exit(0)
492
+ selected_repo_paths = set(selected_repos)
493
+
494
+ cloned = 0
495
+ for entry in [e for e in repo_list if e["path"] in selected_repo_paths]:
496
+ local_path = repos_base / entry["path"] if repos_base else Path.home() / entry["path"]
497
+ if not local_path.exists():
498
+ ui.info(f"Cloning {entry['remote']} → {local_path}")
499
+ try:
500
+ git.clone_repo(entry["remote"], local_path)
501
+ cloned += 1
502
+ except GitError as e:
503
+ ui.warn(f"Could not clone {entry['remote']}: {e}")
504
+ else:
505
+ ui.info(f"Already exists: {local_path}")
506
+ if cloned:
507
+ ui.success(f"Cloned {cloned} repo(s)")
508
+
509
+ # Env files — select which repos' env files to restore
510
+ snapshot_envs = repos.list_snapshot_envs(env_base, [e["path"] for e in repo_list])
511
+ if snapshot_envs:
512
+ ui.header("Env Files")
513
+ selected_env = questionary.checkbox(
514
+ "Select repos to restore env files for:",
515
+ choices=[
516
+ questionary.Choice(
517
+ f"{e['path']} ({', '.join(e['files'])})",
518
+ value=e["path"],
519
+ checked=True,
520
+ )
521
+ for e in snapshot_envs
522
+ ],
523
+ instruction="(space: select, a: all, i: invert)",
524
+ ).ask()
525
+ if selected_env is None:
526
+ sys.exit(0)
527
+
528
+ env_restored = 0
529
+ env_skipped = 0
530
+ for snap_rel in selected_env:
531
+ if repos_base:
532
+ dest_path = repos_base / snap_rel
533
+ else:
534
+ dest_path = Path.home() / snap_rel
535
+ r, s = repos.restore_envs(env_base, snap_rel, dest_path, password)
536
+ env_restored += r
537
+ env_skipped += s
538
+ if env_restored:
539
+ ui.success(f"Restored {env_restored} env file(s) ({env_skipped} skipped)")
540
+
541
+ ui.header("Pull complete.")
ossnap/config.py ADDED
@@ -0,0 +1,47 @@
1
+ import json
2
+ import os
3
+ import tempfile
4
+ from pathlib import Path
5
+
6
+ from .exceptions import ConfigNotFoundError
7
+
8
+ CONFIG_DIR = Path.home() / ".ossnap"
9
+ CONFIG_FILE = CONFIG_DIR / "config.json"
10
+
11
+ DEFAULT_CONFIG = {
12
+ "version": 1,
13
+ "github_repo_url": "",
14
+ "ssh_dir": "~/.ssh",
15
+ "scan_dirs": ["~/Documents", "~/Projects"],
16
+ "env_patterns": [".env", ".env.local", ".env.development", ".env.production"],
17
+ "exclude_dirs": ["node_modules", ".git", "venv", "__pycache__", ".venv"],
18
+ }
19
+
20
+
21
+ def load_config() -> dict:
22
+ if not CONFIG_FILE.exists():
23
+ raise ConfigNotFoundError("Config not found. Run `ossnap init` first.")
24
+ with open(CONFIG_FILE) as f:
25
+ data = json.load(f)
26
+ # Expand ~ in path fields
27
+ if data.get("ssh_dir"):
28
+ data["ssh_dir"] = str(Path(data["ssh_dir"]).expanduser())
29
+ data["scan_dirs"] = [str(Path(d).expanduser()) for d in data.get("scan_dirs", [])]
30
+ return data
31
+
32
+
33
+ def save_config(data: dict) -> None:
34
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
35
+ # Write atomically via temp file
36
+ fd, tmp_path = tempfile.mkstemp(dir=CONFIG_DIR, suffix=".json.tmp")
37
+ try:
38
+ with os.fdopen(fd, "w") as f:
39
+ json.dump(data, f, indent=2)
40
+ os.replace(tmp_path, CONFIG_FILE)
41
+ except Exception:
42
+ os.unlink(tmp_path)
43
+ raise
44
+
45
+
46
+ def default_config() -> dict:
47
+ return DEFAULT_CONFIG.copy()
ossnap/crypto.py ADDED
@@ -0,0 +1,85 @@
1
+ import getpass
2
+ import os
3
+
4
+ from cryptography.fernet import Fernet, InvalidToken
5
+ from cryptography.hazmat.primitives import hashes
6
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
7
+ import base64
8
+
9
+ try:
10
+ import keyring
11
+ _KEYRING_AVAILABLE = True
12
+ except Exception:
13
+ _KEYRING_AVAILABLE = False
14
+
15
+ from .exceptions import DecryptionError
16
+
17
+ KEYCHAIN_SERVICE = "ossnap"
18
+ KEYCHAIN_ACCOUNT = "encryption-password"
19
+ SALT_SIZE = 16
20
+ PBKDF2_ITERATIONS = 600_000
21
+
22
+
23
+ def get_password_if_exists() -> str | None:
24
+ if _KEYRING_AVAILABLE:
25
+ return keyring.get_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)
26
+ return None
27
+
28
+
29
+ def get_or_create_password() -> str:
30
+ if _KEYRING_AVAILABLE:
31
+ pw = keyring.get_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)
32
+ if pw:
33
+ return pw
34
+ pw = getpass.getpass("Encryption password: ")
35
+ if _KEYRING_AVAILABLE:
36
+ keyring.set_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, pw)
37
+ return pw
38
+
39
+
40
+ def set_password(pw: str) -> None:
41
+ if _KEYRING_AVAILABLE:
42
+ keyring.set_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, pw)
43
+
44
+
45
+ def _derive_key(password: str, salt: bytes) -> bytes:
46
+ kdf = PBKDF2HMAC(
47
+ algorithm=hashes.SHA256(),
48
+ length=32,
49
+ salt=salt,
50
+ iterations=PBKDF2_ITERATIONS,
51
+ )
52
+ return base64.urlsafe_b64encode(kdf.derive(password.encode()))
53
+
54
+
55
+ def encrypt_bytes(data: bytes, password: str) -> bytes:
56
+ salt = os.urandom(SALT_SIZE)
57
+ key = _derive_key(password, salt)
58
+ token = Fernet(key).encrypt(data)
59
+ return salt + token
60
+
61
+
62
+ def decrypt_bytes(data: bytes, password: str) -> bytes:
63
+ salt = data[:SALT_SIZE]
64
+ token = data[SALT_SIZE:]
65
+ key = _derive_key(password, salt)
66
+ try:
67
+ return Fernet(key).decrypt(token)
68
+ except InvalidToken:
69
+ raise DecryptionError("Wrong password or corrupted file.")
70
+
71
+
72
+ def encrypt_file(src, dst, password: str) -> None:
73
+ from pathlib import Path
74
+ data = Path(src).read_bytes()
75
+ encrypted = encrypt_bytes(data, password)
76
+ Path(dst).parent.mkdir(parents=True, exist_ok=True)
77
+ Path(dst).write_bytes(encrypted)
78
+
79
+
80
+ def decrypt_file(src, dst, password: str) -> None:
81
+ from pathlib import Path
82
+ data = Path(src).read_bytes()
83
+ decrypted = decrypt_bytes(data, password)
84
+ Path(dst).parent.mkdir(parents=True, exist_ok=True)
85
+ Path(dst).write_bytes(decrypted)
ossnap/exceptions.py ADDED
@@ -0,0 +1,20 @@
1
+ class OSSnapError(Exception):
2
+ pass
3
+
4
+ class ConfigNotFoundError(OSSnapError):
5
+ pass
6
+
7
+ class GitError(OSSnapError):
8
+ pass
9
+
10
+ class DecryptionError(OSSnapError):
11
+ pass
12
+
13
+ class NetworkError(OSSnapError):
14
+ pass
15
+
16
+ class GhNotInstalledError(OSSnapError):
17
+ pass
18
+
19
+ class GhAuthError(OSSnapError):
20
+ pass