openappx 0.6.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.
- openappx/__init__.py +3 -0
- openappx/__main__.py +3 -0
- openappx/blockmap.py +240 -0
- openappx/bundle.py +346 -0
- openappx/cli.py +56 -0
- openappx/deploy.py +482 -0
- openappx/inspect.py +506 -0
- openappx/pack.py +91 -0
- openappx/pack_core.py +358 -0
- openappx/sign/__init__.py +40 -0
- openappx/sign/asn1.py +112 -0
- openappx/sign/certificate.py +116 -0
- openappx/sign/cli.py +118 -0
- openappx/sign/digest.py +173 -0
- openappx/sign/signer.py +363 -0
- openappx/sign/timestamp.py +127 -0
- openappx/unpack.py +90 -0
- openappx/validate.py +169 -0
- openappx-0.6.0.dist-info/METADATA +345 -0
- openappx-0.6.0.dist-info/RECORD +24 -0
- openappx-0.6.0.dist-info/WHEEL +5 -0
- openappx-0.6.0.dist-info/entry_points.txt +2 -0
- openappx-0.6.0.dist-info/licenses/LICENSE +21 -0
- openappx-0.6.0.dist-info/top_level.txt +1 -0
openappx/__init__.py
ADDED
openappx/__main__.py
ADDED
openappx/blockmap.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""AppxBlockMap generation (64 KiB SHA-256 blocks)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import struct
|
|
9
|
+
import zlib
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
BLOCK_SIZE = 64 * 1024
|
|
15
|
+
HASH_METHOD = "http://www.w3.org/2001/04/xmlenc#sha256"
|
|
16
|
+
NS = "http://schemas.microsoft.com/appx/2010/blockmap"
|
|
17
|
+
|
|
18
|
+
# Files generated by the packer; never read from the layout as payload.
|
|
19
|
+
SKIP_NAMES = frozenset(
|
|
20
|
+
{
|
|
21
|
+
"[Content_Types].xml",
|
|
22
|
+
"AppxBlockMap.xml",
|
|
23
|
+
"AppxSignature.p7x",
|
|
24
|
+
"AppxMetadata\\CodeIntegrity.cat",
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class FileBlocks:
|
|
31
|
+
"""One <File> entry: hashes over uncompressed blocks, sizes over compressed ones.
|
|
32
|
+
|
|
33
|
+
`compressed_sizes` is None for stored parts, where the format omits
|
|
34
|
+
Block/@Size entirely.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
size: int
|
|
39
|
+
block_hashes: Sequence[bytes]
|
|
40
|
+
compressed_sizes: Sequence[int] | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def package_path(rel: Path) -> str:
|
|
44
|
+
return rel.as_posix().lstrip("/").replace("/", "\\")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def hash_file_blocks(data: bytes) -> tuple[list[bytes], list[int]]:
|
|
48
|
+
"""SHA-256 over each uncompressed 64 KiB block.
|
|
49
|
+
|
|
50
|
+
An empty file has no blocks at all — `<File Name="…" Size="0" LfhSize="…"/>`
|
|
51
|
+
with no children, as produced by Microsoft's own packer.
|
|
52
|
+
"""
|
|
53
|
+
hashes: list[bytes] = []
|
|
54
|
+
sizes: list[int] = []
|
|
55
|
+
offset = 0
|
|
56
|
+
while offset < len(data):
|
|
57
|
+
chunk = data[offset : offset + BLOCK_SIZE]
|
|
58
|
+
hashes.append(hashlib.sha256(chunk).digest())
|
|
59
|
+
sizes.append(len(chunk))
|
|
60
|
+
offset += BLOCK_SIZE
|
|
61
|
+
return hashes, sizes
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def deflate_blocks(data: bytes, level: int = 6) -> tuple[bytes, list[int]]:
|
|
65
|
+
"""Deflate `data` one 64 KiB block at a time.
|
|
66
|
+
|
|
67
|
+
Returns the raw deflate stream and the compressed length of each block.
|
|
68
|
+
Z_FULL_FLUSH terminates every block so its compressed extent is known — that
|
|
69
|
+
length is what Block/@Size reports. The final `flush()` emits a 2-byte
|
|
70
|
+
end-of-stream marker belonging to no block, which is why the block sizes sum
|
|
71
|
+
to slightly less than the entry's compressed size.
|
|
72
|
+
"""
|
|
73
|
+
compressor = zlib.compressobj(level, zlib.DEFLATED, -zlib.MAX_WBITS)
|
|
74
|
+
stream = bytearray()
|
|
75
|
+
sizes: list[int] = []
|
|
76
|
+
for offset in range(0, len(data), BLOCK_SIZE):
|
|
77
|
+
block = compressor.compress(data[offset : offset + BLOCK_SIZE])
|
|
78
|
+
block += compressor.flush(zlib.Z_FULL_FLUSH)
|
|
79
|
+
stream += block
|
|
80
|
+
sizes.append(len(block))
|
|
81
|
+
stream += compressor.flush()
|
|
82
|
+
return bytes(stream), sizes
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def zip_local_header_size(name_utf8: bytes, extra: bytes = b"") -> int:
|
|
86
|
+
return 30 + len(name_utf8) + len(extra)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
LFH_SIGNATURE = 0x04034B50
|
|
90
|
+
LFH_STRUCT = "<IHHHHHIIIHH"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class LocalHeader:
|
|
95
|
+
"""A ZIP local file header as actually written to the archive."""
|
|
96
|
+
|
|
97
|
+
name: bytes
|
|
98
|
+
flag_bits: int
|
|
99
|
+
size: int # 30 + name length + extra length, i.e. the blockmap's LfhSize
|
|
100
|
+
extra_len: int
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def read_local_header(archive: bytes, offset: int) -> LocalHeader:
|
|
104
|
+
"""Parse the local file header at `offset` in a raw ZIP archive.
|
|
105
|
+
|
|
106
|
+
Used to check declared LfhSize against reality: `zip_local_header_size`
|
|
107
|
+
assumes no extra fields, and nothing in `zipfile` guarantees that.
|
|
108
|
+
"""
|
|
109
|
+
fields = struct.unpack(LFH_STRUCT, archive[offset : offset + 30])
|
|
110
|
+
sig, _ver, flag, _comp, _time, _date, _crc, _csize, _size, name_len, extra_len = (
|
|
111
|
+
fields
|
|
112
|
+
)
|
|
113
|
+
if sig != LFH_SIGNATURE:
|
|
114
|
+
raise ValueError(f"no local file header at offset {offset}")
|
|
115
|
+
return LocalHeader(
|
|
116
|
+
name=archive[offset + 30 : offset + 30 + name_len],
|
|
117
|
+
flag_bits=flag,
|
|
118
|
+
size=30 + name_len + extra_len,
|
|
119
|
+
extra_len=extra_len,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def collect_files(root: Path) -> list[Path]:
|
|
124
|
+
files: list[Path] = []
|
|
125
|
+
for dirpath, _, filenames in os.walk(root):
|
|
126
|
+
for fn in filenames:
|
|
127
|
+
p = Path(dirpath) / fn
|
|
128
|
+
name = package_path(p.relative_to(root))
|
|
129
|
+
if name in SKIP_NAMES:
|
|
130
|
+
continue
|
|
131
|
+
files.append(p)
|
|
132
|
+
files.sort(key=lambda p: package_path(p.relative_to(root)).lower())
|
|
133
|
+
return files
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class PreparedFile:
|
|
138
|
+
"""A payload file ready to be written: blockmap entry plus archive bytes."""
|
|
139
|
+
|
|
140
|
+
blocks: FileBlocks
|
|
141
|
+
payload: bytes # what goes into the archive (deflated or verbatim)
|
|
142
|
+
deflated: bool
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def prepare_file(name: str, data: bytes, level: int = 6) -> PreparedFile:
|
|
146
|
+
"""Hash and compress one payload file.
|
|
147
|
+
|
|
148
|
+
Deflate is used only when it actually pays off; otherwise the part is stored
|
|
149
|
+
and Block/@Size is omitted, matching what Microsoft's packer does for
|
|
150
|
+
already-compressed assets.
|
|
151
|
+
"""
|
|
152
|
+
hashes, _ = hash_file_blocks(data)
|
|
153
|
+
stream, compressed_sizes = deflate_blocks(data, level)
|
|
154
|
+
|
|
155
|
+
if data and len(stream) < len(data):
|
|
156
|
+
return PreparedFile(
|
|
157
|
+
blocks=FileBlocks(name, len(data), hashes, compressed_sizes),
|
|
158
|
+
payload=stream,
|
|
159
|
+
deflated=True,
|
|
160
|
+
)
|
|
161
|
+
return PreparedFile(
|
|
162
|
+
blocks=FileBlocks(name, len(data), hashes, None),
|
|
163
|
+
payload=data,
|
|
164
|
+
deflated=False,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _xml_escape(s: str) -> str:
|
|
169
|
+
return (
|
|
170
|
+
s.replace("&", "&")
|
|
171
|
+
.replace("<", "<")
|
|
172
|
+
.replace(">", ">")
|
|
173
|
+
.replace('"', """)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def render_blockmap_xml(entries: Sequence[FileBlocks], lfh_sizes: dict) -> bytes:
|
|
178
|
+
lines = [
|
|
179
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>',
|
|
180
|
+
f'<BlockMap xmlns="{NS}" HashMethod="{HASH_METHOD}">',
|
|
181
|
+
]
|
|
182
|
+
for e in entries:
|
|
183
|
+
lfh = lfh_sizes[e.name]
|
|
184
|
+
head = f' <File Name="{_xml_escape(e.name)}" Size="{e.size}" LfhSize="{lfh}"'
|
|
185
|
+
if not e.block_hashes: # empty file: no blocks at all
|
|
186
|
+
lines.append(head + "/>")
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
lines.append(head + ">")
|
|
190
|
+
for i, h in enumerate(e.block_hashes):
|
|
191
|
+
b64 = base64.b64encode(h).decode("ascii")
|
|
192
|
+
if e.compressed_sizes is None: # stored part: no compressed length
|
|
193
|
+
lines.append(f' <Block Hash="{b64}"/>')
|
|
194
|
+
else:
|
|
195
|
+
lines.append(
|
|
196
|
+
f' <Block Hash="{b64}" Size="{e.compressed_sizes[i]}"/>'
|
|
197
|
+
)
|
|
198
|
+
lines.append(" </File>")
|
|
199
|
+
lines.append("</BlockMap>")
|
|
200
|
+
return ("\r\n".join(lines) + "\r\n").encode("utf-8")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def content_types_xml(package_paths: Sequence[str]) -> bytes:
|
|
204
|
+
defaults = {
|
|
205
|
+
"xml": "application/vnd.ms-appx.manifest+xml",
|
|
206
|
+
"png": "image/png",
|
|
207
|
+
"jpg": "image/jpeg",
|
|
208
|
+
"jpeg": "image/jpeg",
|
|
209
|
+
"json": "application/json",
|
|
210
|
+
"txt": "text/plain",
|
|
211
|
+
"dll": "application/x-msdownload",
|
|
212
|
+
"exe": "application/x-msdownload",
|
|
213
|
+
"winmd": "application/octet-stream",
|
|
214
|
+
"p7x": "application/vnd.ms-appx.signature",
|
|
215
|
+
}
|
|
216
|
+
for p in package_paths:
|
|
217
|
+
ext = Path(p.replace("\\", "/")).suffix.lstrip(".").lower()
|
|
218
|
+
if ext and ext not in defaults:
|
|
219
|
+
defaults[ext] = "application/octet-stream"
|
|
220
|
+
|
|
221
|
+
lines = [
|
|
222
|
+
'<?xml version="1.0" encoding="utf-8"?>',
|
|
223
|
+
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">',
|
|
224
|
+
]
|
|
225
|
+
for ext, ctype in sorted(defaults.items()):
|
|
226
|
+
lines.append(f' <Default Extension="{ext}" ContentType="{ctype}"/>')
|
|
227
|
+
lines.append(
|
|
228
|
+
' <Override PartName="/AppxManifest.xml" '
|
|
229
|
+
'ContentType="application/vnd.ms-appx.manifest+xml"/>'
|
|
230
|
+
)
|
|
231
|
+
lines.append(
|
|
232
|
+
' <Override PartName="/AppxBlockMap.xml" '
|
|
233
|
+
'ContentType="application/vnd.ms-appx.blockmap+xml"/>'
|
|
234
|
+
)
|
|
235
|
+
lines.append(
|
|
236
|
+
' <Override PartName="/AppxSignature.p7x" '
|
|
237
|
+
'ContentType="application/vnd.ms-appx.signature"/>'
|
|
238
|
+
)
|
|
239
|
+
lines.append("</Types>")
|
|
240
|
+
return ("\r\n".join(lines) + "\r\n").encode("utf-8")
|
openappx/bundle.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Build an `.msixbundle` from finished `.msix` packages.
|
|
2
|
+
|
|
3
|
+
A bundle is the same OPC ZIP container as a package, and this module reuses the
|
|
4
|
+
writer in `pack_core` unchanged. What differs is what goes inside, and every
|
|
5
|
+
point below was read off Microsoft's own reference bundles
|
|
6
|
+
(`src/test/testData/unpack/bundles` upstream), not derived from the schema:
|
|
7
|
+
|
|
8
|
+
- The manifest is `AppxMetadata/AppxBundleManifest.xml`, root element `<Bundle>`
|
|
9
|
+
in the 2013 namespace — not `AppxManifest.xml`.
|
|
10
|
+
- **`AppxBlockMap.xml` covers only that manifest.** A package blockmap hashes
|
|
11
|
+
every payload file; a bundle blockmap has exactly one `<File>`. The payload
|
|
12
|
+
packages carry their own blockmaps and their own signatures.
|
|
13
|
+
- Payload packages are **stored**, never deflated. Their `Offset` in the bundle
|
|
14
|
+
manifest points at the file *data* — the local header offset plus `LfhSize` —
|
|
15
|
+
so it can only be filled in after the archive is laid out.
|
|
16
|
+
- `[Content_Types].xml` maps `appx` and declares the `xml` default as
|
|
17
|
+
`bundlemanifest+xml`, where a package says `manifest+xml`.
|
|
18
|
+
|
|
19
|
+
Deliberately not copied: the reference bundles write data descriptors after each
|
|
20
|
+
entry (24 bytes, `PK\\x07\\x08`). They are optional, our writer emits none, and
|
|
21
|
+
`pack_core.append_stored_part` refuses archives that have them.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
import zipfile
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from openappx.blockmap import (
|
|
32
|
+
_xml_escape,
|
|
33
|
+
prepare_file,
|
|
34
|
+
render_blockmap_xml,
|
|
35
|
+
zip_local_header_size,
|
|
36
|
+
)
|
|
37
|
+
from openappx.pack_core import (
|
|
38
|
+
_METHOD_DEFLATE,
|
|
39
|
+
_METHOD_STORE,
|
|
40
|
+
_Entry,
|
|
41
|
+
_write_central_directory,
|
|
42
|
+
_write_entry,
|
|
43
|
+
zip64_local_extra,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
BUNDLE_MANIFEST = "AppxMetadata/AppxBundleManifest.xml"
|
|
47
|
+
BUNDLE_NS = "http://schemas.microsoft.com/appx/2013/bundle"
|
|
48
|
+
BLOCKMAP = "AppxBlockMap.xml"
|
|
49
|
+
CONTENT_TYPES = "[Content_Types].xml"
|
|
50
|
+
|
|
51
|
+
# The bundle version is the packages' version with the revision zeroed, which is
|
|
52
|
+
# what MakeAppx does when it is not told otherwise.
|
|
53
|
+
_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)\.(\d+)$")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class PackageEntry:
|
|
58
|
+
"""One `.msix` about to go into a bundle, as its own manifest describes it."""
|
|
59
|
+
|
|
60
|
+
path: Path
|
|
61
|
+
name: str
|
|
62
|
+
publisher: str
|
|
63
|
+
version: str
|
|
64
|
+
architecture: str | None # absent for a resource package
|
|
65
|
+
languages: tuple[str, ...]
|
|
66
|
+
resource_id: str
|
|
67
|
+
kind: str # "application" or "resource"
|
|
68
|
+
signed: bool
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _identity(manifest: str, attribute: str) -> str | None:
|
|
72
|
+
element = re.search(r"<Identity\b([^>]*)>", manifest)
|
|
73
|
+
if not element:
|
|
74
|
+
return None
|
|
75
|
+
found = re.search(rf'\b{attribute}\s*=\s*"([^"]*)"', element.group(1))
|
|
76
|
+
return found.group(1) if found else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def read_package(path: Path) -> PackageEntry:
|
|
80
|
+
"""Describe a packed `.msix` well enough to list it in a bundle manifest."""
|
|
81
|
+
path = Path(path).resolve()
|
|
82
|
+
if not zipfile.is_zipfile(path):
|
|
83
|
+
raise ValueError(f"not a ZIP/MSIX container: {path}")
|
|
84
|
+
with zipfile.ZipFile(path) as zf:
|
|
85
|
+
names = zf.namelist()
|
|
86
|
+
if "AppxManifest.xml" not in names:
|
|
87
|
+
raise ValueError(f"{path.name} has no AppxManifest.xml")
|
|
88
|
+
manifest = zf.read("AppxManifest.xml").decode("utf-8", errors="replace")
|
|
89
|
+
signed = "AppxSignature.p7x" in names
|
|
90
|
+
# Comments in these manifests describe the very attributes being read.
|
|
91
|
+
manifest = re.sub(r"<!--.*?-->", "", manifest, flags=re.DOTALL)
|
|
92
|
+
|
|
93
|
+
missing = [
|
|
94
|
+
attribute
|
|
95
|
+
for attribute in ("Name", "Publisher", "Version")
|
|
96
|
+
if not _identity(manifest, attribute)
|
|
97
|
+
]
|
|
98
|
+
if missing:
|
|
99
|
+
raise ValueError(f"{path.name}: Identity/@{', @'.join(missing)} missing")
|
|
100
|
+
|
|
101
|
+
languages = tuple(re.findall(r'<Resource\b[^>]*Language="([^"]+)"', manifest))
|
|
102
|
+
# `Identity/@ResourceId` is what makes a package a resource package — not the
|
|
103
|
+
# absence of <Applications>, which was the obvious guess and is wrong: a
|
|
104
|
+
# device answers 0x80080204 "its package type doesn't match the value found
|
|
105
|
+
# in the bundle manifest". Microsoft's own resource packages pair it with
|
|
106
|
+
# <Properties><ResourcePackage>true, and carry no ProcessorArchitecture.
|
|
107
|
+
resource_id = _identity(manifest, "ResourceId")
|
|
108
|
+
kind = "resource" if resource_id else "application"
|
|
109
|
+
architecture = _identity(manifest, "ProcessorArchitecture")
|
|
110
|
+
return PackageEntry(
|
|
111
|
+
path=path,
|
|
112
|
+
name=_identity(manifest, "Name") or "",
|
|
113
|
+
publisher=_identity(manifest, "Publisher") or "",
|
|
114
|
+
version=_identity(manifest, "Version") or "",
|
|
115
|
+
architecture=None if kind == "resource" else (architecture or "neutral"),
|
|
116
|
+
languages=languages or ("en-us",),
|
|
117
|
+
resource_id=resource_id or "",
|
|
118
|
+
kind=kind,
|
|
119
|
+
signed=signed,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def bundle_version(packages: list[PackageEntry]) -> str:
|
|
124
|
+
"""Highest package version with the revision zeroed, as MakeAppx does."""
|
|
125
|
+
parsed = [
|
|
126
|
+
tuple(int(n) for n in m.groups())
|
|
127
|
+
for m in (_VERSION_RE.match(p.version) for p in packages)
|
|
128
|
+
if m
|
|
129
|
+
]
|
|
130
|
+
if not parsed:
|
|
131
|
+
return "1.0.0.0"
|
|
132
|
+
major, minor, build, _ = max(parsed)
|
|
133
|
+
return f"{major}.{minor}.{build}.0"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def bundle_problems(packages: list[PackageEntry]) -> list[str]:
|
|
137
|
+
"""Reasons these packages cannot go in one bundle (empty list => they can).
|
|
138
|
+
|
|
139
|
+
Identity has to agree: the bundle names one application, and a device
|
|
140
|
+
resolves the packages inside it against that single identity. Two packages
|
|
141
|
+
claiming the same architecture and resource are a mistake for the same
|
|
142
|
+
reason — nothing could choose between them.
|
|
143
|
+
|
|
144
|
+
Deliberately not required: an application package. A bundle of nothing but
|
|
145
|
+
resource packages is legal — upstream ships one as `ContainsOnlyResourcePackages`
|
|
146
|
+
— and is how language packs are distributed.
|
|
147
|
+
"""
|
|
148
|
+
problems = []
|
|
149
|
+
names = {p.name for p in packages}
|
|
150
|
+
if len(names) > 1:
|
|
151
|
+
problems.append(
|
|
152
|
+
f"packages disagree on Identity/@Name: {', '.join(sorted(names))}"
|
|
153
|
+
)
|
|
154
|
+
publishers = {p.publisher for p in packages}
|
|
155
|
+
if len(publishers) > 1:
|
|
156
|
+
problems.append(
|
|
157
|
+
f"packages disagree on Identity/@Publisher: {', '.join(sorted(publishers))}"
|
|
158
|
+
)
|
|
159
|
+
seen: dict[tuple, Path] = {}
|
|
160
|
+
for p in packages:
|
|
161
|
+
key = (p.kind, p.architecture, p.resource_id)
|
|
162
|
+
if key in seen:
|
|
163
|
+
problems.append(
|
|
164
|
+
f"{p.path.name} and {seen[key].name} describe the same "
|
|
165
|
+
f"architecture and resource: "
|
|
166
|
+
f"{p.architecture or 'neutral'}/{p.resource_id}"
|
|
167
|
+
)
|
|
168
|
+
seen[key] = p.path
|
|
169
|
+
if not packages:
|
|
170
|
+
problems.append("a bundle needs at least one package")
|
|
171
|
+
return problems
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def unsigned_payloads(packages: list[PackageEntry]) -> list[str]:
|
|
175
|
+
"""Payload packages carrying no signature of their own.
|
|
176
|
+
|
|
177
|
+
Not a `bundle_problems` entry, on purpose. Signing needs the optional
|
|
178
|
+
`[sign]` extra, and bundling must keep working without it — assembling the
|
|
179
|
+
container is mechanical. It is still a certain install failure, so the CLI
|
|
180
|
+
warns and `inspect` reports it on the finished bundle, which is where this
|
|
181
|
+
codebase puts checks that need the artefact to exist.
|
|
182
|
+
|
|
183
|
+
Signing the bundle is not enough: a device answers 0x800B0100 against the
|
|
184
|
+
*bundle*, which reads as if the bundle itself were unsigned. Measured by
|
|
185
|
+
signing one and not the other.
|
|
186
|
+
"""
|
|
187
|
+
return sorted(p.path.name for p in packages if not p.signed)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def render_bundle_manifest(
|
|
191
|
+
packages: list[PackageEntry], offsets: dict[str, int], version: str
|
|
192
|
+
) -> bytes:
|
|
193
|
+
"""The `<Bundle>` document, with each package's offset inside the archive.
|
|
194
|
+
|
|
195
|
+
Rendered as bytes for the same reason the blockmap is: reference tooling
|
|
196
|
+
compares byte for byte, and a serialiser would reorder attributes.
|
|
197
|
+
"""
|
|
198
|
+
first = packages[0]
|
|
199
|
+
lines = [
|
|
200
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>',
|
|
201
|
+
f'<Bundle SchemaVersion="2.0" xmlns="{BUNDLE_NS}">',
|
|
202
|
+
f' <Identity Name="{_xml_escape(first.name)}" '
|
|
203
|
+
f'Publisher="{_xml_escape(first.publisher)}" Version="{version}"/>',
|
|
204
|
+
" <Packages>",
|
|
205
|
+
]
|
|
206
|
+
for p in packages:
|
|
207
|
+
attributes = [f'Type="{p.kind}"', f'Version="{p.version}"']
|
|
208
|
+
if p.architecture:
|
|
209
|
+
attributes.append(f'Architecture="{p.architecture}"')
|
|
210
|
+
# Only resource packages carry a ResourceId. A real Store bundle omits it
|
|
211
|
+
# on application packages, and a device that finds one there stops
|
|
212
|
+
# matching them by architecture — reporting that the bundle "does not
|
|
213
|
+
# have an appropriate application package for x64", measured.
|
|
214
|
+
if p.kind == "resource":
|
|
215
|
+
attributes.append(f'ResourceId="{_xml_escape(p.resource_id)}"')
|
|
216
|
+
attributes += [
|
|
217
|
+
f'FileName="{_xml_escape(p.path.name)}"',
|
|
218
|
+
f'Offset="{offsets[p.path.name]}"',
|
|
219
|
+
f'Size="{p.path.stat().st_size}"',
|
|
220
|
+
]
|
|
221
|
+
lines.append(f" <Package {' '.join(attributes)}>")
|
|
222
|
+
lines.append(" <Resources>")
|
|
223
|
+
for language in p.languages:
|
|
224
|
+
lines.append(f' <Resource Language="{_xml_escape(language)}"/>')
|
|
225
|
+
lines.append(" </Resources>")
|
|
226
|
+
lines.append(" </Package>")
|
|
227
|
+
lines += [" </Packages>", "</Bundle>"]
|
|
228
|
+
return ("\r\n".join(lines) + "\r\n").encode("utf-8")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def bundle_content_types() -> bytes:
|
|
232
|
+
"""`xml` defaults to bundlemanifest+xml here, where a package says manifest+xml."""
|
|
233
|
+
return (
|
|
234
|
+
"\r\n".join(
|
|
235
|
+
[
|
|
236
|
+
'<?xml version="1.0" encoding="utf-8"?>',
|
|
237
|
+
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/'
|
|
238
|
+
'content-types">',
|
|
239
|
+
' <Default Extension="appx" ContentType="application/vnd.ms-appx"/>',
|
|
240
|
+
' <Default Extension="msix" ContentType="application/vnd.ms-appx"/>',
|
|
241
|
+
' <Default Extension="xml" '
|
|
242
|
+
'ContentType="application/vnd.ms-appx.bundlemanifest+xml"/>',
|
|
243
|
+
' <Override PartName="/AppxBlockMap.xml" '
|
|
244
|
+
'ContentType="application/vnd.ms-appx.blockmap+xml"/>',
|
|
245
|
+
' <Override PartName="/AppxSignature.p7x" '
|
|
246
|
+
'ContentType="application/vnd.ms-appx.signature"/>',
|
|
247
|
+
"</Types>",
|
|
248
|
+
]
|
|
249
|
+
)
|
|
250
|
+
+ "\r\n"
|
|
251
|
+
).encode("utf-8")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def main(argv: list[str] | None = None) -> int:
|
|
255
|
+
import argparse
|
|
256
|
+
import sys
|
|
257
|
+
|
|
258
|
+
ap = argparse.ArgumentParser(
|
|
259
|
+
prog="openappx bundle",
|
|
260
|
+
description="Combine .msix packages into one .msixbundle",
|
|
261
|
+
)
|
|
262
|
+
ap.add_argument(
|
|
263
|
+
"--package",
|
|
264
|
+
action="append",
|
|
265
|
+
required=True,
|
|
266
|
+
type=Path,
|
|
267
|
+
dest="packages",
|
|
268
|
+
help="a packed .msix to include; repeat for each architecture",
|
|
269
|
+
)
|
|
270
|
+
ap.add_argument("--out", required=True, type=Path)
|
|
271
|
+
args = ap.parse_args(argv)
|
|
272
|
+
|
|
273
|
+
missing = [p for p in args.packages if not p.is_file()]
|
|
274
|
+
if missing:
|
|
275
|
+
for p in missing:
|
|
276
|
+
print(f"error: no such package: {p}", file=sys.stderr)
|
|
277
|
+
return 2
|
|
278
|
+
unsigned = unsigned_payloads([read_package(p) for p in args.packages])
|
|
279
|
+
if unsigned:
|
|
280
|
+
print(
|
|
281
|
+
"warning: these payload packages are unsigned, and a device will "
|
|
282
|
+
"refuse the bundle: " + ", ".join(unsigned),
|
|
283
|
+
file=sys.stderr,
|
|
284
|
+
)
|
|
285
|
+
try:
|
|
286
|
+
out = build_bundle(args.packages, args.out)
|
|
287
|
+
except ValueError as e:
|
|
288
|
+
print(f"error: {e}", file=sys.stderr)
|
|
289
|
+
return 2
|
|
290
|
+
print(
|
|
291
|
+
f"Wrote {out} ({out.stat().st_size} bytes) from {len(args.packages)} packages"
|
|
292
|
+
)
|
|
293
|
+
print("Reminder: sign the bundle itself — the packages inside are not enough.")
|
|
294
|
+
return 0
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def build_bundle(packages: list[Path], out: Path) -> Path:
|
|
298
|
+
"""Write `out` as a bundle carrying `packages`, each stored verbatim."""
|
|
299
|
+
entries = [read_package(p) for p in packages]
|
|
300
|
+
problems = bundle_problems(entries)
|
|
301
|
+
if problems:
|
|
302
|
+
raise ValueError("; ".join(problems))
|
|
303
|
+
|
|
304
|
+
archive = bytearray()
|
|
305
|
+
written: list[_Entry] = []
|
|
306
|
+
offsets: dict[str, int] = {}
|
|
307
|
+
for entry in entries:
|
|
308
|
+
data = entry.path.read_bytes()
|
|
309
|
+
header_offset = len(archive)
|
|
310
|
+
written.append(
|
|
311
|
+
_write_entry(archive, entry.path.name, data, data, _METHOD_STORE)
|
|
312
|
+
)
|
|
313
|
+
# Offset is where the data starts, not where the record does.
|
|
314
|
+
offsets[entry.path.name] = header_offset + zip_local_header_size(
|
|
315
|
+
entry.path.name.encode("utf-8"), zip64_local_extra(len(data), len(data))
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
version = bundle_version(entries)
|
|
319
|
+
manifest = render_bundle_manifest(entries, offsets, version)
|
|
320
|
+
prepared = prepare_file(BUNDLE_MANIFEST.replace("/", "\\"), manifest)
|
|
321
|
+
lfh_size = zip_local_header_size(
|
|
322
|
+
BUNDLE_MANIFEST.encode("utf-8"),
|
|
323
|
+
zip64_local_extra(len(manifest), len(prepared.payload)),
|
|
324
|
+
)
|
|
325
|
+
# The blockmap covers this one file, so the package renderer does the job
|
|
326
|
+
# unchanged — the difference between a bundle and a package here is what is
|
|
327
|
+
# in the list, not how it is written.
|
|
328
|
+
blockmap = render_blockmap_xml([prepared.blocks], {prepared.blocks.name: lfh_size})
|
|
329
|
+
|
|
330
|
+
written.append(
|
|
331
|
+
_write_entry(
|
|
332
|
+
archive,
|
|
333
|
+
BUNDLE_MANIFEST,
|
|
334
|
+
prepared.payload,
|
|
335
|
+
manifest,
|
|
336
|
+
_METHOD_DEFLATE if prepared.deflated else _METHOD_STORE,
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
for name, data in ((CONTENT_TYPES, bundle_content_types()), (BLOCKMAP, blockmap)):
|
|
340
|
+
written.append(_write_entry(archive, name, data, data, _METHOD_STORE))
|
|
341
|
+
|
|
342
|
+
_write_central_directory(archive, written)
|
|
343
|
+
out = Path(out).resolve()
|
|
344
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
345
|
+
out.write_bytes(bytes(archive))
|
|
346
|
+
return out
|
openappx/cli.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Unified `openappx` console entrypoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main(argv: list[str] | None = None) -> int:
|
|
9
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
10
|
+
if not argv or argv[0] in ("-h", "--help"):
|
|
11
|
+
print("usage: openappx <pack|bundle|unpack|sign|validate|inspect|deploy> …")
|
|
12
|
+
print(" openappx pack --root DIR --out FILE.msix")
|
|
13
|
+
print(" openappx bundle --package A.msix --package B.msix --out X.msixbundle")
|
|
14
|
+
print(" openappx unpack --package FILE.msix --out DIR")
|
|
15
|
+
print(" openappx sign --package FILE.msix --pfx CERT.pfx")
|
|
16
|
+
print(" openappx validate --root DIR")
|
|
17
|
+
print(" openappx inspect --package FILE.msix [--json]")
|
|
18
|
+
print(" openappx deploy --device URL --user NAME --package FILE.msix")
|
|
19
|
+
return 0 if argv else 2
|
|
20
|
+
|
|
21
|
+
cmd, rest = argv[0], argv[1:]
|
|
22
|
+
if cmd == "pack":
|
|
23
|
+
from openappx.pack import main as pack_main
|
|
24
|
+
|
|
25
|
+
return pack_main(rest)
|
|
26
|
+
if cmd == "bundle":
|
|
27
|
+
from openappx.bundle import main as bundle_main
|
|
28
|
+
|
|
29
|
+
return bundle_main(rest)
|
|
30
|
+
if cmd == "validate":
|
|
31
|
+
from openappx.validate import main as validate_main
|
|
32
|
+
|
|
33
|
+
return validate_main(rest)
|
|
34
|
+
if cmd == "inspect":
|
|
35
|
+
from openappx.inspect import main as inspect_main
|
|
36
|
+
|
|
37
|
+
return inspect_main(rest)
|
|
38
|
+
if cmd == "unpack":
|
|
39
|
+
from openappx.unpack import main as unpack_main
|
|
40
|
+
|
|
41
|
+
return unpack_main(rest)
|
|
42
|
+
if cmd == "sign":
|
|
43
|
+
from openappx.sign.cli import main as sign_main
|
|
44
|
+
|
|
45
|
+
return sign_main(rest)
|
|
46
|
+
if cmd == "deploy":
|
|
47
|
+
from openappx.deploy import main as deploy_main
|
|
48
|
+
|
|
49
|
+
return deploy_main(rest)
|
|
50
|
+
|
|
51
|
+
print(f"unknown command: {cmd}", file=sys.stderr)
|
|
52
|
+
return 2
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
raise SystemExit(main())
|