aas-submodel-validate 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.
Files changed (38) hide show
  1. aas_submodel_validate/__init__.py +4 -0
  2. aas_submodel_validate/__main__.py +5 -0
  3. aas_submodel_validate/cli.py +85 -0
  4. aas_submodel_validate/container.py +576 -0
  5. aas_submodel_validate/data/smt/02003/2.0.1/sha256sums.txt +1 -0
  6. aas_submodel_validate/data/smt/02003/2.0.1/template.json +4546 -0
  7. aas_submodel_validate/data/smt/02004/2.0.1/sha256sums.txt +1 -0
  8. aas_submodel_validate/data/smt/02004/2.0.1/template.json +4235 -0
  9. aas_submodel_validate/data/smt/02035-2/1.0/sha256sums.txt +1 -0
  10. aas_submodel_validate/data/smt/02035-2/1.0/template.json +2991 -0
  11. aas_submodel_validate/loader.py +337 -0
  12. aas_submodel_validate/model.py +223 -0
  13. aas_submodel_validate/registry.py +48 -0
  14. aas_submodel_validate/report.py +71 -0
  15. aas_submodel_validate/rules/__init__.py +2 -0
  16. aas_submodel_validate/rules/battery.py +242 -0
  17. aas_submodel_validate/rules/battery_tables.py +163 -0
  18. aas_submodel_validate/rules/container.py +157 -0
  19. aas_submodel_validate/rules/dbp.py +50 -0
  20. aas_submodel_validate/rules/dbp_tables.py +386 -0
  21. aas_submodel_validate/rules/detect.py +118 -0
  22. aas_submodel_validate/rules/engine.py +324 -0
  23. aas_submodel_validate/rules/handover.py +512 -0
  24. aas_submodel_validate/rules/hd.py +53 -0
  25. aas_submodel_validate/rules/hd_tables.py +647 -0
  26. aas_submodel_validate/rules/profiles.py +276 -0
  27. aas_submodel_validate/rules/td.py +152 -0
  28. aas_submodel_validate/rules/td_tables.py +448 -0
  29. aas_submodel_validate/rules/values.py +62 -0
  30. aas_submodel_validate/runner.py +210 -0
  31. aas_submodel_validate/semantics.py +132 -0
  32. aas_submodel_validate-0.1.0.dist-info/METADATA +176 -0
  33. aas_submodel_validate-0.1.0.dist-info/RECORD +38 -0
  34. aas_submodel_validate-0.1.0.dist-info/WHEEL +5 -0
  35. aas_submodel_validate-0.1.0.dist-info/entry_points.txt +3 -0
  36. aas_submodel_validate-0.1.0.dist-info/licenses/LICENSE +202 -0
  37. aas_submodel_validate-0.1.0.dist-info/licenses/NOTICE +19 -0
  38. aas_submodel_validate-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,4 @@
1
+ """Offline conformance validation of AAS submodel template instances."""
2
+ from __future__ import annotations
3
+
4
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,85 @@
1
+ """The command line. Exit codes are the API a build pipeline calls:
2
+ 0 clean, 1 findings at error severity, 2 could not run -- which covers a
3
+ path that cannot be read and an input this reader refused, because
4
+ nothing about either was judged. A report may still be printed on 2,
5
+ saying what was refused and what to do about it."""
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import sys
11
+ from typing import Optional
12
+
13
+ from . import __version__, runner
14
+ from .loader import UnreadablePath
15
+ from .report import render
16
+
17
+ EXIT_OK = 0
18
+ EXIT_FINDINGS = 1
19
+ EXIT_ERROR = 2
20
+
21
+
22
+ def main(argv: Optional[list] = None) -> int:
23
+ parser = argparse.ArgumentParser(
24
+ prog="smtv",
25
+ description="Validate an AAS submodel against its IDTA template, offline.")
26
+ parser.add_argument("path", nargs="?",
27
+ help=".aasx, AAS environment .json/.xml, or a bare Submodel .json")
28
+ parser.add_argument("-f", "--format", choices=("text", "json"), default="text")
29
+ parser.add_argument("-q", "--quiet", action="store_true", help="exit code only")
30
+ parser.add_argument("-W", "--warnings-as-errors", action="store_true",
31
+ help="exit 1 on warnings too")
32
+ parser.add_argument("--strict-meta", action="store_true",
33
+ help="metamodel findings become errors instead of warnings")
34
+ parser.add_argument("--allow-unmatched", action="store_true",
35
+ help="an input with no known submodel becomes a note, not an error")
36
+ from .rules.battery import _settles_only
37
+ from .rules.profiles import KEYS as _PROFILE_KEYS
38
+ parser.add_argument("--profile", choices=_PROFILE_KEYS + _settles_only(),
39
+ metavar="IDTA",
40
+ help="which template answers where two publish one "
41
+ "submodel identifier: %s choose the table that "
42
+ "judges; %s only settle which template the file "
43
+ "claims to be, because this tool has a table for "
44
+ "neither side of that collision"
45
+ % (", ".join(_PROFILE_KEYS), ", ".join(_settles_only())))
46
+ parser.add_argument("--rules", action="store_true",
47
+ help="list every rule and exit")
48
+ parser.add_argument("--version", action="version",
49
+ version="aas-submodel-validate %s" % __version__)
50
+ args = parser.parse_args(argv)
51
+
52
+ if args.rules:
53
+ from . import rules # noqa: F401 - importing registers
54
+ from .registry import all_rules
55
+ from .runner import _meta_rule
56
+ for rule in list(all_rules()) + [_meta_rule(args.strict_meta)]:
57
+ print("%-8s %-9s %-10s %s" % (rule.id, rule.kind, rule.severity, rule.title))
58
+ return EXIT_OK
59
+ if not args.path:
60
+ parser.error("a path is required (or --rules)")
61
+
62
+ try:
63
+ report = runner.run(args.path, strict_meta=args.strict_meta,
64
+ allow_unmatched=args.allow_unmatched,
65
+ profile=args.profile)
66
+ except UnreadablePath as exc:
67
+ print("smtv: %s" % exc, file=sys.stderr)
68
+ return EXIT_ERROR
69
+
70
+ if not args.quiet:
71
+ if args.format == "json":
72
+ print(json.dumps(report.as_dict(), indent=2))
73
+ else:
74
+ print(render(report))
75
+ if not report.judged:
76
+ # Nothing reached the rules, so there is no verdict to report --
77
+ # and 1 is the code for a verdict. Said on stderr as well, since
78
+ # -q suppressed the report that would otherwise explain it.
79
+ print("smtv: nothing in %s could be read, so nothing was judged"
80
+ % args.path, file=sys.stderr)
81
+ return EXIT_ERROR
82
+ from .model import Severity
83
+ failed = not report.ok or (args.warnings_as_errors
84
+ and report.count(Severity.WARNING) > 0)
85
+ return EXIT_FINDINGS if failed else EXIT_OK
@@ -0,0 +1,576 @@
1
+ """Reading an .aasx container: the OPC chain, followed link by link.
2
+
3
+ An AASX is an OPC package (ECMA-376 Part 2), and OPC's rule is that the
4
+ payload is *found*, never guessed: the package-level `_rels/.rels` names an
5
+ `aasx-origin` part, whose own relationships name the `aas-spec` payload.
6
+ A container whose chain is broken has no payload, however plausible its
7
+ entry names look — so this reader follows the chain and refuses at the
8
+ first missing link, naming it.
9
+
10
+ Deliberately stdlib only (zipfile + ElementTree): the official test
11
+ tooling reads AASX the same way, and this project exists for machines
12
+ where every wheel crosses an air gap by hand.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import posixpath
17
+ import re
18
+ import urllib.parse
19
+ import zipfile
20
+ import zlib
21
+ from pathlib import Path
22
+ from typing import List, Tuple
23
+ from xml.etree import ElementTree
24
+
25
+ #: The OPC relationship vocabulary AASX uses, exactly as the official
26
+ #: IDTA example files spell it.
27
+ ORIGIN_REL = "http://admin-shell.io/aasx/relationships/aasx-origin"
28
+ SPEC_REL = "http://admin-shell.io/aasx/relationships/aas-spec"
29
+ SUPPL_REL = "http://admin-shell.io/aasx/relationships/aas-suppl"
30
+
31
+ #: A package arrives from a supplier, so what it decompresses to is
32
+ #: untrusted. 64 MiB is far above any real AAS metadata document and far
33
+ #: below what exhausts an air-gapped machine.
34
+ #:
35
+ #: The archive's own account of a part's size is a fast way to refuse an
36
+ #: honest one, and nothing more: it is a number the file carries, not a
37
+ #: number this reader measured, and a part may declare a hundred bytes
38
+ #: and hold eight megabytes. What bounds the read is the read.
39
+ MAX_PART_BYTES = 64 * 1024 * 1024
40
+
41
+ #: And one container's parts, together. Every part may sit under the cap
42
+ #: while the whole does not -- an archive of forty honest parts costs
43
+ #: forty times one. Four times the single-part cap leaves room for a
44
+ #: container carrying several environments and refuses the pathological.
45
+ MAX_TOTAL_PART_BYTES = 4 * MAX_PART_BYTES
46
+
47
+ #: And the archive's own account of itself, which costs before either of
48
+ #: the caps above applies. `zipfile` builds one record per name the
49
+ #: central directory declares, inside `ZipFile()`, while nothing has been
50
+ #: decompressed and no part has been chosen. Measured on an archive that
51
+ #: is otherwise perfect -- valid chain, conformant payload, a full
52
+ #: verdict, only real template findings -- 800,000 empty entries weigh
53
+ #: 68.7 MiB on disk and 523 MiB in memory: about thirteen times the
54
+ #: directory's own bytes, linear, with no ceiling.
55
+ #:
56
+ #: The bound is on those bytes and not on the entry count, because the
57
+ #: count is a number the file carries and nothing checks: understating it
58
+ #: in the record and leaving the directory alone builds every entry
59
+ #: anyway. The size is what `zipfile` acts on -- it reads exactly that
60
+ #: many bytes and stops -- so understating *it* costs the attacker the
61
+ #: entries they were trying to smuggle.
62
+ #:
63
+ #: A quarter of the single-part cap, in the same proportion
64
+ #: `MAX_TOTAL_PART_BYTES` uses: thirteen times 16 MiB is about 208 MiB,
65
+ #: under the 256 MiB this reader already lets a container's parts
66
+ #: deliver. It admits a package declaring roughly 150,000 parts at
67
+ #: sixty-character names; the two official example containers declare 13
68
+ #: and 16, for directories of 1,119 and 1,331 bytes.
69
+ MAX_DIRECTORY_BYTES = MAX_PART_BYTES // 4
70
+
71
+ #: What zipfile raises for an archive it cannot make sense of.
72
+ #:
73
+ #: Named once because it was written twice and the two disagreed. Opening
74
+ #: the archive listed two of these; reading a part listed six. The
75
+ #: difference was reachable: the version an entry says it needs is read
76
+ #: while the *directory* is, inside `ZipFile()` itself, so a two-byte edit
77
+ #: to any .aasx raised `NotImplementedError` past every handler in this
78
+ #: project and printed a traceback -- against the one thing this reader
79
+ #: promises about hostile input, which is that a container defect is a
80
+ #: finding.
81
+ #:
82
+ #: Deliberately not `Exception`. A defect in this reader must not arrive
83
+ #: dressed as a defect in the supplier's file.
84
+ UNREADABLE = (zipfile.BadZipFile, NotImplementedError, RuntimeError,
85
+ EOFError, OSError, zlib.error)
86
+
87
+ #: Byte order marks, longest first, because a UTF-32 mark begins with a
88
+ #: UTF-16 one and the order is what tells them apart.
89
+ #:
90
+ #: The UTF-32 rows are here to be *recognised*, not read. The parser
91
+ #: refuses UTF-32 whether it is marked or not, so decoding it here would
92
+ #: admit documents nothing else in the ecosystem will open -- and a
93
+ #: validator calling a file conformant that no other reader can parse has
94
+ #: done the worst thing it can do. Dropping the rows instead is not an
95
+ #: option either: `FF FE 00 00` would then match the UTF-16 mark two rows
96
+ #: below and be read as something it is not.
97
+ #:
98
+ #: The UTF-16 rows name `utf-16` rather than a byte order, because that
99
+ #: codec consumes the mark it just matched on. `utf-16-le` leaves it
100
+ #: behind as U+FEFF, in the one position where a leading character
101
+ #: changes what the rest of this module is looking at.
102
+ #: A UTF-8 mark is recognised for the same reason and read for none: the
103
+ #: bytes behind it already are what everything downstream wants, and the
104
+ #: parser skips the mark itself. Every official AASX in the corpus is
105
+ #: marked UTF-8, so this is the row most documents take -- and the one
106
+ #: where doing nothing is the whole job.
107
+ _BOMS = ((b"\xff\xfe\x00\x00", None), (b"\x00\x00\xfe\xff", None),
108
+ (b"\xff\xfe", "utf-16"), (b"\xfe\xff", "utf-16"),
109
+ (b"\xef\xbb\xbf", None))
110
+
111
+ #: An encoding declaration that survived a decode would contradict the
112
+ #: bytes it is attached to, so it goes with the encoding it named.
113
+ #:
114
+ #: Anchored, because `count=1` takes the first match *anywhere*. A
115
+ #: document carrying a byte order mark and no declaration -- the shape
116
+ #: the official 02003 payload has -- offers no prolog to match, so the
117
+ #: first `encoding="..."` in its content was being deleted instead. This
118
+ #: project reads what it is given and transforms nothing (docs/scope.md).
119
+ _DECLARED_ENCODING = re.compile(
120
+ r'\A(?<\?xml[^?>]*?)\s+encoding\s*=\s*(["\'])[^"\']*\2')
121
+
122
+
123
+ def _sniff(raw: bytes):
124
+ """The encoding the parser will take an unmarked document for, or None.
125
+
126
+ XML requires a byte order mark on UTF-16 and the parser does not
127
+ insist, autodetecting instead -- so a document can be UTF-16 to the
128
+ parser and opaque bytes to every guard below it. That is how a
129
+ `<!DOCTYPE` written UTF-16 walked past a pattern that only ever
130
+ matches UTF-8, and had its entities expanded by the parser that was
131
+ supposed to never see it.
132
+
133
+ Decided on the *shape* of the first four bytes, not on what they are.
134
+ Keying on the document beginning with `<` is keying on the fixtures: a
135
+ document may open with whitespace, a comment or a processing
136
+ instruction, and may carry no declaration at all. Four bytes, not two,
137
+ because unmarked UTF-32-LE also begins `3C 00` -- reading it as UTF-16
138
+ would hand the parser text riddled with nulls and claim to understand
139
+ an encoding it refuses.
140
+ """
141
+ if len(raw) < 4:
142
+ return None
143
+ null = tuple(byte == 0 for byte in raw[:4])
144
+ # Named rather than folded into the fall-through below, and it does
145
+ # not change the answer: neither UTF-32 shape equals either UTF-16
146
+ # shape, so deleting these two lines leaves them reaching the same
147
+ # `return None` at the end. What actually separates the two families
148
+ # is asking four bytes instead of two -- `3C 00` opens both. This
149
+ # says which four-byte shapes were considered and rejected, so that a
150
+ # later reader does not have to re-derive it from the ones that were
151
+ # accepted.
152
+ if null in ((False, True, True, True), (True, True, True, False)):
153
+ return None # unmarked UTF-32
154
+ if null == (False, True, False, True):
155
+ return "utf-16-le"
156
+ if null == (True, False, True, False):
157
+ return "utf-16-be"
158
+ return None
159
+
160
+
161
+ def xml_as_utf8(raw: bytes) -> bytes:
162
+ """One XML document as UTF-8, decided the way the parser decides it.
163
+
164
+ Everything downstream reads these bytes. If this disagrees with the
165
+ parser about what the document says, every guard below is inspecting a
166
+ different document from the one that gets parsed -- which is the whole
167
+ defect this exists to close. Bytes that cannot be decoded as the
168
+ encoding they claim come back untouched: the parser will refuse them
169
+ too, and refusing here instead would be this reader inventing a
170
+ verdict.
171
+
172
+ Untouched is the common case and the intended one. Only a document
173
+ the parser reads as UTF-16 is rewritten, because only there do the
174
+ bytes downstream needs differ from the bytes that arrived.
175
+ """
176
+ for bom, encoding in _BOMS:
177
+ if raw.startswith(bom):
178
+ return raw if encoding is None else _as_utf8(raw, encoding)
179
+ encoding = _sniff(raw)
180
+ if encoding is not None:
181
+ return _as_utf8(raw, encoding)
182
+ return raw
183
+
184
+
185
+ def _as_utf8(raw: bytes, encoding: str) -> bytes:
186
+ try:
187
+ text = raw.decode(encoding)
188
+ except UnicodeDecodeError:
189
+ return raw
190
+ return _DECLARED_ENCODING.sub(r"\1", text, count=1).encode("utf-8")
191
+
192
+
193
+ def declares_doctype(raw: bytes) -> bool:
194
+ """Whether the document declares a DTD, asked of the prolog alone.
195
+
196
+ Asked of bytes that have been through `xml_as_utf8`, and only of
197
+ those: asked of the bytes as they arrived it answers for UTF-8 and
198
+ guesses for everything else. Both readers ask it here so that neither
199
+ can grow its own copy of the question and drift.
200
+
201
+ The prolog is the only place a DTD can be declared, and a conformant
202
+ document is allowed to *mention* one -- in a comment, in CDATA, in
203
+ the text of a page about XML. Matching the token anywhere refuses a
204
+ file for talking about the thing rather than doing it, and a finding
205
+ against a conformant file is the one thing worse than silence.
206
+
207
+ The walk skips processing instructions and comments rather than
208
+ stopping at the first `<`, because a comment in the prolog may
209
+ contain anything at all -- including something shaped like a start
210
+ tag -- and stopping there would leave a real declaration behind it
211
+ unread. That is the direction where being wrong is expensive.
212
+ """
213
+ i, end = 0, len(raw)
214
+ while i < end:
215
+ start = raw.find(b"<", i)
216
+ if start < 0:
217
+ return False
218
+ if raw[start:start + 9].lower() == b"<!doctype":
219
+ return True
220
+ if raw[start:start + 4] == b"<!--":
221
+ close = raw.find(b"-->", start)
222
+ i = end if close < 0 else close + 3
223
+ elif raw[start:start + 2] == b"<?":
224
+ close = raw.find(b"?>", start)
225
+ i = end if close < 0 else close + 2
226
+ else:
227
+ return False # the root element: prolog over
228
+ return False
229
+
230
+ _RELATIONSHIP = "{http://schemas.openxmlformats.org/package/2006/relationships}Relationship"
231
+
232
+
233
+ def canonical_part_name(value: str):
234
+ """The archive entry a part name refers to, or None if it names none.
235
+
236
+ OPC part names are absolute, use "/" as the only separator, escape
237
+ reserved characters, and carry no empty, "." or ".." segments
238
+ (ECMA-376 Part 2). Files in the wild are written by tools that were
239
+ not all reading that: a leading "./", a doubled separator, a step up
240
+ and back, the other slash on a Windows desktop. Those are spellings
241
+ of the same name, and a reader that compares strings calls a
242
+ conformant package broken.
243
+
244
+ None means the value is not a part name -- it climbs out of the
245
+ package, ends in a separator (which names a directory, not a part),
246
+ or has nothing left of it. That is a different defect from a part
247
+ being absent, and the rules say so separately.
248
+ """
249
+ if not value or not value.strip():
250
+ return None
251
+ text = value.replace("\\", "/")
252
+ if text.endswith("/"):
253
+ return None # a directory is not named by any part
254
+ text = urllib.parse.unquote(text)
255
+ segments = []
256
+ for segment in text.split("/"):
257
+ if segment in ("", "."):
258
+ continue
259
+ if segment == "..":
260
+ if not segments:
261
+ return None # a name that climbs out of the package
262
+ segments.pop()
263
+ continue
264
+ segments.append(segment)
265
+ return "/".join(segments) or None
266
+
267
+
268
+ class ContainerError(Exception):
269
+ """The file is not something this reader can follow as an AASX."""
270
+
271
+
272
+ class RefusedContent(ContainerError):
273
+ """This reader will not read this part, and nothing about it is wrong.
274
+
275
+ A sibling of `PartTooLarge` in the one way that matters: it is this
276
+ tool's decision, not a claim about the file, so the remedy is not a
277
+ repair. Kept apart from its parent because the chain is intact -- it
278
+ names the parts it should -- and telling an author to fix it is the
279
+ kind of remedy this project promised not to write.
280
+ """
281
+
282
+
283
+ class NoRelationships(ContainerError):
284
+ """The archive holds no relationships part for this source.
285
+
286
+ Kept apart because it means two different things depending on who
287
+ asked. Walking the chain, a missing `.rels` is the chain going
288
+ nowhere and X2's business. Asking one payload part what it declares,
289
+ it means the part declares nothing -- which is ordinary, and the
290
+ reason that call is allowed to fail quietly.
291
+
292
+ Its siblings were failing quietly there too, sharing this type: a
293
+ `.rels` refused for declaring a DTD, and one that would not parse,
294
+ both came back as "declares nothing" and left a defective container
295
+ reporting `ok` with no findings at all.
296
+ """
297
+
298
+
299
+ class PartTooLarge(ContainerError):
300
+ """The archive is well-formed and this reader will not read it all.
301
+
302
+ Separate from its siblings because it is not a claim about the file.
303
+ Nothing here is malformed; the file is simply larger than a validator
304
+ meant for an air-gapped machine will take in, and the remedy is the
305
+ author's choice of what to send, not a repair.
306
+ """
307
+
308
+
309
+ class DirectoryTooLarge(ContainerError):
310
+ """The archive declares more names than this reader will index.
311
+
312
+ A sibling of PartTooLarge and for the same reason: nothing here is
313
+ malformed. The archive may be perfectly well-formed and its parts all
314
+ honest, and it is still more than a validator meant for an air-gapped
315
+ machine will take in before it has read a byte of payload.
316
+ """
317
+
318
+
319
+ class UnreadablePart(ContainerError):
320
+ """The archive names a part but cannot yield its bytes.
321
+
322
+ Kept apart from its parent because the remedy differs: a chain that
323
+ does not reach a payload is repaired by fixing the relationships,
324
+ while this archive's relationships may be perfect and its own
325
+ description of a part wrong. The loader routes the two differently.
326
+ """
327
+
328
+
329
+ def _rels_name(source: str) -> str:
330
+ """Where OPC keeps the relationships of `source` ("" = the package)."""
331
+ if not source:
332
+ return "_rels/.rels"
333
+ directory, base = posixpath.split(source)
334
+ return posixpath.join(directory, "_rels", base + ".rels")
335
+
336
+
337
+ def _directory_bytes(path):
338
+ """How many bytes of central directory `zipfile` is about to read, or
339
+ None if it cannot be asked.
340
+
341
+ Asked *through* `zipfile`'s own end-of-directory reader rather than by
342
+ reading the record again here. That is the whole design: a second
343
+ reading can disagree with the opener, and where it disagrees it
344
+ refuses files the opener would have read. A file comment holding the
345
+ end-of-directory signature is the case that decides it -- `zipfile`'s
346
+ search lands inside the comment, reports a directory of nothing, and
347
+ then builds nothing, so the two agree; a more careful reader would
348
+ find the real record and refuse an archive `zipfile` opens happily.
349
+ The same argument `xml_as_utf8` makes above, and `part` below.
350
+
351
+ The entry point is private. Everything unexpected -- a file that is
352
+ not an archive, a truncated one, a Python that has moved it -- comes
353
+ back None and the archive is opened as it always was: the bound goes
354
+ away before the reading does. `test_the_private_names_this_bound
355
+ _leans_on_are_still_there` is what notices, in CI rather than at a
356
+ user's.
357
+ """
358
+ try:
359
+ with open(path, "rb") as handle:
360
+ end = zipfile._EndRecData(handle)
361
+ return None if end is None else end[zipfile._ECD_SIZE]
362
+ except Exception: # noqa: BLE001 - failing open is the point
363
+ return None
364
+
365
+
366
+ class AasxPackage:
367
+ """An opened .aasx. Use as a context manager, like ZipFile."""
368
+
369
+ def __init__(self, path):
370
+ self.path = Path(path)
371
+ declared = _directory_bytes(self.path)
372
+ if declared is not None and declared > MAX_DIRECTORY_BYTES:
373
+ raise DirectoryTooLarge(
374
+ "%s: its central directory declares %d bytes of names, above "
375
+ "the %d byte limit" % (self.path, declared, MAX_DIRECTORY_BYTES))
376
+ try:
377
+ self._zip = zipfile.ZipFile(self.path)
378
+ except UNREADABLE as exc:
379
+ raise ContainerError("cannot open %s as a ZIP container: %s: %s"
380
+ % (self.path, type(exc).__name__, exc)) from exc
381
+ self._names = frozenset(self._zip.namelist())
382
+ #: Distinct bytes handed out so far, for MAX_TOTAL_PART_BYTES.
383
+ #: One package object is one validation, so this is that run's
384
+ #: total -- and each part counts once however often it is read.
385
+ self._read_total = 0
386
+ self._counted = set()
387
+ #: Entry names by their normalised spelling, built on first need.
388
+ self._canonical = None
389
+
390
+ # -- files ---------------------------------------------------------------
391
+ def names(self) -> List[str]:
392
+ return self._zip.namelist()
393
+
394
+ def part(self, value: str):
395
+ """Which entry `value` names, or None.
396
+
397
+ Exact first: an archive may hold an entry whose name really does
398
+ contain a percent escape, and decoding it before looking would
399
+ lose that file to a reader trying to be helpful. The normalised
400
+ index answers only for what the literal did not.
401
+ """
402
+ if value in self._names:
403
+ return value
404
+ # And the same name written the way File values conventionally
405
+ # are: with the leading slash OPC part names carry and archive
406
+ # entry names do not. Without this the exact match almost never
407
+ # fired, and an entry whose name really holds a percent escape
408
+ # was reachable only by the decoded reading -- which is to say
409
+ # the literal never won anything.
410
+ literal = value.lstrip("/")
411
+ if literal in self._names:
412
+ return literal
413
+ canonical = canonical_part_name(value)
414
+ if canonical is None:
415
+ return None
416
+ if canonical in self._names:
417
+ return canonical
418
+ if self._canonical is None:
419
+ # In the archive's own order, not the set's: two entries can
420
+ # share a canonical spelling, and `setdefault` keeps the one
421
+ # met first. Iterating the frozenset made "first" mean the
422
+ # process's hash seed -- the same archive resolved a clashing
423
+ # value to different entries on different runs. Measured: a
424
+ # kill on this index landed on 12 of 20 seeds and survived
425
+ # the other 8. Write order is the only order the archive has.
426
+ self._canonical = {}
427
+ for name in self._zip.namelist():
428
+ key = canonical_part_name(name)
429
+ if key is not None:
430
+ self._canonical.setdefault(key, name)
431
+ return self._canonical.get(canonical)
432
+
433
+ def read(self, name: str) -> bytes:
434
+ if name not in self._names:
435
+ raise ContainerError("%s names no part %s" % (self.path, name))
436
+ info = self._zip.getinfo(name)
437
+ if info.file_size > MAX_PART_BYTES:
438
+ raise PartTooLarge(
439
+ "%s refuses %s: %d bytes uncompressed, above the %d byte limit"
440
+ % (self.path, name, info.file_size, MAX_PART_BYTES))
441
+ # zipfile raises for a part the archive describes wrongly: a
442
+ # method no reader implements, an encryption flag, a checksum
443
+ # that does not match what came out. Those are defects in the
444
+ # file, and this reader's promise is that a defect in the file
445
+ # is a finding. zlib.error is in the list because the failure
446
+ # can also happen a layer below zipfile, in the decompressor,
447
+ # where nothing wraps it -- which the first version of this
448
+ # tuple missed.
449
+ # Asked before the decompressor runs, not only after. A container
450
+ # already past its total went on paying a part's worth of work
451
+ # for every part still to come, because the refusal was raised
452
+ # after the read it exists to avoid -- and the caller walks all
453
+ # of them. This changes no boundary: it is the same comparison,
454
+ # reached earlier.
455
+ if self._read_total > MAX_TOTAL_PART_BYTES:
456
+ raise PartTooLarge(
457
+ "%s: its parts come to more than %d bytes together"
458
+ % (self.path, MAX_TOTAL_PART_BYTES))
459
+ try:
460
+ # Asking for a bounded number is what bounds the
461
+ # decompressor: an unbounded read hands it the whole stream
462
+ # and truncates the answer afterwards, having already paid
463
+ # for it. A part declaring a hundred bytes and holding eight
464
+ # megabytes cost eight megabytes of peak memory before this,
465
+ # and costs the cap now.
466
+ #
467
+ # It is not a second gate. zipfile yields no more than the
468
+ # entry declares, and an entry declaring more than the cap
469
+ # was refused above, so the length that comes back can never
470
+ # exceed it -- a check on `len(data)` here would be a branch
471
+ # no input could reach.
472
+ with self._zip.open(name) as part:
473
+ data = part.read(MAX_PART_BYTES)
474
+ except UNREADABLE as exc:
475
+ raise UnreadablePart(
476
+ "%s: %s cannot be read: %s: %s"
477
+ % (self.path, name, type(exc).__name__, exc)) from exc
478
+ if name not in self._counted:
479
+ # Once per part. A rule may re-walk the chain -- X4 does --
480
+ # and reading the same bytes a second time is not the
481
+ # container growing. Counting it twice made the refusal
482
+ # depend on which rule happened to cross the line.
483
+ self._counted.add(name)
484
+ self._read_total += len(data)
485
+ if self._read_total > MAX_TOTAL_PART_BYTES:
486
+ raise PartTooLarge(
487
+ "%s: its parts come to more than %d bytes together"
488
+ % (self.path, MAX_TOTAL_PART_BYTES))
489
+ return data
490
+
491
+ # -- the OPC chain -------------------------------------------------------
492
+ def relationships(self, source: str = "") -> List[Tuple[str, str]]:
493
+ """(type, target) pairs of `source`'s relationships part.
494
+
495
+ Targets come back without their leading slash, ready to use as ZIP
496
+ entry names. Real-world .rels files start with a UTF-8 byte order
497
+ mark, and the parser reads several other encodings besides -- so
498
+ the part is decoded the way the parser will read it before the
499
+ guard below reads a byte of it.
500
+ """
501
+ rels = _rels_name(source)
502
+ if rels not in self._names:
503
+ raise NoRelationships("%s has no %s, so the chain from %r goes nowhere"
504
+ % (self.path, rels, source or "the package root"))
505
+ raw = xml_as_utf8(self.read(rels))
506
+ # A relationships part has no legitimate use for a DTD, and a
507
+ # nested-entity DTD is a decompression-free way to exhaust memory
508
+ # (billion laughs; the parser expands it before any handler runs).
509
+ # Refuse the declaration rather than try to bound the expansion.
510
+ if declares_doctype(raw):
511
+ raise RefusedContent("%s: %s declares a DOCTYPE, which is refused"
512
+ % (self.path, rels))
513
+ try:
514
+ root = ElementTree.fromstring(raw)
515
+ except ElementTree.ParseError as exc:
516
+ raise ContainerError("%s: %s does not parse: %s" % (self.path, rels, exc)) from exc
517
+ # OPC resolves a target that begins with "/" against the package
518
+ # root and any other target against the source part's own directory
519
+ # (ECMA-376 Part 2). Treating every target as root-relative rejected
520
+ # conformant packages that use relative targets.
521
+ base_dir = posixpath.dirname(source)
522
+ resolved = []
523
+ for el in root.iter(_RELATIONSHIP):
524
+ target = el.get("Target", "")
525
+ # Where the name starts from is the difference between the two
526
+ # kinds of string; how it is spelled is not. The absolute
527
+ # branch used to skip normalisation entirely, which left
528
+ # "/a/./b" resolving differently from "a/./b".
529
+ candidate = target[1:] if target.startswith("/") \
530
+ else posixpath.join(base_dir, target)
531
+ # Land on the entry the archive actually holds, so that a
532
+ # payload whose name really contains an escape is readable:
533
+ # `part` tries the literal before the normalised reading, and
534
+ # the loader looks parts up by exact name.
535
+ name = self.part(candidate) or canonical_part_name(candidate) or target
536
+ resolved.append((el.get("Type", ""), name))
537
+ return resolved
538
+
539
+ @property
540
+ def origin(self) -> str:
541
+ """The aasx-origin part the package-level relationships name."""
542
+ for rel_type, target in self.relationships(""):
543
+ if rel_type == ORIGIN_REL:
544
+ return target
545
+ raise ContainerError("%s declares no aasx-origin relationship" % self.path)
546
+
547
+ @property
548
+ def spec_parts(self) -> List[str]:
549
+ """Every aas-spec payload the origin's relationships name."""
550
+ # Once each, in the order first named. A part is a part however
551
+ # many relationships point at it, and the total is bounded per
552
+ # part -- so a repeated target bought a part's worth of work for
553
+ # a relationship's worth of bytes and was counted once, which is
554
+ # a bound that never arrives. Measured before this: sixty-four
555
+ # declarations of a one-megabyte part reached sixteen times the
556
+ # total from an archive of two kilobytes.
557
+ targets = list(dict.fromkeys(
558
+ target for rel_type, target in self.relationships(self.origin)
559
+ if rel_type == SPEC_REL))
560
+ if not targets:
561
+ raise ContainerError("%s declares no aas-spec relationship on its origin"
562
+ % self.path)
563
+ return targets
564
+
565
+ # -- lifecycle -----------------------------------------------------------
566
+ def close(self) -> None:
567
+ self._zip.close()
568
+
569
+ def __enter__(self) -> AasxPackage:
570
+ return self
571
+
572
+ def __exit__(self, *exc) -> None:
573
+ self.close()
574
+
575
+ def __repr__(self) -> str:
576
+ return "AasxPackage(%r)" % str(self.path)
@@ -0,0 +1 @@
1
+ 97aac6192b2657e4a03a2204ecd167494252129b03e58838453e3f1d0abefb6a template.json