obsideo-cli 0.2.9__tar.gz → 0.3.1__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.
Files changed (25) hide show
  1. {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/PKG-INFO +5 -2
  2. {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo/cli.py +92 -2
  3. {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo/sync.py +27 -8
  4. {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_cli.egg-info/PKG-INFO +5 -2
  5. {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_core/login.py +9 -4
  6. obsideo_cli-0.3.1/pyproject.toml +33 -0
  7. {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/tests/test_cli.py +91 -0
  8. {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/tests/test_core.py +154 -124
  9. obsideo_cli-0.2.9/pyproject.toml +0 -27
  10. {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/README.md +0 -0
  11. {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo/__init__.py +0 -0
  12. {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo/__main__.py +0 -0
  13. {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo/manifest.py +0 -0
  14. {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_cli.egg-info/SOURCES.txt +0 -0
  15. {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_cli.egg-info/dependency_links.txt +0 -0
  16. {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_cli.egg-info/entry_points.txt +0 -0
  17. {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_cli.egg-info/requires.txt +0 -0
  18. {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_cli.egg-info/top_level.txt +0 -0
  19. {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_core/__init__.py +0 -0
  20. {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_core/config.py +0 -0
  21. {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_core/crypto.py +0 -0
  22. {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_core/identity.py +0 -0
  23. {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_core/names.py +0 -0
  24. {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_core/storage.py +0 -0
  25. {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/setup.cfg +0 -0
@@ -1,9 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obsideo-cli
3
- Version: 0.2.9
4
- Summary: Obsideo Cloud - encrypted storage we can't read. Save, browse, and sync whatever you want, from your terminal.
3
+ Version: 0.3.1
4
+ Summary: Obsideo Cloud - encrypted storage we can't read, for people and AI agents. 12 GB free, no card. Save, browse, and sync from your terminal; agent integration contract at obsideo.io/agents.md
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://obsideo.io
7
+ Project-URL: Documentation, https://obsideo.io/docs/
8
+ Project-URL: Agent integration (AGENTS.md), https://obsideo.io/agents.md
9
+ Keywords: encrypted storage,ai agents,agent storage,s3,backup,end-to-end encryption,object storage,free tier,cli,sync
7
10
  Requires-Python: >=3.10
8
11
  Description-Content-Type: text/markdown
9
12
  Requires-Dist: boto3>=1.28
@@ -49,6 +49,15 @@ def _human(n: int | None) -> str:
49
49
  f /= 1024
50
50
 
51
51
 
52
+ def _gb(n) -> str:
53
+ """Format a GB count for display: drop a trailing .0 (2.0 -> '2', 2.5 -> '2.5')."""
54
+ try:
55
+ f = float(n)
56
+ except (TypeError, ValueError):
57
+ return str(n)
58
+ return str(int(f)) if f == int(f) else f"{f:g}"
59
+
60
+
52
61
  # ── Operator notices (server-driven broadcasts) ──────────────────────────────
53
62
 
54
63
  _SEEN_FILE = config.CONFIG_DIR / "seen_notices"
@@ -297,9 +306,11 @@ def run_login(url: str | None = None) -> bool:
297
306
  print(" sent.")
298
307
  print(f"Check {email} for a verification code (it may be in spam).")
299
308
  code = input("Enter verification code: ").strip()
309
+ # Optional friend's referral code -> +1 GB (4 GB instead of 3). Blank = skip.
310
+ referral_code = input("Referral code from a friend (optional, Enter to skip): ").strip()
300
311
  print("Verifying + provisioning storage...", end="", flush=True)
301
312
  try:
302
- creds = login.verify(email, code, url)
313
+ creds = login.verify(email, code, url, referral_code=referral_code or None)
303
314
  except login.LoginError as e:
304
315
  print(f"\nVerification failed: {e}")
305
316
  return False
@@ -308,6 +319,11 @@ def run_login(url: str | None = None) -> bool:
308
319
  # Make sure the data key exists + nudge the user to back it up.
309
320
  crypto.data_key()
310
321
  print(f"\nYou're all set. {creds.get('quota_gb', 3)} GB free.")
322
+ if referral_code:
323
+ if creds.get("referral_applied"):
324
+ print(f"Referral applied - enjoy the extra space! (code {referral_code.upper()})")
325
+ else:
326
+ print(f"That referral code ({referral_code}) wasn't recognized, so no bonus was added.")
311
327
  if not creds.get("gateway_registered", True):
312
328
  print("Note: storage activation is finishing rollout; if an upload fails, retry shortly.")
313
329
  print("Your files are encrypted with a local key. Back it up:")
@@ -331,6 +347,7 @@ class ObsideoShell(cmd.Cmd):
331
347
  " ls / cd / mkdir browse your files\n"
332
348
  " sync push / pull / status mirror your sync folder\n"
333
349
  " account your plan and usage\n"
350
+ " refer invite friends for free space\n"
334
351
  " about / faq / messages learn more / team news\n\n"
335
352
  " Type 'help <command>' for details (e.g. 'help sync'), or 'exit' to quit.\n"
336
353
  )
@@ -602,6 +619,15 @@ class ObsideoShell(cmd.Cmd):
602
619
  print(f" Used: {_human(used)} across {n} file(s)")
603
620
  except Exception:
604
621
  print(" Used: (couldn't read storage just now)")
622
+ # Referral summary (only for email-login accounts; pre-shim accounts have
623
+ # no signup token and just don't show this line).
624
+ ref = _fetch_referral() if config.account_token() else None
625
+ if ref:
626
+ if ref.get("active"):
627
+ print(f" Referrals: code {ref['code']} - {ref['active']} active, "
628
+ f"+{_gb(ref.get('earned_gb', 0))} GB earned ('refer' for more)")
629
+ else:
630
+ print(f" Referrals: code {ref['code']} - invite friends for free space ('refer')")
605
631
  print(f" Bucket: {storage.bucket()}")
606
632
  print(f" Sync folder: {sync_mod.ensure_sync_dir()}")
607
633
  print(f" Keys: {config.CONFIG_DIR} (back up data.key)")
@@ -652,7 +678,8 @@ class ObsideoShell(cmd.Cmd):
652
678
  A: `config` shows them; `config set sync_dir <path>` / `config set encrypt_names false`.
653
679
 
654
680
  Q: More space?
655
- A: Reply to any Obsideo email to upgrade. (A referral program is coming.)
681
+ A: Invite friends with `refer` - they get +1 GB, you get +2 GB once they
682
+ upload. Or reply to any Obsideo email to upgrade.
656
683
 
657
684
  Q: Updating?
658
685
  A: pip install -U obsideo-cli - the CLI also nudges you when an update is out.
@@ -678,6 +705,50 @@ class ObsideoShell(cmd.Cmd):
678
705
  print(f" - {n.get('body', '').strip()}")
679
706
  print()
680
707
 
708
+ # ── refer ─────────────────────────────────────────────────────────────────
709
+ def do_refer(self, arg):
710
+ """Invite friends for free space. Usage: refer
711
+
712
+ Share your code: each friend who joins with it gets +1 GB, and you get
713
+ +2 GB once they actually upload something. No limit."""
714
+ if not self._require_login():
715
+ return
716
+ if not config.account_token():
717
+ print(
718
+ "\n Referrals need an email login. Run 'login' to sign up / sign in"
719
+ "\n with your email, then 'refer' shows your invite code.\n"
720
+ )
721
+ return
722
+ info = _fetch_referral()
723
+ if not info:
724
+ print("\n Couldn't load your referral info just now - try again shortly.\n")
725
+ return
726
+
727
+ each = info.get("owner_bonus_gb_each", 2)
728
+ redeemer = info.get("redeemer_bonus_gb", 1)
729
+ print()
730
+ print(" -- Invite friends, get free space --------------------------")
731
+ print(f" Your code: {info['code']}")
732
+ print(f" Share it: a friend runs `obsideo login` and enters your code.")
733
+ print(f" They get +{_gb(redeemer)} GB; you get +{_gb(each)} GB once they upload.")
734
+ print()
735
+ invited = info.get("invited", 0)
736
+ active = info.get("active", 0)
737
+ pending = info.get("pending", 0)
738
+ earned = info.get("earned_gb", 0)
739
+ if invited:
740
+ print(f" Invited: {invited} Active: {active} Pending upload: {pending}")
741
+ print(f" Earned so far: +{_gb(earned)} GB (your quota: {_gb(info.get('quota_gb', 0))} GB)")
742
+ else:
743
+ print(" No referrals yet - share your code to start earning.")
744
+ if info.get("newly_credited"):
745
+ n = info["newly_credited"]
746
+ print(f" ✨ {n} friend(s) just activated - +{_gb(n*each)} GB added!")
747
+ print(" ------------------------------------------------------------")
748
+ print(" Bonuses are a promotional perk; Obsideo reserves the right to change")
749
+ print(" or end the program and to revoke bonuses for abuse.")
750
+ print()
751
+
681
752
  # ── sync ──────────────────────────────────────────────────────────────────
682
753
  def do_sync(self, arg):
683
754
  """Sync your local folder with Obsideo. Usage: sync push|pull|status"""
@@ -755,6 +826,25 @@ def _fetch_usage() -> dict | None:
755
826
  return None
756
827
 
757
828
 
829
+ def _fetch_referral() -> dict | None:
830
+ """Referral code + stats from the signup service (Bearer account token). Also
831
+ triggers the server-side lazy credit check for any friend who just became
832
+ active. Returns None for accounts without a signup token (e.g. pre-shim
833
+ accounts) or if the service is unreachable — callers handle that gracefully."""
834
+ token = config.account_token()
835
+ if not token:
836
+ return None
837
+ try:
838
+ req = urllib.request.Request(
839
+ f"{config.signup_url()}/v1/account/referral",
840
+ headers={"Authorization": f"Bearer {token}", "User-Agent": config.USER_AGENT},
841
+ )
842
+ with urllib.request.urlopen(req, timeout=20, context=config.ssl_context()) as resp:
843
+ return json.loads(resp.read().decode())
844
+ except Exception:
845
+ return None
846
+
847
+
758
848
  def _fetch_account_info() -> dict | None:
759
849
  """Usage + quota for the calling account via the gateway's SigV4-authed
760
850
  /v1/account. Works for ANY account using only the S3 creds we already have
@@ -55,28 +55,41 @@ def _remote_key(name: str) -> str:
55
55
  return f"{REMOTE_PREFIX}{name}"
56
56
 
57
57
 
58
+ def _remote_names() -> tuple[set, bool]:
59
+ """Names actually present under the sync prefix on the remote, and whether we
60
+ could reach it. Used to reconcile against the local manifest — the manifest
61
+ alone can't be trusted (e.g. after switching accounts it still 'remembers'
62
+ files uploaded to the OLD account, which aren't on the new one)."""
63
+ try:
64
+ remote = storage.list_prefix(REMOTE_PREFIX)
65
+ return {f["name"] for f in remote["files"]}, True
66
+ except Exception:
67
+ return set(), False
68
+
69
+
58
70
  def sync_status() -> dict:
59
71
  sync_dir = ensure_sync_dir()
60
72
  entries = manifest.get_all()
61
73
  status = {"to_push": [], "to_pull": [], "synced": []}
62
74
 
63
75
  local_files = {f.name: f for f in sync_dir.iterdir() if f.is_file() and f.name != README_NAME}
76
+ remote_names, remote_known = _remote_names()
64
77
 
65
78
  for name, f in local_files.items():
66
79
  local_hash = manifest.file_sha256(f)
67
80
  entry = entries.get(name)
68
- if entry is None or entry.get("local_hash") != local_hash:
81
+ # A file is only "synced" if the manifest matches AND it's really on the
82
+ # remote. If we couldn't reach the remote, fall back to the manifest so a
83
+ # transient outage doesn't flag everything as needing a re-push.
84
+ on_remote = (name in remote_names) if remote_known else True
85
+ if entry is None or entry.get("local_hash") != local_hash or not on_remote:
69
86
  status["to_push"].append(name)
70
87
  else:
71
88
  status["synced"].append(name)
72
89
 
73
90
  # Remote files we know about but don't have locally.
74
- try:
75
- remote = storage.list_prefix(REMOTE_PREFIX)
76
- remote_names = {f["name"] for f in remote["files"]}
77
- except Exception:
78
- remote_names = set(entries.keys())
79
- for name in remote_names:
91
+ pullable = remote_names if remote_known else set(entries.keys())
92
+ for name in pullable:
80
93
  if name not in local_files:
81
94
  status["to_pull"].append(name)
82
95
 
@@ -94,12 +107,18 @@ def push(verbose: bool = True) -> int:
94
107
 
95
108
  do_encrypt = config.load_config().get("encrypt", True)
96
109
  entries = manifest.get_all()
110
+ # Reconcile against the real remote: only skip a file if the manifest matches
111
+ # AND it's actually up there. This self-heals a stale manifest (e.g. after an
112
+ # account switch, where the manifest still lists files from the old account)
113
+ # without the user having to clear anything.
114
+ remote_names, remote_known = _remote_names()
97
115
  pushed = 0
98
116
 
99
117
  for f in files:
100
118
  local_hash = manifest.file_sha256(f)
101
119
  entry = entries.get(f.name)
102
- if entry and entry.get("local_hash") == local_hash:
120
+ on_remote = (f.name in remote_names) if remote_known else True
121
+ if entry and entry.get("local_hash") == local_hash and on_remote:
103
122
  if verbose:
104
123
  print(f" {f.name} - unchanged, skipping")
105
124
  continue
@@ -1,9 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obsideo-cli
3
- Version: 0.2.9
4
- Summary: Obsideo Cloud - encrypted storage we can't read. Save, browse, and sync whatever you want, from your terminal.
3
+ Version: 0.3.1
4
+ Summary: Obsideo Cloud - encrypted storage we can't read, for people and AI agents. 12 GB free, no card. Save, browse, and sync from your terminal; agent integration contract at obsideo.io/agents.md
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://obsideo.io
7
+ Project-URL: Documentation, https://obsideo.io/docs/
8
+ Project-URL: Agent integration (AGENTS.md), https://obsideo.io/agents.md
9
+ Keywords: encrypted storage,ai agents,agent storage,s3,backup,end-to-end encryption,object storage,free tier,cli,sync
7
10
  Requires-Python: >=3.10
8
11
  Description-Content-Type: text/markdown
9
12
  Requires-Dist: boto3>=1.28
@@ -42,17 +42,22 @@ def start(email: str, url: str | None = None) -> None:
42
42
  _post_json(f"{url}/v1/auth/start", {"email": email})
43
43
 
44
44
 
45
- def verify(email: str, code: str, url: str | None = None) -> dict:
45
+ def verify(email: str, code: str, url: str | None = None,
46
+ referral_code: str | None = None) -> dict:
46
47
  """Verify the code + provision an account. Generates the local signing key,
47
- sends only its public half, persists the returned credentials. Returns the
48
+ sends only its public half, persists the returned credentials. An optional
49
+ referral_code (a friend's invite) grants the new account +1 GB. Returns the
48
50
  credential bundle.
49
51
  """
50
52
  url = url or config.signup_url()
51
53
  signing_pubkey = identity.get_or_create_signing_pubkey()
52
- creds = _post_json(f"{url}/v1/auth/verify", {
54
+ payload = {
53
55
  "email": email,
54
56
  "code": code,
55
57
  "customer_signing_public_key": signing_pubkey,
56
- })
58
+ }
59
+ if referral_code:
60
+ payload["referral_code"] = referral_code
61
+ creds = _post_json(f"{url}/v1/auth/verify", payload)
57
62
  config.write_credentials(creds)
58
63
  return creds
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "obsideo-cli"
7
+ version = "0.3.1"
8
+ description = "Obsideo Cloud - encrypted storage we can't read, for people and AI agents. 12 GB free, no card. Save, browse, and sync from your terminal; agent integration contract at obsideo.io/agents.md"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ keywords = [
13
+ "encrypted storage", "ai agents", "agent storage", "s3", "backup",
14
+ "end-to-end encryption", "object storage", "free tier", "cli", "sync",
15
+ ]
16
+ dependencies = [
17
+ "boto3>=1.28",
18
+ "cryptography>=41.0",
19
+ "certifi",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://obsideo.io"
24
+ Documentation = "https://obsideo.io/docs/"
25
+ "Agent integration (AGENTS.md)" = "https://obsideo.io/agents.md"
26
+
27
+ [project.scripts]
28
+ obsideo = "obsideo.cli:main"
29
+ obsideo-cli = "obsideo.cli:main"
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["."]
33
+ include = ["obsideo*", "obsideo_core*"]
@@ -284,6 +284,45 @@ def test_messages_handles_unreachable(monkeypatch, capsys):
284
284
  assert "couldn't reach" in capsys.readouterr().out.lower()
285
285
 
286
286
 
287
+ def test_sync_push_reuploads_when_remote_missing(tmp_path, monkeypatch, capsys):
288
+ # Stale manifest (e.g. after an account switch) says a file is synced, but the
289
+ # remote is actually empty -> push must RE-UPLOAD, not skip. Regression for the
290
+ # "unchanged, skipping" + empty-remote bug after re-login to a new account.
291
+ from obsideo import sync, manifest
292
+ sd = tmp_path / "obsideo-sync"
293
+ monkeypatch.setattr(sync, "_sync_dir", lambda: sd)
294
+ sync.ensure_sync_dir()
295
+ f = sd / "a.txt"; f.write_bytes(b"hello")
296
+ h = manifest.file_sha256(f)
297
+ monkeypatch.setattr(manifest, "get_all", lambda: {"a.txt": {"local_hash": h}})
298
+ monkeypatch.setattr(manifest, "upsert", lambda *a, **k: None)
299
+ monkeypatch.setattr(cli.storage, "list_prefix", lambda prefix: {"folders": [], "files": []})
300
+ put = []
301
+ monkeypatch.setattr(cli.storage, "put", lambda key, body: put.append(key) or key)
302
+ n = sync.push(verbose=True)
303
+ assert n == 1 and put and "a.txt" in put[0]
304
+ assert "uploaded" in capsys.readouterr().out
305
+
306
+
307
+ def test_sync_push_skips_when_present_and_unchanged(tmp_path, monkeypatch, capsys):
308
+ # The optimization still holds: a file that's genuinely unchanged AND present
309
+ # on the remote is skipped (no needless re-upload).
310
+ from obsideo import sync, manifest
311
+ sd = tmp_path / "obsideo-sync"
312
+ monkeypatch.setattr(sync, "_sync_dir", lambda: sd)
313
+ sync.ensure_sync_dir()
314
+ f = sd / "a.txt"; f.write_bytes(b"hello")
315
+ h = manifest.file_sha256(f)
316
+ monkeypatch.setattr(manifest, "get_all", lambda: {"a.txt": {"local_hash": h}})
317
+ monkeypatch.setattr(cli.storage, "list_prefix",
318
+ lambda prefix: {"folders": [], "files": [{"name": "a.txt", "key": "sync/a.txt", "size": 5}]})
319
+ put = []
320
+ monkeypatch.setattr(cli.storage, "put", lambda key, body: put.append(key) or key)
321
+ n = sync.push(verbose=True)
322
+ assert n == 0 and put == []
323
+ assert "skipping" in capsys.readouterr().out
324
+
325
+
287
326
  def test_sync_readme_created_and_not_pushed(tmp_path, monkeypatch, capsys):
288
327
  from obsideo import sync
289
328
  sd = tmp_path / "obsideo-sync"
@@ -330,3 +369,55 @@ def test_precmd_strips_obsideo_prefix():
330
369
  assert sh.precmd("obsideo ls") == "ls"
331
370
  assert sh.precmd("obsideo login") == "login"
332
371
  assert sh.precmd("ls") == "ls" # unchanged when no prefix
372
+
373
+
374
+ # ── referral program ──────────────────────────────────────────────────────────
375
+
376
+ def test_gb_formats_cleanly():
377
+ assert cli._gb(2.0) == "2" and cli._gb(2) == "2"
378
+ assert cli._gb(2.5) == "2.5"
379
+ assert cli._gb(0) == "0"
380
+
381
+
382
+ def test_refer_shows_code_and_stats(monkeypatch, capsys):
383
+ monkeypatch.setattr(cli.config, "account_token", lambda: "obt_x")
384
+ monkeypatch.setattr(cli, "_fetch_referral", lambda: {
385
+ "code": "ABCD234", "invited": 2, "active": 1, "pending": 1,
386
+ "earned_gb": 2.0, "newly_credited": 1, "owner_bonus_gb_each": 2,
387
+ "redeemer_bonus_gb": 1, "quota_gb": 5.0,
388
+ })
389
+ cli.ObsideoShell().do_refer("")
390
+ out = capsys.readouterr().out
391
+ assert "ABCD234" in out
392
+ assert "Invited: 2" in out and "Active: 1" in out
393
+ assert "+2 GB earned" not in out # that phrasing is the account line, not refer
394
+ assert "just activated" in out # newly_credited celebration
395
+ assert "reserves the right" in out # disclaimer present
396
+
397
+
398
+ def test_refer_without_token_guides_to_login(monkeypatch, capsys):
399
+ monkeypatch.setattr(cli.config, "account_token", lambda: None)
400
+ cli.ObsideoShell().do_refer("")
401
+ out = capsys.readouterr().out.lower()
402
+ assert "login" in out and "email" in out
403
+
404
+
405
+ def test_refer_handles_unreachable_service(monkeypatch, capsys):
406
+ monkeypatch.setattr(cli.config, "account_token", lambda: "obt_x")
407
+ monkeypatch.setattr(cli, "_fetch_referral", lambda: None)
408
+ cli.ObsideoShell().do_refer("")
409
+ assert "couldn't load" in capsys.readouterr().out.lower()
410
+
411
+
412
+ def test_account_shows_referral_line(monkeypatch, tmp_path, capsys):
413
+ from obsideo import sync
414
+ monkeypatch.setattr(cli, "_fetch_account_info",
415
+ lambda: {"tier": "promo", "used_bytes": 1, "quota_bytes": 5_368_709_120})
416
+ monkeypatch.setattr(cli.config, "account_token", lambda: "obt_x")
417
+ monkeypatch.setattr(cli, "_fetch_referral",
418
+ lambda: {"code": "ABCD234", "active": 2, "earned_gb": 4.0})
419
+ monkeypatch.setattr(cli.storage, "bucket", lambda: "obsideo")
420
+ monkeypatch.setattr(sync, "_sync_dir", lambda: tmp_path / "s")
421
+ cli.ObsideoShell().do_account("")
422
+ out = capsys.readouterr().out
423
+ assert "Referrals: code ABCD234" in out and "4 GB earned" in out
@@ -1,124 +1,154 @@
1
- """Core tests: crypto round-trip + S3 list parsing (mocked client)."""
2
-
3
- import os
4
- import tempfile
5
- from pathlib import Path
6
- from unittest import mock
7
-
8
- import pytest
9
-
10
- # Isolate config dir so tests never touch ~/.obsideo.
11
- os.environ["OBSIDEO_DATA_KEY"] = os.urandom(32).hex()
12
-
13
- from obsideo_core import crypto, storage
14
-
15
-
16
- def test_crypto_round_trip():
17
- data = os.urandom(4096)
18
- blob = crypto.encrypt(data)
19
- assert blob[:12] != data[:12] # nonce prepended, not plaintext
20
- assert crypto.decrypt(blob) == data
21
-
22
-
23
- def test_crypto_wrong_key_fails():
24
- blob = crypto.encrypt(b"secret")
25
- other = os.urandom(32).hex()
26
- with mock.patch.dict(os.environ, {"OBSIDEO_DATA_KEY": other}):
27
- with pytest.raises(Exception):
28
- crypto.decrypt(blob)
29
-
30
-
31
- @pytest.fixture()
32
- def s3_creds(monkeypatch):
33
- monkeypatch.setenv("OBSIDEO_S3_ACCESS_KEY", "AKIATEST")
34
- monkeypatch.setenv("OBSIDEO_S3_SECRET_KEY", "secret")
35
- monkeypatch.setenv("OBSIDEO_S3_BUCKET", "obsideo")
36
- storage.reset_client()
37
- yield
38
- storage.reset_client()
39
-
40
-
41
- def test_list_prefix_parses_folders_and_files(s3_creds, monkeypatch):
42
- monkeypatch.setattr(storage, "_names_on", lambda: False) # plain keys for this test
43
- fake = mock.MagicMock()
44
- fake.list_objects_v2.return_value = {
45
- "CommonPrefixes": [{"Prefix": "docs/"}, {"Prefix": "photos/"}],
46
- "Contents": [
47
- {"Key": "readme.txt", "Size": 12},
48
- {"Key": "note.md", "Size": 40},
49
- {"Key": "", "Size": 0}, # folder marker for root - skipped
50
- ],
51
- "IsTruncated": False,
52
- }
53
- monkeypatch.setattr(storage, "_s3", lambda: fake)
54
-
55
- out = storage.list_prefix("")
56
- assert out["folders"] == ["docs", "photos"]
57
- names = [f["name"] for f in out["files"]]
58
- assert names == ["note.md", "readme.txt"] # sorted
59
-
60
-
61
- def test_get_uses_full_object_get(s3_creds, monkeypatch):
62
- monkeypatch.setattr(storage, "_names_on", lambda: False) # check exact plain key
63
- fake = mock.MagicMock()
64
- body = mock.MagicMock(); body.read.return_value = b"ciphertext"
65
- fake.get_object.return_value = {"Body": body}
66
- monkeypatch.setattr(storage, "_s3", lambda: fake)
67
-
68
- assert storage.get("k") == b"ciphertext"
69
- fake.get_object.assert_called_once_with(Bucket="obsideo", Key="k")
70
- assert not fake.download_file.called # never ranged
71
-
72
-
73
- # ── filename encryption (Level 1 metadata privacy) ──────────────────────────
74
-
75
- def test_name_encryption_round_trip():
76
- from obsideo_core import names
77
- tok = names.encrypt_name("my secret folder")
78
- assert tok != "my secret folder"
79
- assert "/" not in tok # safe as a single path component
80
- assert names.decrypt_name(tok) == "my secret folder"
81
-
82
-
83
- def test_name_encryption_is_deterministic():
84
- from obsideo_core import names
85
- # Deterministic so server-side prefix listing works.
86
- assert names.encrypt_name("photos") == names.encrypt_name("photos")
87
-
88
-
89
- def test_encrypt_path_per_component():
90
- from obsideo_core import names
91
- enc = names.encrypt_path("a/b/c.txt")
92
- assert enc.count("/") == 2 # structure preserved (3 components)
93
- assert "a" not in enc and "c.txt" not in enc # names hidden
94
-
95
-
96
- def test_storage_stores_under_encrypted_key(s3_creds, monkeypatch):
97
- fake = mock.MagicMock()
98
- monkeypatch.setattr(storage, "_s3", lambda: fake)
99
- monkeypatch.setattr(storage, "ensure_bucket", lambda: None)
100
- storage.put("docs/secret.txt", b"x") # names ON by default
101
- _, args, kwargs = fake.mock_calls[0]
102
- stored_key = args[2]
103
- assert "secret.txt" not in stored_key and "docs" not in stored_key
104
-
105
-
106
- def test_list_prefix_decrypts_names_roundtrip(s3_creds, monkeypatch):
107
- """Store under encrypted keys, then list and confirm real names come back."""
108
- from obsideo_core import names
109
- enc_prefix = names.encrypt_path("vault") + "/"
110
- enc_file = enc_prefix + names.encrypt_name("passwords.txt")
111
- enc_sub = enc_prefix + names.encrypt_name("sub") + "/"
112
-
113
- fake = mock.MagicMock()
114
- fake.list_objects_v2.return_value = {
115
- "CommonPrefixes": [{"Prefix": enc_sub}],
116
- "Contents": [{"Key": enc_file, "Size": 99}],
117
- "IsTruncated": False,
118
- }
119
- monkeypatch.setattr(storage, "_s3", lambda: fake)
120
-
121
- out = storage.list_prefix("vault")
122
- assert out["folders"] == ["sub"]
123
- assert out["files"][0]["name"] == "passwords.txt"
124
- assert out["files"][0]["key"] == "vault/passwords.txt" # real path for re-fetch
1
+ """Core tests: crypto round-trip + S3 list parsing (mocked client)."""
2
+
3
+ import os
4
+ import tempfile
5
+ from pathlib import Path
6
+ from unittest import mock
7
+
8
+ import pytest
9
+
10
+ # Isolate config dir so tests never touch ~/.obsideo.
11
+ os.environ["OBSIDEO_DATA_KEY"] = os.urandom(32).hex()
12
+
13
+ from obsideo_core import crypto, storage
14
+
15
+
16
+ def test_crypto_round_trip():
17
+ data = os.urandom(4096)
18
+ blob = crypto.encrypt(data)
19
+ assert blob[:12] != data[:12] # nonce prepended, not plaintext
20
+ assert crypto.decrypt(blob) == data
21
+
22
+
23
+ def test_crypto_wrong_key_fails():
24
+ blob = crypto.encrypt(b"secret")
25
+ other = os.urandom(32).hex()
26
+ with mock.patch.dict(os.environ, {"OBSIDEO_DATA_KEY": other}):
27
+ with pytest.raises(Exception):
28
+ crypto.decrypt(blob)
29
+
30
+
31
+ @pytest.fixture()
32
+ def s3_creds(monkeypatch):
33
+ monkeypatch.setenv("OBSIDEO_S3_ACCESS_KEY", "AKIATEST")
34
+ monkeypatch.setenv("OBSIDEO_S3_SECRET_KEY", "secret")
35
+ monkeypatch.setenv("OBSIDEO_S3_BUCKET", "obsideo")
36
+ storage.reset_client()
37
+ yield
38
+ storage.reset_client()
39
+
40
+
41
+ def test_list_prefix_parses_folders_and_files(s3_creds, monkeypatch):
42
+ monkeypatch.setattr(storage, "_names_on", lambda: False) # plain keys for this test
43
+ fake = mock.MagicMock()
44
+ fake.list_objects_v2.return_value = {
45
+ "CommonPrefixes": [{"Prefix": "docs/"}, {"Prefix": "photos/"}],
46
+ "Contents": [
47
+ {"Key": "readme.txt", "Size": 12},
48
+ {"Key": "note.md", "Size": 40},
49
+ {"Key": "", "Size": 0}, # folder marker for root - skipped
50
+ ],
51
+ "IsTruncated": False,
52
+ }
53
+ monkeypatch.setattr(storage, "_s3", lambda: fake)
54
+
55
+ out = storage.list_prefix("")
56
+ assert out["folders"] == ["docs", "photos"]
57
+ names = [f["name"] for f in out["files"]]
58
+ assert names == ["note.md", "readme.txt"] # sorted
59
+
60
+
61
+ def test_get_uses_full_object_get(s3_creds, monkeypatch):
62
+ monkeypatch.setattr(storage, "_names_on", lambda: False) # check exact plain key
63
+ fake = mock.MagicMock()
64
+ body = mock.MagicMock(); body.read.return_value = b"ciphertext"
65
+ fake.get_object.return_value = {"Body": body}
66
+ monkeypatch.setattr(storage, "_s3", lambda: fake)
67
+
68
+ assert storage.get("k") == b"ciphertext"
69
+ fake.get_object.assert_called_once_with(Bucket="obsideo", Key="k")
70
+ assert not fake.download_file.called # never ranged
71
+
72
+
73
+ # ── filename encryption (Level 1 metadata privacy) ──────────────────────────
74
+
75
+ def test_name_encryption_round_trip():
76
+ from obsideo_core import names
77
+ tok = names.encrypt_name("my secret folder")
78
+ assert tok != "my secret folder"
79
+ assert "/" not in tok # safe as a single path component
80
+ assert names.decrypt_name(tok) == "my secret folder"
81
+
82
+
83
+ def test_name_encryption_is_deterministic():
84
+ from obsideo_core import names
85
+ # Deterministic so server-side prefix listing works.
86
+ assert names.encrypt_name("photos") == names.encrypt_name("photos")
87
+
88
+
89
+ def test_encrypt_path_per_component():
90
+ from obsideo_core import names
91
+ enc = names.encrypt_path("a/b/c.txt")
92
+ segs = enc.split("/")
93
+ assert len(segs) == 3 # structure preserved (3 components)
94
+ # Each component is hidden (token != plaintext) yet round-trips back. Compare
95
+ # per-segment rather than substring-scanning the whole path: a 1-char name
96
+ # like "a" appears by chance inside base64 tokens, which made this flaky.
97
+ for plain, tok in zip(["a", "b", "c.txt"], segs):
98
+ assert tok != plain
99
+ assert names.decrypt_name(tok) == plain
100
+
101
+
102
+ def test_storage_stores_under_encrypted_key(s3_creds, monkeypatch):
103
+ fake = mock.MagicMock()
104
+ monkeypatch.setattr(storage, "_s3", lambda: fake)
105
+ monkeypatch.setattr(storage, "ensure_bucket", lambda: None)
106
+ storage.put("docs/secret.txt", b"x") # names ON by default
107
+ _, args, kwargs = fake.mock_calls[0]
108
+ stored_key = args[2]
109
+ assert "secret.txt" not in stored_key and "docs" not in stored_key
110
+
111
+
112
+ def test_list_prefix_decrypts_names_roundtrip(s3_creds, monkeypatch):
113
+ """Store under encrypted keys, then list and confirm real names come back."""
114
+ from obsideo_core import names
115
+ enc_prefix = names.encrypt_path("vault") + "/"
116
+ enc_file = enc_prefix + names.encrypt_name("passwords.txt")
117
+ enc_sub = enc_prefix + names.encrypt_name("sub") + "/"
118
+
119
+ fake = mock.MagicMock()
120
+ fake.list_objects_v2.return_value = {
121
+ "CommonPrefixes": [{"Prefix": enc_sub}],
122
+ "Contents": [{"Key": enc_file, "Size": 99}],
123
+ "IsTruncated": False,
124
+ }
125
+ monkeypatch.setattr(storage, "_s3", lambda: fake)
126
+
127
+ out = storage.list_prefix("vault")
128
+ assert out["folders"] == ["sub"]
129
+ assert out["files"][0]["name"] == "passwords.txt"
130
+ assert out["files"][0]["key"] == "vault/passwords.txt" # real path for re-fetch
131
+
132
+
133
+ # ── login: referral code threading ────────────────────────────────────────────
134
+
135
+ def test_verify_includes_referral_code_when_given(monkeypatch):
136
+ from obsideo_core import login, identity, config
137
+ sent = {}
138
+ monkeypatch.setattr(login, "_post_json", lambda url, payload: sent.update(payload) or {"access_key": "AKIA", "secret_key": "s"})
139
+ monkeypatch.setattr(identity, "get_or_create_signing_pubkey", lambda: "obk_sig_" + "A" * 43)
140
+ monkeypatch.setattr(config, "write_credentials", lambda creds: None)
141
+
142
+ login.verify("a@b.com", "123456", url="http://x", referral_code="ABCD234")
143
+ assert sent["referral_code"] == "ABCD234"
144
+
145
+
146
+ def test_verify_omits_referral_code_when_blank(monkeypatch):
147
+ from obsideo_core import login, identity, config
148
+ sent = {}
149
+ monkeypatch.setattr(login, "_post_json", lambda url, payload: sent.update(payload) or {"access_key": "AKIA", "secret_key": "s"})
150
+ monkeypatch.setattr(identity, "get_or_create_signing_pubkey", lambda: "obk_sig_" + "A" * 43)
151
+ monkeypatch.setattr(config, "write_credentials", lambda creds: None)
152
+
153
+ login.verify("a@b.com", "123456", url="http://x")
154
+ assert "referral_code" not in sent # never send an empty/None code
@@ -1,27 +0,0 @@
1
- [build-system]
2
- requires = ["setuptools>=61"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- name = "obsideo-cli"
7
- version = "0.2.9"
8
- description = "Obsideo Cloud - encrypted storage we can't read. Save, browse, and sync whatever you want, from your terminal."
9
- readme = "README.md"
10
- requires-python = ">=3.10"
11
- license = {text = "MIT"}
12
- dependencies = [
13
- "boto3>=1.28",
14
- "cryptography>=41.0",
15
- "certifi",
16
- ]
17
-
18
- [project.urls]
19
- Homepage = "https://obsideo.io"
20
-
21
- [project.scripts]
22
- obsideo = "obsideo.cli:main"
23
- obsideo-cli = "obsideo.cli:main"
24
-
25
- [tool.setuptools.packages.find]
26
- where = ["."]
27
- include = ["obsideo*", "obsideo_core*"]
File without changes
File without changes