mirror-dedupe 0.1.0__py3-none-any.whl
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.
- mirror_dedupe/__init__.py +13 -0
- mirror_dedupe/cli.py +140 -0
- mirror_dedupe/config.py +55 -0
- mirror_dedupe/dedupe.py +107 -0
- mirror_dedupe/download.py +86 -0
- mirror_dedupe/indices.py +189 -0
- mirror_dedupe/orchestrate.py +534 -0
- mirror_dedupe/sync.py +204 -0
- mirror_dedupe/utils.py +132 -0
- mirror_dedupe-0.1.0.dist-info/METADATA +137 -0
- mirror_dedupe-0.1.0.dist-info/RECORD +14 -0
- mirror_dedupe-0.1.0.dist-info/WHEEL +5 -0
- mirror_dedupe-0.1.0.dist-info/entry_points.txt +2 -0
- mirror_dedupe-0.1.0.dist-info/top_level.txt +1 -0
mirror_dedupe/cli.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
cli.py
|
|
4
|
+
|
|
5
|
+
Ubuntu mirror synchronisation with global deduplication
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2025 Tim Hosking
|
|
8
|
+
Email: tim@mungerware.com
|
|
9
|
+
Website: https://github.com/munger
|
|
10
|
+
Licence: MIT
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import subprocess
|
|
16
|
+
import atexit
|
|
17
|
+
import signal
|
|
18
|
+
import argparse
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
21
|
+
from collections import defaultdict
|
|
22
|
+
|
|
23
|
+
from .config import load_config
|
|
24
|
+
from .utils import (acquire_lock, release_lock, signal_handler,
|
|
25
|
+
get_disk_usage, format_bytes)
|
|
26
|
+
from .dedupe import expand_distributions
|
|
27
|
+
from .orchestrate import (run_orchestrator_mode, sync_mirrors, collect_files,
|
|
28
|
+
analyse_deduplication, check_existing_files,
|
|
29
|
+
process_files, cleanup_mirrors, print_final_summary)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main():
|
|
33
|
+
"""Main entry point for mirror-dedupe"""
|
|
34
|
+
parser = argparse.ArgumentParser(
|
|
35
|
+
description='Mirror repository with global deduplication',
|
|
36
|
+
formatter_class=argparse.RawDescriptionHelpFormatter
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
parser.add_argument('config_dir', nargs='?', default='/etc/mirror-dedupe',
|
|
40
|
+
help='Path to configuration directory (default: /etc/mirror-dedupe)')
|
|
41
|
+
parser.add_argument('--dry-run', action='store_true',
|
|
42
|
+
help='Show what would be done without actually doing it')
|
|
43
|
+
parser.add_argument('--mirror', type=str,
|
|
44
|
+
help='Process only the specified mirror (by name)')
|
|
45
|
+
parser.add_argument('--dedupe-only', action='store_true',
|
|
46
|
+
help='Only run deduplication phase (skip mirror sync)')
|
|
47
|
+
|
|
48
|
+
args = parser.parse_args()
|
|
49
|
+
|
|
50
|
+
# Determine mode and acquire appropriate lock
|
|
51
|
+
if args.dedupe_only:
|
|
52
|
+
if not acquire_lock('dedupe'):
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
atexit.register(release_lock)
|
|
55
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
|
56
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
57
|
+
elif args.mirror:
|
|
58
|
+
if not acquire_lock(args.mirror):
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
atexit.register(release_lock)
|
|
61
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
|
62
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
63
|
+
|
|
64
|
+
# Load configuration
|
|
65
|
+
config_dir = args.config_dir
|
|
66
|
+
config = load_config(config_dir)
|
|
67
|
+
mirrors = config.get('mirrors', [])
|
|
68
|
+
|
|
69
|
+
if not mirrors:
|
|
70
|
+
print("No mirrors defined in configuration")
|
|
71
|
+
sys.exit(1)
|
|
72
|
+
|
|
73
|
+
print(f"\n{'='*60}")
|
|
74
|
+
print(f"Loaded {len(mirrors)} mirror(s) from configuration")
|
|
75
|
+
print(f"{'='*60}")
|
|
76
|
+
|
|
77
|
+
# Orchestrator mode: spawn subprocess for each mirror
|
|
78
|
+
if not args.mirror and not args.dedupe_only:
|
|
79
|
+
run_orchestrator_mode(mirrors, config_dir, args.dry_run)
|
|
80
|
+
# This function exits, so we never reach here
|
|
81
|
+
|
|
82
|
+
# Filter mirrors if --mirror specified
|
|
83
|
+
if args.mirror:
|
|
84
|
+
filtered_mirrors = [m for m in mirrors if m['name'] == args.mirror]
|
|
85
|
+
if not filtered_mirrors:
|
|
86
|
+
print(f"ERROR: Mirror '{args.mirror}' not found in configuration")
|
|
87
|
+
sys.exit(1)
|
|
88
|
+
mirrors = filtered_mirrors
|
|
89
|
+
print(f"\n{'='*60}")
|
|
90
|
+
print(f"SINGLE MIRROR MODE: Processing '{args.mirror}'")
|
|
91
|
+
print(f"{'='*60}")
|
|
92
|
+
|
|
93
|
+
# Skip mirror sync if --dedupe-only
|
|
94
|
+
if args.dedupe_only:
|
|
95
|
+
print(f"\n{'='*60}")
|
|
96
|
+
print("DEDUPE-ONLY MODE: Skipping mirror sync")
|
|
97
|
+
print(f"{'='*60}")
|
|
98
|
+
else:
|
|
99
|
+
sync_mirrors(mirrors, args.dry_run)
|
|
100
|
+
|
|
101
|
+
# Collect all files needed across all mirrors
|
|
102
|
+
global_files = collect_files(mirrors)
|
|
103
|
+
|
|
104
|
+
# Analyse deduplication potential
|
|
105
|
+
hash_to_files, unique_files = analyse_deduplication(global_files)
|
|
106
|
+
|
|
107
|
+
# Check existing files
|
|
108
|
+
check_existing_files(hash_to_files)
|
|
109
|
+
|
|
110
|
+
# Get initial disk usage
|
|
111
|
+
print(f"\n{'='*60}")
|
|
112
|
+
print("Initial disk usage")
|
|
113
|
+
print(f"{'='*60}")
|
|
114
|
+
first_dest = mirrors[0]['dest']
|
|
115
|
+
total, initial_used, free = get_disk_usage(first_dest)
|
|
116
|
+
print(f"Overall mirror filesystem: Used: {format_bytes(initial_used)}, Free: {format_bytes(free)}")
|
|
117
|
+
|
|
118
|
+
# In single-mirror mode, note that cross-mirror deduplication will be handled separately
|
|
119
|
+
if args.mirror:
|
|
120
|
+
print(f"\n{'='*60}")
|
|
121
|
+
print(f"Single mirror mode: Deduplication will be handled separately")
|
|
122
|
+
print(f"{'='*60}")
|
|
123
|
+
|
|
124
|
+
# Process files (download and hardlink)
|
|
125
|
+
downloaded, hardlinked, skipped = process_files(hash_to_files, unique_files, config, args.dry_run)
|
|
126
|
+
|
|
127
|
+
# In single-mirror mode, exit after downloading (skip cleanup and cross-mirror dedup)
|
|
128
|
+
if args.mirror:
|
|
129
|
+
print(f"\nMirror '{args.mirror}' sync completed successfully!")
|
|
130
|
+
sys.exit(0)
|
|
131
|
+
|
|
132
|
+
# Cleanup mirrors
|
|
133
|
+
cleanup_mirrors(mirrors, global_files, args.dry_run)
|
|
134
|
+
|
|
135
|
+
# Print final summary
|
|
136
|
+
print_final_summary(mirrors, downloaded, hardlinked, skipped, initial_used)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == '__main__':
|
|
140
|
+
main()
|
mirror_dedupe/config.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
config.py
|
|
4
|
+
|
|
5
|
+
Ubuntu mirror synchronisation with global deduplication
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2025 Tim Hosking
|
|
8
|
+
Email: tim@mungerware.com
|
|
9
|
+
Website: https://github.com/munger
|
|
10
|
+
Licence: MIT
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import yaml
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_config(config_dir: str) -> dict:
|
|
20
|
+
"""Load mirror configuration from config directory
|
|
21
|
+
|
|
22
|
+
Loads main config and all repo definitions from repos.d/
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
# Load main config
|
|
26
|
+
main_config_path = Path(config_dir) / 'mirror-dedupe.conf'
|
|
27
|
+
with open(main_config_path, 'r') as f:
|
|
28
|
+
config = yaml.safe_load(f) or {}
|
|
29
|
+
|
|
30
|
+
# Load repo definitions from repos.d/
|
|
31
|
+
repos_dir = Path(config_dir) / 'repos.d'
|
|
32
|
+
mirrors = []
|
|
33
|
+
|
|
34
|
+
if repos_dir.exists() and repos_dir.is_dir():
|
|
35
|
+
for repo_file in sorted(repos_dir.glob('*.conf')):
|
|
36
|
+
try:
|
|
37
|
+
with open(repo_file, 'r') as f:
|
|
38
|
+
mirror = yaml.safe_load(f)
|
|
39
|
+
if mirror:
|
|
40
|
+
# Prepend repo_root to dest if it's relative
|
|
41
|
+
repo_root = config.get('repo_root', '/srv/mirror/repos')
|
|
42
|
+
dest = mirror.get('dest', '')
|
|
43
|
+
if not os.path.isabs(dest):
|
|
44
|
+
mirror['dest'] = os.path.join(repo_root, dest)
|
|
45
|
+
mirrors.append(mirror)
|
|
46
|
+
except Exception as e:
|
|
47
|
+
print(f"Warning: Failed to load {repo_file}: {e}")
|
|
48
|
+
|
|
49
|
+
# Add mirrors to config
|
|
50
|
+
config['mirrors'] = mirrors
|
|
51
|
+
|
|
52
|
+
return config
|
|
53
|
+
except Exception as e:
|
|
54
|
+
print(f"Error loading config: {e}")
|
|
55
|
+
sys.exit(1)
|
mirror_dedupe/dedupe.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
dedupe.py
|
|
4
|
+
|
|
5
|
+
Ubuntu mirror synchronisation with global deduplication
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2025 Tim Hosking
|
|
8
|
+
Email: tim@mungerware.com
|
|
9
|
+
Website: https://github.com/munger
|
|
10
|
+
Licence: MIT
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from typing import Set, Tuple
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def hardlink_file(source: str, dest: str, expected_hash: str = None) -> bool:
|
|
18
|
+
"""Create hardlink from source to dest"""
|
|
19
|
+
dest_dir = os.path.dirname(dest)
|
|
20
|
+
os.makedirs(dest_dir, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
# Check if source and dest are already the same file (hardlinked)
|
|
24
|
+
if os.path.exists(dest) and os.path.samefile(source, dest):
|
|
25
|
+
return True # Already hardlinked, nothing to do
|
|
26
|
+
|
|
27
|
+
# Remove dest if it exists (whether it's a file, different hardlink, or corrupted)
|
|
28
|
+
if os.path.exists(dest):
|
|
29
|
+
os.remove(dest)
|
|
30
|
+
|
|
31
|
+
# Create hardlink
|
|
32
|
+
os.link(source, dest)
|
|
33
|
+
return True
|
|
34
|
+
except Exception as e:
|
|
35
|
+
print(f" Error hardlinking {source} -> {dest}: {e}")
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def expand_distributions(distributions: list) -> list:
|
|
40
|
+
"""Expand distribution names to include variants"""
|
|
41
|
+
expanded = []
|
|
42
|
+
for dist in distributions:
|
|
43
|
+
expanded.append(dist)
|
|
44
|
+
if '-' not in dist:
|
|
45
|
+
expanded.extend([
|
|
46
|
+
f"{dist}-updates",
|
|
47
|
+
f"{dist}-security",
|
|
48
|
+
f"{dist}-backports",
|
|
49
|
+
f"{dist}-proposed"
|
|
50
|
+
])
|
|
51
|
+
return expanded
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cleanup_pool(dest_base: str, expected_files: Set[str], dry_run: bool = False) -> Tuple[int, int]:
|
|
55
|
+
"""Remove files from pool/ that aren't in the expected list"""
|
|
56
|
+
print(f"\n{'='*60}")
|
|
57
|
+
print("Cleaning up pool directory")
|
|
58
|
+
print(f"{'='*60}")
|
|
59
|
+
|
|
60
|
+
pool_path = os.path.join(dest_base, 'pool')
|
|
61
|
+
if not os.path.exists(pool_path):
|
|
62
|
+
print(" No pool directory found")
|
|
63
|
+
return (0, 0)
|
|
64
|
+
|
|
65
|
+
removed_files = 0
|
|
66
|
+
removed_dirs = 0
|
|
67
|
+
|
|
68
|
+
# Walk through pool/ and find files to remove
|
|
69
|
+
for root, dirs, files in os.walk(pool_path, topdown=False):
|
|
70
|
+
for filename in files:
|
|
71
|
+
full_path = os.path.join(root, filename)
|
|
72
|
+
# Get relative path from dest_base
|
|
73
|
+
rel_path = os.path.relpath(full_path, dest_base)
|
|
74
|
+
|
|
75
|
+
if rel_path not in expected_files:
|
|
76
|
+
if dry_run:
|
|
77
|
+
print(f" Would remove: {rel_path}")
|
|
78
|
+
removed_files += 1
|
|
79
|
+
else:
|
|
80
|
+
try:
|
|
81
|
+
os.remove(full_path)
|
|
82
|
+
removed_files += 1
|
|
83
|
+
|
|
84
|
+
if removed_files % 100 == 0:
|
|
85
|
+
print(f" Removed {removed_files} files...")
|
|
86
|
+
except Exception as e:
|
|
87
|
+
print(f" Error removing {rel_path}: {e}")
|
|
88
|
+
|
|
89
|
+
# Remove empty directories
|
|
90
|
+
for dirname in dirs:
|
|
91
|
+
dir_path = os.path.join(root, dirname)
|
|
92
|
+
try:
|
|
93
|
+
if not os.listdir(dir_path): # Directory is empty
|
|
94
|
+
if dry_run:
|
|
95
|
+
removed_dirs += 1
|
|
96
|
+
else:
|
|
97
|
+
os.rmdir(dir_path)
|
|
98
|
+
removed_dirs += 1
|
|
99
|
+
except:
|
|
100
|
+
pass # Directory not empty or other issue
|
|
101
|
+
|
|
102
|
+
if dry_run:
|
|
103
|
+
print(f"\nWould remove: {removed_files} files, {removed_dirs} directories")
|
|
104
|
+
else:
|
|
105
|
+
print(f"\nRemoved: {removed_files} files, {removed_dirs} directories")
|
|
106
|
+
|
|
107
|
+
return (removed_files, removed_dirs)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
download.py
|
|
4
|
+
|
|
5
|
+
Ubuntu mirror synchronisation with global deduplication
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2025 Tim Hosking
|
|
8
|
+
Email: tim@mungerware.com
|
|
9
|
+
Website: https://github.com/munger
|
|
10
|
+
Licence: MIT
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import hashlib
|
|
16
|
+
from .utils import download_lock, active_downloads
|
|
17
|
+
|
|
18
|
+
# Check if sha256sum is available at startup
|
|
19
|
+
USE_SHA256SUM = False
|
|
20
|
+
try:
|
|
21
|
+
result = subprocess.run(['sha256sum', '--version'], capture_output=True)
|
|
22
|
+
USE_SHA256SUM = result.returncode == 0
|
|
23
|
+
except:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def download_with_curl(url: str, dest_path: str, timeout: int = 300, progress_info: str = "") -> bool:
|
|
28
|
+
"""Download file with curl, supports resuming partial downloads"""
|
|
29
|
+
from . import utils
|
|
30
|
+
|
|
31
|
+
dest_dir = os.path.dirname(dest_path)
|
|
32
|
+
os.makedirs(dest_dir, exist_ok=True)
|
|
33
|
+
|
|
34
|
+
# Show what we're downloading
|
|
35
|
+
filename = os.path.basename(dest_path)
|
|
36
|
+
with utils.download_lock:
|
|
37
|
+
utils.active_downloads += 1
|
|
38
|
+
current_active = utils.active_downloads
|
|
39
|
+
print(f" ⬇ Downloading: {filename} ({current_active} active){progress_info}", flush=True)
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
# -C - enables automatic resume of partial downloads
|
|
43
|
+
cmd = ['curl', '-f', '-L', '-C', '-', '--max-time', str(timeout), '-o', dest_path, url]
|
|
44
|
+
result = subprocess.run(cmd, capture_output=True)
|
|
45
|
+
|
|
46
|
+
with utils.download_lock:
|
|
47
|
+
utils.active_downloads -= 1
|
|
48
|
+
remaining = utils.active_downloads
|
|
49
|
+
|
|
50
|
+
if result.returncode == 0:
|
|
51
|
+
print(f" [OK] Completed: {filename} ({remaining} remaining)", flush=True)
|
|
52
|
+
else:
|
|
53
|
+
print(f" [FAIL] Failed: {filename} ({remaining} remaining)", flush=True)
|
|
54
|
+
|
|
55
|
+
return result.returncode == 0
|
|
56
|
+
except Exception as e:
|
|
57
|
+
with utils.download_lock:
|
|
58
|
+
utils.active_downloads -= 1
|
|
59
|
+
remaining = utils.active_downloads
|
|
60
|
+
print(f" [ERROR] Error: {filename} ({remaining} remaining) - {e}", flush=True)
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def verify_sha256(file_path: str, expected_hash: str, buffer_size: int = 1048576) -> bool:
|
|
65
|
+
"""Verify file SHA256 hash using sha256sum or Python hashlib"""
|
|
66
|
+
if USE_SHA256SUM:
|
|
67
|
+
# Use fast sha256sum command
|
|
68
|
+
try:
|
|
69
|
+
result = subprocess.run(['sha256sum', file_path],
|
|
70
|
+
capture_output=True, text=True)
|
|
71
|
+
if result.returncode == 0:
|
|
72
|
+
actual_hash = result.stdout.split()[0]
|
|
73
|
+
return actual_hash == expected_hash
|
|
74
|
+
return False
|
|
75
|
+
except:
|
|
76
|
+
return False
|
|
77
|
+
else:
|
|
78
|
+
# Use Python hashlib
|
|
79
|
+
try:
|
|
80
|
+
sha256 = hashlib.sha256()
|
|
81
|
+
with open(file_path, 'rb') as f:
|
|
82
|
+
for chunk in iter(lambda: f.read(buffer_size), b''):
|
|
83
|
+
sha256.update(chunk)
|
|
84
|
+
return sha256.hexdigest() == expected_hash
|
|
85
|
+
except:
|
|
86
|
+
return False
|
mirror_dedupe/indices.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
indices.py
|
|
4
|
+
|
|
5
|
+
Ubuntu mirror synchronisation with global deduplication
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2025 Tim Hosking
|
|
8
|
+
Email: tim@mungerware.com
|
|
9
|
+
Website: https://github.com/munger
|
|
10
|
+
Licence: MIT
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import gzip
|
|
15
|
+
from typing import Dict, Set
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def read_gzipped_file(filepath: str) -> str:
|
|
19
|
+
"""Read and decompress a gzipped file from local filesystem"""
|
|
20
|
+
try:
|
|
21
|
+
with gzip.open(filepath, 'rt', encoding='utf-8') as f:
|
|
22
|
+
return f.read()
|
|
23
|
+
except Exception as e:
|
|
24
|
+
print(f" Error reading {filepath}: {e}")
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_packages_file(content: str) -> Dict[str, Dict[str, str]]:
|
|
29
|
+
"""
|
|
30
|
+
Parse Packages file and extract package info.
|
|
31
|
+
Returns: {filename: {'sha256': hash, 'size': size, 'package': name}}
|
|
32
|
+
"""
|
|
33
|
+
packages = {}
|
|
34
|
+
current_package = {}
|
|
35
|
+
|
|
36
|
+
for line in content.split('\n'):
|
|
37
|
+
line = line.rstrip()
|
|
38
|
+
|
|
39
|
+
if not line:
|
|
40
|
+
if 'filename' in current_package and 'sha256' in current_package:
|
|
41
|
+
filename = current_package['filename']
|
|
42
|
+
packages[filename] = {
|
|
43
|
+
'sha256': current_package['sha256'],
|
|
44
|
+
'size': current_package.get('size', '0'),
|
|
45
|
+
'package': current_package.get('package', 'unknown')
|
|
46
|
+
}
|
|
47
|
+
current_package = {}
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
if ':' in line and not line.startswith(' '):
|
|
51
|
+
key, value = line.split(':', 1)
|
|
52
|
+
key = key.strip().lower()
|
|
53
|
+
value = value.strip()
|
|
54
|
+
|
|
55
|
+
if key == 'package':
|
|
56
|
+
current_package['package'] = value
|
|
57
|
+
elif key == 'filename':
|
|
58
|
+
current_package['filename'] = value
|
|
59
|
+
elif key == 'sha256':
|
|
60
|
+
current_package['sha256'] = value
|
|
61
|
+
elif key == 'size':
|
|
62
|
+
current_package['size'] = value
|
|
63
|
+
|
|
64
|
+
return packages
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_sources_file(content: str) -> Dict[str, Dict[str, str]]:
|
|
68
|
+
"""
|
|
69
|
+
Parse Sources file and extract source package info.
|
|
70
|
+
Returns: {filename: {'sha256': hash, 'size': size, 'package': name}}
|
|
71
|
+
"""
|
|
72
|
+
sources = {}
|
|
73
|
+
current_package = {}
|
|
74
|
+
current_files = []
|
|
75
|
+
|
|
76
|
+
for line in content.split('\n'):
|
|
77
|
+
line = line.rstrip()
|
|
78
|
+
|
|
79
|
+
if not line:
|
|
80
|
+
if 'directory' in current_package and current_files:
|
|
81
|
+
directory = current_package['directory']
|
|
82
|
+
for filename, size, sha256 in current_files:
|
|
83
|
+
full_path = f"{directory}/{filename}"
|
|
84
|
+
sources[full_path] = {
|
|
85
|
+
'sha256': sha256,
|
|
86
|
+
'size': size,
|
|
87
|
+
'package': current_package.get('package', 'unknown')
|
|
88
|
+
}
|
|
89
|
+
current_package = {}
|
|
90
|
+
current_files = []
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
if ':' in line and not line.startswith(' '):
|
|
94
|
+
key, value = line.split(':', 1)
|
|
95
|
+
key = key.strip().lower()
|
|
96
|
+
value = value.strip()
|
|
97
|
+
|
|
98
|
+
if key == 'package':
|
|
99
|
+
current_package['package'] = value
|
|
100
|
+
current_package['in_checksums'] = False
|
|
101
|
+
elif key == 'directory':
|
|
102
|
+
current_package['directory'] = value
|
|
103
|
+
current_package['in_checksums'] = False
|
|
104
|
+
elif key == 'checksums-sha256':
|
|
105
|
+
current_package['in_checksums'] = True
|
|
106
|
+
else:
|
|
107
|
+
current_package['in_checksums'] = False
|
|
108
|
+
elif line.startswith(' ') and current_package.get('in_checksums'):
|
|
109
|
+
parts = line.strip().split()
|
|
110
|
+
if len(parts) >= 3:
|
|
111
|
+
sha256 = parts[0]
|
|
112
|
+
size = parts[1]
|
|
113
|
+
filename = ' '.join(parts[2:])
|
|
114
|
+
if filename and not filename.endswith(')') and '(' not in filename:
|
|
115
|
+
current_files.append((filename, size, sha256))
|
|
116
|
+
|
|
117
|
+
if 'directory' in current_package and current_files:
|
|
118
|
+
directory = current_package['directory']
|
|
119
|
+
for filename, size, sha256 in current_files:
|
|
120
|
+
full_path = f"{directory}/{filename}"
|
|
121
|
+
sources[full_path] = {
|
|
122
|
+
'sha256': sha256,
|
|
123
|
+
'size': size,
|
|
124
|
+
'package': current_package.get('package', 'unknown')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return sources
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def parse_release_file(dest_base: str, distribution: str) -> Set[str]:
|
|
131
|
+
"""
|
|
132
|
+
Parse Release file and return set of available index files.
|
|
133
|
+
Returns: set of relative paths like 'main/binary-amd64/Packages.gz'
|
|
134
|
+
"""
|
|
135
|
+
release_path = f"{dest_base}/dists/{distribution}/Release"
|
|
136
|
+
|
|
137
|
+
if not os.path.exists(release_path):
|
|
138
|
+
return set()
|
|
139
|
+
|
|
140
|
+
available_files = set()
|
|
141
|
+
in_sha256_section = False
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
with open(release_path, 'r') as f:
|
|
145
|
+
for line in f:
|
|
146
|
+
line = line.rstrip()
|
|
147
|
+
|
|
148
|
+
# Look for SHA256 section
|
|
149
|
+
if line.startswith('SHA256:'):
|
|
150
|
+
in_sha256_section = True
|
|
151
|
+
continue
|
|
152
|
+
elif line and not line.startswith(' '):
|
|
153
|
+
# New section started
|
|
154
|
+
in_sha256_section = False
|
|
155
|
+
|
|
156
|
+
# Parse file entries in SHA256 section
|
|
157
|
+
if in_sha256_section and line.startswith(' '):
|
|
158
|
+
parts = line.split()
|
|
159
|
+
if len(parts) >= 3:
|
|
160
|
+
# Format: " <hash> <size> <path>"
|
|
161
|
+
file_path = parts[2]
|
|
162
|
+
available_files.add(file_path)
|
|
163
|
+
except Exception as e:
|
|
164
|
+
print(f" Warning: Could not parse Release file: {e}")
|
|
165
|
+
return set()
|
|
166
|
+
|
|
167
|
+
return available_files
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_packages_index(dest_base: str, distribution: str, component: str, arch: str) -> Dict[str, Dict[str, str]]:
|
|
171
|
+
"""Read and parse local Packages.gz for a specific component/arch"""
|
|
172
|
+
packages_path = f"{dest_base}/dists/{distribution}/{component}/binary-{arch}/Packages.gz"
|
|
173
|
+
content = read_gzipped_file(packages_path)
|
|
174
|
+
|
|
175
|
+
if content is None:
|
|
176
|
+
return {}
|
|
177
|
+
|
|
178
|
+
return parse_packages_file(content)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def get_sources_index(dest_base: str, distribution: str, component: str) -> Dict[str, Dict[str, str]]:
|
|
182
|
+
"""Read and parse local Sources.gz for a specific component"""
|
|
183
|
+
sources_path = f"{dest_base}/dists/{distribution}/{component}/source/Sources.gz"
|
|
184
|
+
content = read_gzipped_file(sources_path)
|
|
185
|
+
|
|
186
|
+
if content is None:
|
|
187
|
+
return {}
|
|
188
|
+
|
|
189
|
+
return parse_sources_file(content)
|