twinerd-browser-vault 0.1.0__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.
- twinerd_browser_vault-0.1.0/PKG-INFO +59 -0
- twinerd_browser_vault-0.1.0/README.md +50 -0
- twinerd_browser_vault-0.1.0/pyproject.toml +25 -0
- twinerd_browser_vault-0.1.0/setup.cfg +4 -0
- twinerd_browser_vault-0.1.0/src/twinerd_browser_vault/__init__.py +59 -0
- twinerd_browser_vault-0.1.0/src/twinerd_browser_vault/cli.py +131 -0
- twinerd_browser_vault-0.1.0/src/twinerd_browser_vault/cookies.py +274 -0
- twinerd_browser_vault-0.1.0/src/twinerd_browser_vault/models.py +47 -0
- twinerd_browser_vault-0.1.0/src/twinerd_browser_vault/passwords.py +166 -0
- twinerd_browser_vault-0.1.0/src/twinerd_browser_vault/profiles.py +204 -0
- twinerd_browser_vault-0.1.0/src/twinerd_browser_vault.egg-info/PKG-INFO +59 -0
- twinerd_browser_vault-0.1.0/src/twinerd_browser_vault.egg-info/SOURCES.txt +16 -0
- twinerd_browser_vault-0.1.0/src/twinerd_browser_vault.egg-info/dependency_links.txt +1 -0
- twinerd_browser_vault-0.1.0/src/twinerd_browser_vault.egg-info/entry_points.txt +2 -0
- twinerd_browser_vault-0.1.0/src/twinerd_browser_vault.egg-info/top_level.txt +1 -0
- twinerd_browser_vault-0.1.0/tests/test_cookies.py +110 -0
- twinerd_browser_vault-0.1.0/tests/test_passwords.py +69 -0
- twinerd_browser_vault-0.1.0/tests/test_profiles.py +54 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: twinerd-browser-vault
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Browser profile snapshots, session cookie migration, and credential vault for Chrome, Chromium, and Firefox
|
|
5
|
+
Author-email: Tomasz Sapletta <info@softreck.com>
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# twinerd-browser-vault
|
|
11
|
+
|
|
12
|
+
Browser profile snapshots, session cookie migration, and credential vault management for Chrome, Chromium, and Firefox.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- **Profile Discovery & Inspection**: Locates and analyzes profile state and size for Firefox, Chromium, Google Chrome, Brave, Edge, Opera, and Vivaldi.
|
|
17
|
+
- **Session Continuity (Cookie Sync)**: Migrates authenticated session cookies from Firefox (`cookies.sqlite`) into Chromium/Chrome SQLite `Cookies` table (mapping 1601-epoch timestamps).
|
|
18
|
+
- **Profile Snapshotting & Restore**: Clean backups omitting ephemeral caches, temporary locks, and GPU cache files.
|
|
19
|
+
- **Credential & Password Extraction**: Extracts credentials from isolated Firefox profiles (`logins.json` and external decrypt tools) with host and provider matching.
|
|
20
|
+
- **Zero Mandatory External Dependencies**: Built with Python 3.10+ standard library (`sqlite3`, `pathlib`, `shutil`, `urllib.parse`).
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install -e /path/to/twinerd-browser-vault
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## CLI Usage
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Detect browser profiles on current system
|
|
32
|
+
twinerd-browser-vault detect
|
|
33
|
+
|
|
34
|
+
# Inspect a profile directory
|
|
35
|
+
twinerd-browser-vault inspect firefox ~/.mozilla/firefox/default
|
|
36
|
+
|
|
37
|
+
# Snapshot profile omitting cache
|
|
38
|
+
twinerd-browser-vault snapshot chrome --account dev --dest ~/snapshots/
|
|
39
|
+
|
|
40
|
+
# Migrate session cookies from Firefox to Chromium for an account
|
|
41
|
+
twinerd-browser-vault sync-cookies --home /home/worker1 --provider anthropic
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Python API
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from twinerd_browser_vault import (
|
|
48
|
+
detect_available_profiles,
|
|
49
|
+
sync_firefox_cookies_to_chromium,
|
|
50
|
+
create_profile_snapshot,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Sync cookies
|
|
54
|
+
result = sync_firefox_cookies_to_chromium(
|
|
55
|
+
home=Path("/home/worker1"),
|
|
56
|
+
provider="chatgpt",
|
|
57
|
+
only_missing=True,
|
|
58
|
+
)
|
|
59
|
+
```
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# twinerd-browser-vault
|
|
2
|
+
|
|
3
|
+
Browser profile snapshots, session cookie migration, and credential vault management for Chrome, Chromium, and Firefox.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Profile Discovery & Inspection**: Locates and analyzes profile state and size for Firefox, Chromium, Google Chrome, Brave, Edge, Opera, and Vivaldi.
|
|
8
|
+
- **Session Continuity (Cookie Sync)**: Migrates authenticated session cookies from Firefox (`cookies.sqlite`) into Chromium/Chrome SQLite `Cookies` table (mapping 1601-epoch timestamps).
|
|
9
|
+
- **Profile Snapshotting & Restore**: Clean backups omitting ephemeral caches, temporary locks, and GPU cache files.
|
|
10
|
+
- **Credential & Password Extraction**: Extracts credentials from isolated Firefox profiles (`logins.json` and external decrypt tools) with host and provider matching.
|
|
11
|
+
- **Zero Mandatory External Dependencies**: Built with Python 3.10+ standard library (`sqlite3`, `pathlib`, `shutil`, `urllib.parse`).
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install -e /path/to/twinerd-browser-vault
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## CLI Usage
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Detect browser profiles on current system
|
|
23
|
+
twinerd-browser-vault detect
|
|
24
|
+
|
|
25
|
+
# Inspect a profile directory
|
|
26
|
+
twinerd-browser-vault inspect firefox ~/.mozilla/firefox/default
|
|
27
|
+
|
|
28
|
+
# Snapshot profile omitting cache
|
|
29
|
+
twinerd-browser-vault snapshot chrome --account dev --dest ~/snapshots/
|
|
30
|
+
|
|
31
|
+
# Migrate session cookies from Firefox to Chromium for an account
|
|
32
|
+
twinerd-browser-vault sync-cookies --home /home/worker1 --provider anthropic
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Python API
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from twinerd_browser_vault import (
|
|
39
|
+
detect_available_profiles,
|
|
40
|
+
sync_firefox_cookies_to_chromium,
|
|
41
|
+
create_profile_snapshot,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Sync cookies
|
|
45
|
+
result = sync_firefox_cookies_to_chromium(
|
|
46
|
+
home=Path("/home/worker1"),
|
|
47
|
+
provider="chatgpt",
|
|
48
|
+
only_missing=True,
|
|
49
|
+
)
|
|
50
|
+
```
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "twinerd-browser-vault"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Browser profile snapshots, session cookie migration, and credential vault for Chrome, Chromium, and Firefox"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Proprietary" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Tomasz Sapletta", email = "info@softreck.com" }
|
|
14
|
+
]
|
|
15
|
+
dependencies = []
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
twinerd-browser-vault = "twinerd_browser_vault.cli:main"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
where = ["src"]
|
|
22
|
+
|
|
23
|
+
[tool.pytest.ini_options]
|
|
24
|
+
testpaths = ["tests"]
|
|
25
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""twinerd_browser_vault — Browser profile snapshots, session cookie migration, and credential vault."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from twinerd_browser_vault.models import (
|
|
6
|
+
PasswordRecord,
|
|
7
|
+
CookieRecord,
|
|
8
|
+
ProfileMetadata,
|
|
9
|
+
)
|
|
10
|
+
from twinerd_browser_vault.profiles import (
|
|
11
|
+
detect_available_profiles,
|
|
12
|
+
get_profile_size,
|
|
13
|
+
format_size,
|
|
14
|
+
inspect_profile,
|
|
15
|
+
create_profile_snapshot,
|
|
16
|
+
restore_profile_snapshot,
|
|
17
|
+
)
|
|
18
|
+
from twinerd_browser_vault.cookies import (
|
|
19
|
+
sync_firefox_cookies_to_chromium,
|
|
20
|
+
read_firefox_cookies,
|
|
21
|
+
write_chromium_cookies,
|
|
22
|
+
find_firefox_cookies_db,
|
|
23
|
+
find_chromium_cookies_db,
|
|
24
|
+
DEFAULT_SYNC_HOST_MARKERS,
|
|
25
|
+
PROVIDER_COOKIE_HOST_MARKERS,
|
|
26
|
+
)
|
|
27
|
+
from twinerd_browser_vault.passwords import (
|
|
28
|
+
extract_login_hosts,
|
|
29
|
+
load_isolated_firefox_profiles,
|
|
30
|
+
read_firefox_logins_json,
|
|
31
|
+
decrypt_firefox_logins,
|
|
32
|
+
FALLBACK_HOSTS,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"PasswordRecord",
|
|
37
|
+
"CookieRecord",
|
|
38
|
+
"ProfileMetadata",
|
|
39
|
+
"detect_available_profiles",
|
|
40
|
+
"get_profile_size",
|
|
41
|
+
"format_size",
|
|
42
|
+
"inspect_profile",
|
|
43
|
+
"create_profile_snapshot",
|
|
44
|
+
"restore_profile_snapshot",
|
|
45
|
+
"sync_firefox_cookies_to_chromium",
|
|
46
|
+
"read_firefox_cookies",
|
|
47
|
+
"write_chromium_cookies",
|
|
48
|
+
"find_firefox_cookies_db",
|
|
49
|
+
"find_chromium_cookies_db",
|
|
50
|
+
"DEFAULT_SYNC_HOST_MARKERS",
|
|
51
|
+
"PROVIDER_COOKIE_HOST_MARKERS",
|
|
52
|
+
"extract_login_hosts",
|
|
53
|
+
"load_isolated_firefox_profiles",
|
|
54
|
+
"read_firefox_logins_json",
|
|
55
|
+
"decrypt_firefox_logins",
|
|
56
|
+
"FALLBACK_HOSTS",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""twinerd_browser_vault.cli — Command-line interface for twinerd-browser-vault."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Sequence
|
|
10
|
+
|
|
11
|
+
from twinerd_browser_vault.profiles import (
|
|
12
|
+
detect_available_profiles,
|
|
13
|
+
inspect_profile,
|
|
14
|
+
create_profile_snapshot,
|
|
15
|
+
restore_profile_snapshot,
|
|
16
|
+
)
|
|
17
|
+
from twinerd_browser_vault.cookies import (
|
|
18
|
+
sync_firefox_cookies_to_chromium,
|
|
19
|
+
read_firefox_cookies,
|
|
20
|
+
find_firefox_cookies_db,
|
|
21
|
+
DEFAULT_SYNC_HOST_MARKERS,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
26
|
+
parser = argparse.ArgumentParser(
|
|
27
|
+
prog="twinerd-browser-vault",
|
|
28
|
+
description="Browser profile snapshotting, session cookie migration, and credential vault for Twinerd",
|
|
29
|
+
)
|
|
30
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
31
|
+
|
|
32
|
+
# detect
|
|
33
|
+
p_detect = sub.add_parser("detect", help="Detect installed browser profiles on system or account home")
|
|
34
|
+
p_detect.add_argument("--home", type=Path, default=None, help="Custom home directory")
|
|
35
|
+
|
|
36
|
+
# inspect
|
|
37
|
+
p_inspect = sub.add_parser("inspect", help="Inspect size and database state of a profile directory")
|
|
38
|
+
p_inspect.add_argument("browser", help="Browser type (firefox, chromium, chrome, etc.)")
|
|
39
|
+
p_inspect.add_argument("path", type=Path, help="Profile directory path")
|
|
40
|
+
|
|
41
|
+
# snapshot
|
|
42
|
+
p_snap = sub.add_parser("snapshot", help="Create a clean snapshot of a browser profile")
|
|
43
|
+
p_snap.add_argument("browser", help="Browser type (firefox, chromium, chrome)")
|
|
44
|
+
p_snap.add_argument("--account", default="default", help="Account ID / tag")
|
|
45
|
+
p_snap.add_argument("--dest", type=Path, required=True, help="Destination directory for snapshots")
|
|
46
|
+
p_snap.add_argument("--home", type=Path, default=None, help="Custom home directory")
|
|
47
|
+
|
|
48
|
+
# restore
|
|
49
|
+
p_restore = sub.add_parser("restore", help="Restore a profile snapshot to a target directory")
|
|
50
|
+
p_restore.add_argument("snapshot", type=Path, help="Snapshot directory path")
|
|
51
|
+
p_restore.add_argument("target", type=Path, help="Target profile directory")
|
|
52
|
+
p_restore.add_argument("--no-backup", action="store_true", help="Do not backup existing target directory")
|
|
53
|
+
|
|
54
|
+
# sync-cookies
|
|
55
|
+
p_sync = sub.add_parser("sync-cookies", help="Migrate session cookies from Firefox to Chromium/Chrome")
|
|
56
|
+
p_sync.add_argument("--home", type=Path, required=True, help="Account home directory containing .mozilla and .config")
|
|
57
|
+
p_sync.add_argument("--provider", default=None, help="Specific LLM provider (chatgpt, anthropic, google, etc.)")
|
|
58
|
+
p_sync.add_argument("--all-hosts", action="store_true", help="Sync all matching common LLM hosts")
|
|
59
|
+
p_sync.add_argument("--force-overwrite", action="store_true", help="Overwrite existing cookies instead of only missing")
|
|
60
|
+
|
|
61
|
+
# list-cookies
|
|
62
|
+
p_list_c = sub.add_parser("list-cookies", help="List available session cookies from Firefox profile")
|
|
63
|
+
p_list_c.add_argument("--home", type=Path, required=True, help="Account home directory")
|
|
64
|
+
|
|
65
|
+
return parser
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
69
|
+
parser = build_parser()
|
|
70
|
+
args = parser.parse_args(argv)
|
|
71
|
+
|
|
72
|
+
if args.command == "detect":
|
|
73
|
+
profiles = detect_available_profiles(home=args.home)
|
|
74
|
+
print(f"Detected {len(profiles)} browser profile(s):")
|
|
75
|
+
for b, p in profiles.items():
|
|
76
|
+
meta = inspect_profile(b, p)
|
|
77
|
+
print(f" • {b.ljust(10)}: {p} ({meta.size_human})")
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
if args.command == "inspect":
|
|
81
|
+
meta = inspect_profile(args.browser, args.path)
|
|
82
|
+
print(f"Browser: {meta.browser}")
|
|
83
|
+
print(f"Path: {meta.path}")
|
|
84
|
+
print(f"Size: {meta.size_human} ({meta.size_bytes} bytes)")
|
|
85
|
+
print(f"Cookies DB: {meta.cookie_db or 'None'}")
|
|
86
|
+
print(f"Logins DB: {meta.login_db or 'None'}")
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
if args.command == "snapshot":
|
|
90
|
+
res = create_profile_snapshot(args.browser, args.account, args.dest, home=args.home)
|
|
91
|
+
if res:
|
|
92
|
+
print(f"Snapshot created successfully: {res}")
|
|
93
|
+
return 0
|
|
94
|
+
else:
|
|
95
|
+
print("Failed to create profile snapshot.", file=sys.stderr)
|
|
96
|
+
return 1
|
|
97
|
+
|
|
98
|
+
if args.command == "restore":
|
|
99
|
+
ok = restore_profile_snapshot(args.snapshot, args.target, backup_existing=not args.no_backup)
|
|
100
|
+
if ok:
|
|
101
|
+
print(f"Snapshot restored successfully to: {args.target}")
|
|
102
|
+
return 0
|
|
103
|
+
else:
|
|
104
|
+
print("Failed to restore profile snapshot.", file=sys.stderr)
|
|
105
|
+
return 1
|
|
106
|
+
|
|
107
|
+
if args.command == "sync-cookies":
|
|
108
|
+
res = sync_firefox_cookies_to_chromium(
|
|
109
|
+
home=args.home,
|
|
110
|
+
provider=args.provider,
|
|
111
|
+
only_missing=not args.force_overwrite,
|
|
112
|
+
)
|
|
113
|
+
print(json.dumps(res, indent=2))
|
|
114
|
+
return 0 if res.get("status") == "ok" else 1
|
|
115
|
+
|
|
116
|
+
if args.command == "list-cookies":
|
|
117
|
+
ff_db = find_firefox_cookies_db(args.home)
|
|
118
|
+
if not ff_db:
|
|
119
|
+
print(f"No Firefox cookies.sqlite found in {args.home}", file=sys.stderr)
|
|
120
|
+
return 1
|
|
121
|
+
cookies = read_firefox_cookies(ff_db, host_filter=DEFAULT_SYNC_HOST_MARKERS)
|
|
122
|
+
print(f"Found {len(cookies)} matching session cookies:")
|
|
123
|
+
for c in cookies:
|
|
124
|
+
print(f" [{c.host}] {c.name} (secure={c.is_secure}, httponly={c.is_httponly})")
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
sys.exit(main())
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""twinerd_browser_vault.cookies — Session cookie migration and sync across Firefox and Chromium/Chrome."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import sqlite3
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Sequence, Optional
|
|
11
|
+
|
|
12
|
+
from twinerd_browser_vault.models import CookieRecord
|
|
13
|
+
|
|
14
|
+
# Chromium expires_utc: microseconds since 1601-01-01 UTC.
|
|
15
|
+
CHROME_EPOCH_OFFSET_S = 11644473600
|
|
16
|
+
|
|
17
|
+
DEFAULT_SYNC_HOST_MARKERS = (
|
|
18
|
+
"openai.com",
|
|
19
|
+
"chatgpt.com",
|
|
20
|
+
"anthropic.com",
|
|
21
|
+
"claude.ai",
|
|
22
|
+
"google.com",
|
|
23
|
+
"google.pl",
|
|
24
|
+
"accounts.google.com",
|
|
25
|
+
"x.ai",
|
|
26
|
+
"deepseek.com",
|
|
27
|
+
"github.com",
|
|
28
|
+
"openrouter.ai",
|
|
29
|
+
"mistral.ai",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
PROVIDER_COOKIE_HOST_MARKERS: dict[str, tuple[str, ...]] = {
|
|
33
|
+
"chatgpt": ("chatgpt.com", "openai.com", "auth0.openai.com"),
|
|
34
|
+
"anthropic": ("anthropic.com", "claude.ai"),
|
|
35
|
+
"google": ("google.com", "google.pl", "accounts.google.com", "gemini.google.com"),
|
|
36
|
+
"xai": ("x.ai", "console.x.ai"),
|
|
37
|
+
"deepseek": ("deepseek.com", "platform.deepseek.com"),
|
|
38
|
+
"github-copilot": ("github.com", "api.github.com"),
|
|
39
|
+
"openrouter": ("openrouter.ai", "openrouter.com"),
|
|
40
|
+
"mistral": ("mistral.ai", "console.mistral.ai"),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def chrome_expires(unix_expiry: int) -> int:
|
|
45
|
+
"""Converts a Unix epoch second timestamp into Chrome 1601 epoch microsecond timestamp."""
|
|
46
|
+
if unix_expiry <= 0:
|
|
47
|
+
return 0
|
|
48
|
+
return int((unix_expiry + CHROME_EPOCH_OFFSET_S) * 1_000_000)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def host_matches(host: str, markers: Sequence[str]) -> bool:
|
|
52
|
+
"""Checks if a host matches any of the given host markers."""
|
|
53
|
+
lowered = host.lstrip(".").lower()
|
|
54
|
+
return any(marker.lower() in lowered or lowered.endswith(marker.lower()) for marker in markers)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def markers_for_provider(provider: str | None) -> tuple[str, ...]:
|
|
58
|
+
if not provider:
|
|
59
|
+
return DEFAULT_SYNC_HOST_MARKERS
|
|
60
|
+
return PROVIDER_COOKIE_HOST_MARKERS.get(provider.lower(), DEFAULT_SYNC_HOST_MARKERS)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def find_firefox_cookies_db(home: Path) -> Path | None:
|
|
64
|
+
firefox = home / ".mozilla" / "firefox"
|
|
65
|
+
if not firefox.is_dir():
|
|
66
|
+
return None
|
|
67
|
+
best: tuple[int, Path] | None = None
|
|
68
|
+
for cookies in firefox.glob("*/cookies.sqlite"):
|
|
69
|
+
try:
|
|
70
|
+
size = cookies.stat().st_size
|
|
71
|
+
except OSError:
|
|
72
|
+
continue
|
|
73
|
+
if best is None or size > best[0]:
|
|
74
|
+
best = (size, cookies)
|
|
75
|
+
return best[1] if best else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def find_chromium_cookies_db(home: Path) -> Path:
|
|
79
|
+
chromium = home / ".config" / "chromium" / "Default" / "Cookies"
|
|
80
|
+
chrome = home / ".config" / "google-chrome" / "Default" / "Cookies"
|
|
81
|
+
if chromium.is_file() or (home / ".config" / "chromium" / "Default").is_dir():
|
|
82
|
+
return chromium
|
|
83
|
+
if chrome.is_file() or (home / ".config" / "google-chrome" / "Default").is_dir():
|
|
84
|
+
return chrome
|
|
85
|
+
return chromium
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def ensure_chromium_cookies_db(path: Path) -> None:
|
|
89
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
if path.is_file():
|
|
91
|
+
return
|
|
92
|
+
con = sqlite3.connect(str(path))
|
|
93
|
+
con.execute(
|
|
94
|
+
"""
|
|
95
|
+
CREATE TABLE IF NOT EXISTS cookies (
|
|
96
|
+
creation_utc INTEGER NOT NULL,
|
|
97
|
+
host_key TEXT NOT NULL,
|
|
98
|
+
top_frame_site_key TEXT NOT NULL DEFAULT '',
|
|
99
|
+
name TEXT NOT NULL,
|
|
100
|
+
value TEXT NOT NULL,
|
|
101
|
+
encrypted_value BLOB NOT NULL DEFAULT X'',
|
|
102
|
+
path TEXT NOT NULL,
|
|
103
|
+
expires_utc INTEGER NOT NULL,
|
|
104
|
+
is_secure INTEGER NOT NULL,
|
|
105
|
+
is_httponly INTEGER NOT NULL,
|
|
106
|
+
last_access_utc INTEGER NOT NULL,
|
|
107
|
+
has_expires INTEGER NOT NULL DEFAULT 1,
|
|
108
|
+
is_persistent INTEGER NOT NULL DEFAULT 1,
|
|
109
|
+
priority INTEGER NOT NULL DEFAULT 1,
|
|
110
|
+
samesite INTEGER NOT NULL DEFAULT -1,
|
|
111
|
+
source_scheme INTEGER NOT NULL DEFAULT 0,
|
|
112
|
+
source_port INTEGER NOT NULL DEFAULT -1,
|
|
113
|
+
last_update_utc INTEGER NOT NULL DEFAULT 0,
|
|
114
|
+
source_type INTEGER NOT NULL DEFAULT 0,
|
|
115
|
+
has_cross_site_ancestor INTEGER NOT NULL DEFAULT 0
|
|
116
|
+
)
|
|
117
|
+
"""
|
|
118
|
+
)
|
|
119
|
+
con.execute(
|
|
120
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS cookies_unique_index "
|
|
121
|
+
"ON cookies(host_key, top_frame_site_key, name, path, source_scheme, source_port)"
|
|
122
|
+
)
|
|
123
|
+
con.commit()
|
|
124
|
+
con.close()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def read_firefox_cookies(
|
|
128
|
+
db_path: Path,
|
|
129
|
+
host_filter: Optional[Sequence[str]] = None,
|
|
130
|
+
) -> list[CookieRecord]:
|
|
131
|
+
"""Reads cookies from Firefox cookies.sqlite, using a temporary snapshot to avoid SQLite locking."""
|
|
132
|
+
if not db_path.is_file():
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
cookies: list[CookieRecord] = []
|
|
136
|
+
with tempfile.TemporaryDirectory() as td:
|
|
137
|
+
snap = Path(td) / "cookies.sqlite"
|
|
138
|
+
shutil.copy2(db_path, snap)
|
|
139
|
+
con = sqlite3.connect(str(snap))
|
|
140
|
+
try:
|
|
141
|
+
cur = con.cursor()
|
|
142
|
+
cur.execute(
|
|
143
|
+
"""
|
|
144
|
+
SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite
|
|
145
|
+
FROM moz_cookies
|
|
146
|
+
"""
|
|
147
|
+
)
|
|
148
|
+
for row in cur.fetchall():
|
|
149
|
+
name, val, host, path, exp, sec, http_only, ss = row
|
|
150
|
+
if host_filter and not host_matches(host, host_filter):
|
|
151
|
+
continue
|
|
152
|
+
cookies.append(
|
|
153
|
+
CookieRecord(
|
|
154
|
+
name=name,
|
|
155
|
+
value=val,
|
|
156
|
+
host=host,
|
|
157
|
+
path=path,
|
|
158
|
+
expiry=exp or 0,
|
|
159
|
+
is_secure=bool(sec),
|
|
160
|
+
is_httponly=bool(http_only),
|
|
161
|
+
samesite=int(ss) if ss is not None else -1,
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
finally:
|
|
165
|
+
con.close()
|
|
166
|
+
return cookies
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def write_chromium_cookies(
|
|
170
|
+
db_path: Path,
|
|
171
|
+
cookies: list[CookieRecord],
|
|
172
|
+
only_missing: bool = True,
|
|
173
|
+
) -> tuple[int, int]:
|
|
174
|
+
"""Writes cookies into Chromium SQLite Cookies table.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
(inserted_count, skipped_count)
|
|
178
|
+
"""
|
|
179
|
+
ensure_chromium_cookies_db(db_path)
|
|
180
|
+
con = sqlite3.connect(str(db_path))
|
|
181
|
+
inserted = 0
|
|
182
|
+
skipped = 0
|
|
183
|
+
try:
|
|
184
|
+
cur = con.cursor()
|
|
185
|
+
for c in cookies:
|
|
186
|
+
if only_missing:
|
|
187
|
+
cur.execute(
|
|
188
|
+
"SELECT 1 FROM cookies WHERE host_key = ? AND name = ? AND path = ? LIMIT 1",
|
|
189
|
+
(c.host, c.name, c.path),
|
|
190
|
+
)
|
|
191
|
+
if cur.fetchone():
|
|
192
|
+
skipped += 1
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
now_chrome = chrome_expires(int(db_path.stat().st_mtime if db_path.exists() else 0))
|
|
196
|
+
exp_chrome = chrome_expires(c.expiry)
|
|
197
|
+
|
|
198
|
+
cur.execute(
|
|
199
|
+
"""
|
|
200
|
+
INSERT OR REPLACE INTO cookies (
|
|
201
|
+
creation_utc, host_key, top_frame_site_key, name, value, encrypted_value,
|
|
202
|
+
path, expires_utc, is_secure, is_httponly, last_access_utc,
|
|
203
|
+
has_expires, is_persistent, priority, samesite, source_scheme, source_port,
|
|
204
|
+
last_update_utc, source_type, has_cross_site_ancestor
|
|
205
|
+
) VALUES (
|
|
206
|
+
?, ?, '', ?, ?, X'',
|
|
207
|
+
?, ?, ?, ?, ?,
|
|
208
|
+
1, 1, 1, ?, 2, 443,
|
|
209
|
+
?, 0, 0
|
|
210
|
+
)
|
|
211
|
+
""",
|
|
212
|
+
(
|
|
213
|
+
now_chrome,
|
|
214
|
+
c.host,
|
|
215
|
+
c.name,
|
|
216
|
+
c.value,
|
|
217
|
+
c.path,
|
|
218
|
+
exp_chrome,
|
|
219
|
+
1 if c.is_secure else 0,
|
|
220
|
+
1 if c.is_httponly else 0,
|
|
221
|
+
now_chrome,
|
|
222
|
+
c.samesite,
|
|
223
|
+
now_chrome,
|
|
224
|
+
),
|
|
225
|
+
)
|
|
226
|
+
inserted += 1
|
|
227
|
+
con.commit()
|
|
228
|
+
finally:
|
|
229
|
+
con.close()
|
|
230
|
+
|
|
231
|
+
return inserted, skipped
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def sync_firefox_cookies_to_chromium(
|
|
235
|
+
home: Path,
|
|
236
|
+
provider: Optional[str] = None,
|
|
237
|
+
host_markers: Optional[Sequence[str]] = None,
|
|
238
|
+
only_missing: bool = True,
|
|
239
|
+
) -> dict[str, Any]:
|
|
240
|
+
"""Synchronizes Firefox session cookies into Chromium/Chrome."""
|
|
241
|
+
ff_db = find_firefox_cookies_db(home)
|
|
242
|
+
if not ff_db:
|
|
243
|
+
return {
|
|
244
|
+
"status": "skipped",
|
|
245
|
+
"reason": "no_firefox_profile",
|
|
246
|
+
"inserted": 0,
|
|
247
|
+
"skipped_existing": 0,
|
|
248
|
+
"hosts": [],
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
chrom_db = find_chromium_cookies_db(home)
|
|
252
|
+
markers = host_markers or markers_for_provider(provider)
|
|
253
|
+
|
|
254
|
+
cookies = read_firefox_cookies(ff_db, host_filter=markers)
|
|
255
|
+
matched_hosts = sorted(list({c.host.lstrip(".") for c in cookies}))
|
|
256
|
+
|
|
257
|
+
if not cookies:
|
|
258
|
+
return {
|
|
259
|
+
"status": "ok",
|
|
260
|
+
"inserted": 0,
|
|
261
|
+
"skipped_existing": 0,
|
|
262
|
+
"hosts": [],
|
|
263
|
+
"reason": "no_matching_cookies",
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
inserted, skipped = write_chromium_cookies(chrom_db, cookies, only_missing=only_missing)
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
"status": "ok",
|
|
270
|
+
"inserted": inserted,
|
|
271
|
+
"skipped_existing": skipped,
|
|
272
|
+
"hosts": matched_hosts,
|
|
273
|
+
"provider": provider,
|
|
274
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, asdict
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class PasswordRecord:
|
|
10
|
+
"""Represents a credential record extracted from a browser password manager."""
|
|
11
|
+
provider: str
|
|
12
|
+
host: str
|
|
13
|
+
username: str
|
|
14
|
+
password: str
|
|
15
|
+
profile: Path
|
|
16
|
+
|
|
17
|
+
def to_dict(self) -> dict[str, Any]:
|
|
18
|
+
d = asdict(self)
|
|
19
|
+
d["profile"] = str(self.profile)
|
|
20
|
+
return d
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class CookieRecord:
|
|
25
|
+
"""Represents a browser session cookie."""
|
|
26
|
+
name: str
|
|
27
|
+
value: str
|
|
28
|
+
host: str
|
|
29
|
+
path: str
|
|
30
|
+
expiry: int
|
|
31
|
+
is_secure: bool
|
|
32
|
+
is_httponly: bool
|
|
33
|
+
samesite: int = -1
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict[str, Any]:
|
|
36
|
+
return asdict(self)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ProfileMetadata:
|
|
41
|
+
"""Metadata regarding a browser profile on disk."""
|
|
42
|
+
browser: str
|
|
43
|
+
path: Path
|
|
44
|
+
size_bytes: int
|
|
45
|
+
size_human: str
|
|
46
|
+
cookie_db: Path | None = None
|
|
47
|
+
login_db: Path | None = None
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""twinerd_browser_vault.passwords — Credential extraction and parsing from browser password stores."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import configparser
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shlex
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Sequence, Optional
|
|
15
|
+
from urllib.parse import urlparse
|
|
16
|
+
|
|
17
|
+
from twinerd_browser_vault.models import PasswordRecord
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
DECRYPT_TIMEOUT_SECONDS = 20
|
|
22
|
+
|
|
23
|
+
FALLBACK_HOSTS: dict[str, tuple[str, ...]] = {
|
|
24
|
+
"chatgpt": ("chatgpt.com", "openai.com", "platform.openai.com"),
|
|
25
|
+
"anthropic": ("anthropic.com", "claude.ai", "console.anthropic.com"),
|
|
26
|
+
"google": ("accounts.google.com", "gemini.google.com"),
|
|
27
|
+
"github-copilot": ("github.com", "github.com/login"),
|
|
28
|
+
"azure-openai": ("portal.azure.com", "azure.com"),
|
|
29
|
+
"openrouter": ("openrouter.ai", "openrouter.com"),
|
|
30
|
+
"xai": ("x.ai", "console.x.ai"),
|
|
31
|
+
"deepseek": ("deepseek.com", "platform.deepseek.com"),
|
|
32
|
+
"mistral": ("mistral.ai", "console.mistral.ai"),
|
|
33
|
+
"groq": ("groq.com", "console.groq.com"),
|
|
34
|
+
"cohere": ("cohere.com", "dashboard.cohere.com"),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def url_host(value: str) -> str:
|
|
39
|
+
"""Extracts hostname from an URL or hostname string."""
|
|
40
|
+
candidate = value.strip()
|
|
41
|
+
if not candidate:
|
|
42
|
+
return ""
|
|
43
|
+
if "://" not in candidate:
|
|
44
|
+
candidate = f"https://{candidate}"
|
|
45
|
+
try:
|
|
46
|
+
return (urlparse(candidate).hostname or "").lower()
|
|
47
|
+
except ValueError:
|
|
48
|
+
return ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def extract_login_hosts(login: dict[str, Any]) -> list[str]:
|
|
52
|
+
"""Extracts all potential hostnames from a login record dictionary."""
|
|
53
|
+
hosts: list[str] = []
|
|
54
|
+
for key in ("hostname", "formSubmitURL", "formSubmitUrl", "httpRealm", "origin", "url", "URL"):
|
|
55
|
+
host = url_host(str(login.get(key, "") or ""))
|
|
56
|
+
if host and host not in hosts:
|
|
57
|
+
hosts.append(host)
|
|
58
|
+
return hosts
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_inside(root: Path, candidate: Path) -> bool:
|
|
62
|
+
try:
|
|
63
|
+
candidate.relative_to(root)
|
|
64
|
+
except ValueError:
|
|
65
|
+
return False
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_isolated_firefox_profiles(home: Path) -> list[Path]:
|
|
70
|
+
"""Return only profiles contained within the account's Firefox directory."""
|
|
71
|
+
firefox_root = (home / ".mozilla" / "firefox").resolve()
|
|
72
|
+
if not firefox_root.is_dir():
|
|
73
|
+
return []
|
|
74
|
+
|
|
75
|
+
def accepted(candidate: Path) -> Path | None:
|
|
76
|
+
resolved = candidate.expanduser().resolve()
|
|
77
|
+
return resolved if resolved.is_dir() and is_inside(firefox_root, resolved) else None
|
|
78
|
+
|
|
79
|
+
profiles_ini = firefox_root / "profiles.ini"
|
|
80
|
+
if not profiles_ini.is_file():
|
|
81
|
+
return sorted(
|
|
82
|
+
profile
|
|
83
|
+
for item in firefox_root.iterdir()
|
|
84
|
+
if item.is_dir() and not item.name.endswith(".ini")
|
|
85
|
+
if (profile := accepted(item)) is not None
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
parser = configparser.ConfigParser(interpolation=None)
|
|
89
|
+
parser.optionxform = str
|
|
90
|
+
parser.read(profiles_ini, encoding="utf-8")
|
|
91
|
+
profiles: list[Path] = []
|
|
92
|
+
for section in parser.sections():
|
|
93
|
+
if not section.startswith("Profile") or not parser.has_option(section, "Path"):
|
|
94
|
+
continue
|
|
95
|
+
configured = parser.get(section, "Path", fallback="").strip()
|
|
96
|
+
if not configured:
|
|
97
|
+
continue
|
|
98
|
+
relative = parser.getboolean(section, "IsRelative", fallback=True)
|
|
99
|
+
candidate = firefox_root / configured if relative else Path(configured)
|
|
100
|
+
profile = accepted(candidate)
|
|
101
|
+
if profile is not None and profile not in profiles:
|
|
102
|
+
profiles.append(profile)
|
|
103
|
+
return profiles
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def read_firefox_logins_json(profile_path: Path) -> list[dict[str, Any]]:
|
|
107
|
+
"""Reads raw encrypted login records from logins.json if present."""
|
|
108
|
+
logins_file = profile_path / "logins.json"
|
|
109
|
+
if not logins_file.is_file():
|
|
110
|
+
return []
|
|
111
|
+
try:
|
|
112
|
+
data = json.loads(logins_file.read_text(encoding="utf-8"))
|
|
113
|
+
if isinstance(data, dict):
|
|
114
|
+
return data.get("logins", [])
|
|
115
|
+
except Exception as e:
|
|
116
|
+
log.warning(f"Error reading logins.json in {profile_path}: {e}")
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def decrypt_firefox_logins(
|
|
121
|
+
profile_path: Path,
|
|
122
|
+
decrypt_tool: Optional[str] = None,
|
|
123
|
+
provider: Optional[str] = None,
|
|
124
|
+
) -> list[PasswordRecord]:
|
|
125
|
+
"""Decrypts Firefox logins using an available external tool (like firefox-decrypt / firefox_decrypt)."""
|
|
126
|
+
tool = decrypt_tool or shutil.which("firefox-decrypt") or shutil.which("firefox_decrypt")
|
|
127
|
+
if not tool or not Path(tool).is_file():
|
|
128
|
+
return []
|
|
129
|
+
|
|
130
|
+
cmd = [tool, "-f", "json", str(profile_path)]
|
|
131
|
+
try:
|
|
132
|
+
res = subprocess.run(
|
|
133
|
+
cmd,
|
|
134
|
+
capture_output=True,
|
|
135
|
+
text=True,
|
|
136
|
+
timeout=DECRYPT_TIMEOUT_SECONDS,
|
|
137
|
+
)
|
|
138
|
+
if res.returncode != 0:
|
|
139
|
+
return []
|
|
140
|
+
|
|
141
|
+
data = json.loads(res.stdout)
|
|
142
|
+
records_raw = data.get("logins", data) if isinstance(data, dict) else data
|
|
143
|
+
if not isinstance(records_raw, list):
|
|
144
|
+
return []
|
|
145
|
+
|
|
146
|
+
records: list[PasswordRecord] = []
|
|
147
|
+
for item in records_raw:
|
|
148
|
+
if not isinstance(item, dict):
|
|
149
|
+
continue
|
|
150
|
+
h = url_host(str(item.get("hostname", item.get("host", item.get("url", ""))) or ""))
|
|
151
|
+
u = str(item.get("username", item.get("user", item.get("login", ""))) or "").strip()
|
|
152
|
+
p = str(item.get("password", item.get("passwd", item.get("pass", ""))) or "").strip()
|
|
153
|
+
if h and u and p:
|
|
154
|
+
records.append(
|
|
155
|
+
PasswordRecord(
|
|
156
|
+
provider=provider or "custom",
|
|
157
|
+
host=h,
|
|
158
|
+
username=u,
|
|
159
|
+
password=p,
|
|
160
|
+
profile=profile_path,
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
return records
|
|
164
|
+
except Exception as e:
|
|
165
|
+
log.warning(f"Failed decrypting profile {profile_path}: {e}")
|
|
166
|
+
return []
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""twinerd_browser_vault.profiles — Browser profile discovery, size calculation, and snapshots."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from twinerd_browser_vault.models import ProfileMetadata
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# Standard browser profile locations on Linux
|
|
17
|
+
BROWSER_PROFILE_PATHS = {
|
|
18
|
+
"firefox": {
|
|
19
|
+
"config": Path(".mozilla") / "firefox",
|
|
20
|
+
"cache": Path(".cache") / "mozilla" / "firefox",
|
|
21
|
+
},
|
|
22
|
+
"chromium": {
|
|
23
|
+
"config": Path(".config") / "chromium",
|
|
24
|
+
"cache": Path(".cache") / "chromium",
|
|
25
|
+
},
|
|
26
|
+
"chrome": {
|
|
27
|
+
"config": Path(".config") / "google-chrome",
|
|
28
|
+
"cache": Path(".cache") / "google-chrome",
|
|
29
|
+
},
|
|
30
|
+
"brave": {
|
|
31
|
+
"config": Path(".config") / "BraveSoftware" / "Brave-Browser",
|
|
32
|
+
"cache": Path(".cache") / "BraveSoftware" / "Brave-Browser",
|
|
33
|
+
},
|
|
34
|
+
"edge": {
|
|
35
|
+
"config": Path(".config") / "microsoft-edge",
|
|
36
|
+
"cache": Path(".cache") / "microsoft-edge",
|
|
37
|
+
},
|
|
38
|
+
"opera": {
|
|
39
|
+
"config": Path(".config") / "opera",
|
|
40
|
+
"cache": Path(".cache") / "opera",
|
|
41
|
+
},
|
|
42
|
+
"vivaldi": {
|
|
43
|
+
"config": Path(".config") / "vivaldi",
|
|
44
|
+
"cache": Path(".cache") / "vivaldi",
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
CONTAINER_PROFILE_PATHS = {
|
|
49
|
+
"firefox": "/home/browser/.mozilla/firefox",
|
|
50
|
+
"chromium": "/home/browser/.config/chromium",
|
|
51
|
+
"chrome": "/home/browser/.config/google-chrome",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def detect_available_profiles(home: Optional[Path] = None) -> dict[str, Path]:
|
|
56
|
+
"""Detect available browser profiles on the given or host home system.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Dictionary mapping browser name to profile config path.
|
|
60
|
+
"""
|
|
61
|
+
base_home = home or Path.home()
|
|
62
|
+
available = {}
|
|
63
|
+
for browser_name, paths in BROWSER_PROFILE_PATHS.items():
|
|
64
|
+
cfg_path = base_home / paths["config"]
|
|
65
|
+
if cfg_path.exists():
|
|
66
|
+
available[browser_name] = cfg_path
|
|
67
|
+
return available
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_profile_size(profile_path: Path) -> int:
|
|
71
|
+
"""Get total size of a browser profile in bytes."""
|
|
72
|
+
if not profile_path.exists():
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
total = 0
|
|
76
|
+
for item in profile_path.rglob("*"):
|
|
77
|
+
try:
|
|
78
|
+
if item.is_file(follow_symlinks=False):
|
|
79
|
+
total += item.stat().st_size
|
|
80
|
+
except (OSError, PermissionError):
|
|
81
|
+
pass
|
|
82
|
+
return total
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def format_size(size_bytes: int) -> str:
|
|
86
|
+
"""Format bytes to human-readable size."""
|
|
87
|
+
size_flt = float(size_bytes)
|
|
88
|
+
for unit in ["B", "KB", "MB", "GB"]:
|
|
89
|
+
if size_flt < 1024:
|
|
90
|
+
return f"{size_flt:.1f} {unit}"
|
|
91
|
+
size_flt /= 1024.0
|
|
92
|
+
return f"{size_flt:.1f} TB"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def inspect_profile(browser: str, profile_path: Path) -> ProfileMetadata:
|
|
96
|
+
"""Inspects a browser profile directory, calculating size and locating key databases."""
|
|
97
|
+
size_b = get_profile_size(profile_path)
|
|
98
|
+
cookie_db = None
|
|
99
|
+
login_db = None
|
|
100
|
+
|
|
101
|
+
if browser == "firefox":
|
|
102
|
+
for cand in profile_path.glob("*/cookies.sqlite"):
|
|
103
|
+
cookie_db = cand
|
|
104
|
+
break
|
|
105
|
+
for cand in profile_path.glob("*/logins.json"):
|
|
106
|
+
login_db = cand
|
|
107
|
+
break
|
|
108
|
+
else:
|
|
109
|
+
for cand in [
|
|
110
|
+
profile_path / "Default" / "Cookies",
|
|
111
|
+
profile_path / "Cookies",
|
|
112
|
+
]:
|
|
113
|
+
if cand.is_file():
|
|
114
|
+
cookie_db = cand
|
|
115
|
+
break
|
|
116
|
+
for cand in [
|
|
117
|
+
profile_path / "Default" / "Login Data",
|
|
118
|
+
profile_path / "Login Data",
|
|
119
|
+
]:
|
|
120
|
+
if cand.is_file():
|
|
121
|
+
login_db = cand
|
|
122
|
+
break
|
|
123
|
+
|
|
124
|
+
return ProfileMetadata(
|
|
125
|
+
browser=browser,
|
|
126
|
+
path=profile_path,
|
|
127
|
+
size_bytes=size_b,
|
|
128
|
+
size_human=format_size(size_b),
|
|
129
|
+
cookie_db=cookie_db,
|
|
130
|
+
login_db=login_db,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def create_profile_snapshot(
|
|
135
|
+
browser: str,
|
|
136
|
+
account_id: str,
|
|
137
|
+
snapshot_dir: Path,
|
|
138
|
+
home: Optional[Path] = None,
|
|
139
|
+
) -> Optional[Path]:
|
|
140
|
+
"""Create a snapshot of a browser profile for an account."""
|
|
141
|
+
base_home = home or Path.home()
|
|
142
|
+
if browser not in BROWSER_PROFILE_PATHS:
|
|
143
|
+
log.warning(f"Unknown browser: {browser}")
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
source_path = base_home / BROWSER_PROFILE_PATHS[browser]["config"]
|
|
147
|
+
if not source_path.exists():
|
|
148
|
+
log.warning(f"Browser profile not found: {browser} ({source_path})")
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
152
|
+
dest_path = snapshot_dir / f"{account_id}-{browser}-{timestamp}"
|
|
153
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
shutil.copytree(
|
|
157
|
+
source_path,
|
|
158
|
+
dest_path,
|
|
159
|
+
symlinks=False,
|
|
160
|
+
ignore=shutil.ignore_patterns(
|
|
161
|
+
"Cache",
|
|
162
|
+
"cache",
|
|
163
|
+
"GPUCache",
|
|
164
|
+
"Code Cache",
|
|
165
|
+
"*.tmp",
|
|
166
|
+
"*lock*",
|
|
167
|
+
".parentlock",
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
log.info(f"Snapshot created: {dest_path}")
|
|
171
|
+
return dest_path
|
|
172
|
+
except Exception as e:
|
|
173
|
+
log.error(f"Failed to create snapshot: {e}")
|
|
174
|
+
if dest_path.exists():
|
|
175
|
+
shutil.rmtree(dest_path, ignore_errors=True)
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def restore_profile_snapshot(
|
|
180
|
+
snapshot_path: Path,
|
|
181
|
+
target_dir: Path,
|
|
182
|
+
backup_existing: bool = True,
|
|
183
|
+
) -> bool:
|
|
184
|
+
"""Restore a profile snapshot to a target directory."""
|
|
185
|
+
if not snapshot_path.exists() or not snapshot_path.is_dir():
|
|
186
|
+
log.error(f"Snapshot path does not exist: {snapshot_path}")
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
if target_dir.exists() and backup_existing:
|
|
190
|
+
backup_path = target_dir.with_name(f"{target_dir.name}-backup-{int(datetime.now().timestamp())}")
|
|
191
|
+
try:
|
|
192
|
+
shutil.move(str(target_dir), str(backup_path))
|
|
193
|
+
log.info(f"Existing profile backed up to: {backup_path}")
|
|
194
|
+
except Exception as e:
|
|
195
|
+
log.error(f"Failed to backup existing profile: {e}")
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
shutil.copytree(snapshot_path, target_dir)
|
|
200
|
+
log.info(f"Snapshot restored to: {target_dir}")
|
|
201
|
+
return True
|
|
202
|
+
except Exception as e:
|
|
203
|
+
log.error(f"Failed to restore snapshot: {e}")
|
|
204
|
+
return False
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: twinerd-browser-vault
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Browser profile snapshots, session cookie migration, and credential vault for Chrome, Chromium, and Firefox
|
|
5
|
+
Author-email: Tomasz Sapletta <info@softreck.com>
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# twinerd-browser-vault
|
|
11
|
+
|
|
12
|
+
Browser profile snapshots, session cookie migration, and credential vault management for Chrome, Chromium, and Firefox.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- **Profile Discovery & Inspection**: Locates and analyzes profile state and size for Firefox, Chromium, Google Chrome, Brave, Edge, Opera, and Vivaldi.
|
|
17
|
+
- **Session Continuity (Cookie Sync)**: Migrates authenticated session cookies from Firefox (`cookies.sqlite`) into Chromium/Chrome SQLite `Cookies` table (mapping 1601-epoch timestamps).
|
|
18
|
+
- **Profile Snapshotting & Restore**: Clean backups omitting ephemeral caches, temporary locks, and GPU cache files.
|
|
19
|
+
- **Credential & Password Extraction**: Extracts credentials from isolated Firefox profiles (`logins.json` and external decrypt tools) with host and provider matching.
|
|
20
|
+
- **Zero Mandatory External Dependencies**: Built with Python 3.10+ standard library (`sqlite3`, `pathlib`, `shutil`, `urllib.parse`).
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install -e /path/to/twinerd-browser-vault
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## CLI Usage
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Detect browser profiles on current system
|
|
32
|
+
twinerd-browser-vault detect
|
|
33
|
+
|
|
34
|
+
# Inspect a profile directory
|
|
35
|
+
twinerd-browser-vault inspect firefox ~/.mozilla/firefox/default
|
|
36
|
+
|
|
37
|
+
# Snapshot profile omitting cache
|
|
38
|
+
twinerd-browser-vault snapshot chrome --account dev --dest ~/snapshots/
|
|
39
|
+
|
|
40
|
+
# Migrate session cookies from Firefox to Chromium for an account
|
|
41
|
+
twinerd-browser-vault sync-cookies --home /home/worker1 --provider anthropic
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Python API
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from twinerd_browser_vault import (
|
|
48
|
+
detect_available_profiles,
|
|
49
|
+
sync_firefox_cookies_to_chromium,
|
|
50
|
+
create_profile_snapshot,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Sync cookies
|
|
54
|
+
result = sync_firefox_cookies_to_chromium(
|
|
55
|
+
home=Path("/home/worker1"),
|
|
56
|
+
provider="chatgpt",
|
|
57
|
+
only_missing=True,
|
|
58
|
+
)
|
|
59
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/twinerd_browser_vault/__init__.py
|
|
4
|
+
src/twinerd_browser_vault/cli.py
|
|
5
|
+
src/twinerd_browser_vault/cookies.py
|
|
6
|
+
src/twinerd_browser_vault/models.py
|
|
7
|
+
src/twinerd_browser_vault/passwords.py
|
|
8
|
+
src/twinerd_browser_vault/profiles.py
|
|
9
|
+
src/twinerd_browser_vault.egg-info/PKG-INFO
|
|
10
|
+
src/twinerd_browser_vault.egg-info/SOURCES.txt
|
|
11
|
+
src/twinerd_browser_vault.egg-info/dependency_links.txt
|
|
12
|
+
src/twinerd_browser_vault.egg-info/entry_points.txt
|
|
13
|
+
src/twinerd_browser_vault.egg-info/top_level.txt
|
|
14
|
+
tests/test_cookies.py
|
|
15
|
+
tests/test_passwords.py
|
|
16
|
+
tests/test_profiles.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
twinerd_browser_vault
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from twinerd_browser_vault.cookies import (
|
|
7
|
+
sync_firefox_cookies_to_chromium,
|
|
8
|
+
read_firefox_cookies,
|
|
9
|
+
write_chromium_cookies,
|
|
10
|
+
chrome_expires,
|
|
11
|
+
host_matches,
|
|
12
|
+
)
|
|
13
|
+
from twinerd_browser_vault.models import CookieRecord
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _create_mock_ff_cookies(home: Path) -> Path:
|
|
17
|
+
profile = home / ".mozilla" / "firefox" / "abcd.default"
|
|
18
|
+
profile.mkdir(parents=True)
|
|
19
|
+
db = profile / "cookies.sqlite"
|
|
20
|
+
con = sqlite3.connect(str(db))
|
|
21
|
+
con.execute(
|
|
22
|
+
"""
|
|
23
|
+
CREATE TABLE moz_cookies (
|
|
24
|
+
id INTEGER PRIMARY KEY,
|
|
25
|
+
name TEXT,
|
|
26
|
+
value TEXT,
|
|
27
|
+
host TEXT,
|
|
28
|
+
path TEXT,
|
|
29
|
+
expiry INTEGER,
|
|
30
|
+
isSecure INTEGER,
|
|
31
|
+
isHttpOnly INTEGER,
|
|
32
|
+
sameSite INTEGER
|
|
33
|
+
)
|
|
34
|
+
"""
|
|
35
|
+
)
|
|
36
|
+
con.execute(
|
|
37
|
+
"INSERT INTO moz_cookies (name, value, host, path, expiry, isSecure, isHttpOnly, sameSite) "
|
|
38
|
+
"VALUES (?, ?, ?, ?, ?, 1, 1, 0)",
|
|
39
|
+
("sessionKey", "secret-claude-session", ".claude.ai", "/", 2000000000),
|
|
40
|
+
)
|
|
41
|
+
con.execute(
|
|
42
|
+
"INSERT INTO moz_cookies (name, value, host, path, expiry, isSecure, isHttpOnly, sameSite) "
|
|
43
|
+
"VALUES (?, ?, ?, ?, ?, 1, 1, 0)",
|
|
44
|
+
("other", "ignore-me", ".example.com", "/", 2000000000),
|
|
45
|
+
)
|
|
46
|
+
con.commit()
|
|
47
|
+
con.close()
|
|
48
|
+
return db
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_host_matches():
|
|
52
|
+
assert host_matches(".claude.ai", ["claude.ai"])
|
|
53
|
+
assert host_matches("subdomain.openai.com", ["openai.com"])
|
|
54
|
+
assert not host_matches("evil-phishing.com", ["openai.com"])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_chrome_expires():
|
|
58
|
+
assert chrome_expires(0) == 0
|
|
59
|
+
# Unix timestamp > 0 should convert to 1601 epoch
|
|
60
|
+
exp = chrome_expires(1700000000)
|
|
61
|
+
assert exp > 1700000000 * 1_000_000
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_read_and_write_cookies(tmp_path: Path):
|
|
65
|
+
home = tmp_path / "acct"
|
|
66
|
+
home.mkdir()
|
|
67
|
+
ff_db = _create_mock_ff_cookies(home)
|
|
68
|
+
|
|
69
|
+
cookies = read_firefox_cookies(ff_db, host_filter=["claude.ai"])
|
|
70
|
+
assert len(cookies) == 1
|
|
71
|
+
assert cookies[0].name == "sessionKey"
|
|
72
|
+
assert cookies[0].value == "secret-claude-session"
|
|
73
|
+
|
|
74
|
+
chrom_db = home / ".config" / "chromium" / "Default" / "Cookies"
|
|
75
|
+
inserted, skipped = write_chromium_cookies(chrom_db, cookies, only_missing=True)
|
|
76
|
+
assert inserted == 1
|
|
77
|
+
assert skipped == 0
|
|
78
|
+
|
|
79
|
+
# Second write with only_missing=True should skip
|
|
80
|
+
inserted2, skipped2 = write_chromium_cookies(chrom_db, cookies, only_missing=True)
|
|
81
|
+
assert inserted2 == 0
|
|
82
|
+
assert skipped2 == 1
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_sync_firefox_cookies_to_chromium_flow(tmp_path: Path):
|
|
86
|
+
home = tmp_path / "acct"
|
|
87
|
+
home.mkdir()
|
|
88
|
+
_create_mock_ff_cookies(home)
|
|
89
|
+
|
|
90
|
+
res = sync_firefox_cookies_to_chromium(home, provider="anthropic", only_missing=True)
|
|
91
|
+
assert res["status"] == "ok"
|
|
92
|
+
assert res["inserted"] == 1
|
|
93
|
+
assert res["skipped_existing"] == 0
|
|
94
|
+
assert "claude.ai" in res["hosts"]
|
|
95
|
+
assert "example.com" not in res["hosts"]
|
|
96
|
+
|
|
97
|
+
# Verify directly in SQLite
|
|
98
|
+
chrome_db = home / ".config" / "chromium" / "Default" / "Cookies"
|
|
99
|
+
assert chrome_db.is_file()
|
|
100
|
+
con = sqlite3.connect(str(chrome_db))
|
|
101
|
+
row = con.execute("SELECT name, value, host_key FROM cookies WHERE host_key LIKE '%claude%'").fetchone()
|
|
102
|
+
con.close()
|
|
103
|
+
assert row is not None
|
|
104
|
+
assert row[0] == "sessionKey"
|
|
105
|
+
assert row[1] == "secret-claude-session"
|
|
106
|
+
|
|
107
|
+
# Idempotent second sync
|
|
108
|
+
res2 = sync_firefox_cookies_to_chromium(home, provider="anthropic", only_missing=True)
|
|
109
|
+
assert res2["inserted"] == 0
|
|
110
|
+
assert res2["skipped_existing"] == 1
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from twinerd_browser_vault.passwords import (
|
|
7
|
+
url_host,
|
|
8
|
+
extract_login_hosts,
|
|
9
|
+
load_isolated_firefox_profiles,
|
|
10
|
+
read_firefox_logins_json,
|
|
11
|
+
decrypt_firefox_logins,
|
|
12
|
+
)
|
|
13
|
+
from twinerd_browser_vault.models import PasswordRecord
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_url_host():
|
|
17
|
+
assert url_host("https://chatgpt.com/auth/login") == "chatgpt.com"
|
|
18
|
+
assert url_host("api.anthropic.com") == "api.anthropic.com"
|
|
19
|
+
assert url_host("https://admin:secret@host.example.com:8443/test") == "host.example.com"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_extract_login_hosts():
|
|
23
|
+
login = {
|
|
24
|
+
"hostname": "https://accounts.google.com/signin",
|
|
25
|
+
"formSubmitURL": "https://accounts.google.com/CheckCookie",
|
|
26
|
+
"httpRealm": None,
|
|
27
|
+
}
|
|
28
|
+
hosts = extract_login_hosts(login)
|
|
29
|
+
assert hosts == ["accounts.google.com"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_load_isolated_firefox_profiles(tmp_path: Path):
|
|
33
|
+
home = tmp_path / "home"
|
|
34
|
+
ff = home / ".mozilla" / "firefox"
|
|
35
|
+
ff.mkdir(parents=True)
|
|
36
|
+
|
|
37
|
+
prof1 = ff / "random.default"
|
|
38
|
+
prof1.mkdir()
|
|
39
|
+
prof2 = ff / "random.dev-edition"
|
|
40
|
+
prof2.mkdir()
|
|
41
|
+
|
|
42
|
+
profiles = load_isolated_firefox_profiles(home)
|
|
43
|
+
assert len(profiles) == 2
|
|
44
|
+
assert prof1.resolve() in profiles
|
|
45
|
+
assert prof2.resolve() in profiles
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_decrypt_firefox_logins_with_mock_tool(tmp_path: Path):
|
|
49
|
+
prof = tmp_path / "prof"
|
|
50
|
+
prof.mkdir()
|
|
51
|
+
|
|
52
|
+
# Create dummy decrypt tool
|
|
53
|
+
tool = tmp_path / "mock-decrypt"
|
|
54
|
+
payload = json.dumps([
|
|
55
|
+
{
|
|
56
|
+
"hostname": "https://chatgpt.com",
|
|
57
|
+
"username": "user@example.com",
|
|
58
|
+
"password": "secret-password",
|
|
59
|
+
}
|
|
60
|
+
])
|
|
61
|
+
tool.write_text(f"#!/bin/sh\nprintf '%s\\n' '{payload}'\n", encoding="utf-8")
|
|
62
|
+
tool.chmod(0o755)
|
|
63
|
+
|
|
64
|
+
records = decrypt_firefox_logins(prof, decrypt_tool=str(tool), provider="chatgpt")
|
|
65
|
+
assert len(records) == 1
|
|
66
|
+
assert records[0].provider == "chatgpt"
|
|
67
|
+
assert records[0].host == "chatgpt.com"
|
|
68
|
+
assert records[0].username == "user@example.com"
|
|
69
|
+
assert records[0].password == "secret-password"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from twinerd_browser_vault.profiles import (
|
|
6
|
+
detect_available_profiles,
|
|
7
|
+
get_profile_size,
|
|
8
|
+
format_size,
|
|
9
|
+
inspect_profile,
|
|
10
|
+
create_profile_snapshot,
|
|
11
|
+
restore_profile_snapshot,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_format_size():
|
|
16
|
+
assert format_size(500) == "500.0 B"
|
|
17
|
+
assert format_size(2048) == "2.0 KB"
|
|
18
|
+
assert format_size(1048576 * 5) == "5.0 MB"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_profile_snapshot_and_restore(tmp_path: Path):
|
|
22
|
+
home = tmp_path / "home"
|
|
23
|
+
chrome_profile = home / ".config" / "google-chrome" / "Default"
|
|
24
|
+
chrome_profile.mkdir(parents=True)
|
|
25
|
+
(chrome_profile / "Preferences").write_text('{"test": true}', encoding="utf-8")
|
|
26
|
+
(chrome_profile / "History").write_text("dummy-history", encoding="utf-8")
|
|
27
|
+
|
|
28
|
+
# Detect
|
|
29
|
+
detected = detect_available_profiles(home=home)
|
|
30
|
+
assert "chrome" in detected
|
|
31
|
+
assert detected["chrome"] == home / ".config" / "google-chrome"
|
|
32
|
+
|
|
33
|
+
# Size
|
|
34
|
+
size = get_profile_size(detected["chrome"])
|
|
35
|
+
assert size > 0
|
|
36
|
+
|
|
37
|
+
# Inspect
|
|
38
|
+
meta = inspect_profile("chrome", detected["chrome"])
|
|
39
|
+
assert meta.browser == "chrome"
|
|
40
|
+
assert meta.size_bytes == size
|
|
41
|
+
|
|
42
|
+
# Snapshot
|
|
43
|
+
snapshots_dir = tmp_path / "snapshots"
|
|
44
|
+
snap = create_profile_snapshot("chrome", "acc1", snapshots_dir, home=home)
|
|
45
|
+
assert snap is not None
|
|
46
|
+
assert snap.is_dir()
|
|
47
|
+
assert (snap / "Default" / "Preferences").is_file()
|
|
48
|
+
|
|
49
|
+
# Restore to another directory
|
|
50
|
+
target = tmp_path / "restored" / ".config" / "google-chrome"
|
|
51
|
+
target.parent.mkdir(parents=True)
|
|
52
|
+
ok = restore_profile_snapshot(snap, target)
|
|
53
|
+
assert ok is True
|
|
54
|
+
assert (target / "Default" / "Preferences").is_file()
|