napt 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.
- napt/__init__.py +91 -0
- napt/build/__init__.py +47 -0
- napt/build/manager.py +1087 -0
- napt/build/packager.py +315 -0
- napt/build/template.py +301 -0
- napt/cli.py +602 -0
- napt/config/__init__.py +42 -0
- napt/config/loader.py +465 -0
- napt/core.py +385 -0
- napt/detection.py +630 -0
- napt/discovery/__init__.py +86 -0
- napt/discovery/api_github.py +445 -0
- napt/discovery/api_json.py +452 -0
- napt/discovery/base.py +244 -0
- napt/discovery/url_download.py +304 -0
- napt/discovery/web_scrape.py +467 -0
- napt/exceptions.py +149 -0
- napt/io/__init__.py +42 -0
- napt/io/download.py +357 -0
- napt/io/upload.py +37 -0
- napt/logging.py +230 -0
- napt/policy/__init__.py +50 -0
- napt/policy/updates.py +126 -0
- napt/psadt/__init__.py +43 -0
- napt/psadt/release.py +309 -0
- napt/requirements.py +566 -0
- napt/results.py +143 -0
- napt/state/__init__.py +58 -0
- napt/state/tracker.py +371 -0
- napt/validation.py +467 -0
- napt/versioning/__init__.py +115 -0
- napt/versioning/keys.py +309 -0
- napt/versioning/msi.py +725 -0
- napt-0.3.1.dist-info/METADATA +114 -0
- napt-0.3.1.dist-info/RECORD +38 -0
- napt-0.3.1.dist-info/WHEEL +4 -0
- napt-0.3.1.dist-info/entry_points.txt +3 -0
- napt-0.3.1.dist-info/licenses/LICENSE +202 -0
napt/versioning/msi.py
ADDED
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
# Copyright 2025 Roger Cibrian
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""MSI metadata extraction for NAPT.
|
|
16
|
+
|
|
17
|
+
This module extracts metadata from Windows Installer (MSI) database files,
|
|
18
|
+
including ProductVersion, ProductName, and architecture (from Template).
|
|
19
|
+
It tries multiple backends in order of preference to maximize cross-platform
|
|
20
|
+
compatibility.
|
|
21
|
+
|
|
22
|
+
Backend Priority:
|
|
23
|
+
|
|
24
|
+
On Windows:
|
|
25
|
+
|
|
26
|
+
1. msilib (Python standard library, Python 3.10 and earlier only - removed in 3.13)
|
|
27
|
+
2. _msi (CPython extension module, Windows-specific)
|
|
28
|
+
3. PowerShell COM (Windows Installer COM API, always available)
|
|
29
|
+
|
|
30
|
+
On Linux/macOS:
|
|
31
|
+
|
|
32
|
+
1. msiinfo (from msitools package, must be installed separately)
|
|
33
|
+
|
|
34
|
+
The PowerShell fallback makes this truly universal on Windows systems,
|
|
35
|
+
even when Python MSI libraries aren't available.
|
|
36
|
+
|
|
37
|
+
Installation Requirements:
|
|
38
|
+
|
|
39
|
+
Windows:
|
|
40
|
+
|
|
41
|
+
- No additional packages required (PowerShell fallback always works)
|
|
42
|
+
- Optional: Ensure msilib is available for better performance
|
|
43
|
+
|
|
44
|
+
Linux/macOS:
|
|
45
|
+
|
|
46
|
+
- Install msitools package:
|
|
47
|
+
- Debian/Ubuntu: `sudo apt-get install msitools`
|
|
48
|
+
- RHEL/Fedora: `sudo dnf install msitools`
|
|
49
|
+
- macOS: `brew install msitools`
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
Extract version from MSI:
|
|
53
|
+
```python
|
|
54
|
+
from pathlib import Path
|
|
55
|
+
from napt.versioning.msi import version_from_msi_product_version
|
|
56
|
+
discovered = version_from_msi_product_version("chrome.msi")
|
|
57
|
+
print(f"{discovered.version} from {discovered.source}")
|
|
58
|
+
# 141.0.7390.123 from msi
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Error handling:
|
|
62
|
+
```python
|
|
63
|
+
try:
|
|
64
|
+
discovered = version_from_msi_product_version("missing.msi")
|
|
65
|
+
except PackagingError as e:
|
|
66
|
+
print(f"Extraction failed: {e}")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Note:
|
|
70
|
+
This is pure file introspection; no network calls are made. All backends
|
|
71
|
+
query the MSI Property table for ProductVersion. The PowerShell approach
|
|
72
|
+
uses COM (WindowsInstaller.Installer). Errors are chained for debugging
|
|
73
|
+
(check 'from err' clause).
|
|
74
|
+
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
from __future__ import annotations
|
|
78
|
+
|
|
79
|
+
from pathlib import Path
|
|
80
|
+
import shutil
|
|
81
|
+
import subprocess
|
|
82
|
+
import sys
|
|
83
|
+
from typing import Literal
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
import msilib # type: ignore # Windows-only standard library module
|
|
87
|
+
except ImportError:
|
|
88
|
+
msilib = None # type: ignore
|
|
89
|
+
|
|
90
|
+
from dataclasses import dataclass
|
|
91
|
+
|
|
92
|
+
from napt.exceptions import ConfigError, PackagingError
|
|
93
|
+
|
|
94
|
+
from .keys import DiscoveredVersion # reuse the shared DTO
|
|
95
|
+
|
|
96
|
+
# MSI Template platform mapping
|
|
97
|
+
# See: https://learn.microsoft.com/en-us/windows/win32/msi/template-summary
|
|
98
|
+
_TEMPLATE_TO_ARCH: dict[str, str] = {
|
|
99
|
+
"intel": "x86", # Official 32-bit
|
|
100
|
+
"x64": "x64", # Official 64-bit (AMD64/x86-64)
|
|
101
|
+
"amd64": "x64", # Unofficial alias (defensive)
|
|
102
|
+
"arm64": "arm64", # Official 64-bit ARM
|
|
103
|
+
# Note: Empty string defaults to Intel (x86) per MS docs
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# Unsupported platforms that raise ConfigError
|
|
107
|
+
_UNSUPPORTED_PLATFORMS: dict[str, str] = {
|
|
108
|
+
"intel64": "Itanium (Intel64) is not supported by Intune",
|
|
109
|
+
"arm": "Windows RT 32-bit ARM is not supported by Intune",
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# Type alias for architecture values
|
|
113
|
+
Architecture = Literal["x86", "x64", "arm64"]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class MSIMetadata:
|
|
118
|
+
"""Represents metadata extracted from an MSI Property table.
|
|
119
|
+
|
|
120
|
+
Attributes:
|
|
121
|
+
product_name: ProductName from MSI (display name).
|
|
122
|
+
product_version: ProductVersion from MSI.
|
|
123
|
+
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
product_name: str
|
|
127
|
+
product_version: str
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def version_from_msi_product_version(
|
|
131
|
+
file_path: str | Path,
|
|
132
|
+
) -> DiscoveredVersion:
|
|
133
|
+
"""Extract ProductVersion from an MSI file.
|
|
134
|
+
|
|
135
|
+
Uses cross-platform backends to read the MSI Property table. On Windows,
|
|
136
|
+
tries msilib (Python 3.10 and earlier - removed in 3.13), _msi extension,
|
|
137
|
+
then PowerShell COM API as universal fallback. On Linux/macOS, requires
|
|
138
|
+
msitools package.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
file_path: Path to the MSI file.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Discovered version with source information.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
PackagingError: If the MSI file doesn't exist or version extraction fails.
|
|
148
|
+
NotImplementedError: If no extraction backend is available on this system.
|
|
149
|
+
|
|
150
|
+
"""
|
|
151
|
+
from napt.logging import get_global_logger
|
|
152
|
+
|
|
153
|
+
logger = get_global_logger()
|
|
154
|
+
p = Path(file_path)
|
|
155
|
+
if not p.exists():
|
|
156
|
+
raise PackagingError(f"MSI not found: {p}")
|
|
157
|
+
|
|
158
|
+
logger.verbose("VERSION", "Strategy: msi")
|
|
159
|
+
logger.verbose("VERSION", f"Extracting version from: {p.name}")
|
|
160
|
+
|
|
161
|
+
# Try msilib first (standard library on Windows)
|
|
162
|
+
if sys.platform.startswith("win") and msilib is not None:
|
|
163
|
+
logger.debug("VERSION", "Trying backend: msilib...")
|
|
164
|
+
try:
|
|
165
|
+
db = msilib.OpenDatabase(str(p), msilib.MSIDBOPEN_READONLY)
|
|
166
|
+
view = db.OpenView(
|
|
167
|
+
"SELECT `Value` FROM `Property` WHERE `Property`='ProductVersion'"
|
|
168
|
+
)
|
|
169
|
+
view.Execute(None)
|
|
170
|
+
rec = view.Fetch()
|
|
171
|
+
if rec is None:
|
|
172
|
+
db.Close()
|
|
173
|
+
raise PackagingError("ProductVersion not found in MSI Property table.")
|
|
174
|
+
version = rec.GetString(1)
|
|
175
|
+
db.Close()
|
|
176
|
+
if not version:
|
|
177
|
+
raise PackagingError("Empty ProductVersion in MSI Property table.")
|
|
178
|
+
logger.verbose("VERSION", f"Success! Extracted: {version} (via msilib)")
|
|
179
|
+
return DiscoveredVersion(version=version, source="msi")
|
|
180
|
+
except Exception as err:
|
|
181
|
+
logger.debug("VERSION", "msilib failed, trying next backend...")
|
|
182
|
+
raise PackagingError(
|
|
183
|
+
f"failed to read MSI ProductVersion via msilib: {err}"
|
|
184
|
+
) from err
|
|
185
|
+
|
|
186
|
+
# Try _msi module (alternative Windows approach)
|
|
187
|
+
if sys.platform.startswith("win"):
|
|
188
|
+
logger.debug("VERSION", "Trying backend: _msi...")
|
|
189
|
+
try:
|
|
190
|
+
import _msi # type: ignore
|
|
191
|
+
except ImportError:
|
|
192
|
+
# _msi not available, fall through to msiinfo
|
|
193
|
+
logger.debug("VERSION", "_msi not available, trying next backend...")
|
|
194
|
+
pass
|
|
195
|
+
else:
|
|
196
|
+
try:
|
|
197
|
+
db = _msi.OpenDatabase(str(p), 0) # 0: read-only
|
|
198
|
+
view = db.OpenView(
|
|
199
|
+
"SELECT `Value` FROM `Property` WHERE `Property`='ProductVersion'"
|
|
200
|
+
)
|
|
201
|
+
view.Execute(None)
|
|
202
|
+
rec = view.Fetch()
|
|
203
|
+
if rec is None:
|
|
204
|
+
raise PackagingError(
|
|
205
|
+
"ProductVersion not found in MSI Property table."
|
|
206
|
+
)
|
|
207
|
+
version = rec.GetString(1)
|
|
208
|
+
if not version:
|
|
209
|
+
raise PackagingError("Empty ProductVersion in MSI Property table.")
|
|
210
|
+
view.Close()
|
|
211
|
+
db.Close()
|
|
212
|
+
logger.verbose("VERSION", f"Success! Extracted: {version} (via _msi)")
|
|
213
|
+
return DiscoveredVersion(version=version, source="msi")
|
|
214
|
+
except Exception as err:
|
|
215
|
+
logger.debug("VERSION", "_msi failed, trying next backend...")
|
|
216
|
+
raise PackagingError(
|
|
217
|
+
f"failed to read MSI ProductVersion via _msi: {err}"
|
|
218
|
+
) from err
|
|
219
|
+
|
|
220
|
+
# Try PowerShell with Windows Installer COM on Windows
|
|
221
|
+
if sys.platform.startswith("win"):
|
|
222
|
+
logger.debug("VERSION", "Trying backend: PowerShell COM...")
|
|
223
|
+
try:
|
|
224
|
+
ps_script = f"""
|
|
225
|
+
$installer = New-Object -ComObject WindowsInstaller.Installer
|
|
226
|
+
$db = $installer.OpenDatabase('{p}', 0)
|
|
227
|
+
$view = $db.OpenView("SELECT Value FROM Property WHERE Property='ProductVersion'")
|
|
228
|
+
$view.Execute()
|
|
229
|
+
$record = $view.Fetch()
|
|
230
|
+
if ($record) {{
|
|
231
|
+
$record.StringData(1)
|
|
232
|
+
}} else {{
|
|
233
|
+
Write-Error "ProductVersion not found"
|
|
234
|
+
exit 1
|
|
235
|
+
}}
|
|
236
|
+
"""
|
|
237
|
+
result = subprocess.run(
|
|
238
|
+
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
|
|
239
|
+
check=True,
|
|
240
|
+
capture_output=True,
|
|
241
|
+
text=True,
|
|
242
|
+
timeout=10,
|
|
243
|
+
)
|
|
244
|
+
version = result.stdout.strip()
|
|
245
|
+
if version:
|
|
246
|
+
logger.verbose(
|
|
247
|
+
"VERSION", f"Success! Extracted: {version} (via PowerShell COM)"
|
|
248
|
+
)
|
|
249
|
+
return DiscoveredVersion(version=version, source="msi")
|
|
250
|
+
except subprocess.CalledProcessError as err:
|
|
251
|
+
logger.debug("VERSION", "PowerShell COM failed, trying next backend...")
|
|
252
|
+
raise PackagingError(f"PowerShell MSI query failed: {err}") from err
|
|
253
|
+
except subprocess.TimeoutExpired:
|
|
254
|
+
logger.debug("VERSION", "PowerShell COM timed out, trying next backend...")
|
|
255
|
+
raise PackagingError("PowerShell MSI query timed out") from None
|
|
256
|
+
|
|
257
|
+
# Try msiinfo on Linux/macOS
|
|
258
|
+
msiinfo = shutil.which("msiinfo")
|
|
259
|
+
if msiinfo:
|
|
260
|
+
logger.debug("VERSION", "Trying backend: msiinfo (msitools)...")
|
|
261
|
+
try:
|
|
262
|
+
# msiinfo export <package> Property -> stdout (tab-separated)
|
|
263
|
+
result = subprocess.run(
|
|
264
|
+
[msiinfo, "export", str(p), "Property"],
|
|
265
|
+
check=True,
|
|
266
|
+
capture_output=True,
|
|
267
|
+
text=True,
|
|
268
|
+
)
|
|
269
|
+
version_str: str | None = None
|
|
270
|
+
for line in result.stdout.splitlines():
|
|
271
|
+
parts = line.strip().split("\t", 1) # "Property<TAB>Value"
|
|
272
|
+
if len(parts) == 2 and parts[0] == "ProductVersion":
|
|
273
|
+
version_str = parts[1]
|
|
274
|
+
break
|
|
275
|
+
if not version_str:
|
|
276
|
+
raise PackagingError("ProductVersion not found in MSI Property output.")
|
|
277
|
+
logger.verbose(
|
|
278
|
+
"VERSION", f"Success! Extracted: {version_str} (via msiinfo)"
|
|
279
|
+
)
|
|
280
|
+
return DiscoveredVersion(version=version_str, source="msi")
|
|
281
|
+
except subprocess.CalledProcessError as err:
|
|
282
|
+
logger.debug("VERSION", "msiinfo failed")
|
|
283
|
+
raise PackagingError(f"msiinfo failed: {err}") from err
|
|
284
|
+
|
|
285
|
+
logger.debug("VERSION", "No MSI extraction backend available on this system")
|
|
286
|
+
raise NotImplementedError(
|
|
287
|
+
"MSI version extraction is not available on this host. "
|
|
288
|
+
"On Windows, ensure PowerShell is available. "
|
|
289
|
+
"On Linux/macOS, install 'msitools'."
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def extract_msi_metadata(file_path: str | Path) -> MSIMetadata:
|
|
294
|
+
"""Extract ProductName and ProductVersion from MSI file.
|
|
295
|
+
|
|
296
|
+
Uses cross-platform backends to read the MSI Property table. On Windows,
|
|
297
|
+
tries msilib (Python 3.10 and earlier - removed in 3.13), _msi extension,
|
|
298
|
+
then PowerShell COM API as universal fallback. On Linux/macOS, requires
|
|
299
|
+
msitools package.
|
|
300
|
+
|
|
301
|
+
This function extracts multiple properties in one pass for efficiency.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
file_path: Path to the MSI file.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
MSIMetadata with product information.
|
|
308
|
+
|
|
309
|
+
Raises:
|
|
310
|
+
PackagingError: If the MSI file doesn't exist or metadata extraction
|
|
311
|
+
fails.
|
|
312
|
+
NotImplementedError: If no extraction backend is available on this
|
|
313
|
+
system.
|
|
314
|
+
|
|
315
|
+
Example:
|
|
316
|
+
Extract MSI metadata:
|
|
317
|
+
```python
|
|
318
|
+
from pathlib import Path
|
|
319
|
+
from napt.versioning.msi import extract_msi_metadata
|
|
320
|
+
|
|
321
|
+
metadata = extract_msi_metadata(Path("chrome.msi"))
|
|
322
|
+
print(f"{metadata.product_name} {metadata.product_version}")
|
|
323
|
+
# Google Chrome 131.0.6778.86
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Note:
|
|
327
|
+
ProductName may be empty string if not found in MSI. This is handled
|
|
328
|
+
gracefully - build phase will fallback to recipe AppName if ProductName
|
|
329
|
+
is empty.
|
|
330
|
+
|
|
331
|
+
"""
|
|
332
|
+
from napt.logging import get_global_logger
|
|
333
|
+
|
|
334
|
+
logger = get_global_logger()
|
|
335
|
+
p = Path(file_path)
|
|
336
|
+
if not p.exists():
|
|
337
|
+
raise PackagingError(f"MSI not found: {p}")
|
|
338
|
+
|
|
339
|
+
logger.verbose("MSI", f"Extracting metadata from: {p.name}")
|
|
340
|
+
|
|
341
|
+
# Try msilib first (standard library on Windows)
|
|
342
|
+
if sys.platform.startswith("win") and msilib is not None:
|
|
343
|
+
logger.debug("MSI", "Trying backend: msilib...")
|
|
344
|
+
try:
|
|
345
|
+
db = msilib.OpenDatabase(str(p), msilib.MSIDBOPEN_READONLY)
|
|
346
|
+
# Query for ProductName and ProductVersion
|
|
347
|
+
view = db.OpenView(
|
|
348
|
+
"SELECT `Property`,`Value` FROM `Property` "
|
|
349
|
+
"WHERE `Property` IN ('ProductName','ProductVersion')"
|
|
350
|
+
)
|
|
351
|
+
view.Execute(None)
|
|
352
|
+
|
|
353
|
+
metadata = {"ProductName": "", "ProductVersion": ""}
|
|
354
|
+
while True:
|
|
355
|
+
rec = view.Fetch()
|
|
356
|
+
if rec is None:
|
|
357
|
+
break
|
|
358
|
+
prop_name = rec.GetString(1)
|
|
359
|
+
prop_value = rec.GetString(2)
|
|
360
|
+
if prop_name in metadata:
|
|
361
|
+
metadata[prop_name] = prop_value
|
|
362
|
+
|
|
363
|
+
view.Close()
|
|
364
|
+
db.Close()
|
|
365
|
+
|
|
366
|
+
if not metadata["ProductVersion"]:
|
|
367
|
+
raise PackagingError("ProductVersion not found in MSI Property table.")
|
|
368
|
+
|
|
369
|
+
logger.verbose(
|
|
370
|
+
"MSI",
|
|
371
|
+
f"Success! Extracted: {metadata['ProductName']} "
|
|
372
|
+
f"{metadata['ProductVersion']} (via msilib)",
|
|
373
|
+
)
|
|
374
|
+
return MSIMetadata(
|
|
375
|
+
product_name=metadata["ProductName"] or "",
|
|
376
|
+
product_version=metadata["ProductVersion"],
|
|
377
|
+
)
|
|
378
|
+
except Exception as err:
|
|
379
|
+
logger.debug("MSI", f"msilib failed: {err}, trying next backend...")
|
|
380
|
+
|
|
381
|
+
# Try _msi module (alternative Windows approach)
|
|
382
|
+
if sys.platform.startswith("win"):
|
|
383
|
+
logger.debug("MSI", "Trying backend: _msi...")
|
|
384
|
+
try:
|
|
385
|
+
import _msi # type: ignore
|
|
386
|
+
except ImportError:
|
|
387
|
+
logger.debug("MSI", "_msi not available, trying next backend...")
|
|
388
|
+
else:
|
|
389
|
+
try:
|
|
390
|
+
db = _msi.OpenDatabase(str(p), 0) # 0: read-only
|
|
391
|
+
view = db.OpenView(
|
|
392
|
+
"SELECT `Property`,`Value` FROM `Property` "
|
|
393
|
+
"WHERE `Property` IN ('ProductName','ProductVersion')"
|
|
394
|
+
)
|
|
395
|
+
view.Execute(None)
|
|
396
|
+
|
|
397
|
+
metadata = {
|
|
398
|
+
"ProductName": "",
|
|
399
|
+
"ProductVersion": "",
|
|
400
|
+
}
|
|
401
|
+
while True:
|
|
402
|
+
rec = view.Fetch()
|
|
403
|
+
if rec is None:
|
|
404
|
+
break
|
|
405
|
+
prop_name = rec.GetString(1)
|
|
406
|
+
prop_value = rec.GetString(2)
|
|
407
|
+
if prop_name in metadata:
|
|
408
|
+
metadata[prop_name] = prop_value
|
|
409
|
+
|
|
410
|
+
view.Close()
|
|
411
|
+
db.Close()
|
|
412
|
+
|
|
413
|
+
if not metadata["ProductVersion"]:
|
|
414
|
+
raise PackagingError(
|
|
415
|
+
"ProductVersion not found in MSI Property table."
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
logger.verbose(
|
|
419
|
+
"MSI",
|
|
420
|
+
f"Success! Extracted: {metadata['ProductName']} "
|
|
421
|
+
f"{metadata['ProductVersion']} (via _msi)",
|
|
422
|
+
)
|
|
423
|
+
return MSIMetadata(
|
|
424
|
+
product_name=metadata["ProductName"] or "",
|
|
425
|
+
product_version=metadata["ProductVersion"],
|
|
426
|
+
)
|
|
427
|
+
except Exception as err:
|
|
428
|
+
logger.debug("MSI", f"_msi failed: {err}, trying next backend...")
|
|
429
|
+
|
|
430
|
+
# Try PowerShell with Windows Installer COM on Windows
|
|
431
|
+
if sys.platform.startswith("win"):
|
|
432
|
+
logger.debug("MSI", "Trying backend: PowerShell COM...")
|
|
433
|
+
try:
|
|
434
|
+
# Escape single quotes in path by doubling them (PowerShell escaping)
|
|
435
|
+
escaped_path = str(p).replace("'", "''")
|
|
436
|
+
# Use double quotes for SQL string (PowerShell), single quotes for string literals (MSI SQL)
|
|
437
|
+
# No backticks needed - Property and Value aren't reserved words
|
|
438
|
+
ps_script = f"""
|
|
439
|
+
$installer = New-Object -ComObject WindowsInstaller.Installer
|
|
440
|
+
$db = $installer.OpenDatabase('{escaped_path}', 0)
|
|
441
|
+
if ($null -eq $db) {{
|
|
442
|
+
Write-Error "Failed to open database: '{escaped_path}'"
|
|
443
|
+
exit 1
|
|
444
|
+
}}
|
|
445
|
+
$sqlQuery = "SELECT Property, Value FROM Property WHERE Property = 'ProductName' OR Property = 'ProductVersion'"
|
|
446
|
+
$view = $db.OpenView($sqlQuery)
|
|
447
|
+
if ($null -eq $view) {{
|
|
448
|
+
Write-Error "OpenView returned null for SQL: $sqlQuery"
|
|
449
|
+
exit 1
|
|
450
|
+
}}
|
|
451
|
+
$view.Execute()
|
|
452
|
+
$metadata = @{{}}
|
|
453
|
+
while ($record = $view.Fetch()) {{
|
|
454
|
+
$prop = $record.StringData(1)
|
|
455
|
+
$value = $record.StringData(2)
|
|
456
|
+
if ($prop -in @('ProductName','ProductVersion')) {{
|
|
457
|
+
$metadata[$prop] = $value
|
|
458
|
+
}}
|
|
459
|
+
}}
|
|
460
|
+
$view.Close()
|
|
461
|
+
$db.Close()
|
|
462
|
+
if (-not $metadata['ProductVersion']) {{
|
|
463
|
+
Write-Error "ProductVersion not found"
|
|
464
|
+
exit 1
|
|
465
|
+
}}
|
|
466
|
+
@($metadata['ProductName'], $metadata['ProductVersion']) -join "`n"
|
|
467
|
+
"""
|
|
468
|
+
result = subprocess.run(
|
|
469
|
+
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
|
|
470
|
+
check=True,
|
|
471
|
+
capture_output=True,
|
|
472
|
+
text=True,
|
|
473
|
+
timeout=10,
|
|
474
|
+
)
|
|
475
|
+
lines = result.stdout.strip().split("\n")
|
|
476
|
+
product_name = lines[0] if len(lines) > 0 else ""
|
|
477
|
+
product_version = lines[1] if len(lines) > 1 else ""
|
|
478
|
+
|
|
479
|
+
if not product_version:
|
|
480
|
+
raise PackagingError("ProductVersion not found in MSI Property table.")
|
|
481
|
+
|
|
482
|
+
logger.verbose(
|
|
483
|
+
"MSI",
|
|
484
|
+
f"Success! Extracted: {product_name} {product_version} "
|
|
485
|
+
"(via PowerShell COM)",
|
|
486
|
+
)
|
|
487
|
+
return MSIMetadata(
|
|
488
|
+
product_name=product_name or "",
|
|
489
|
+
product_version=product_version,
|
|
490
|
+
)
|
|
491
|
+
except subprocess.CalledProcessError as err:
|
|
492
|
+
# Capture stderr to see what PowerShell actually reported
|
|
493
|
+
stderr_output = err.stderr if err.stderr else "No stderr captured"
|
|
494
|
+
stdout_output = err.stdout if err.stdout else "No stdout captured"
|
|
495
|
+
logger.debug(
|
|
496
|
+
"MSI",
|
|
497
|
+
f"PowerShell COM failed: {err}. stdout: {stdout_output}, stderr: {stderr_output}. Trying next backend...",
|
|
498
|
+
)
|
|
499
|
+
except subprocess.TimeoutExpired:
|
|
500
|
+
logger.debug("MSI", "PowerShell COM timed out, trying next backend...")
|
|
501
|
+
except Exception as err:
|
|
502
|
+
logger.debug("MSI", f"PowerShell COM failed: {err}, trying next backend...")
|
|
503
|
+
|
|
504
|
+
# Try msiinfo on Linux/macOS (or as last resort on Windows)
|
|
505
|
+
msiinfo = shutil.which("msiinfo")
|
|
506
|
+
if msiinfo:
|
|
507
|
+
logger.debug("MSI", "Trying backend: msiinfo (msitools)...")
|
|
508
|
+
try:
|
|
509
|
+
# msiinfo export <package> Property -> stdout (tab-separated)
|
|
510
|
+
result = subprocess.run(
|
|
511
|
+
[msiinfo, "export", str(p), "Property"],
|
|
512
|
+
check=True,
|
|
513
|
+
capture_output=True,
|
|
514
|
+
text=True,
|
|
515
|
+
)
|
|
516
|
+
metadata = {"ProductName": "", "ProductVersion": ""}
|
|
517
|
+
for line in result.stdout.splitlines():
|
|
518
|
+
parts = line.strip().split("\t", 1) # "Property<TAB>Value"
|
|
519
|
+
if len(parts) == 2:
|
|
520
|
+
prop_name = parts[0]
|
|
521
|
+
prop_value = parts[1]
|
|
522
|
+
if prop_name in metadata:
|
|
523
|
+
metadata[prop_name] = prop_value
|
|
524
|
+
|
|
525
|
+
if not metadata["ProductVersion"]:
|
|
526
|
+
raise PackagingError("ProductVersion not found in MSI Property output.")
|
|
527
|
+
|
|
528
|
+
logger.verbose(
|
|
529
|
+
"MSI",
|
|
530
|
+
f"Success! Extracted: {metadata['ProductName']} "
|
|
531
|
+
f"{metadata['ProductVersion']} (via msiinfo)",
|
|
532
|
+
)
|
|
533
|
+
return MSIMetadata(
|
|
534
|
+
product_name=metadata["ProductName"] or "",
|
|
535
|
+
product_version=metadata["ProductVersion"],
|
|
536
|
+
)
|
|
537
|
+
except Exception as err:
|
|
538
|
+
logger.debug("MSI", f"msiinfo failed: {err}")
|
|
539
|
+
|
|
540
|
+
# All backends failed
|
|
541
|
+
logger.debug("MSI", "No MSI extraction backend available on this system")
|
|
542
|
+
raise NotImplementedError(
|
|
543
|
+
"MSI metadata extraction is not available on this host. "
|
|
544
|
+
"On Windows, ensure PowerShell is available. "
|
|
545
|
+
"On Linux/macOS, install 'msitools'."
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def architecture_from_template(template: str) -> Architecture:
|
|
550
|
+
"""Parse MSI Template property into NAPT architecture value.
|
|
551
|
+
|
|
552
|
+
The Template property format is platform;language_id (semicolon, then
|
|
553
|
+
optional language codes). Examples: "x64;1033", "Intel;1033,1041",
|
|
554
|
+
";1033" (empty platform defaults to Intel).
|
|
555
|
+
|
|
556
|
+
Args:
|
|
557
|
+
template: Raw Template property string from MSI Summary Information.
|
|
558
|
+
|
|
559
|
+
Returns:
|
|
560
|
+
Architecture value: "x86", "x64", or "arm64".
|
|
561
|
+
|
|
562
|
+
Raises:
|
|
563
|
+
ConfigError: If the platform is not supported by Intune (Itanium, ARM32).
|
|
564
|
+
|
|
565
|
+
Example:
|
|
566
|
+
Parse template strings:
|
|
567
|
+
```python
|
|
568
|
+
arch = architecture_from_template("x64;1033")
|
|
569
|
+
# Returns: "x64"
|
|
570
|
+
|
|
571
|
+
arch = architecture_from_template("Intel;1033")
|
|
572
|
+
# Returns: "x86"
|
|
573
|
+
|
|
574
|
+
arch = architecture_from_template(";1033")
|
|
575
|
+
# Returns: "x86" (empty defaults to Intel)
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
"""
|
|
579
|
+
# Split on semicolon and take only the first token (platform)
|
|
580
|
+
# Discard remaining tokens (language codes like 1033)
|
|
581
|
+
platform = template.split(";")[0].strip().lower()
|
|
582
|
+
|
|
583
|
+
# Empty platform defaults to Intel (x86) per Microsoft docs
|
|
584
|
+
if not platform:
|
|
585
|
+
return "x86"
|
|
586
|
+
|
|
587
|
+
# Check for unsupported platforms first
|
|
588
|
+
if platform in _UNSUPPORTED_PLATFORMS:
|
|
589
|
+
raise ConfigError(
|
|
590
|
+
f"MSI platform '{platform}' is not supported. "
|
|
591
|
+
f"{_UNSUPPORTED_PLATFORMS[platform]}."
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
# Map to NAPT architecture
|
|
595
|
+
arch = _TEMPLATE_TO_ARCH.get(platform)
|
|
596
|
+
if arch is None:
|
|
597
|
+
raise ConfigError(
|
|
598
|
+
f"Unknown MSI platform '{platform}' in Template property. "
|
|
599
|
+
f"Expected one of: Intel, x64, AMD64, Arm64."
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
return arch # type: ignore[return-value]
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def extract_msi_architecture(file_path: str | Path) -> Architecture:
|
|
606
|
+
"""Extract architecture from MSI Summary Information Template property.
|
|
607
|
+
|
|
608
|
+
Uses cross-platform backends to read the MSI Summary Information stream.
|
|
609
|
+
On Windows, uses PowerShell COM API. On Linux/macOS, requires msitools.
|
|
610
|
+
|
|
611
|
+
Args:
|
|
612
|
+
file_path: Path to the MSI file.
|
|
613
|
+
|
|
614
|
+
Returns:
|
|
615
|
+
Architecture value: "x86", "x64", or "arm64".
|
|
616
|
+
|
|
617
|
+
Raises:
|
|
618
|
+
PackagingError: If the MSI file doesn't exist or extraction fails.
|
|
619
|
+
ConfigError: If the platform is not supported by Intune.
|
|
620
|
+
NotImplementedError: If no extraction backend is available.
|
|
621
|
+
|
|
622
|
+
Example:
|
|
623
|
+
Extract architecture from MSI:
|
|
624
|
+
```python
|
|
625
|
+
from napt.versioning.msi import extract_msi_architecture
|
|
626
|
+
|
|
627
|
+
arch = extract_msi_architecture("chrome.msi")
|
|
628
|
+
print(f"Architecture: {arch}")
|
|
629
|
+
# Architecture: x64
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
"""
|
|
633
|
+
from napt.logging import get_global_logger
|
|
634
|
+
|
|
635
|
+
logger = get_global_logger()
|
|
636
|
+
p = Path(file_path)
|
|
637
|
+
if not p.exists():
|
|
638
|
+
raise PackagingError(f"MSI not found: {p}")
|
|
639
|
+
|
|
640
|
+
logger.verbose("MSI", f"Extracting architecture from: {p.name}")
|
|
641
|
+
|
|
642
|
+
template: str | None = None
|
|
643
|
+
|
|
644
|
+
# Try PowerShell with Windows Installer COM on Windows
|
|
645
|
+
# Summary Information Property ID 7 is Template
|
|
646
|
+
if sys.platform.startswith("win"):
|
|
647
|
+
logger.debug("MSI", "Trying backend: PowerShell COM (SummaryInformation)...")
|
|
648
|
+
try:
|
|
649
|
+
escaped_path = str(p).replace("'", "''")
|
|
650
|
+
ps_script = f"""
|
|
651
|
+
$installer = New-Object -ComObject WindowsInstaller.Installer
|
|
652
|
+
$db = $installer.OpenDatabase('{escaped_path}', 0)
|
|
653
|
+
if ($null -eq $db) {{
|
|
654
|
+
Write-Error "Failed to open database: '{escaped_path}'"
|
|
655
|
+
exit 1
|
|
656
|
+
}}
|
|
657
|
+
$sumInfo = $db.SummaryInformation(0)
|
|
658
|
+
$template = $sumInfo.Property(7)
|
|
659
|
+
$db.Close()
|
|
660
|
+
$template
|
|
661
|
+
"""
|
|
662
|
+
result = subprocess.run(
|
|
663
|
+
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
|
|
664
|
+
check=True,
|
|
665
|
+
capture_output=True,
|
|
666
|
+
text=True,
|
|
667
|
+
timeout=10,
|
|
668
|
+
)
|
|
669
|
+
template = result.stdout.strip()
|
|
670
|
+
logger.verbose(
|
|
671
|
+
"MSI",
|
|
672
|
+
f"Success! Extracted Template: '{template}' (via PowerShell COM)",
|
|
673
|
+
)
|
|
674
|
+
except subprocess.CalledProcessError as err:
|
|
675
|
+
stderr_output = err.stderr if err.stderr else "No stderr"
|
|
676
|
+
logger.debug(
|
|
677
|
+
"MSI",
|
|
678
|
+
f"PowerShell COM failed: {err}. stderr: {stderr_output}. "
|
|
679
|
+
"Trying next backend...",
|
|
680
|
+
)
|
|
681
|
+
except subprocess.TimeoutExpired:
|
|
682
|
+
logger.debug("MSI", "PowerShell COM timed out, trying next backend...")
|
|
683
|
+
except Exception as err:
|
|
684
|
+
logger.debug("MSI", f"PowerShell COM failed: {err}, trying next backend...")
|
|
685
|
+
|
|
686
|
+
# Try msiinfo on Linux/macOS (or as fallback on Windows)
|
|
687
|
+
if template is None:
|
|
688
|
+
msiinfo = shutil.which("msiinfo")
|
|
689
|
+
if msiinfo:
|
|
690
|
+
logger.debug("MSI", "Trying backend: msiinfo suminfo...")
|
|
691
|
+
try:
|
|
692
|
+
# msiinfo suminfo <package> outputs summary information
|
|
693
|
+
result = subprocess.run(
|
|
694
|
+
[msiinfo, "suminfo", str(p)],
|
|
695
|
+
check=True,
|
|
696
|
+
capture_output=True,
|
|
697
|
+
text=True,
|
|
698
|
+
)
|
|
699
|
+
# Parse output for Template line
|
|
700
|
+
# Format: "Template: x64;1033"
|
|
701
|
+
for line in result.stdout.splitlines():
|
|
702
|
+
if line.startswith("Template:"):
|
|
703
|
+
template = line.split(":", 1)[1].strip()
|
|
704
|
+
break
|
|
705
|
+
|
|
706
|
+
if template is not None:
|
|
707
|
+
logger.verbose(
|
|
708
|
+
"MSI",
|
|
709
|
+
f"Success! Extracted Template: '{template}' (via msiinfo)",
|
|
710
|
+
)
|
|
711
|
+
except subprocess.CalledProcessError as err:
|
|
712
|
+
logger.debug("MSI", f"msiinfo suminfo failed: {err}")
|
|
713
|
+
except Exception as err:
|
|
714
|
+
logger.debug("MSI", f"msiinfo failed: {err}")
|
|
715
|
+
|
|
716
|
+
if template is None:
|
|
717
|
+
logger.debug("MSI", "No backend could extract Template property")
|
|
718
|
+
raise NotImplementedError(
|
|
719
|
+
"MSI architecture extraction is not available on this host. "
|
|
720
|
+
"On Windows, ensure PowerShell is available. "
|
|
721
|
+
"On Linux/macOS, install 'msitools'."
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
# Parse the template into architecture
|
|
725
|
+
return architecture_from_template(template)
|