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.
- aixand_lib_github_source-0.1.0b0.dist-info/METADATA +134 -0
- aixand_lib_github_source-0.1.0b0.dist-info/RECORD +13 -0
- aixand_lib_github_source-0.1.0b0.dist-info/WHEEL +5 -0
- aixand_lib_github_source-0.1.0b0.dist-info/licenses/LICENSE +202 -0
- aixand_lib_github_source-0.1.0b0.dist-info/top_level.txt +1 -0
- github_source/__init__.py +17 -0
- github_source/client.py +553 -0
- github_source/contracts.py +211 -0
- github_source/errors.py +17 -0
- github_source/py.typed +0 -0
- github_source/snapshots.py +113 -0
- github_source/types.py +40 -0
- github_source/urls.py +67 -0
|
@@ -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
|
+
}
|
github_source/errors.py
ADDED
|
@@ -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 []
|