Skip to content
Bitcoin Roots

v31.1-roots.1 · Commit 8: [PATCH 08/17] chore(roots): integrate v31 identity and maintenance tooling

ci/release/archive.py

Before: /dev/null · After: ci/release/archive.py

File metadata
new file mode 100755
index 0000000000000000000000000000000000000000..ff09feeedc61f7b5ddaf789e744778604511d8c9

Download original patch · Release notes

Diff rows 1–194 of 194. Line numbers refer to the original before/after files.

+ Added− RemovedUnmarked lines are context.

Scroll the code area horizontally to read long lines. The two line-number columns refer to before and after.

ci/release/archive.py ADDED
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) 2026-present The Bitcoin Roots developers
3
+ # Distributed under the MIT software license, see the accompanying
4
+ # file COPYING or http://www.opensource.org/licenses/mit-license.php.
5
+
6
+ import argparse
7
+ from collections import deque
8
+ import os
9
+ from pathlib import Path
10
+ import re
11
+ import shutil
12
+ import sys
13
+ import tarfile
14
+ import zipfile
15
+
16
+
17
+ VERSION_TAG_RE = re.compile(r"^v[0-9]+\.[0-9]+(?:\.[0-9]+|rc[0-9]+)?-roots\.[1-9][0-9]*$")
18
+ GIT_SHA_RE = re.compile(r"^[0-9A-Fa-f]{12,64}$")
19
+
20
+
21
+ def archive_root_name(release_tag, git_sha):
22
+ if release_tag:
23
+ if not VERSION_TAG_RE.fullmatch(release_tag):
24
+ raise ValueError(f"Invalid Roots release tag: {release_tag}")
25
+ version = release_tag.removeprefix("v")
26
+ else:
27
+ if not GIT_SHA_RE.fullmatch(git_sha):
28
+ raise ValueError("GITHUB_SHA must contain at least 12 hexadecimal characters")
29
+ version = f"git-{git_sha[:12].lower()}"
30
+ return f"bitcoin-roots-{version}"
31
+
32
+
33
+ def archive_members(archive):
34
+ if archive.name.endswith(".tar.gz"):
35
+ with tarfile.open(archive, mode="r:gz") as package:
36
+ return package.getmembers()
37
+ if archive.suffix == ".zip":
38
+ with zipfile.ZipFile(archive) as package:
39
+ return package.infolist()
40
+ raise ValueError(f"Unsupported release archive: {archive.name}")
41
+
42
+
43
+ def validate_archive_root(archive, expected_root, copying=None):
44
+ members = archive_members(archive)
45
+ if not members:
46
+ raise ValueError(f"Release archive is empty: {archive.name}")
47
+
48
+ has_payload = False
49
+ paths = set()
50
+ symlinks = {}
51
+ license_member = None
52
+ for member in members:
53
+ name = member.name if isinstance(member, tarfile.TarInfo) else member.filename
54
+ normalized = name.rstrip("/")
55
+ if not normalized:
56
+ continue
57
+ parts = normalized.split("/")
58
+ if "\\" in normalized or any(part in {"", ".", ".."} for part in parts):
59
+ raise ValueError(f"Unsafe path in {archive.name}: {name}")
60
+ if normalized in paths:
61
+ raise ValueError(f"Duplicate archive member in {archive.name}: {name}")
62
+ paths.add(normalized)
63
+ if isinstance(member, tarfile.TarInfo):
64
+ if not (member.isfile() or member.isdir()):
65
+ raise ValueError(f"Unsupported archive member in {archive.name}: {name}")
66
+ is_regular_file = member.isfile()
67
+ else:
68
+ member_type = (member.external_attr >> 16) & 0o170000
69
+ if member_type not in {0, 0o100000, 0o40000, 0o120000}:
70
+ raise ValueError(f"Unsupported archive member in {archive.name}: {name}")
71
+ if member_type == 0o120000:
72
+ symlinks[normalized] = member
73
+ is_regular_file = not member.is_dir() and member_type in {0, 0o100000}
74
+ if parts[0] != expected_root:
75
+ raise ValueError(
76
+ f"Expected root directory {expected_root} in {archive.name}, found: {name}"
77
+ )
78
+ has_payload |= len(parts) > 1 and is_regular_file
79
+ if normalized == f"{expected_root}/COPYING" and is_regular_file:
80
+ license_member = member
81
+
82
+ if symlinks:
83
+ with zipfile.ZipFile(archive) as package:
84
+ for name, member in symlinks.items():
85
+ if not 0 < member.file_size <= 4096:
86
+ raise ValueError(f"Invalid symlink target in {archive.name}: {name}")
87
+ try:
88
+ target = package.read(member).decode("utf-8")
89
+ except UnicodeDecodeError as error:
90
+ raise ValueError(f"Invalid symlink target in {archive.name}: {name}") from error
91
+ if target.startswith("/") or "\\" in target or "\0" in target or re.match(r"^[A-Za-z]:", target):
92
+ raise ValueError(f"Unsafe symlink target in {archive.name}: {name}")
93
+ symlinks[name] = target
94
+
95
+ def resolve_target(path):
96
+ pending = deque(path.split("/"))
97
+ parts = []
98
+ expanding = set()
99
+ while pending:
100
+ part = pending.popleft()
101
+ # End markers limit cycle detection to active target expansion.
102
+ # A later path component may legitimately traverse the same link.
103
+ if isinstance(part, tuple):
104
+ expanding.remove(part[0])
105
+ continue
106
+ if part in {"", "."}:
107
+ continue
108
+ if part == "..":
109
+ if len(parts) <= 1:
110
+ raise ValueError(f"Symlink escapes archive root in {archive.name}: {path}")
111
+ parts.pop()
112
+ continue
113
+ parts.append(part)
114
+ if parts[0] != expected_root:
115
+ raise ValueError(f"Symlink escapes archive root in {archive.name}: {path}")
116
+ prefix = "/".join(parts)
117
+ if prefix in symlinks:
118
+ if prefix in expanding:
119
+ raise ValueError(f"Symlink cycle in {archive.name}: {prefix}")
120
+ expanding.add(prefix)
121
+ parts.pop()
122
+ pending.extendleft(reversed([*symlinks[prefix].split("/"), (prefix,)]))
123
+ resolved = "/".join(parts)
124
+ if not parts or (resolved not in paths and not any(name.startswith(resolved + "/") for name in paths)):
125
+ raise ValueError(f"Missing symlink target in {archive.name}: {path}")
126
+ return resolved
127
+
128
+ for name in symlinks:
129
+ resolve_target(name)
130
+ # Member paths may themselves pass through a link. Validate those too,
131
+ # so extraction cannot redirect an otherwise valid-looking payload.
132
+ for name in paths:
133
+ resolve_target(name)
134
+
135
+ if not has_payload:
136
+ raise ValueError(f"Release archive has no files below {expected_root}: {archive.name}")
137
+ if copying is not None:
138
+ if license_member is None:
139
+ raise ValueError(f"Release archive is missing regular COPYING: {archive.name}")
140
+ expected_notice = copying.read_bytes()
141
+ notice_size = license_member.size if isinstance(license_member, tarfile.TarInfo) else license_member.file_size
142
+ if notice_size != len(expected_notice):
143
+ raise ValueError(f"Release archive COPYING differs from canonical notice: {archive.name}")
144
+ if isinstance(license_member, tarfile.TarInfo):
145
+ with tarfile.open(archive, mode="r:gz") as package:
146
+ notice = package.extractfile(license_member).read()
147
+ else:
148
+ with zipfile.ZipFile(archive) as package:
149
+ notice = package.read(license_member)
150
+ if notice != expected_notice:
151
+ raise ValueError(f"Release archive COPYING differs from canonical notice: {archive.name}")
152
+
153
+
154
+ def stage_copying(directory):
155
+ if not directory.is_dir():
156
+ raise ValueError(f"License staging directory does not exist: {directory}")
157
+ destination = directory / "COPYING"
158
+ if destination.is_symlink():
159
+ raise ValueError(f"License staging refuses a COPYING symlink: {directory}")
160
+ shutil.copyfile(Path(__file__).resolve().parents[2] / "COPYING", destination)
161
+
162
+
163
+ def main():
164
+ parser = argparse.ArgumentParser()
165
+ subparsers = parser.add_subparsers(dest="command", required=True)
166
+
167
+ root_parser = subparsers.add_parser("root-name")
168
+ root_parser.add_argument("--tag", default=os.environ.get("RELEASE_TAG", ""))
169
+ root_parser.add_argument("--sha", default=os.environ.get("GITHUB_SHA", ""))
170
+
171
+ validate_parser = subparsers.add_parser("validate")
172
+ validate_parser.add_argument("archive", type=Path)
173
+ validate_parser.add_argument("expected_root")
174
+ validate_parser.add_argument("--copying", type=Path)
175
+
176
+ copying_parser = subparsers.add_parser("stage-copying")
177
+ copying_parser.add_argument("directory", type=Path)
178
+
179
+ args = parser.parse_args()
180
+ try:
181
+ if args.command == "root-name":
182
+ print(archive_root_name(args.tag, args.sha))
183
+ elif args.command == "stage-copying":
184
+ stage_copying(args.directory)
185
+ else:
186
+ validate_archive_root(args.archive, args.expected_root, args.copying)
187
+ except (OSError, ValueError, tarfile.TarError, zipfile.BadZipFile) as error:
188
+ print(error, file=sys.stderr)
189
+ return 1
190
+ return 0
191
+
192
+
193
+ if __name__ == "__main__":
194
+ sys.exit(main())