aixand-lib-github-source 0.1.0b0__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.
@@ -0,0 +1,211 @@
1
+ """Bounded, portable, immutable GitHub source trees. No persistence or application policy."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import unicodedata
7
+ from pathlib import PurePosixPath
8
+
9
+ from .errors import GitHubError
10
+
11
+ MAX_DOWNLOAD = 64 * 1024 * 1024
12
+ MAX_TOTAL = 128 * 1024 * 1024
13
+ MAX_FILE = 16 * 1024 * 1024
14
+ MAX_ENTRIES = 10000
15
+
16
+
17
+ def require(condition, message, code="validation"):
18
+ if not condition:
19
+ raise GitHubError(code, message)
20
+
21
+
22
+ def canonical(value):
23
+ def check(item):
24
+ if isinstance(item, dict):
25
+ require(
26
+ all(isinstance(key, str) for key in item),
27
+ "JSON keys must be text",
28
+ "integrity",
29
+ )
30
+ for child in item.values():
31
+ check(child)
32
+ elif isinstance(item, list):
33
+ for child in item:
34
+ check(child)
35
+ else:
36
+ require(
37
+ item is None or type(item) in (str, int, float, bool),
38
+ "Invalid JSON value",
39
+ "integrity",
40
+ )
41
+
42
+ try:
43
+ check(value)
44
+ return json.dumps(
45
+ value,
46
+ ensure_ascii=False,
47
+ sort_keys=True,
48
+ separators=(",", ":"),
49
+ allow_nan=False,
50
+ ).encode("utf-8")
51
+ except (ValueError, TypeError, UnicodeError, RecursionError) as error:
52
+ raise GitHubError("integrity", "Expected finite UTF-8 JSON") from error
53
+
54
+
55
+ def digest(value):
56
+ return "sha256:" + hashlib.sha256(canonical(value)).hexdigest()
57
+
58
+
59
+ def loads(data):
60
+ def pairs(items):
61
+ result = {}
62
+ for key, value in items:
63
+ require(key not in result, "Duplicate JSON key", "integrity")
64
+ result[key] = value
65
+ return result
66
+
67
+ try:
68
+ result = json.loads(
69
+ data,
70
+ object_pairs_hook=pairs,
71
+ parse_constant=lambda _: require(False, "Non-finite JSON", "integrity"),
72
+ )
73
+ canonical(result)
74
+ return result
75
+ except (ValueError, UnicodeError) as error:
76
+ raise GitHubError("validation", "Invalid UTF-8 JSON") from error
77
+
78
+
79
+ def relative(value, *, empty=False):
80
+ require(
81
+ isinstance(value, str)
82
+ and len(value) <= 1024
83
+ and "\\" not in value
84
+ and "\0" not in value
85
+ and ":" not in value,
86
+ "Invalid package path",
87
+ )
88
+ if value == "" and empty:
89
+ return ""
90
+ try:
91
+ value.encode("utf-8")
92
+ except UnicodeError as error:
93
+ raise GitHubError("validation", "Source path must be valid UTF-8") from error
94
+ p = PurePosixPath(value)
95
+ require(
96
+ value
97
+ and not p.is_absolute()
98
+ and all(part not in ("", ".", "..") for part in value.split("/"))
99
+ and str(p) == value,
100
+ "Package path must stay inside its root",
101
+ )
102
+ require(
103
+ len(p.parts) <= 32
104
+ and all(
105
+ len(part.encode("utf-8")) <= 255
106
+ and not part.endswith((".", " "))
107
+ and not any(ord(c) < 32 or ord(c) == 127 for c in part)
108
+ and not any(c in part for c in '<>"|?*')
109
+ and part.split(".")[0].upper()
110
+ not in {
111
+ "CON",
112
+ "PRN",
113
+ "AUX",
114
+ "NUL",
115
+ *(f"COM{i}" for i in range(1, 10)),
116
+ *(f"LPT{i}" for i in range(1, 10)),
117
+ }
118
+ for part in p.parts
119
+ ),
120
+ "Source path is not portable",
121
+ )
122
+ return value
123
+
124
+
125
+ def validate_tree(tree):
126
+ require(
127
+ isinstance(tree, dict)
128
+ and set(tree) == {"schema", "files", "directories"}
129
+ and tree.get("schema") == "github-source/tree-1"
130
+ and isinstance(tree.get("files"), dict)
131
+ and isinstance(tree.get("directories"), list),
132
+ "Invalid source tree",
133
+ "integrity",
134
+ )
135
+ require(
136
+ len(tree["files"]) + len(tree["directories"]) <= MAX_ENTRIES,
137
+ "Too many source entries",
138
+ )
139
+ require(
140
+ all(isinstance(path, str) for path in [*tree["directories"], *tree["files"]]),
141
+ "Source paths must be text",
142
+ "integrity",
143
+ )
144
+ total, folded = 0, set()
145
+ require(
146
+ len(set(tree["directories"])) == len(tree["directories"]),
147
+ "Duplicate source directories",
148
+ )
149
+ all_directories = set(tree["directories"])
150
+ for path in [*tree["directories"], *tree["files"]]:
151
+ relative(path)
152
+ all_directories.update(
153
+ str(parent) for parent in PurePosixPath(path).parents if str(parent) != "."
154
+ )
155
+ require(
156
+ len(all_directories) + len(tree["files"]) <= MAX_ENTRIES,
157
+ "Too many source entries",
158
+ "size",
159
+ )
160
+ for path in [*all_directories, *tree["files"]]:
161
+ relative(path)
162
+ require(
163
+ unicodedata.normalize("NFC", path).casefold() not in folded,
164
+ "Source contains colliding paths",
165
+ )
166
+ folded.add(unicodedata.normalize("NFC", path).casefold())
167
+ for path, row in tree["files"].items():
168
+ require(
169
+ isinstance(row, dict)
170
+ and set(row) == {"content_b64", "executable"}
171
+ and type(row["executable"]) is bool
172
+ and isinstance(row["content_b64"], str),
173
+ "Invalid source file",
174
+ "integrity",
175
+ )
176
+ try:
177
+ data = base64.b64decode(row["content_b64"], validate=True)
178
+ except (ValueError, TypeError) as error:
179
+ raise GitHubError("integrity", "Invalid source file encoding") from error
180
+ total += len(data)
181
+ require(
182
+ len(data) <= MAX_FILE and total <= MAX_TOTAL, "Source exceeds size limits"
183
+ )
184
+ parents = PurePosixPath(path).parents
185
+ require(
186
+ not any(str(p) in tree["files"] for p in parents),
187
+ "Source file is also a directory",
188
+ )
189
+ return {
190
+ **tree,
191
+ "directories": sorted(all_directories),
192
+ "files": dict(sorted(tree["files"].items())),
193
+ }
194
+
195
+
196
+ def subtree(tree, path):
197
+ relative(path, empty=True)
198
+ if not path:
199
+ return tree
200
+ prefix = path + "/"
201
+ return {
202
+ "schema": tree["schema"],
203
+ "files": {
204
+ key[len(prefix) :]: value
205
+ for key, value in tree["files"].items()
206
+ if key.startswith(prefix)
207
+ },
208
+ "directories": [
209
+ key[len(prefix) :] for key in tree["directories"] if key.startswith(prefix)
210
+ ],
211
+ }
@@ -0,0 +1,17 @@
1
+ """Stable acquisition errors; content activation is always the caller's decision."""
2
+
3
+
4
+ class GitHubError(Exception):
5
+ def __init__(self, code, message, *, status=None, retry_after=None):
6
+ super().__init__(message)
7
+ self.code = code
8
+ self.status = status
9
+ self.retry_after = retry_after
10
+
11
+ def to_dict(self):
12
+ result = {"code": self.code, "message": str(self)}
13
+ if self.status is not None:
14
+ result["status"] = self.status
15
+ if self.retry_after is not None:
16
+ result["retry_after"] = self.retry_after
17
+ return result
github_source/py.typed ADDED
File without changes
@@ -0,0 +1,113 @@
1
+ """Safe, bounded in-memory repository archive capture."""
2
+
3
+ import base64
4
+ import gzip
5
+ import io
6
+ import tarfile
7
+ import zlib
8
+
9
+ from .contracts import (
10
+ MAX_DOWNLOAD,
11
+ MAX_ENTRIES,
12
+ MAX_FILE,
13
+ MAX_TOTAL,
14
+ relative,
15
+ require,
16
+ validate_tree,
17
+ )
18
+ from .errors import GitHubError
19
+
20
+
21
+ class ExpandedLimit:
22
+ """Also bound tar metadata/PAX expansion before tarfile allocates it."""
23
+
24
+ def __init__(self, stream):
25
+ self.stream, self.count = stream, 0
26
+
27
+ def read(self, size=-1):
28
+ remaining = MAX_TOTAL + MAX_ENTRIES * 4096 - self.count
29
+ value = self.stream.read(
30
+ min(size, remaining + 1) if size >= 0 else remaining + 1
31
+ )
32
+ self.count += len(value)
33
+ require(
34
+ len(value) <= remaining, "Expanded archive metadata exceeds limits", "size"
35
+ )
36
+ return value
37
+
38
+
39
+ def archive_tree(data, *, check=None):
40
+ files, directories, total, seen, prefix = {}, set(), 0, set(), None
41
+ require(len(data) <= MAX_DOWNLOAD, "Archive exceeds size limit")
42
+ expanded = gzip.GzipFile(fileobj=io.BytesIO(data))
43
+ try:
44
+ with tarfile.open(fileobj=ExpandedLimit(expanded), mode="r|") as archive:
45
+ count = 0
46
+ for entry in archive:
47
+ if check is not None:
48
+ check()
49
+ count += 1
50
+ require(
51
+ count <= MAX_ENTRIES + 1, "Archive has too many entries", "size"
52
+ )
53
+ name = entry.name.rstrip("/")
54
+ relative(name)
55
+ parts = name.split("/", 1)
56
+ prefix = prefix or parts[0]
57
+ require(parts[0] == prefix, "Archive contains several roots")
58
+ if len(parts) == 1:
59
+ require(entry.isdir(), "Archive root must be a directory")
60
+ continue
61
+ path = relative(parts[1])
62
+ require(path.casefold() not in seen, "Archive contains duplicate paths")
63
+ seen.add(path.casefold())
64
+ require(
65
+ entry.isdir() or entry.isfile(),
66
+ "Archive contains a link or special file",
67
+ )
68
+ if entry.isdir():
69
+ directories.add(path)
70
+ else:
71
+ require(
72
+ 0 <= entry.size <= MAX_FILE,
73
+ "Archive file exceeds size limit",
74
+ "size",
75
+ )
76
+ total += entry.size
77
+ require(
78
+ total <= MAX_TOTAL,
79
+ "Expanded archive exceeds size limit",
80
+ "size",
81
+ )
82
+ stream = archive.extractfile(entry)
83
+ require(stream is not None, "Archive file unavailable", "integrity")
84
+ content = stream.read(entry.size + 1)
85
+ require(
86
+ len(content) == entry.size, "Truncated archive", "integrity"
87
+ )
88
+ require(
89
+ not content.startswith(
90
+ b"version https://git-lfs.github.com/spec/v1\n"
91
+ ),
92
+ "Git LFS assets require a complete local checkout; import that local package",
93
+ "unsupported",
94
+ )
95
+ files[path] = {
96
+ "content_b64": base64.b64encode(content).decode("ascii"),
97
+ "executable": bool(entry.mode & 0o111),
98
+ }
99
+ parent = path.rpartition("/")[0]
100
+ while parent:
101
+ directories.add(parent)
102
+ parent = parent.rpartition("/")[0]
103
+ except (tarfile.TarError, EOFError, gzip.BadGzipFile, zlib.error) as error:
104
+ raise GitHubError("integrity", "Invalid repository archive") from error
105
+ finally:
106
+ expanded.close()
107
+ return validate_tree(
108
+ {
109
+ "schema": "github-source/tree-1",
110
+ "files": files,
111
+ "directories": sorted(directories),
112
+ }
113
+ )
github_source/types.py ADDED
@@ -0,0 +1,40 @@
1
+ """Versioned JSON contracts. Paths in a selection remain repository-relative."""
2
+
3
+ from typing import Literal, NotRequired, TypedDict
4
+
5
+
6
+ class ResolvedSource(TypedDict):
7
+ schema: str
8
+ kind: Literal["github"]
9
+ repository_id: str
10
+ repository: str
11
+ url: str
12
+ ref: str
13
+ path: str
14
+ commit: str
15
+ reference_kind: Literal["branch", "tag", "commit"]
16
+ tree_sha: NotRequired[str]
17
+
18
+
19
+ class FileEntry(TypedDict):
20
+ content_b64: str
21
+ executable: bool
22
+
23
+
24
+ class SourceTree(TypedDict):
25
+ schema: str
26
+ files: dict[str, FileEntry]
27
+ directories: list[str]
28
+
29
+
30
+ class Snapshot(TypedDict):
31
+ schema: str
32
+ source: ResolvedSource
33
+ tree: SourceTree
34
+ digest: str
35
+
36
+
37
+ class Comparison(TypedDict):
38
+ before: str
39
+ after: str
40
+ relationship: Literal["ahead", "identical", "behind", "diverged"]
github_source/urls.py ADDED
@@ -0,0 +1,67 @@
1
+ """Public GitHub URLs and restricted HTTPS redirects."""
2
+
3
+ import re
4
+ from urllib.parse import unquote, urlsplit
5
+ from urllib.request import HTTPRedirectHandler
6
+
7
+ from .contracts import require
8
+ from .errors import GitHubError
9
+
10
+ COMMIT = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})")
11
+
12
+ REPO_PART = re.compile(r"[A-Za-z0-9_.-]{1,100}")
13
+
14
+
15
+ class Redirects(HTTPRedirectHandler):
16
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
17
+ parts = urlsplit(newurl)
18
+ require(
19
+ parts.scheme == "https"
20
+ and parts.hostname
21
+ in {
22
+ "api.github.com",
23
+ "codeload.github.com",
24
+ "github.com",
25
+ "raw.githubusercontent.com",
26
+ }
27
+ and parts.port in (None, 443)
28
+ and not parts.username,
29
+ "GitHub redirected outside the supported hosts",
30
+ "network",
31
+ )
32
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
33
+
34
+
35
+ def parse_url(url):
36
+ require(isinstance(url, str) and len(url) <= 2048, "Invalid GitHub URL")
37
+ require(not any(ord(c) < 32 or ord(c) == 127 for c in url), "Invalid GitHub URL")
38
+ try:
39
+ parts = urlsplit(url)
40
+ parts.port
41
+ except ValueError as error:
42
+ raise GitHubError("validation", "Invalid GitHub URL") from error
43
+ require(
44
+ parts.scheme == "https"
45
+ and parts.hostname == "github.com"
46
+ and parts.port in (None, 443)
47
+ and not parts.username
48
+ and not parts.password
49
+ and not parts.query
50
+ and not parts.fragment,
51
+ "Use an HTTPS github.com repository or file URL",
52
+ )
53
+ path = unquote(parts.path).strip("/").split("/")
54
+ require(
55
+ len(path) >= 2
56
+ and all(REPO_PART.fullmatch(p) and p not in (".", "..") for p in path[:2]),
57
+ "Invalid GitHub repository",
58
+ )
59
+ owner, repo = path[:2]
60
+ repo = repo.removesuffix(".git")
61
+ require(repo and repo not in (".", ".."), "Invalid GitHub repository")
62
+ tail = path[2:]
63
+ require(
64
+ not tail or (tail[0] in ("tree", "blob") and len(tail) >= 2),
65
+ "Use a repository, tree or blob URL",
66
+ )
67
+ return owner, repo, tail[1:] if tail else []