Skip to content

Commit 712e52a

Browse files
committed
fix(backup): fixed wiki backup, hardened security and reliability
Fixed wiki backup that never worked due to BackupResult dataclass being incorrectly unpacked as tuple. Added PAT credential masking in all log outputs and alert messages to prevent token leakage. Elevated error logging from debug to error level for backup failures. Added GitHub API retry logic with exponential backoff, proactive rate limit handling, git clone timeout (1h), atomic state file writes, and S3 bundle upload verification. Replaced GitPython with subprocess for mirror clones, removing the dependency entirely. Pinned APScheduler alpha version to prevent unexpected breaks.
1 parent f2b8f0a commit 712e52a

9 files changed

Lines changed: 127 additions & 52 deletions

File tree

src/backup/git_operations.py

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,26 @@
66
"""
77

88
import os
9+
import re
910
import shutil
1011
import subprocess
1112
import tarfile
1213
from dataclasses import dataclass
1314
from pathlib import Path
1415
from typing import Optional
1516

16-
from git import Repo, GitCommandError
17-
1817
from ui.console import backup_logger
1918

2019

20+
def mask_credentials(text: str) -> str:
21+
"""Mask embedded credentials in URLs within a string.
22+
23+
Replaces patterns like https://TOKEN@github.com with https://***@github.com
24+
to prevent leaking PATs in logs, alerts, or error messages.
25+
"""
26+
return re.sub(r"https://[^@]+@", "https://***@", str(text))
27+
28+
2129
@dataclass
2230
class BackupResult:
2331
"""Result of a repository backup operation."""
@@ -58,7 +66,8 @@ def mirror_clone(self, repo_url: str, repo_name: str) -> Path:
5866
Path to the cloned mirror repository.
5967
6068
Raises:
61-
GitCommandError: If cloning fails.
69+
subprocess.CalledProcessError: If cloning fails.
70+
subprocess.TimeoutExpired: If cloning exceeds timeout.
6271
"""
6372
mirror_path = self.work_dir / f"{repo_name}.git"
6473

@@ -68,14 +77,21 @@ def mirror_clone(self, repo_url: str, repo_name: str) -> Path:
6877

6978
backup_logger.debug(f"Cloning {repo_name} as mirror...")
7079

71-
# Clone - let caller handle logging for failures
72-
Repo.clone_from(
73-
repo_url,
74-
str(mirror_path),
75-
mirror=True,
76-
env={"GIT_TERMINAL_PROMPT": "0"}
80+
result = subprocess.run(
81+
["git", "clone", "--mirror", repo_url, str(mirror_path)],
82+
capture_output=True,
83+
text=True,
84+
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
85+
timeout=3600, # 1 hour timeout
7786
)
7887

88+
if result.returncode != 0:
89+
# Mask credentials in error output before raising
90+
safe_stderr = mask_credentials(result.stderr)
91+
raise subprocess.CalledProcessError(
92+
result.returncode, "git clone --mirror", output=result.stdout, stderr=safe_stderr
93+
)
94+
7995
return mirror_path
8096

8197
def is_empty_repo(self, mirror_path: Path) -> bool:
@@ -325,8 +341,3 @@ def cleanup(self) -> None:
325341
item.unlink()
326342
elif item.is_dir():
327343
shutil.rmtree(item)
328-
329-
330-
class WikiBackupError(Exception):
331-
"""Exception raised when wiki backup fails."""
332-
pass

src/backup/github_client.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,17 @@
88
- Unauthenticated: Without GITHUB_PAT - public repos only, 60 requests/hour
99
"""
1010

11+
import time
1112
from dataclasses import dataclass
12-
from datetime import datetime
13+
from datetime import datetime, timezone
1314
from typing import Generator, Optional, Union
1415

15-
from github import Github, GithubException
16+
from github import Github, GithubException, RateLimitExceededException
1617
from github.Repository import Repository
1718
from github.Organization import Organization
1819
from github.AuthenticatedUser import AuthenticatedUser
1920
from github.NamedUser import NamedUser
21+
from urllib3.util.retry import Retry
2022

2123
from config import Settings
2224
from ui.console import backup_logger
@@ -71,12 +73,15 @@ def __init__(self, settings: Settings):
7173
self.settings = settings
7274
self._authenticated = settings.is_authenticated
7375

76+
# Retry on transient server errors (502, 503, 504)
77+
retry = Retry(total=3, backoff_factor=1, status_forcelist=[500, 502, 503, 504])
78+
7479
if self._authenticated:
7580
# Use per_page=100 for better performance with large orgs
76-
self.gh = Github(settings.github_pat, per_page=100)
81+
self.gh = Github(settings.github_pat, per_page=100, retry=retry)
7782
backup_logger.debug("GitHub client initialized with authentication (5000 req/hour)")
7883
else:
79-
self.gh = Github(per_page=100) # Unauthenticated
84+
self.gh = Github(per_page=100, retry=retry) # Unauthenticated
8085
backup_logger.debug(
8186
"GitHub client initialized WITHOUT authentication. "
8287
"Only public repositories accessible (60 req/hour rate limit). "
@@ -110,6 +115,27 @@ def get_rate_limit_info(self) -> dict:
110115
"reset": rate.core.reset.isoformat() if rate.core.reset else None,
111116
}
112117

118+
def wait_for_rate_limit(self, min_remaining: int = 100) -> None:
119+
"""Check rate limit and wait if near exhaustion.
120+
121+
Args:
122+
min_remaining: Minimum remaining requests before waiting.
123+
"""
124+
try:
125+
rate = self.gh.get_rate_limit()
126+
remaining = rate.core.remaining
127+
if remaining < min_remaining:
128+
reset_time = rate.core.reset
129+
wait_seconds = (reset_time - datetime.now(timezone.utc)).total_seconds() + 5
130+
if wait_seconds > 0:
131+
backup_logger.warning(
132+
f"Rate limit low ({remaining}/{rate.core.limit} remaining), "
133+
f"waiting {wait_seconds:.0f}s until reset"
134+
)
135+
time.sleep(min(wait_seconds, 3600)) # Cap at 1 hour
136+
except GithubException:
137+
pass # Don't fail backup over rate limit check itself
138+
113139
def _resolve_owner(self) -> OwnerType:
114140
"""Resolve the owner name to an Organization or User object.
115141

src/backup/metadata_exporter.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,15 @@ def export_issues(self, repo: Repository, output_path: Path) -> list[dict]:
8282

8383
issues = []
8484
for issue in repo.get_issues(state="all"):
85-
# Skip pull requests (they appear in issues API too)
86-
if issue.pull_request:
87-
continue
85+
try:
86+
# Skip pull requests (they appear in issues API too)
87+
if issue.pull_request:
88+
continue
8889

89-
issue_data = self._issue_to_dict(issue)
90-
issues.append(issue_data)
90+
issue_data = self._issue_to_dict(issue)
91+
issues.append(issue_data)
92+
except GithubException as e:
93+
backup_logger.warning(f"Failed to export issue #{issue.number} for {repo.name}: {e}")
9194

9295
self._write_json(issues, output_path)
9396
return issues
@@ -106,8 +109,11 @@ def export_pull_requests(self, repo: Repository, output_path: Path) -> list[dict
106109

107110
prs = []
108111
for pr in repo.get_pulls(state="all"):
109-
pr_data = self._pr_to_dict(pr)
110-
prs.append(pr_data)
112+
try:
113+
pr_data = self._pr_to_dict(pr)
114+
prs.append(pr_data)
115+
except GithubException as e:
116+
backup_logger.warning(f"Failed to export PR #{pr.number} for {repo.name}: {e}")
111117

112118
self._write_json(prs, output_path)
113119
return prs
@@ -126,8 +132,11 @@ def export_releases(self, repo: Repository, output_path: Path) -> list[dict]:
126132

127133
releases = []
128134
for release in repo.get_releases():
129-
release_data = self._release_to_dict(release)
130-
releases.append(release_data)
135+
try:
136+
release_data = self._release_to_dict(release)
137+
releases.append(release_data)
138+
except GithubException as e:
139+
backup_logger.warning(f"Failed to export release {release.tag_name} for {repo.name}: {e}")
131140

132141
self._write_json(releases, output_path)
133142
return releases

src/backup/wiki_backup.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,12 @@
44
Handles backup of repository wikis.
55
"""
66

7+
import subprocess
78
from pathlib import Path
89
from typing import Optional
910

10-
from git import GitCommandError
11-
1211
from ui.console import backup_logger
13-
from .git_operations import GitBackup
12+
from .git_operations import GitBackup, mask_credentials
1413

1514

1615
class WikiBackup:
@@ -45,21 +44,24 @@ def backup_wiki(
4544
wiki_name = f"{repo_name}.wiki"
4645

4746
try:
48-
bundle_path, bundle_size = self.git_backup.clone_and_bundle(
49-
wiki_url, wiki_name
50-
)
47+
result = self.git_backup.clone_and_bundle(wiki_url, wiki_name)
48+
49+
if result.is_empty or result.bundle_path is None:
50+
backup_logger.debug(f"Wiki is empty for {repo_name}")
51+
return None, None
52+
5153
backup_logger.info(f"Wiki backup created for {repo_name}")
52-
return bundle_path, bundle_size
54+
return result.bundle_path, result.bundle_size
5355

54-
except GitCommandError as e:
56+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
5557
# Wiki might be enabled but empty, or access denied
56-
error_msg = str(e).lower()
58+
error_msg = mask_credentials(e).lower()
5759
if "repository not found" in error_msg or "not exist" in error_msg:
5860
backup_logger.debug(f"Wiki not available for {repo_name} (empty or disabled)")
5961
else:
60-
backup_logger.warning(f"Failed to backup wiki for {repo_name}: {e}")
62+
backup_logger.warning(f"Failed to backup wiki for {repo_name}: {error_msg}")
6163
return None, None
6264

6365
except Exception as e:
64-
backup_logger.warning(f"Unexpected error backing up wiki for {repo_name}: {e}")
66+
backup_logger.warning(f"Unexpected error backing up wiki for {repo_name}: {mask_credentials(e)}")
6567
return None, None

src/main.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from sync_state_manager import SyncStateManager
2020
from alerting.manager import AlertManager
2121
from backup.github_client import GitHubBackupClient
22-
from backup.git_operations import GitBackup, BackupResult
22+
from backup.git_operations import GitBackup, BackupResult, mask_credentials
2323
from backup.metadata_exporter import MetadataExporter
2424
from backup.wiki_backup import WikiBackup
2525
from storage.s3_client import S3Storage
@@ -238,6 +238,10 @@ def run_backup(settings: Settings) -> bool:
238238
stats["lfs_repos"] = stats.get("lfs_repos", 0) + 1
239239
s3_storage.upload_file(backup_result.lfs_path, backup_id, repo_name)
240240

241+
# Check rate limit before metadata-heavy operations
242+
if settings.backup_include_metadata:
243+
gh_client.wait_for_rate_limit()
244+
241245
# Backup metadata (use underlying repo object)
242246
if settings.backup_include_metadata:
243247
meta_counts = metadata_exporter.export_all(repo_info.repo)
@@ -275,10 +279,11 @@ def run_backup(settings: Settings) -> bool:
275279
)
276280

277281
except Exception as e:
278-
backup_logger.debug(f"Failed to backup {repo_name}: {e}")
279-
repo_stats["error"] = str(e)
282+
safe_error = mask_credentials(e)
283+
backup_logger.error(f"Failed to backup {repo_name}: {safe_error}")
284+
repo_stats["error"] = safe_error
280285
stats["errors"] += 1
281-
error_messages.append(f"{repo_name}: {e}")
286+
error_messages.append(f"{repo_name}: {safe_error}")
282287

283288
# Print status for this repo
284289
print_repo_status(

src/requirements.txt

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,6 @@
77
# ───────────────────────────────────────────────────────────────────────────────
88
PyGithub>=2.8.0
99

10-
# ───────────────────────────────────────────────────────────────────────────────
11-
# Git Operations
12-
# ───────────────────────────────────────────────────────────────────────────────
13-
GitPython>=3.1.46
14-
1510
# ───────────────────────────────────────────────────────────────────────────────
1611
# S3/MinIO Storage
1712
# ───────────────────────────────────────────────────────────────────────────────
@@ -20,7 +15,7 @@ boto3>=1.42.0
2015
# ───────────────────────────────────────────────────────────────────────────────
2116
# Scheduler
2217
# ───────────────────────────────────────────────────────────────────────────────
23-
APScheduler>=4.0.0a6
18+
APScheduler==4.0.0a6
2419

2520
# ───────────────────────────────────────────────────────────────────────────────
2621
# CLI & Console UI

src/scheduler.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def _run_backup_with_state(self) -> None:
4444
self.state_manager.update_sync_time()
4545
backup_logger.debug("Sync state updated after successful backup")
4646
except Exception as e:
47-
backup_logger.debug(f"Backup execution failed: {e}")
47+
backup_logger.error(f"Backup execution failed: {e}")
4848
raise
4949

5050
def _job_listener(self, event: Event) -> None:

src/storage/s3_client.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,18 @@ def upload_file(self, local_path: Path, backup_id: str, repo_name: str) -> str:
184184

185185
try:
186186
self.uploader.upload_file(local_path, key)
187+
188+
# Verify critical uploads (bundles) arrived with correct size
189+
if local_path.suffix == ".bundle":
190+
response = self.s3.head_object(Bucket=self.bucket, Key=key)
191+
remote_size = response["ContentLength"]
192+
local_size = local_path.stat().st_size
193+
if remote_size != local_size:
194+
raise RuntimeError(
195+
f"Upload verification failed for {key}: "
196+
f"local={local_size}, remote={remote_size}"
197+
)
198+
187199
return key
188200
except ClientError as e:
189201
backup_logger.error(f"Failed to upload {local_path}: {e}")

src/sync_state_manager.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
"""
1010

1111
import json
12+
import os
13+
import tempfile
1214
from datetime import datetime, timedelta
1315
from pathlib import Path
1416
from typing import Optional, TYPE_CHECKING
@@ -152,15 +154,28 @@ def _load_state(self) -> dict:
152154
return self._state
153155

154156
def _save_state(self) -> None:
155-
"""Save state to file and sync to S3."""
157+
"""Save state to file atomically and sync to S3.
158+
159+
Uses write-to-temp-then-rename pattern to prevent corruption
160+
if the process crashes mid-write.
161+
"""
156162
if self._state is None:
157163
return
158164

159165
self._state["updated_at"] = datetime.now().isoformat()
160166

161167
try:
162-
with open(self.state_file, "w") as f:
163-
json.dump(self._state, f, indent=2)
168+
# Atomic write: temp file + rename prevents corruption on crash
169+
fd, tmp_path = tempfile.mkstemp(
170+
dir=str(self.state_file.parent), suffix=".tmp"
171+
)
172+
try:
173+
with os.fdopen(fd, "w") as f:
174+
json.dump(self._state, f, indent=2)
175+
os.replace(tmp_path, str(self.state_file))
176+
except Exception:
177+
os.unlink(tmp_path)
178+
raise
164179
# Sync to S3 for persistence
165180
self._sync_state_to_s3()
166181
except IOError as e:

0 commit comments

Comments
 (0)