termux-sync 1.2.3__tar.gz → 1.2.6__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.
- {termux_sync-1.2.3 → termux_sync-1.2.6}/CHANGELOG.md +28 -1
- {termux_sync-1.2.3 → termux_sync-1.2.6}/PKG-INFO +2 -2
- {termux_sync-1.2.3 → termux_sync-1.2.6}/pyproject.toml +1 -1
- {termux_sync-1.2.3 → termux_sync-1.2.6}/src/termux_sync/__init__.py +420 -134
- {termux_sync-1.2.3 → termux_sync-1.2.6}/LICENSE +0 -0
- {termux_sync-1.2.3 → termux_sync-1.2.6}/README.md +0 -0
|
@@ -9,6 +9,33 @@ Version numbers follow [Semantic Versioning](https://semver.org/).
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## [1.2.6] - 2026-10-15
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- Errors are now shown as a clean panel (what happened, where, how to fix) instead of a raw Python traceback. The full traceback is written to the log file; use `--debug` to print it.
|
|
18
|
+
- `termux-sync help` / `-h` now renders a grouped, readable help screen instead of the long argparse dump.
|
|
19
|
+
- Unknown commands show "Did you mean ...?" suggestions (and point `update` to `tsctl updater`).
|
|
20
|
+
- Closed input (EOF / Ctrl+D) during a prompt is reported clearly instead of crashing.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- Missing `rich` no longer causes a `NameError`; it is installed automatically on first run.
|
|
24
|
+
- Invalid "Max backups" input in `setup` no longer crashes.
|
|
25
|
+
- Error text containing square brackets can no longer break rich markup.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## [1.2.4] - 2026-07-28
|
|
30
|
+
|
|
31
|
+
### Security
|
|
32
|
+
|
|
33
|
+
- **`restore` — arbitrary file write via attacker-controlled `source` field in `manifest.json` (High, GHSA-259w-wmqg-fp76)** — during restore, `manifest["files"][key]["source"]` was taken from the backup archive itself (fully attacker-controlled) and used directly as the extraction destination, and `extract_archive()` called `tarfile.extract()` on every member with no path-containment check. A malicious or tampered backup could therefore write/overwrite arbitrary files anywhere the Termux user has write access, including shell startup files. Fixed by: (1) deriving the extraction destination only from the app's own known `BACKUP_ITEMS` roots instead of manifest content, refusing unknown manifest keys; (2) validating every tar member's resolved path (and symlink/hardlink targets) stays inside the destination before extraction, and rejecting device/fifo entries; (3) using Python's built-in `filter="data"` extraction filter on 3.12+; (4) sanitizing `backup_name` before it's used to build the temp restore path. Reported privately by [Niranj R Mahaswar](https://github.com/NiranjMahaswar).
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **`delete` (GitHub storage) — `Failed to delete backup from GitHub: Expecting value: line 1 column 1 (char 0)`** — `GitHubStorage._api()` unconditionally called `json.loads()` on every API response, but GitHub's REST API returns `204 No Content` with an empty body for `DELETE /releases/{id}` and `DELETE /git/refs/tags/{tag}`, so the empty body failed JSON parsing and the delete was reported as failed (even when it actually succeeded on GitHub's side). `_api()` now returns `{}` for empty/204 responses instead of trying to parse them as JSON.
|
|
38
|
+
|
|
12
39
|
### Planned
|
|
13
40
|
|
|
14
41
|
- Archive encryption using a user-provided password
|
|
@@ -30,7 +57,7 @@ Version numbers follow [Semantic Versioning](https://semver.org/).
|
|
|
30
57
|
|
|
31
58
|
### Fixed
|
|
32
59
|
|
|
33
|
-
- **`restore` — stuck retrying and failing with `HTTP Error 404: Not Found` when the GitHub backend points to a private repo** — `GitHubStorage.download()` and the `manifest.json` lookup in `list_backups()` downloaded release assets via `asset["browser_download_url"]` using the standard `Accept: application/vnd.github.v3+json` header. That URL only resolves without further authentication for public repos; for private repos GitHub returns 404 even with a valid token, since the download must go through the `/repos/{repo}/releases/assets/{id}` API endpoint with `Accept: application/octet-stream`. Added `GitHubStorage._asset_headers()` and `_asset_url()` and switched both call sites to use them, so restoring from a private-repo backend now downloads correctly instead of exhausting all 8 retries.
|
|
60
|
+
- **`restore` — stuck retrying and failing with `HTTP Error 404: Not Found` when the GitHub backend points to a private repo** — `GitHubStorage.download()` and the `manifest.json` lookup in `list_backups()` downloaded release assets via `asset["browser_download_url"]` using the standard `Accept: application/vnd.github.v3+json` header. That URL only resolves without further authentication for public repos; for private repos GitHub returns 404 even with a valid token, since the download must go through the `/repos/{repo}/releases/assets/{id}` API endpoint with `Accept: application/octet-stream`. Added `GitHubStorage._asset_headers()` and `_asset_url()` and switched both call sites to use them, so restoring from a private-repo backend now downloads correctly instead of exhausting all 8 retries. [Issues #13](https://github.com/djunekz/termux-sync/issues/13)
|
|
34
61
|
|
|
35
62
|
---
|
|
36
63
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: termux-sync
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.6
|
|
4
4
|
Summary: Backup and restore your entire Termux environment across devices
|
|
5
5
|
Project-URL: Homepage, https://github.com/djunekz/termux-sync
|
|
6
6
|
Project-URL: Repository, https://github.com/djunekz/termux-sync
|
|
@@ -9,8 +9,12 @@ Supports Local, Google Drive, and GitHub (private repo) storage.
|
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
import os
|
|
12
|
+
import re
|
|
12
13
|
import sys
|
|
13
14
|
import json
|
|
15
|
+
import errno
|
|
16
|
+
import difflib
|
|
17
|
+
import traceback
|
|
14
18
|
import time
|
|
15
19
|
import shutil
|
|
16
20
|
import hashlib
|
|
@@ -39,11 +43,24 @@ try:
|
|
|
39
43
|
from rich.rule import Rule
|
|
40
44
|
from rich.syntax import Syntax
|
|
41
45
|
from rich.align import Align
|
|
46
|
+
from rich.markup import escape
|
|
47
|
+
from rich.padding import Padding
|
|
42
48
|
RICH_AVAILABLE = True
|
|
43
49
|
except ImportError:
|
|
44
|
-
|
|
50
|
+
if os.environ.get("_TERMUX_SYNC_BOOTSTRAP") != "1":
|
|
51
|
+
sys.stderr.write("\n termux-sync: installing required package 'rich' ...\n\n")
|
|
52
|
+
_r = subprocess.run([sys.executable, "-m", "pip", "install", "--quiet", "rich"])
|
|
53
|
+
if _r.returncode == 0:
|
|
54
|
+
os.environ["_TERMUX_SYNC_BOOTSTRAP"] = "1"
|
|
55
|
+
os.execv(sys.executable, [sys.executable] + sys.argv)
|
|
56
|
+
sys.stderr.write(
|
|
57
|
+
"\n ERROR: the Python package 'rich' is required but could not be installed.\n"
|
|
58
|
+
" Install it manually, then run termux-sync again:\n\n"
|
|
59
|
+
" pip install rich\n\n"
|
|
60
|
+
)
|
|
61
|
+
sys.exit(1)
|
|
45
62
|
|
|
46
|
-
VERSION = "1.2.
|
|
63
|
+
VERSION = "1.2.6"
|
|
47
64
|
APP_NAME = "termux-sync"
|
|
48
65
|
CONFIG_DIR = Path.home() / ".config" / APP_NAME
|
|
49
66
|
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
@@ -101,8 +118,16 @@ DEFAULT_CONFIG = {
|
|
|
101
118
|
def load_config() -> dict:
|
|
102
119
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
103
120
|
if CONFIG_FILE.exists():
|
|
104
|
-
|
|
105
|
-
|
|
121
|
+
try:
|
|
122
|
+
with open(CONFIG_FILE) as f:
|
|
123
|
+
data = json.load(f)
|
|
124
|
+
except json.JSONDecodeError as e:
|
|
125
|
+
raise UserError(
|
|
126
|
+
f"The config file is not valid JSON (line {e.lineno}, column {e.colno}).\n"
|
|
127
|
+
f"File: {CONFIG_FILE}",
|
|
128
|
+
hint="Fix the file by hand, or delete it and run [bold]termux-sync setup[/bold] again.",
|
|
129
|
+
title="Config file is corrupted",
|
|
130
|
+
) from e
|
|
106
131
|
for k, v in DEFAULT_CONFIG.items():
|
|
107
132
|
data.setdefault(k, v)
|
|
108
133
|
return data
|
|
@@ -117,6 +142,121 @@ def save_config(cfg: dict):
|
|
|
117
142
|
|
|
118
143
|
console = Console()
|
|
119
144
|
|
|
145
|
+
DEBUG = bool(os.environ.get("TERMUX_SYNC_DEBUG"))
|
|
146
|
+
ISSUES_URL = "https://github.com/djunekz/termux-sync/issues"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ───────────────────────── Error handling ─────────────────────────
|
|
150
|
+
class UserError(Exception):
|
|
151
|
+
"""An expected, fixable problem. Shown as a clean message — no traceback."""
|
|
152
|
+
|
|
153
|
+
def __init__(self, message: str, hint: str = "", title: str = "Cannot continue"):
|
|
154
|
+
super().__init__(message)
|
|
155
|
+
self.message = message
|
|
156
|
+
self.hint = hint # may contain rich markup (written by us, not user input)
|
|
157
|
+
self.title = title
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def print_error(title: str, message: str, hint: str = "", where: str = ""):
|
|
161
|
+
"""Render one tidy error panel. `message` is escaped; `hint` may use markup."""
|
|
162
|
+
body = [f"[bold red]{escape(title)}[/bold red]", "", escape(message)]
|
|
163
|
+
if where:
|
|
164
|
+
body += ["", f"[dim]at {escape(where)}[/dim]"]
|
|
165
|
+
if hint:
|
|
166
|
+
body += ["", "[bold yellow]How to fix[/bold yellow]", hint]
|
|
167
|
+
console.print()
|
|
168
|
+
console.print(Panel(
|
|
169
|
+
"\n".join(body),
|
|
170
|
+
title="[bold red] ERROR [/bold red]",
|
|
171
|
+
border_style="red",
|
|
172
|
+
padding=(1, 2),
|
|
173
|
+
expand=False,
|
|
174
|
+
))
|
|
175
|
+
console.print()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _origin(exc: BaseException) -> str:
|
|
179
|
+
"""Last stack frame that lives in this file, e.g. 'termux-sync.py:1217 in cmd_setup()'."""
|
|
180
|
+
found, tb = "", exc.__traceback__
|
|
181
|
+
while tb is not None:
|
|
182
|
+
if tb.tb_frame.f_code.co_filename == __file__:
|
|
183
|
+
found = f"{Path(__file__).name}:{tb.tb_lineno} in {tb.tb_frame.f_code.co_name}()"
|
|
184
|
+
tb = tb.tb_next
|
|
185
|
+
return found
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _explain(exc: Exception) -> tuple:
|
|
189
|
+
"""Translate an exception into (title, message, hint)."""
|
|
190
|
+
import urllib.error
|
|
191
|
+
import socket
|
|
192
|
+
|
|
193
|
+
if isinstance(exc, UserError):
|
|
194
|
+
return exc.title, exc.message, exc.hint
|
|
195
|
+
if isinstance(exc, EOFError):
|
|
196
|
+
return (
|
|
197
|
+
"Input was closed",
|
|
198
|
+
"termux-sync asked a question, but no answer could be read "
|
|
199
|
+
"(the input stream ended, or you pressed Ctrl+D).",
|
|
200
|
+
"Run the command directly in the Termux terminal — not through a pipe, "
|
|
201
|
+
"script, or widget — and answer the prompts.",
|
|
202
|
+
)
|
|
203
|
+
if isinstance(exc, FileNotFoundError):
|
|
204
|
+
return ("File or folder not found", str(exc.filename or exc),
|
|
205
|
+
"Check that the path exists and is spelled correctly.")
|
|
206
|
+
if isinstance(exc, PermissionError):
|
|
207
|
+
return ("Permission denied", str(exc.filename or exc),
|
|
208
|
+
"Pick a path inside your home directory, or run "
|
|
209
|
+
"[bold]termux-setup-storage[/bold] to grant access to shared storage.")
|
|
210
|
+
if isinstance(exc, OSError) and exc.errno == errno.ENOSPC:
|
|
211
|
+
return ("Device is out of space", "There is not enough free storage to finish this.",
|
|
212
|
+
"Free some space with [bold]termux-sync clear-cache[/bold] "
|
|
213
|
+
"or check usage with [bold]termux-sync check[/bold].")
|
|
214
|
+
if isinstance(exc, urllib.error.HTTPError):
|
|
215
|
+
hint = "Check your token and repository name with [bold]termux-sync setup[/bold]."
|
|
216
|
+
if exc.code in (401, 403):
|
|
217
|
+
hint = "Your token is invalid, expired, or lacks the [bold]repo[/bold] scope. Create a new one and run [bold]termux-sync setup[/bold]."
|
|
218
|
+
elif exc.code == 404:
|
|
219
|
+
hint = "The repository or release was not found. Check the repo name in [bold]termux-sync setup[/bold]."
|
|
220
|
+
return f"GitHub returned HTTP {exc.code}", str(exc.reason), hint
|
|
221
|
+
if isinstance(exc, (urllib.error.URLError, socket.timeout, TimeoutError, ConnectionError)):
|
|
222
|
+
return ("Network problem", str(getattr(exc, "reason", None) or exc),
|
|
223
|
+
"Check your internet connection and try again.")
|
|
224
|
+
if isinstance(exc, subprocess.CalledProcessError):
|
|
225
|
+
cmd = exc.cmd if isinstance(exc.cmd, str) else " ".join(map(str, exc.cmd))
|
|
226
|
+
return ("A command failed", f"{cmd}\nexit code {exc.returncode}",
|
|
227
|
+
"Run that command by hand to see its full output.")
|
|
228
|
+
if isinstance(exc, (RuntimeError, ValueError)):
|
|
229
|
+
return "Operation failed", str(exc), "See the message above. Details are saved in the log."
|
|
230
|
+
return (
|
|
231
|
+
"Unexpected error",
|
|
232
|
+
f"{type(exc).__name__}: {exc}",
|
|
233
|
+
f"Run again with [bold]--debug[/bold] for the full traceback.\n"
|
|
234
|
+
f"If it keeps happening, please report it: [cyan]{ISSUES_URL}[/cyan]",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def handle_crash(exc: Exception):
|
|
239
|
+
"""Log the full traceback to the log file; show a clean panel on screen."""
|
|
240
|
+
try:
|
|
241
|
+
log("ERROR", f"{type(exc).__name__}: {exc}")
|
|
242
|
+
for line in "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)).splitlines():
|
|
243
|
+
log("TRACE", line)
|
|
244
|
+
except Exception:
|
|
245
|
+
pass
|
|
246
|
+
|
|
247
|
+
console.print() # leave the half-finished prompt line
|
|
248
|
+
title, message, hint = _explain(exc)
|
|
249
|
+
expected = isinstance(exc, UserError)
|
|
250
|
+
print_error(title, message, hint, where="" if expected and not DEBUG else _origin(exc))
|
|
251
|
+
|
|
252
|
+
if DEBUG:
|
|
253
|
+
console.print("[bold dim]Full traceback (--debug)[/bold dim]")
|
|
254
|
+
console.print_exception(show_locals=False, max_frames=10, suppress=[sys.modules["rich"]])
|
|
255
|
+
console.print()
|
|
256
|
+
else:
|
|
257
|
+
shown = str(LOG_FILE).replace(str(Path.home()), "~")
|
|
258
|
+
console.print(f" [dim]Log: {shown}[/dim]\n")
|
|
259
|
+
|
|
120
260
|
|
|
121
261
|
def print_banner():
|
|
122
262
|
console.print(
|
|
@@ -277,18 +417,62 @@ def create_archive(
|
|
|
277
417
|
return out
|
|
278
418
|
|
|
279
419
|
|
|
420
|
+
def _safe_extract_member(member: tarfile.TarInfo, dest_path: Path):
|
|
421
|
+
dest_root = dest_path.resolve()
|
|
422
|
+
|
|
423
|
+
member_name = member.name
|
|
424
|
+
if member_name.startswith("/") or member_name.startswith("\\"):
|
|
425
|
+
raise ValueError(f"Unsafe absolute path in archive: {member_name!r}")
|
|
426
|
+
if any(part == ".." for part in Path(member_name).parts):
|
|
427
|
+
raise ValueError(f"Path traversal attempt in archive: {member_name!r}")
|
|
428
|
+
|
|
429
|
+
target = (dest_path / member_name).resolve()
|
|
430
|
+
try:
|
|
431
|
+
target.relative_to(dest_root)
|
|
432
|
+
except ValueError:
|
|
433
|
+
raise ValueError(f"Archive member escapes destination: {member_name!r} -> {target}")
|
|
434
|
+
|
|
435
|
+
if member.issym() or member.islnk():
|
|
436
|
+
link_target = member.linkname
|
|
437
|
+
if os.path.isabs(link_target):
|
|
438
|
+
raise ValueError(f"Unsafe absolute link target in archive: {member_name!r} -> {link_target!r}")
|
|
439
|
+
resolved_link = (target.parent / link_target).resolve()
|
|
440
|
+
try:
|
|
441
|
+
resolved_link.relative_to(dest_root)
|
|
442
|
+
except ValueError:
|
|
443
|
+
raise ValueError(f"Archive link escapes destination: {member_name!r} -> {link_target!r}")
|
|
444
|
+
|
|
445
|
+
if member.ischr() or member.isblk() or member.isfifo():
|
|
446
|
+
raise ValueError(f"Refusing device/fifo entry in archive: {member_name!r}")
|
|
447
|
+
|
|
448
|
+
|
|
280
449
|
def extract_archive(archive_path: Path, dest_path: Path, label: str, progress: Progress):
|
|
281
450
|
task = progress.add_task(f"[cyan]{label}", total=None)
|
|
282
451
|
dest_path.mkdir(parents=True, exist_ok=True)
|
|
452
|
+
|
|
453
|
+
has_data_filter = hasattr(tarfile, "data_filter")
|
|
454
|
+
|
|
283
455
|
with tarfile.open(archive_path, "r:*") as tf:
|
|
284
456
|
for member in tf.getmembers():
|
|
285
457
|
try:
|
|
286
|
-
|
|
458
|
+
_safe_extract_member(member, dest_path)
|
|
459
|
+
except ValueError as e:
|
|
460
|
+
log("WARNING", f"Skipped unsafe archive member during restore: {e}")
|
|
461
|
+
continue
|
|
462
|
+
|
|
463
|
+
try:
|
|
464
|
+
if has_data_filter:
|
|
465
|
+
tf.extract(member, dest_path, set_attrs=True, filter="data")
|
|
466
|
+
else:
|
|
467
|
+
tf.extract(member, dest_path, set_attrs=True)
|
|
287
468
|
except Exception:
|
|
288
469
|
try:
|
|
289
470
|
member.uid = os.getuid()
|
|
290
471
|
member.gid = os.getgid()
|
|
291
|
-
|
|
472
|
+
if has_data_filter:
|
|
473
|
+
tf.extract(member, dest_path, set_attrs=False, filter="data")
|
|
474
|
+
else:
|
|
475
|
+
tf.extract(member, dest_path, set_attrs=False)
|
|
292
476
|
except Exception:
|
|
293
477
|
pass
|
|
294
478
|
progress.update(task, total=1, completed=1, description=f"[green]{label} ✓")
|
|
@@ -374,8 +558,12 @@ class GDriveStorage:
|
|
|
374
558
|
|
|
375
559
|
def _check_rclone(self):
|
|
376
560
|
if shutil.which("rclone") is None:
|
|
377
|
-
|
|
378
|
-
|
|
561
|
+
raise UserError(
|
|
562
|
+
"Google Drive storage needs rclone, but it is not installed.",
|
|
563
|
+
hint="Install it with [bold]pkg install rclone[/bold], then configure a remote "
|
|
564
|
+
"named [bold]termux-sync-gdrive[/bold] using [bold]rclone config[/bold].",
|
|
565
|
+
title="rclone not found",
|
|
566
|
+
)
|
|
379
567
|
|
|
380
568
|
def _remote_path(self, name: str = "") -> str:
|
|
381
569
|
base = f"{self.REMOTE}:"
|
|
@@ -460,8 +648,12 @@ class GitHubStorage:
|
|
|
460
648
|
self.repo = cfg.get("github_repo", "")
|
|
461
649
|
self.branch = cfg.get("github_branch", "main")
|
|
462
650
|
if not self.token or not self.repo:
|
|
463
|
-
|
|
464
|
-
|
|
651
|
+
raise UserError(
|
|
652
|
+
"GitHub storage is selected, but the token or repository is not set.",
|
|
653
|
+
hint="Run [bold]termux-sync setup[/bold] and enter your Personal Access Token "
|
|
654
|
+
"and repo (owner/repo).",
|
|
655
|
+
title="GitHub is not configured",
|
|
656
|
+
)
|
|
465
657
|
|
|
466
658
|
def _headers(self) -> dict:
|
|
467
659
|
return {
|
|
@@ -485,7 +677,12 @@ class GitHubStorage:
|
|
|
485
677
|
req = urllib.request.Request(url, data=data, headers=self._headers(), method=method)
|
|
486
678
|
try:
|
|
487
679
|
with urllib.request.urlopen(req) as resp:
|
|
488
|
-
|
|
680
|
+
if resp.status == 204:
|
|
681
|
+
return {}
|
|
682
|
+
body = resp.read()
|
|
683
|
+
if not body:
|
|
684
|
+
return {}
|
|
685
|
+
return json.loads(body)
|
|
489
686
|
except urllib.error.HTTPError as e:
|
|
490
687
|
body = e.read().decode()
|
|
491
688
|
raise RuntimeError(f"GitHub API error {e.code}: {body}") from e
|
|
@@ -828,7 +1025,7 @@ def cmd_backup(cfg: dict, label: str = ""):
|
|
|
828
1025
|
}
|
|
829
1026
|
log("INFO", f"Archived {src} → {actual_out.name} ({human_size(actual_out.stat().st_size)})")
|
|
830
1027
|
except Exception as e:
|
|
831
|
-
console.print(f" [red]✗ Failed to archive {src}: {e}[/red]")
|
|
1028
|
+
console.print(f" [red]✗ Failed to archive {src}: {escape(str(e))}[/red]")
|
|
832
1029
|
log("ERROR", f"Archive failed for {src}: {e}")
|
|
833
1030
|
|
|
834
1031
|
console.print()
|
|
@@ -855,7 +1052,7 @@ def cmd_backup(cfg: dict, label: str = ""):
|
|
|
855
1052
|
_cleanup_tmp()
|
|
856
1053
|
raise
|
|
857
1054
|
except Exception as e:
|
|
858
|
-
console.print(f" [red]✗ Upload failed: {e}[/red]")
|
|
1055
|
+
console.print(f" [red]✗ Upload failed: {escape(str(e))}[/red]")
|
|
859
1056
|
log("ERROR", f"Upload failed: {e}")
|
|
860
1057
|
_cleanup_tmp()
|
|
861
1058
|
return
|
|
@@ -930,6 +1127,12 @@ def cmd_restore(cfg: dict, backup_name: str = ""):
|
|
|
930
1127
|
except ValueError:
|
|
931
1128
|
backup_name = choice
|
|
932
1129
|
|
|
1130
|
+
safe_backup_name = os.path.basename(str(backup_name).replace("\\", "/")).strip()
|
|
1131
|
+
if not safe_backup_name or safe_backup_name in (".", "..") or "/" in safe_backup_name:
|
|
1132
|
+
console.print(f" [red]✗ Invalid backup name: {backup_name!r}[/red]")
|
|
1133
|
+
return
|
|
1134
|
+
backup_name = safe_backup_name
|
|
1135
|
+
|
|
933
1136
|
tmp_dir = Path(os.environ.get("TMPDIR", "/data/data/com.termux/files/usr/tmp")) / f"restore_{backup_name}"
|
|
934
1137
|
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
935
1138
|
|
|
@@ -945,7 +1148,7 @@ def cmd_restore(cfg: dict, backup_name: str = ""):
|
|
|
945
1148
|
try:
|
|
946
1149
|
storage.download(backup_name, tmp_dir, progress)
|
|
947
1150
|
except Exception as e:
|
|
948
|
-
console.print(f" [red]✗ Download failed: {e}[/red]")
|
|
1151
|
+
console.print(f" [red]✗ Download failed: {escape(str(e))}[/red]")
|
|
949
1152
|
log("ERROR", f"Download failed: {e}")
|
|
950
1153
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
951
1154
|
return
|
|
@@ -1003,14 +1206,31 @@ def cmd_restore(cfg: dict, backup_name: str = ""):
|
|
|
1003
1206
|
TimeElapsedColumn(),
|
|
1004
1207
|
console=console,
|
|
1005
1208
|
) as progress:
|
|
1209
|
+
# Build the set of destinations this app is actually willing to restore into.
|
|
1210
|
+
# Manifest-supplied "source" values are attacker-controlled backup content
|
|
1211
|
+
# and must never be trusted as an extraction root — only used to double
|
|
1212
|
+
# check they agree with the known-safe root for that item key.
|
|
1213
|
+
allowed_roots = {}
|
|
1214
|
+
for item_key, _label, item_root in BACKUP_ITEMS:
|
|
1215
|
+
if item_root is not None:
|
|
1216
|
+
allowed_roots[item_key] = Path(item_root).resolve().parent
|
|
1217
|
+
|
|
1006
1218
|
for key, info in manifest.get("files", {}).items():
|
|
1007
1219
|
archive = tmp_dir / info["archive"]
|
|
1008
|
-
|
|
1220
|
+
|
|
1221
|
+
src_dir = allowed_roots.get(key)
|
|
1222
|
+
if src_dir is None:
|
|
1223
|
+
# Unknown key not part of this app's known backup items — refuse
|
|
1224
|
+
# to derive a destination from untrusted manifest content.
|
|
1225
|
+
console.print(f" [red]✗ Refusing unknown/untrusted restore target for {key!r}[/red]")
|
|
1226
|
+
log("WARNING", f"Refused restore for unknown manifest key: {key!r}")
|
|
1227
|
+
continue
|
|
1228
|
+
|
|
1009
1229
|
try:
|
|
1010
1230
|
extract_archive(archive, src_dir, info["archive"], progress)
|
|
1011
1231
|
log("INFO", f"Extracted {info['archive']} → {src_dir}")
|
|
1012
1232
|
except Exception as e:
|
|
1013
|
-
console.print(f" [red]✗ Extract failed: {e}[/red]")
|
|
1233
|
+
console.print(f" [red]✗ Extract failed: {escape(str(e))}[/red]")
|
|
1014
1234
|
log("ERROR", f"Extract failed for {info['archive']}: {e}")
|
|
1015
1235
|
|
|
1016
1236
|
pkgs = manifest.get("packages", [])
|
|
@@ -1126,7 +1346,7 @@ def cmd_delete(cfg: dict, backup_name: str = ""):
|
|
|
1126
1346
|
console.print(f"\n [bold green]✓ Backup '{backup_name}' deleted successfully.[/bold green]\n")
|
|
1127
1347
|
log("INFO", f"Deleted backup: {backup_name}")
|
|
1128
1348
|
except Exception as e:
|
|
1129
|
-
console.print(f"\n [red]✗ Failed to delete backup: {e}[/red]\n")
|
|
1349
|
+
console.print(f"\n [red]✗ Failed to delete backup: {escape(str(e))}[/red]\n")
|
|
1130
1350
|
log("ERROR", f"Delete failed for {backup_name}: {e}")
|
|
1131
1351
|
|
|
1132
1352
|
|
|
@@ -1210,9 +1430,12 @@ def cmd_setup():
|
|
|
1210
1430
|
" Compression (gz=fast, bz2=balanced, xz=best)",
|
|
1211
1431
|
choices=["gz", "bz2", "xz"], default=cfg.get("compression", "gz")
|
|
1212
1432
|
)
|
|
1213
|
-
|
|
1214
|
-
" Max backups to keep", default=str(cfg.get("max_backups", 5))
|
|
1215
|
-
|
|
1433
|
+
while True:
|
|
1434
|
+
raw = Prompt.ask(" Max backups to keep", default=str(cfg.get("max_backups", 5))).strip()
|
|
1435
|
+
if raw.isdigit() and int(raw) >= 1:
|
|
1436
|
+
cfg["max_backups"] = int(raw)
|
|
1437
|
+
break
|
|
1438
|
+
console.print(" [red]✗ Enter a whole number of 1 or more.[/red]")
|
|
1216
1439
|
|
|
1217
1440
|
console.print()
|
|
1218
1441
|
current_patterns = cfg.get("exclude_patterns", [])
|
|
@@ -1407,14 +1630,25 @@ def cmd_logs(lines: int = 40):
|
|
|
1407
1630
|
if not LOG_FILE.exists():
|
|
1408
1631
|
console.print(" [dim]No log file yet.[/dim]")
|
|
1409
1632
|
return
|
|
1410
|
-
|
|
1633
|
+
all_lines = LOG_FILE.read_text().splitlines()
|
|
1634
|
+
if not DEBUG:
|
|
1635
|
+
all_lines = [ln for ln in all_lines if "[TRACE]" not in ln]
|
|
1636
|
+
log_lines = all_lines[-lines:]
|
|
1637
|
+
if not log_lines:
|
|
1638
|
+
console.print(" [dim]No log entries.[/dim]")
|
|
1639
|
+
return
|
|
1640
|
+
if not DEBUG:
|
|
1641
|
+
console.print(" [dim]Tip: add --debug to also show saved tracebacks.[/dim]\n")
|
|
1411
1642
|
for line in log_lines:
|
|
1643
|
+
safe = escape(line)
|
|
1412
1644
|
if "[ERROR]" in line:
|
|
1413
|
-
console.print(f" [red]{
|
|
1645
|
+
console.print(f" [red]{safe}[/red]")
|
|
1414
1646
|
elif "[WARN]" in line:
|
|
1415
|
-
console.print(f" [yellow]{
|
|
1647
|
+
console.print(f" [yellow]{safe}[/yellow]")
|
|
1648
|
+
elif "[TRACE]" in line:
|
|
1649
|
+
console.print(f" [dim red]{safe}[/dim red]")
|
|
1416
1650
|
else:
|
|
1417
|
-
console.print(f" [dim]{
|
|
1651
|
+
console.print(f" [dim]{safe}[/dim]")
|
|
1418
1652
|
|
|
1419
1653
|
|
|
1420
1654
|
def cmd_clear_logs():
|
|
@@ -1644,7 +1878,7 @@ def cmd_clear_cache():
|
|
|
1644
1878
|
else:
|
|
1645
1879
|
child.unlink(missing_ok=True)
|
|
1646
1880
|
except Exception as e:
|
|
1647
|
-
log("WARN", f"Could not remove {child}: {e}")
|
|
1881
|
+
log("WARN", f"Could not remove {child}: {escape(str(e))}")
|
|
1648
1882
|
prog.update(task, total=1, completed=1,
|
|
1649
1883
|
description=f"[green]{label} cleared ({human_size(size)}) ✓")
|
|
1650
1884
|
cleared += size
|
|
@@ -1692,15 +1926,17 @@ def cmd_import_config(input_path: str = ""):
|
|
|
1692
1926
|
|
|
1693
1927
|
src = Path(input_path).expanduser()
|
|
1694
1928
|
if not src.exists():
|
|
1695
|
-
|
|
1696
|
-
return
|
|
1929
|
+
raise UserError(str(src), hint="Check the path and try again.", title="File not found")
|
|
1697
1930
|
|
|
1698
1931
|
try:
|
|
1699
1932
|
with open(src) as f:
|
|
1700
1933
|
new_cfg = json.load(f)
|
|
1701
1934
|
except json.JSONDecodeError as e:
|
|
1702
|
-
|
|
1703
|
-
|
|
1935
|
+
raise UserError(
|
|
1936
|
+
f"{src}\nLine {e.lineno}, column {e.colno}: {e.msg}",
|
|
1937
|
+
hint="Make sure the file is a valid JSON export from [bold]termux-sync export-config[/bold].",
|
|
1938
|
+
title="Not a valid JSON file",
|
|
1939
|
+
) from e
|
|
1704
1940
|
|
|
1705
1941
|
existing = load_config()
|
|
1706
1942
|
|
|
@@ -1717,117 +1953,127 @@ def cmd_import_config(input_path: str = ""):
|
|
|
1717
1953
|
log("INFO", f"Config imported from {src}")
|
|
1718
1954
|
|
|
1719
1955
|
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
Examples:
|
|
1755
|
-
termux-sync setup
|
|
1756
|
-
termux-sync backup --label "before-new-phone"
|
|
1757
|
-
termux-sync list
|
|
1758
|
-
termux-sync restore
|
|
1759
|
-
termux-sync delete
|
|
1760
|
-
termux-sync delete --name termux_backup_20240501_120000
|
|
1761
|
-
termux-sync schedule
|
|
1762
|
-
termux-sync status
|
|
1763
|
-
termux-sync logs --lines 100
|
|
1764
|
-
termux-sync clear-logs
|
|
1765
|
-
termux-sync check
|
|
1766
|
-
termux-sync check ~
|
|
1767
|
-
termux-sync check $PREFIX
|
|
1768
|
-
termux-sync clear-cache
|
|
1769
|
-
termux-sync export-config
|
|
1770
|
-
termux-sync import-config ~/termux-sync-config-export.json
|
|
1771
|
-
""",
|
|
1772
|
-
)
|
|
1773
|
-
parser.add_argument("command", nargs="?", default="help",
|
|
1774
|
-
choices=["backup", "restore", "list", "delete", "setup", "schedule",
|
|
1775
|
-
"daemon", "status", "logs", "clear-logs", "check",
|
|
1776
|
-
"clear-cache", "export-config", "import-config", "help"])
|
|
1777
|
-
parser.add_argument("target", nargs="?", default="", help="Optional path for check / import-config")
|
|
1778
|
-
parser.add_argument("--label", "-l", default="", help="Label for this backup")
|
|
1779
|
-
parser.add_argument("--name", "-n", default="", help="Backup name to restore / delete")
|
|
1780
|
-
parser.add_argument("--lines", default=40, type=int, help="Lines to show in logs")
|
|
1781
|
-
parser.add_argument("--version", "-v", action="store_true", help="Print version and exit")
|
|
1782
|
-
|
|
1783
|
-
args = parser.parse_args()
|
|
1956
|
+
# ───────────────────────── Command line ─────────────────────────
|
|
1957
|
+
COMMAND_GROUPS = [
|
|
1958
|
+
("Backup & Restore", [
|
|
1959
|
+
("backup", "Create a new backup"),
|
|
1960
|
+
("restore", "Restore from a backup"),
|
|
1961
|
+
("list", "List all available backups"),
|
|
1962
|
+
("delete", "Delete a specific backup"),
|
|
1963
|
+
]),
|
|
1964
|
+
("Configuration", [
|
|
1965
|
+
("setup", "Interactive configuration wizard"),
|
|
1966
|
+
("schedule", "Configure auto-backup schedule"),
|
|
1967
|
+
("daemon", "Run background scheduler (used by Termux:Boot)"),
|
|
1968
|
+
("export-config", "Export config to a file (tokens redacted)"),
|
|
1969
|
+
("import-config", "Import config from a file"),
|
|
1970
|
+
]),
|
|
1971
|
+
("Maintenance", [
|
|
1972
|
+
("status", "Show current config and environment"),
|
|
1973
|
+
("logs", "Show recent log entries"),
|
|
1974
|
+
("clear-logs", "Clear / reset the log file"),
|
|
1975
|
+
("check", "Show disk usage (~, $PREFIX, or a custom path)"),
|
|
1976
|
+
("clear-cache", "Remove temporary and cached files"),
|
|
1977
|
+
("help", "Show this help"),
|
|
1978
|
+
]),
|
|
1979
|
+
]
|
|
1980
|
+
COMMANDS = [name for _, items in COMMAND_GROUPS for name, _ in items]
|
|
1981
|
+
|
|
1982
|
+
OPTIONS = [
|
|
1983
|
+
("-l, --label LABEL", "Label for this backup"),
|
|
1984
|
+
("-n, --name NAME", "Backup name to restore / delete"),
|
|
1985
|
+
("--lines N", "Lines to show in logs (default 40)"),
|
|
1986
|
+
("--debug", "Show the full traceback if something fails"),
|
|
1987
|
+
("-v, --version", "Print version and exit"),
|
|
1988
|
+
("-h, --help", "Show this help"),
|
|
1989
|
+
]
|
|
1784
1990
|
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1991
|
+
EXAMPLES = [
|
|
1992
|
+
("termux-sync setup", "first-time configuration"),
|
|
1993
|
+
("termux-sync backup --label before-new-phone", "create a labelled backup"),
|
|
1994
|
+
("termux-sync list", "see all backups"),
|
|
1995
|
+
("termux-sync restore", "pick a backup to restore"),
|
|
1996
|
+
("termux-sync delete --name <backup-name>", "delete a specific backup"),
|
|
1997
|
+
("termux-sync logs --lines 100", "show the last 100 log lines"),
|
|
1998
|
+
("termux-sync check ~", "disk usage of your home"),
|
|
1999
|
+
("termux-sync import-config <file>", "load a config file"),
|
|
2000
|
+
]
|
|
1788
2001
|
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
2002
|
+
# Commands people commonly try that live elsewhere.
|
|
2003
|
+
COMMAND_HINTS = {
|
|
2004
|
+
"update": "termux-sync has no built-in update command. Use [bold]tsctl updater[/bold] to update.",
|
|
2005
|
+
"upgrade": "To update, use [bold]tsctl updater[/bold].",
|
|
2006
|
+
"install": "To install or reinstall, use the install script, or [bold]tsctl[/bold].",
|
|
2007
|
+
"remove": "To delete a backup use [bold]termux-sync delete[/bold]. To uninstall the tool use [bold]tsctl uninstall[/bold].",
|
|
2008
|
+
"config": "Use [bold]termux-sync setup[/bold] to change settings, or [bold]termux-sync status[/bold] to view them.",
|
|
2009
|
+
"sync": "Use [bold]termux-sync backup[/bold] to upload, or [bold]termux-sync restore[/bold] to download.",
|
|
2010
|
+
}
|
|
1793
2011
|
|
|
1794
|
-
cfg = load_config()
|
|
1795
2012
|
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
2013
|
+
def print_help():
|
|
2014
|
+
print_banner()
|
|
2015
|
+
console.print(f" [bold]Usage:[/bold] termux-sync [cyan]<command>[/cyan] [dim]{escape('[target] [options]')}[/dim]")
|
|
2016
|
+
console.print()
|
|
2017
|
+
|
|
2018
|
+
for group, items in COMMAND_GROUPS:
|
|
2019
|
+
table = Table(box=None, show_header=False, padding=(0, 2), pad_edge=False)
|
|
2020
|
+
table.add_column(style="bold cyan", no_wrap=True)
|
|
2021
|
+
table.add_column()
|
|
2022
|
+
for name, desc in items:
|
|
2023
|
+
table.add_row(name, escape(desc))
|
|
2024
|
+
console.print(f" [bold yellow]{group}[/bold yellow]")
|
|
2025
|
+
console.print(Padding(table, (0, 0, 1, 2)))
|
|
2026
|
+
|
|
2027
|
+
table = Table(box=None, show_header=False, padding=(0, 2), pad_edge=False)
|
|
2028
|
+
table.add_column(style="green", no_wrap=True)
|
|
2029
|
+
table.add_column()
|
|
2030
|
+
for flag, desc in OPTIONS:
|
|
2031
|
+
table.add_row(escape(flag), escape(desc))
|
|
2032
|
+
console.print(" [bold yellow]Options[/bold yellow]")
|
|
2033
|
+
console.print(Padding(table, (0, 0, 1, 2)))
|
|
2034
|
+
|
|
2035
|
+
console.print(" [bold yellow]Examples[/bold yellow]")
|
|
2036
|
+
for cmd, _ in EXAMPLES:
|
|
2037
|
+
console.print(f" [dim]$[/dim] [white]{escape(cmd)}[/white]", soft_wrap=True)
|
|
2038
|
+
console.print()
|
|
2039
|
+
|
|
2040
|
+
|
|
2041
|
+
class FriendlyParser(argparse.ArgumentParser):
|
|
2042
|
+
"""argparse, but errors are shown as a tidy panel instead of a usage dump."""
|
|
2043
|
+
|
|
2044
|
+
def error(self, message):
|
|
2045
|
+
m = re.search(r"invalid choice: '([^']*)'", message)
|
|
2046
|
+
if m:
|
|
2047
|
+
bad = m.group(1)
|
|
2048
|
+
hint_lines = []
|
|
2049
|
+
if bad in COMMAND_HINTS:
|
|
2050
|
+
hint_lines.append(COMMAND_HINTS[bad])
|
|
2051
|
+
close = [] if bad in COMMAND_HINTS else difflib.get_close_matches(bad, COMMANDS, n=3, cutoff=0.6)
|
|
2052
|
+
if close:
|
|
2053
|
+
hint_lines.append("Did you mean: " + " ".join(f"[bold cyan]{c}[/bold cyan]" for c in close))
|
|
2054
|
+
hint_lines.append("Run [bold]termux-sync help[/bold] to see all commands.")
|
|
2055
|
+
print_error("Unknown command", f"'{bad}' is not a termux-sync command.", "\n\n".join(hint_lines))
|
|
2056
|
+
else:
|
|
2057
|
+
nice = message.replace("argument --lines/", "--lines").replace("argument ", "")
|
|
2058
|
+
print_error("Invalid usage", nice,
|
|
2059
|
+
"Run [bold]termux-sync help[/bold] to see the correct usage.")
|
|
2060
|
+
sys.exit(2)
|
|
2061
|
+
|
|
2062
|
+
|
|
2063
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
2064
|
+
parser = FriendlyParser(prog="termux-sync", add_help=False)
|
|
2065
|
+
parser.add_argument("command", nargs="?", default="help", choices=COMMANDS, metavar="command")
|
|
2066
|
+
parser.add_argument("target", nargs="?", default="")
|
|
2067
|
+
parser.add_argument("--label", "-l", default="")
|
|
2068
|
+
parser.add_argument("--name", "-n", default="")
|
|
2069
|
+
parser.add_argument("--lines", default=40, type=int)
|
|
2070
|
+
parser.add_argument("--debug", action="store_true")
|
|
2071
|
+
parser.add_argument("--version", "-v", action="store_true")
|
|
2072
|
+
parser.add_argument("--help", "-h", action="store_true")
|
|
2073
|
+
return parser
|
|
1827
2074
|
|
|
1828
2075
|
|
|
1829
2076
|
def _abort():
|
|
1830
|
-
console.print()
|
|
1831
2077
|
console.print()
|
|
1832
2078
|
console.print(
|
|
1833
2079
|
Panel(
|
|
@@ -1837,9 +2083,49 @@ def _abort():
|
|
|
1837
2083
|
" Run the command again whenever you're ready.",
|
|
1838
2084
|
border_style="yellow",
|
|
1839
2085
|
padding=(1, 2),
|
|
2086
|
+
expand=False,
|
|
1840
2087
|
)
|
|
1841
2088
|
)
|
|
1842
|
-
sys.exit(
|
|
2089
|
+
sys.exit(130)
|
|
2090
|
+
|
|
2091
|
+
|
|
2092
|
+
def main(argv=None):
|
|
2093
|
+
global DEBUG
|
|
2094
|
+
args = build_parser().parse_args(argv)
|
|
2095
|
+
DEBUG = DEBUG or args.debug
|
|
2096
|
+
|
|
2097
|
+
if args.version:
|
|
2098
|
+
print(f"termux-sync v{VERSION}")
|
|
2099
|
+
return
|
|
2100
|
+
|
|
2101
|
+
if args.help or args.command in (None, "help"):
|
|
2102
|
+
print_help()
|
|
2103
|
+
return
|
|
2104
|
+
|
|
2105
|
+
try:
|
|
2106
|
+
cfg = load_config()
|
|
2107
|
+
handlers = {
|
|
2108
|
+
"setup": lambda: cmd_setup(),
|
|
2109
|
+
"backup": lambda: cmd_backup(cfg, label=args.label),
|
|
2110
|
+
"restore": lambda: cmd_restore(cfg, backup_name=args.name),
|
|
2111
|
+
"list": lambda: cmd_list(cfg),
|
|
2112
|
+
"delete": lambda: cmd_delete(cfg, backup_name=args.name),
|
|
2113
|
+
"schedule": lambda: cmd_schedule(),
|
|
2114
|
+
"daemon": lambda: cmd_daemon(cfg),
|
|
2115
|
+
"status": lambda: cmd_status(cfg),
|
|
2116
|
+
"logs": lambda: cmd_logs(args.lines),
|
|
2117
|
+
"clear-logs": lambda: cmd_clear_logs(),
|
|
2118
|
+
"check": lambda: cmd_check(args.target or ""),
|
|
2119
|
+
"clear-cache": lambda: cmd_clear_cache(),
|
|
2120
|
+
"export-config": lambda: cmd_export_config(args.target or ""),
|
|
2121
|
+
"import-config": lambda: cmd_import_config(args.target or ""),
|
|
2122
|
+
}
|
|
2123
|
+
handlers[args.command]()
|
|
2124
|
+
except KeyboardInterrupt:
|
|
2125
|
+
_abort()
|
|
2126
|
+
except Exception as exc:
|
|
2127
|
+
handle_crash(exc)
|
|
2128
|
+
sys.exit(1)
|
|
1843
2129
|
|
|
1844
2130
|
|
|
1845
2131
|
if __name__ == "__main__":
|
|
File without changes
|
|
File without changes
|