macrostates-cli 0.3.1__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.
- macrostates/__init__.py +23 -0
- macrostates/__main__.py +4 -0
- macrostates/_build_version.py +56 -0
- macrostates/_cli.py +158 -0
- macrostates/_formats/__init__.py +42 -0
- macrostates/_formats/common.py +102 -0
- macrostates/_formats/v0.py +11 -0
- macrostates/_formats/v1.py +44 -0
- macrostates/_integrity.py +144 -0
- macrostates/_io.py +118 -0
- macrostates/_models.py +71 -0
- macrostates/_mutations.py +53 -0
- macrostates/_project.py +527 -0
- macrostates/_scopes.py +34 -0
- macrostates/_sources.py +153 -0
- macrostates/_staged.py +56 -0
- macrostates/_validation.py +274 -0
- macrostates/py.typed +0 -0
- macrostates_cli-0.3.1.dist-info/METADATA +259 -0
- macrostates_cli-0.3.1.dist-info/RECORD +24 -0
- macrostates_cli-0.3.1.dist-info/WHEEL +5 -0
- macrostates_cli-0.3.1.dist-info/entry_points.txt +2 -0
- macrostates_cli-0.3.1.dist-info/licenses/LICENSE +21 -0
- macrostates_cli-0.3.1.dist-info/top_level.txt +1 -0
macrostates/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""The public API. Imports perform no network or project-local file access."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from ._models import Diagnostic, MacrostatesError, Report
|
|
6
|
+
from ._project import Project
|
|
7
|
+
from ._sources import GitHubSource, SourceProvider
|
|
8
|
+
|
|
9
|
+
__all__ = ["Diagnostic", "GitHubSource", "MacrostatesError", "Project", "Report", "SourceProvider"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def __getattr__(name: str) -> str:
|
|
13
|
+
if name != "__version__":
|
|
14
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
15
|
+
# Read distribution metadata only when a caller explicitly requests a version.
|
|
16
|
+
try:
|
|
17
|
+
return version("macrostates-cli")
|
|
18
|
+
except PackageNotFoundError:
|
|
19
|
+
return "0+uninstalled"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def __dir__() -> list[str]:
|
|
23
|
+
return sorted([*globals(), "__version__"])
|
macrostates/__main__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Setuptools version source, evaluated only during a distribution build."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Hashable
|
|
5
|
+
from datetime import date
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DeclarationLoader(yaml.BaseLoader):
|
|
13
|
+
"""Build isolation cannot import the application; keep this reader standalone."""
|
|
14
|
+
|
|
15
|
+
def construct_mapping(self, node: Any, deep: bool = False) -> dict[Hashable, Any]:
|
|
16
|
+
result: dict[Hashable, Any] = {}
|
|
17
|
+
for key_node, value_node in node.value:
|
|
18
|
+
key = self.construct_object(key_node, deep=deep)
|
|
19
|
+
if not isinstance(key, str) or key in result:
|
|
20
|
+
raise ValueError("Invalid or duplicate build declaration key")
|
|
21
|
+
result[key] = self.construct_object(value_node, deep=deep)
|
|
22
|
+
return result
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def numeric_version(value: Any) -> tuple[int, int, int]:
|
|
26
|
+
if not isinstance(value, str) or not re.fullmatch(
|
|
27
|
+
r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", value
|
|
28
|
+
):
|
|
29
|
+
raise ValueError("Build version must be MAJOR.MINOR.REVISION")
|
|
30
|
+
major, minor, revision = value.split(".")
|
|
31
|
+
return int(major), int(minor), int(revision)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def release_version() -> str:
|
|
35
|
+
declaration = Path(__file__).parents[2] / ".macrostates/implementation/release.yaml"
|
|
36
|
+
if declaration.is_symlink():
|
|
37
|
+
raise ValueError("Build declaration must not be a symbolic link")
|
|
38
|
+
data = yaml.load(declaration.read_text(encoding="utf-8"), Loader=DeclarationLoader)
|
|
39
|
+
if not isinstance(data, dict):
|
|
40
|
+
raise ValueError("Build declaration must be a mapping")
|
|
41
|
+
selected = data.get("version")
|
|
42
|
+
current = numeric_version(selected)
|
|
43
|
+
baseline = data.get("specification")
|
|
44
|
+
if not isinstance(baseline, str) or not baseline.startswith("spec-"):
|
|
45
|
+
raise ValueError("Build declaration needs specification: spec-MAJOR.MINOR.REVISION")
|
|
46
|
+
if current[:2] != numeric_version(baseline.removeprefix("spec-"))[:2]:
|
|
47
|
+
raise ValueError("Build declaration Major.Minor is not aligned")
|
|
48
|
+
stamp = data.get("release_date")
|
|
49
|
+
if not isinstance(stamp, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", stamp):
|
|
50
|
+
raise ValueError("Build declaration needs release_date: YYYY-MM-DD")
|
|
51
|
+
date.fromisoformat(stamp)
|
|
52
|
+
assert isinstance(selected, str)
|
|
53
|
+
return selected
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
VERSION = release_version()
|
macrostates/_cli.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Thin argparse interface over the public project API."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from dataclasses import asdict
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from . import Project, Report, __version__
|
|
13
|
+
from ._models import MacrostatesError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parser() -> argparse.ArgumentParser:
|
|
17
|
+
result = argparse.ArgumentParser(
|
|
18
|
+
prog="macrostates", description="Compose, install and check Macrostates specifications."
|
|
19
|
+
)
|
|
20
|
+
result.add_argument("--version", action="version", version=f"macrostates {__version__}")
|
|
21
|
+
result.add_argument(
|
|
22
|
+
"--project", default=".", help="Project directory (default: current directory)"
|
|
23
|
+
)
|
|
24
|
+
result.add_argument("--json", action="store_true", help="Output structured JSON")
|
|
25
|
+
commands = result.add_subparsers(dest="command", required=True)
|
|
26
|
+
for command, description in (
|
|
27
|
+
("init", "Prepare project specification files"),
|
|
28
|
+
("install", "Install selected archive packages"),
|
|
29
|
+
("lock", "Verify existing copies against source and record integrity"),
|
|
30
|
+
("verify", "Check package integrity offline"),
|
|
31
|
+
("lint", "Check specification structure offline"),
|
|
32
|
+
("info", "Show composition and package dependencies"),
|
|
33
|
+
("check", "Run lint and integrity verification"),
|
|
34
|
+
):
|
|
35
|
+
sub = commands.add_parser(command, help=description)
|
|
36
|
+
sub.add_argument("--project", default=argparse.SUPPRESS)
|
|
37
|
+
sub.add_argument("--json", action="store_true", default=argparse.SUPPRESS)
|
|
38
|
+
if command == "init":
|
|
39
|
+
sub.add_argument("--name", help="Project name when selecting packages")
|
|
40
|
+
sub.add_argument(
|
|
41
|
+
"--package",
|
|
42
|
+
action="append",
|
|
43
|
+
default=[],
|
|
44
|
+
metavar="NAME@VERSION",
|
|
45
|
+
help="Explicit package selection, repeatable",
|
|
46
|
+
)
|
|
47
|
+
sub.add_argument(
|
|
48
|
+
"--from",
|
|
49
|
+
dest="manifest",
|
|
50
|
+
type=Path,
|
|
51
|
+
help="Copy a supplied composition, preserving extension fields",
|
|
52
|
+
)
|
|
53
|
+
sub.add_argument("--layout", choices=["modern", "legacy"], default="modern")
|
|
54
|
+
elif command == "install":
|
|
55
|
+
sub.add_argument(
|
|
56
|
+
"--locked", action="store_true", help="Require existing, unchanged lock selections"
|
|
57
|
+
)
|
|
58
|
+
elif command == "check":
|
|
59
|
+
sub.add_argument(
|
|
60
|
+
"--staged", action="store_true", help="Check the Git index instead of working files"
|
|
61
|
+
)
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def display(payload: dict[str, Any], *, json_output: bool) -> None:
|
|
66
|
+
if json_output:
|
|
67
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
68
|
+
return
|
|
69
|
+
if "diagnostics" in payload:
|
|
70
|
+
for diagnostic in payload["diagnostics"]:
|
|
71
|
+
path = f" ({diagnostic['path']})" if diagnostic["path"] else ""
|
|
72
|
+
print(f"{diagnostic['severity']}: [{diagnostic['code']}] {diagnostic['message']}{path}")
|
|
73
|
+
print("Checks passed." if payload["ok"] else "Checks failed.")
|
|
74
|
+
elif "packages" in payload:
|
|
75
|
+
print(
|
|
76
|
+
f"Project: {payload['project']}\nSpecifications: {payload['specs']}\nFormat: {payload['schema_version']}"
|
|
77
|
+
)
|
|
78
|
+
for package in payload["packages"]:
|
|
79
|
+
source = package["source"]
|
|
80
|
+
print(
|
|
81
|
+
f" {package['name']} {package['version']} — {package['path']} ({source.get('type', 'unspecified')})"
|
|
82
|
+
)
|
|
83
|
+
if source.get("repository"):
|
|
84
|
+
print(f" {source['repository']} @ {source['tag']}")
|
|
85
|
+
for dependency in package.get("dependencies", []):
|
|
86
|
+
print(
|
|
87
|
+
f" requires {dependency['name']} {dependency['constraint']} {dependency['version']}"
|
|
88
|
+
)
|
|
89
|
+
print("Reading order: " + " → ".join(payload["reading_order"]))
|
|
90
|
+
print("Authority (highest first): " + " → ".join(payload["authority_order"]))
|
|
91
|
+
for scope in payload.get("directory_specifications", []):
|
|
92
|
+
print(f"Directory scope: {scope['scope']} — {scope['entrypoint']}")
|
|
93
|
+
else:
|
|
94
|
+
print(payload.get("message", "Done."))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
98
|
+
arguments = parser().parse_args(argv)
|
|
99
|
+
logging.basicConfig(level=logging.WARNING)
|
|
100
|
+
try:
|
|
101
|
+
if arguments.command == "init":
|
|
102
|
+
packages = []
|
|
103
|
+
for selection in arguments.package:
|
|
104
|
+
parts = selection.rsplit("@", 1)
|
|
105
|
+
if len(parts) != 2:
|
|
106
|
+
raise MacrostatesError("Package selections must be NAME@VERSION")
|
|
107
|
+
packages.append((parts[0], parts[1]))
|
|
108
|
+
project = Project.initialize(
|
|
109
|
+
arguments.project,
|
|
110
|
+
name=arguments.name,
|
|
111
|
+
packages=packages,
|
|
112
|
+
manifest=arguments.manifest,
|
|
113
|
+
layout=arguments.layout,
|
|
114
|
+
)
|
|
115
|
+
display(
|
|
116
|
+
{
|
|
117
|
+
"ok": True,
|
|
118
|
+
"message": f"Initialized {project.specs.relative_to(project.root).as_posix()}; review authority and reading order, then run macrostates install.",
|
|
119
|
+
},
|
|
120
|
+
json_output=arguments.json,
|
|
121
|
+
)
|
|
122
|
+
return 0
|
|
123
|
+
project = Project.open(arguments.project)
|
|
124
|
+
if arguments.command == "info":
|
|
125
|
+
display(project.info(), json_output=arguments.json)
|
|
126
|
+
return 0
|
|
127
|
+
report: Report
|
|
128
|
+
match arguments.command:
|
|
129
|
+
case "install":
|
|
130
|
+
report = project.install(locked=arguments.locked)
|
|
131
|
+
case "lock":
|
|
132
|
+
report = project.lock()
|
|
133
|
+
case "verify":
|
|
134
|
+
report = project.verify()
|
|
135
|
+
case "lint":
|
|
136
|
+
report = project.lint()
|
|
137
|
+
case "check":
|
|
138
|
+
report = project.check(staged=arguments.staged)
|
|
139
|
+
case _:
|
|
140
|
+
raise MacrostatesError("Unknown command")
|
|
141
|
+
display(
|
|
142
|
+
{"ok": report.ok, "diagnostics": [asdict(item) for item in report.diagnostics]},
|
|
143
|
+
json_output=arguments.json,
|
|
144
|
+
)
|
|
145
|
+
return 0 if report.ok else 1
|
|
146
|
+
except (MacrostatesError, OSError, UnicodeError) as exc:
|
|
147
|
+
if arguments.json:
|
|
148
|
+
print(
|
|
149
|
+
json.dumps(
|
|
150
|
+
{"ok": False, "error": {"code": "operation.invalid", "message": str(exc)}}
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
else:
|
|
154
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
155
|
+
return 2
|
|
156
|
+
except KeyboardInterrupt:
|
|
157
|
+
print("Operation interrupted.", file=sys.stderr)
|
|
158
|
+
return 130
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Explicit format registry. New formats add readers without changing old readers."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Protocol
|
|
4
|
+
|
|
5
|
+
from .._models import Composition, MacrostatesError, Metadata
|
|
6
|
+
from .v0 import LegacyComposition
|
|
7
|
+
from .v1 import CompositionV1, MetadataV1
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CompositionReader(Protocol):
|
|
11
|
+
def read(self, data: dict[str, Any]) -> Composition: ...
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MetadataReader(Protocol):
|
|
15
|
+
def read(self, data: dict[str, Any]) -> Metadata: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
COMPOSITION_READERS: dict[int, CompositionReader] = {0: LegacyComposition(), 1: CompositionV1()}
|
|
19
|
+
METADATA_READERS: dict[int, MetadataReader] = {1: MetadataV1()}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def select_version(data: dict[str, Any], default: int) -> int:
|
|
23
|
+
selected = data.get("schema_version", default)
|
|
24
|
+
if type(selected) is not int:
|
|
25
|
+
raise MacrostatesError("schema_version must be an integer")
|
|
26
|
+
return selected
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def read_composition(data: dict[str, Any]) -> Composition:
|
|
30
|
+
selected = select_version(data, 0)
|
|
31
|
+
reader = COMPOSITION_READERS.get(selected)
|
|
32
|
+
if reader is None:
|
|
33
|
+
raise MacrostatesError(f"Unsupported composition schema_version: {selected}")
|
|
34
|
+
return reader.read(data)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read_metadata(data: dict[str, Any]) -> Metadata:
|
|
38
|
+
selected = select_version(data, 1)
|
|
39
|
+
reader = METADATA_READERS.get(selected)
|
|
40
|
+
if reader is None:
|
|
41
|
+
raise MacrostatesError(f"Unsupported package metadata schema_version: {selected}")
|
|
42
|
+
return reader.read(data)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Format-1 field primitives; compatibility changes require new adapter modules."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .._io import mapping, relative_path, string, version
|
|
7
|
+
from .._models import Composition, Dependency, MacrostatesError, Package
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def dependencies(raw: Any, label: str) -> tuple[Dependency, ...]:
|
|
11
|
+
if not isinstance(raw, list):
|
|
12
|
+
raise MacrostatesError(f"{label} must be a list")
|
|
13
|
+
result = []
|
|
14
|
+
for item in raw:
|
|
15
|
+
item = mapping(item, label)
|
|
16
|
+
name = string(item.get("name"), "dependency name")
|
|
17
|
+
selected = string(item.get("version"), "dependency version")
|
|
18
|
+
version(selected)
|
|
19
|
+
constraint = string(item.get("constraint"), "dependency constraint")
|
|
20
|
+
if constraint not in ("exact", "compatible", "at_least"):
|
|
21
|
+
raise MacrostatesError(f"Unsupported dependency constraint: {constraint}")
|
|
22
|
+
if name in {dependency.name for dependency in result}:
|
|
23
|
+
raise MacrostatesError(f"Duplicate dependency: {name}")
|
|
24
|
+
result.append(Dependency(name, selected, constraint))
|
|
25
|
+
return tuple(result)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def composition_fields(data: dict[str, Any], schema: int) -> Composition:
|
|
29
|
+
project = mapping(data.get("project"), "project")
|
|
30
|
+
name = string(project.get("name"), "project.name")
|
|
31
|
+
entrypoint = relative_path(project.get("entrypoint", "main.md"), "project.entrypoint")
|
|
32
|
+
selected = data.get("packages")
|
|
33
|
+
if not isinstance(selected, list) or not selected:
|
|
34
|
+
raise MacrostatesError("packages must be a nonempty list")
|
|
35
|
+
packages: list[Package] = []
|
|
36
|
+
for item in selected:
|
|
37
|
+
item = mapping(item, "package selection")
|
|
38
|
+
package_name = string(item.get("name"), "package name")
|
|
39
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", package_name):
|
|
40
|
+
raise MacrostatesError("Package names must be portable identifiers")
|
|
41
|
+
selected_version = string(item.get("version"), "package version")
|
|
42
|
+
version(selected_version)
|
|
43
|
+
path = relative_path(item.get("path"), "package path")
|
|
44
|
+
entry = relative_path(item.get("entrypoint", "README.md"), "package entrypoint")
|
|
45
|
+
if package_name in {package.name for package in packages}:
|
|
46
|
+
raise MacrostatesError(f"Duplicate package name: {package_name}")
|
|
47
|
+
for package in packages:
|
|
48
|
+
left, right = package.path.casefold(), path.casefold()
|
|
49
|
+
if left == right or left.startswith(right + "/") or right.startswith(left + "/"):
|
|
50
|
+
raise MacrostatesError("Package destinations overlap or collide by case")
|
|
51
|
+
source = mapping(item.get("source", {}), "source")
|
|
52
|
+
if any(
|
|
53
|
+
key.casefold()
|
|
54
|
+
in {"token", "password", "credentials", "authorization", "access_token", "github_token"}
|
|
55
|
+
for key in source
|
|
56
|
+
):
|
|
57
|
+
raise MacrostatesError(
|
|
58
|
+
"Source credentials belong in explicit authentication, not composition metadata"
|
|
59
|
+
)
|
|
60
|
+
if source.get("type") not in (None, "local", "github-archive", "git-subtree"):
|
|
61
|
+
raise MacrostatesError("Unsupported package source type")
|
|
62
|
+
if source.get("type") in ("github-archive", "git-subtree"):
|
|
63
|
+
string(source.get("repository"), "source.repository")
|
|
64
|
+
if source.get("tag") != "v" + selected_version:
|
|
65
|
+
raise MacrostatesError("source.tag must equal v<selected package version>")
|
|
66
|
+
if "commit" in source:
|
|
67
|
+
raise MacrostatesError("Legacy source.commit needs an explicit tag migration")
|
|
68
|
+
if source["type"] == "git-subtree":
|
|
69
|
+
string(source.get("branch"), "source.branch")
|
|
70
|
+
relative_path(source.get("prefix"), "source.prefix")
|
|
71
|
+
legacy = {
|
|
72
|
+
key: item[key] for key in ("dependencies", "optional_dependencies") if key in item
|
|
73
|
+
}
|
|
74
|
+
packages.append(Package(package_name, selected_version, path, entry, source, legacy))
|
|
75
|
+
|
|
76
|
+
def order(key: str) -> tuple[str, ...]:
|
|
77
|
+
values = data.get(key, [])
|
|
78
|
+
if not isinstance(values, list):
|
|
79
|
+
raise MacrostatesError(f"{key} must be a list")
|
|
80
|
+
references = {
|
|
81
|
+
reference: package.name
|
|
82
|
+
for package in packages
|
|
83
|
+
for reference in (package.name, package.path + "/" + package.entrypoint)
|
|
84
|
+
}
|
|
85
|
+
result = []
|
|
86
|
+
for value in values:
|
|
87
|
+
if not isinstance(value, str) or value not in references:
|
|
88
|
+
raise MacrostatesError(f"{key} references an unselected package or entrypoint")
|
|
89
|
+
if references[value] in result:
|
|
90
|
+
raise MacrostatesError(f"{key} contains a duplicate package")
|
|
91
|
+
result.append(references[value])
|
|
92
|
+
return tuple(result)
|
|
93
|
+
|
|
94
|
+
return Composition(
|
|
95
|
+
schema,
|
|
96
|
+
name,
|
|
97
|
+
entrypoint,
|
|
98
|
+
tuple(packages),
|
|
99
|
+
order("authority_order"),
|
|
100
|
+
order("reading_order"),
|
|
101
|
+
data,
|
|
102
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Reader for existing unversioned Meta compositions. Never writes a migration."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .._models import Composition
|
|
6
|
+
from .common import composition_fields
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LegacyComposition:
|
|
10
|
+
def read(self, data: dict[str, Any]) -> Composition:
|
|
11
|
+
return composition_fields(data, 0)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Version 1 archive composition and existing package metadata readers."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .._io import relative_path, string, version
|
|
8
|
+
from .._models import Composition, MacrostatesError, Metadata
|
|
9
|
+
from .common import composition_fields, dependencies
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CompositionV1:
|
|
13
|
+
def read(self, data: dict[str, Any]) -> Composition:
|
|
14
|
+
result = composition_fields(data, 1)
|
|
15
|
+
names = {package.name for package in result.packages}
|
|
16
|
+
for field in (result.authority_order, result.reading_order):
|
|
17
|
+
if set(field) != names:
|
|
18
|
+
raise MacrostatesError(
|
|
19
|
+
"Format 1 requires complete authority_order and reading_order"
|
|
20
|
+
)
|
|
21
|
+
return result
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MetadataV1:
|
|
25
|
+
def read(self, data: dict[str, Any]) -> Metadata:
|
|
26
|
+
name = string(data.get("name"), "package.name")
|
|
27
|
+
selected = string(data.get("version"), "package.version")
|
|
28
|
+
version(selected)
|
|
29
|
+
string(data.get("description"), "package.description")
|
|
30
|
+
stamp = string(data.get("versioned_at"), "package.versioned_at")
|
|
31
|
+
if not re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}", stamp):
|
|
32
|
+
raise MacrostatesError("versioned_at must be YYYY-MM-DD HH:mm")
|
|
33
|
+
try:
|
|
34
|
+
datetime.strptime(stamp, "%Y-%m-%d %H:%M")
|
|
35
|
+
except ValueError as exc:
|
|
36
|
+
raise MacrostatesError("versioned_at must be YYYY-MM-DD HH:mm") from exc
|
|
37
|
+
entry = relative_path(data.get("entrypoint"), "package.entrypoint")
|
|
38
|
+
return Metadata(
|
|
39
|
+
name,
|
|
40
|
+
selected,
|
|
41
|
+
entry,
|
|
42
|
+
dependencies(data.get("dependencies", []), "dependencies"),
|
|
43
|
+
dependencies(data.get("optional_dependencies", []), "optional_dependencies"),
|
|
44
|
+
)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Content inventory and bounded extraction of GitHub source archives."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import io
|
|
5
|
+
import re
|
|
6
|
+
import tarfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ._io import canonical, mapping, relative_path
|
|
11
|
+
from ._models import MacrostatesError, Package
|
|
12
|
+
|
|
13
|
+
MAX_ARCHIVE = 32 * 1024 * 1024
|
|
14
|
+
MAX_EXTRACTED = 128 * 1024 * 1024
|
|
15
|
+
MAX_FILES = 10000
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def extract_archive(data: bytes, target: Path) -> None:
|
|
19
|
+
if len(data) > MAX_ARCHIVE:
|
|
20
|
+
raise MacrostatesError("Compressed archive exceeds 32 MiB")
|
|
21
|
+
try:
|
|
22
|
+
archive = tarfile.open(fileobj=io.BytesIO(data), mode="r:*")
|
|
23
|
+
except tarfile.TarError as exc:
|
|
24
|
+
raise MacrostatesError("Source is not a valid tar archive") from exc
|
|
25
|
+
with archive:
|
|
26
|
+
seen: set[str] = set()
|
|
27
|
+
root: str | None = None
|
|
28
|
+
total = 0
|
|
29
|
+
plan: list[tuple[tarfile.TarInfo, str]] = []
|
|
30
|
+
for member in archive:
|
|
31
|
+
if len(seen) >= MAX_FILES:
|
|
32
|
+
raise MacrostatesError("Archive exceeds 10000 entries")
|
|
33
|
+
name = relative_path(member.name.rstrip("/"), "archive member")
|
|
34
|
+
parts = name.split("/")
|
|
35
|
+
if root is None:
|
|
36
|
+
root = parts[0]
|
|
37
|
+
if parts[0] != root:
|
|
38
|
+
raise MacrostatesError("Archive must have exactly one root directory")
|
|
39
|
+
folded = name.casefold()
|
|
40
|
+
if folded in seen:
|
|
41
|
+
raise MacrostatesError("Archive has duplicate or case-colliding members")
|
|
42
|
+
seen.add(folded)
|
|
43
|
+
if not (member.isfile() or member.isdir()):
|
|
44
|
+
raise MacrostatesError("Archive links and special files are unsupported")
|
|
45
|
+
if len(parts) == 1:
|
|
46
|
+
if not member.isdir():
|
|
47
|
+
raise MacrostatesError("Archive files must live below its root directory")
|
|
48
|
+
continue
|
|
49
|
+
if member.size < 0:
|
|
50
|
+
raise MacrostatesError("Archive has an invalid member size")
|
|
51
|
+
total += member.size
|
|
52
|
+
if total > MAX_EXTRACTED:
|
|
53
|
+
raise MacrostatesError("Extracted archive exceeds 128 MiB")
|
|
54
|
+
plan.append((member, "/".join(parts[1:])))
|
|
55
|
+
if not plan:
|
|
56
|
+
raise MacrostatesError("Archive contains no package files")
|
|
57
|
+
try:
|
|
58
|
+
for member, relative in plan:
|
|
59
|
+
destination = target / relative
|
|
60
|
+
if member.isdir():
|
|
61
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
else:
|
|
63
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
stream = archive.extractfile(member)
|
|
65
|
+
if stream is None:
|
|
66
|
+
raise MacrostatesError("Archive member could not be read")
|
|
67
|
+
with stream, destination.open("xb") as output:
|
|
68
|
+
output.write(stream.read(member.size + 1))
|
|
69
|
+
if destination.stat().st_size != member.size:
|
|
70
|
+
raise MacrostatesError("Truncated archive member")
|
|
71
|
+
destination.chmod(0o755 if member.mode & 0o111 else 0o644)
|
|
72
|
+
except (OSError, tarfile.TarError) as exc:
|
|
73
|
+
raise MacrostatesError("Archive has conflicting or invalid file entries") from exc
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def inventory(root: Path) -> dict[str, dict[str, Any]]:
|
|
77
|
+
if root.is_symlink() or not root.is_dir():
|
|
78
|
+
raise MacrostatesError("Package directory is missing or is a symbolic link")
|
|
79
|
+
result: dict[str, dict[str, Any]] = {}
|
|
80
|
+
seen: set[str] = set()
|
|
81
|
+
for path in sorted(root.rglob("*")):
|
|
82
|
+
if path.is_symlink():
|
|
83
|
+
raise MacrostatesError("Package contains a symbolic link")
|
|
84
|
+
relative = relative_path(path.relative_to(root).as_posix(), "package file")
|
|
85
|
+
if relative.casefold() in seen:
|
|
86
|
+
raise MacrostatesError("Package contains case-colliding paths")
|
|
87
|
+
seen.add(relative.casefold())
|
|
88
|
+
if path.is_dir():
|
|
89
|
+
continue
|
|
90
|
+
if not path.is_file():
|
|
91
|
+
raise MacrostatesError("Package contains a special file")
|
|
92
|
+
digest = hashlib.sha256()
|
|
93
|
+
with path.open("rb") as stream:
|
|
94
|
+
for chunk in iter(lambda: stream.read(65536), b""):
|
|
95
|
+
digest.update(chunk)
|
|
96
|
+
result[relative] = {
|
|
97
|
+
"sha256": digest.hexdigest(),
|
|
98
|
+
"executable": bool(path.stat().st_mode & 0o111),
|
|
99
|
+
}
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def content_hash(files: dict[str, Any]) -> str:
|
|
104
|
+
return hashlib.sha256(canonical(files)).hexdigest()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def binding(package: Package) -> dict[str, Any]:
|
|
108
|
+
return {
|
|
109
|
+
"name": package.name,
|
|
110
|
+
"version": package.version,
|
|
111
|
+
"path": package.path,
|
|
112
|
+
"entrypoint": package.entrypoint,
|
|
113
|
+
"source": package.source,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def parse_lock(data: dict[str, Any]) -> dict[str, Any]:
|
|
118
|
+
if type(data.get("schema_version")) is not int or data["schema_version"] != 1:
|
|
119
|
+
raise MacrostatesError("Unsupported lock schema_version; supported: 1")
|
|
120
|
+
packages = mapping(data.get("packages"), "lock.packages")
|
|
121
|
+
for name, raw in packages.items():
|
|
122
|
+
record = mapping(raw, "locked package")
|
|
123
|
+
mapping(record.get("selection"), "lock selection")
|
|
124
|
+
if not isinstance(record.get("commit"), str) or not re.fullmatch(
|
|
125
|
+
r"[0-9a-f]{40}|[0-9a-f]{64}", record["commit"]
|
|
126
|
+
):
|
|
127
|
+
raise MacrostatesError("Lock commit must be a full Git object ID")
|
|
128
|
+
files = mapping(record.get("files"), "lock files")
|
|
129
|
+
seen: set[str] = set()
|
|
130
|
+
for path, value in files.items():
|
|
131
|
+
relative_path(path, "locked file")
|
|
132
|
+
if path.casefold() in seen:
|
|
133
|
+
raise MacrostatesError("Lock has case-colliding file paths")
|
|
134
|
+
seen.add(path.casefold())
|
|
135
|
+
item = mapping(value, "locked file")
|
|
136
|
+
if not isinstance(item.get("sha256"), str) or not re.fullmatch(
|
|
137
|
+
r"[0-9a-f]{64}", item["sha256"]
|
|
138
|
+
):
|
|
139
|
+
raise MacrostatesError("Invalid locked file checksum")
|
|
140
|
+
if type(item.get("executable")) is not bool:
|
|
141
|
+
raise MacrostatesError("Locked executable flag must be boolean")
|
|
142
|
+
if record.get("content_sha256") != content_hash(files):
|
|
143
|
+
raise MacrostatesError(f"Inconsistent lock checksum for {name}")
|
|
144
|
+
return packages
|