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.
- {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/PKG-INFO +5 -2
- {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo/cli.py +92 -2
- {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo/sync.py +27 -8
- {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_cli.egg-info/PKG-INFO +5 -2
- {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_core/login.py +9 -4
- obsideo_cli-0.3.1/pyproject.toml +33 -0
- {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/tests/test_cli.py +91 -0
- {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/tests/test_core.py +154 -124
- obsideo_cli-0.2.9/pyproject.toml +0 -27
- {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/README.md +0 -0
- {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo/__init__.py +0 -0
- {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo/__main__.py +0 -0
- {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo/manifest.py +0 -0
- {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_cli.egg-info/SOURCES.txt +0 -0
- {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_cli.egg-info/dependency_links.txt +0 -0
- {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_cli.egg-info/entry_points.txt +0 -0
- {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_cli.egg-info/requires.txt +0 -0
- {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_cli.egg-info/top_level.txt +0 -0
- {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_core/__init__.py +0 -0
- {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_core/config.py +0 -0
- {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_core/crypto.py +0 -0
- {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_core/identity.py +0 -0
- {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_core/names.py +0 -0
- {obsideo_cli-0.2.9 → obsideo_cli-0.3.1}/obsideo_core/storage.py +0 -0
- {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.
|
|
4
|
-
Summary: Obsideo Cloud - encrypted storage we can't read. Save, browse, and sync
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
Summary: Obsideo Cloud - encrypted storage we can't read. Save, browse, and sync
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
assert
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
obsideo_cli-0.2.9/pyproject.toml
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|