wimcast 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.
wimcast/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """wimcast: Linux-native Windows image builder for Foreman."""
2
+
3
+ __version__ = "0.1.0"
wimcast/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """wimcast CLI entry point."""
2
+
3
+ from wimcast.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
wimcast/bootfiles.py ADDED
@@ -0,0 +1,151 @@
1
+ """Boot payload: pinned wimboot and the ISO's boot files (plan 5.2 stage 8).
2
+
3
+ wimboot is downloaded from the GitHub release matching the pinned version
4
+ (config [wimboot] version, default 2.9.0, see README "Why wimboot is
5
+ pinned"). bootmgr, bootmgr.efi, boot\\boot.sdi and boot\\bcd come from the
6
+ source ISO, preserving the exact bits the image was built from.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import hashlib
13
+ import os
14
+ import tempfile
15
+ import urllib.request
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING
18
+
19
+ if TYPE_CHECKING:
20
+ from wimcast.cache import Cache
21
+
22
+ from wimcast.iso import IsoReader, open_iso
23
+
24
+ WIMBOOT_BASE_URL = "https://github.com/ipxe/wimboot/releases/download/v{version}/wimboot"
25
+ WIMBOOT_SHA256 = {
26
+ "2.9.0": "5f067ccdc4d084d5bf77b6c853bd0f8402dfc2b4cd1b103d358993ae97fae8e3",
27
+ }
28
+ _WIMBOOT_CHUNK = 1024 * 1024
29
+
30
+ # ISO members copied to the final layout, (iso member, relative dest)
31
+ BOOT_MEMBERS: tuple[tuple[str, str], ...] = (
32
+ ("bootmgr", "bootmgr"),
33
+ ("bootmgr.efi", "bootmgr.efi"),
34
+ ("boot/boot.sdi", "boot/boot.sdi"),
35
+ ("boot/bcd", "boot/bcd"),
36
+ )
37
+
38
+
39
+ class BootfilesError(RuntimeError):
40
+ """A boot-file fetch or extraction failure."""
41
+
42
+ def __init__(self, message: str) -> None:
43
+ super().__init__(message)
44
+ self.message = message
45
+
46
+
47
+ def _sha256_file(path: Path) -> str:
48
+ digest = hashlib.sha256()
49
+ with path.open("rb") as fd:
50
+ for chunk in iter(lambda: fd.read(_WIMBOOT_CHUNK), b""):
51
+ digest.update(chunk)
52
+ return digest.hexdigest()
53
+
54
+
55
+ class Bootfiles:
56
+ def __init__(
57
+ self,
58
+ cache: Cache | None = None,
59
+ sevenzip: str | None = None,
60
+ sha256: str | None = None,
61
+ ) -> None:
62
+ self._cache = cache
63
+ self._sevenzip = sevenzip
64
+ self._sha256 = sha256.lower() if sha256 else None
65
+
66
+ def _expected_sha256(self, version: str) -> str:
67
+ if self._sha256 is not None:
68
+ return self._sha256
69
+ digest = WIMBOOT_SHA256.get(version)
70
+ if digest is None:
71
+ raise BootfilesError(
72
+ f"no pinned SHA-256 for wimboot v{version}; "
73
+ "set [wimboot] sha256 to the digest of the release asset"
74
+ )
75
+ return digest
76
+
77
+ async def wimboot(self, version: str, dest: Path) -> Path:
78
+ """Pinned wimboot binary, cached; downloads on miss."""
79
+ version = version.lstrip("v")
80
+ expected = self._expected_sha256(version)
81
+ key = f"wimboot:v{version}:{expected}"
82
+ if self._cache is not None and self._cache.materialize(key, dest):
83
+ return dest
84
+ url = WIMBOOT_BASE_URL.format(version=version)
85
+ data = await asyncio.to_thread(self._download, url)
86
+ if not data:
87
+ raise BootfilesError(f"wimboot v{version} downloaded empty payload")
88
+ sha = hashlib.sha256(data).hexdigest()
89
+ if sha != expected:
90
+ raise BootfilesError(
91
+ f"wimboot v{version} checksum mismatch: expected {expected}, got {sha}"
92
+ )
93
+ dest.parent.mkdir(parents=True, exist_ok=True)
94
+ fd, tmp_name = tempfile.mkstemp(prefix=".wimboot-", suffix=".tmp", dir=dest.parent)
95
+ tmp = Path(tmp_name)
96
+ try:
97
+ with os.fdopen(fd, "wb") as out:
98
+ out.write(data)
99
+ out.flush()
100
+ os.fsync(out.fileno())
101
+ if self._cache is not None:
102
+ blob = self._cache.put(key, tmp, sha, len(data))
103
+ if self._cache.enabled:
104
+ if not self._cache.materialize(key, dest):
105
+ raise BootfilesError(f"cache lost wimboot entry {key}")
106
+ else:
107
+ blob.replace(dest)
108
+ else:
109
+ tmp.replace(dest)
110
+ finally:
111
+ tmp.unlink(missing_ok=True)
112
+ return dest
113
+
114
+ @staticmethod
115
+ def _download(url: str) -> bytes:
116
+ try:
117
+ with urllib.request.urlopen(url, timeout=60) as response:
118
+ return bytes(response.read())
119
+ except OSError as exc:
120
+ raise BootfilesError(f"cannot download {url}: {exc}") from exc
121
+
122
+ async def iso_boot_files(self, iso_path: Path, dest_dir: Path) -> None:
123
+ """Copy the boot payload members from the ISO into dest_dir."""
124
+ await asyncio.to_thread(self._iso_boot_files_sync, iso_path, dest_dir)
125
+
126
+ def _iso_boot_files_sync(self, iso_path: Path, dest_dir: Path) -> None:
127
+ reader = open_iso(iso_path, self._sevenzip)
128
+ try:
129
+ self._copy_members(reader, dest_dir)
130
+ finally:
131
+ reader.close()
132
+
133
+ def _copy_members(self, reader: IsoReader, dest_dir: Path) -> None:
134
+ for member, rel in BOOT_MEMBERS:
135
+ if not reader.exists(member):
136
+ raise BootfilesError(f"boot member not found in ISO: {member}")
137
+ self._copy_member(reader, member, dest_dir / rel)
138
+
139
+ def _copy_member(self, reader: IsoReader, member: str, dest: Path) -> None:
140
+ dest.parent.mkdir(parents=True, exist_ok=True)
141
+ fd, tmp_name = tempfile.mkstemp(prefix=".boot-", suffix=".tmp", dir=dest.parent)
142
+ tmp = Path(tmp_name)
143
+ try:
144
+ with os.fdopen(fd, "wb") as out:
145
+ for chunk in reader.stream_file(member):
146
+ out.write(chunk)
147
+ out.flush()
148
+ os.fsync(out.fileno())
149
+ tmp.replace(dest)
150
+ finally:
151
+ tmp.unlink(missing_ok=True)
wimcast/build.py ADDED
@@ -0,0 +1,453 @@
1
+ """Build orchestrator (plan 5.2).
2
+
3
+ One source leaf = one ISO = one finalized/<arch>/<name>/ tree. Stage
4
+ ordering, caching and verification:
5
+
6
+ probe -> hash -> extract -> info -> filter -> export -> winpe -> bootfiles
7
+ -> publish -> manifest -> verify
8
+
9
+ Every expensive stage is keyed in the content-addressed store; a hit skips
10
+ the work entirely and only republishes the files.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import hashlib
17
+ import logging
18
+ import shutil
19
+ import tempfile
20
+ import time
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING
24
+
25
+ if TYPE_CHECKING:
26
+ from wimcast.cache import Cache
27
+ from wimcast.config import Config
28
+
29
+ from wimcast import __version__
30
+ from wimcast.bootfiles import Bootfiles
31
+ from wimcast.cache import stage_key
32
+ from wimcast.drivers import Drivers
33
+ from wimcast.iso import open_iso
34
+ from wimcast.manifest import (
35
+ BuildMeta,
36
+ edition_filenames,
37
+ write_build_json,
38
+ write_images_ini,
39
+ )
40
+ from wimcast.model import Artifact, Source, WimImage
41
+ from wimcast.probe import BOOT_MEMBER, ISO_MEMBERS, SourceError, discover
42
+ from wimcast.publish import prune_tree, publish_file, publish_tree
43
+ from wimcast.run import Runner, ToolError
44
+ from wimcast.wim import WimError, Wimlib, WimOperations, parse_wimlib_version
45
+ from wimcast.winpe import build_winpe
46
+
47
+ log = logging.getLogger("wimcast.build")
48
+
49
+ _LAYOUT_VERSION = "v1"
50
+ _MIN_WIMLIB = (1, 13, 0)
51
+ _CHUNK = 1024 * 1024
52
+
53
+
54
+ class BuildError(RuntimeError):
55
+ pass
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Stage:
60
+ name: str
61
+ duration_s: float
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class SourceResult:
66
+ arch: str
67
+ name: str
68
+ images: tuple[WimImage, ...]
69
+ exported: tuple[tuple[int, str, str], ...]
70
+ artifacts: tuple[Artifact, ...]
71
+ stages: tuple[Stage, ...]
72
+
73
+
74
+ @dataclass
75
+ class BuildSummary:
76
+ sources: list[SourceResult] = field(default_factory=list)
77
+ durations_s: float = 0.0
78
+
79
+
80
+ def config_hash(config: Config) -> str:
81
+ return hashlib.sha256(repr(config).encode("utf-8")).hexdigest()
82
+
83
+
84
+ def tree_hash(directory: Path) -> str:
85
+ """Deterministic hash of a directory tree (relative paths + contents)."""
86
+ digest = hashlib.sha256()
87
+ for path in sorted(directory.rglob("*")):
88
+ if path.is_file():
89
+ rel = path.relative_to(directory).as_posix().encode("utf-8")
90
+ digest.update(rel)
91
+ with path.open("rb") as fd:
92
+ for chunk in iter(lambda: fd.read(_CHUNK), b""):
93
+ digest.update(chunk)
94
+ return digest.hexdigest()
95
+
96
+
97
+ def file_sha256(path: Path) -> str:
98
+ digest = hashlib.sha256()
99
+ with path.open("rb") as fd:
100
+ for chunk in iter(lambda: fd.read(_CHUNK), b""):
101
+ digest.update(chunk)
102
+ return digest.hexdigest()
103
+
104
+
105
+ class IsoHashMemo:
106
+ """In-process memo of ISO hashes, keyed on (path, size, mtime)."""
107
+
108
+ def __init__(self) -> None:
109
+ self._entries: dict[tuple[str, int, int], str] = {}
110
+
111
+ def get(self, path: Path) -> str:
112
+ stat = path.stat()
113
+ key = (str(path), stat.st_size, stat.st_mtime_ns)
114
+ cached = self._entries.get(key)
115
+ if cached is not None:
116
+ return cached
117
+ sha = file_sha256(path)
118
+ self._entries[key] = sha
119
+ return sha
120
+
121
+
122
+ class Builder:
123
+ def __init__(
124
+ self,
125
+ config: Config,
126
+ runner: Runner,
127
+ cache: Cache | None = None,
128
+ wimlib: WimOperations | None = None,
129
+ workdir_base: Path | None = None,
130
+ finalized_root: Path = Path("finalized"),
131
+ ) -> None:
132
+ from wimcast.cache import Cache
133
+
134
+ config.validate()
135
+ self._config = config
136
+ self._runner = runner
137
+ self._cache = cache if cache is not None else Cache(config.cache.directory)
138
+ self._wimlib = wimlib or Wimlib(runner, config.tools.wimlib_imagex)
139
+ self._drivers = Drivers(config)
140
+ self._memo = IsoHashMemo()
141
+ self._workdir_base = workdir_base
142
+ self._finalized_root = finalized_root
143
+ self._wimlib_version: str | None = None
144
+
145
+ async def _tool_version(self) -> str:
146
+ if self._wimlib_version is None:
147
+ exe = self._config.tools.wimlib_imagex or "wimlib-imagex"
148
+ try:
149
+ result = await self._runner.run(exe, "--version")
150
+ except ToolError as exc:
151
+ raise BuildError(f"wimlib-imagex not usable: {exc}") from exc
152
+ version = parse_wimlib_version(result.stdout)
153
+ if version is None:
154
+ raise BuildError(f"cannot parse wimlib-imagex version from {result.stdout!r}")
155
+ if version < _MIN_WIMLIB:
156
+ raise BuildError(
157
+ "wimlib-imagex >= 1.13 required, found "
158
+ f"{'.'.join(str(part) for part in version)}"
159
+ )
160
+ self._wimlib_version = f"wimlib-imagex {'.'.join(str(part) for part in version)}"
161
+ return self._wimlib_version
162
+
163
+ async def build(self, path: Path | None = None) -> BuildSummary:
164
+ path = path or Path("sources")
165
+ try:
166
+ sources = discover(path)
167
+ except SourceError as exc:
168
+ raise BuildError(str(exc)) from exc
169
+ if not sources:
170
+ raise BuildError(f"no sources found under {path}")
171
+ summary = BuildSummary()
172
+ for source in sources:
173
+ started = time.monotonic()
174
+ result = await self.build_source(source)
175
+ summary.sources.append(result)
176
+ summary.durations_s += time.monotonic() - started
177
+ return summary
178
+
179
+ async def build_source(self, source: Source) -> SourceResult:
180
+ stages: list[Stage] = []
181
+ workdir = self._new_workdir(source)
182
+ try:
183
+ cfg_hash = config_hash(self._config)
184
+ tool_version = await self._tool_version()
185
+
186
+ iso_sha = await asyncio.to_thread(self._memo.get, source.iso_path)
187
+
188
+ started = time.monotonic()
189
+ install_wim, boot_wim, backend = await asyncio.to_thread(
190
+ self._extract, source.iso_path, workdir
191
+ )
192
+ stages.append(Stage("extract", time.monotonic() - started))
193
+ install_sha = await asyncio.to_thread(file_sha256, install_wim)
194
+
195
+ started = time.monotonic()
196
+ images = await self._wimlib.info(install_wim)
197
+ stages.append(Stage("info", time.monotonic() - started))
198
+
199
+ selected = self._select_editions(images)
200
+ if not selected:
201
+ raise BuildError(
202
+ f"no edition matched [editions] allowed={self._config.editions.allowed!r}"
203
+ )
204
+
205
+ started = time.monotonic()
206
+ exported = await self._export(
207
+ selected, install_wim, install_sha, cfg_hash, tool_version, workdir
208
+ )
209
+ stages.append(Stage("export", time.monotonic() - started))
210
+
211
+ started = time.monotonic()
212
+ boot_wim_final = await self._build_winpe(
213
+ source, boot_wim, cfg_hash, tool_version, workdir
214
+ )
215
+ stages.append(Stage("winpe", time.monotonic() - started))
216
+
217
+ # Emit driver reports (boot.wim injected, install.wim deferred).
218
+ self._drivers.report_for(source)
219
+
220
+ started = time.monotonic()
221
+ bootfiles = Bootfiles(
222
+ self._cache,
223
+ sevenzip=self._config.tools.sevenzip,
224
+ sha256=self._config.wimboot.sha256,
225
+ )
226
+ boot_dir = workdir / "boot"
227
+ await bootfiles.wimboot(self._config.wimboot.version, boot_dir / "wimboot")
228
+ await bootfiles.iso_boot_files(source.iso_path, boot_dir)
229
+ stages.append(Stage("bootfiles", time.monotonic() - started))
230
+
231
+ started = time.monotonic()
232
+ published = await asyncio.to_thread(
233
+ self._publish,
234
+ source,
235
+ workdir,
236
+ exported,
237
+ iso_sha,
238
+ tool_version,
239
+ backend,
240
+ boot_wim_final,
241
+ )
242
+ stages.append(Stage("publish", time.monotonic() - started))
243
+
244
+ started = time.monotonic()
245
+ await self._verify(source, exported)
246
+ stages.append(Stage("verify", time.monotonic() - started))
247
+
248
+ started = time.monotonic()
249
+ artifacts = await asyncio.to_thread(self._published_artifacts, source)
250
+ dest = self._finalized_root / source.arch / source.name
251
+ meta = BuildMeta(
252
+ layout_version=_LAYOUT_VERSION,
253
+ wimcast_version=__version__,
254
+ tool_wimlib_imagex=tool_version,
255
+ iso_backend=backend,
256
+ iso=source.iso_path.name,
257
+ iso_sha256=iso_sha,
258
+ arch=source.arch,
259
+ name=source.name,
260
+ edition_images=tuple(exported),
261
+ artifacts=artifacts,
262
+ start_offset_s=0.0,
263
+ end_offset_s=sum(stage.duration_s for stage in stages),
264
+ )
265
+ await asyncio.to_thread(write_build_json, dest / "build.json", meta)
266
+ expected = published | {dest / "build.json"}
267
+ await asyncio.to_thread(prune_tree, dest, expected)
268
+ stages.append(Stage("manifest", time.monotonic() - started))
269
+
270
+ return SourceResult(
271
+ arch=source.arch,
272
+ name=source.name,
273
+ images=images,
274
+ exported=tuple(exported),
275
+ artifacts=artifacts,
276
+ stages=tuple(stages),
277
+ )
278
+ finally:
279
+ if workdir != self._workdir_base:
280
+ try:
281
+ shutil.rmtree(workdir)
282
+ except OSError as exc:
283
+ log.warning("cannot remove workdir %s: %s", workdir, exc)
284
+
285
+ def _new_workdir(self, source: Source) -> Path:
286
+ if self._workdir_base is not None:
287
+ workdir = self._workdir_base / source.arch / source.name
288
+ else:
289
+ workdir = Path(tempfile.mkdtemp(prefix="wimcast-"))
290
+ workdir.mkdir(parents=True, exist_ok=True)
291
+ return workdir
292
+
293
+ def _extract(self, iso_path: Path, workdir: Path) -> tuple[Path, Path | None, str]:
294
+ """Extract install.wim (or install.esd) and boot.wim from the ISO."""
295
+ reader = open_iso(iso_path, self._config.tools.sevenzip)
296
+ dest_dir = workdir / "iso"
297
+ dest_dir.mkdir(parents=True, exist_ok=True)
298
+ try:
299
+ member = next((m for m in ISO_MEMBERS if reader.exists(m)), None)
300
+ if member is None:
301
+ raise BuildError(f"neither {' nor '.join(ISO_MEMBERS)} found in {iso_path}")
302
+ install_dest = dest_dir / "install.wim"
303
+ reader.extract_file(member, install_dest)
304
+ boot_dest: Path | None = None
305
+ if reader.exists(BOOT_MEMBER):
306
+ boot_dest = dest_dir / "boot.wim"
307
+ reader.extract_file(BOOT_MEMBER, boot_dest)
308
+ return install_dest, boot_dest, reader.backend
309
+ finally:
310
+ reader.close()
311
+
312
+ def _select_editions(self, images: tuple[WimImage, ...]) -> tuple[WimImage, ...]:
313
+ allowed = self._config.editions.allowed
314
+ if not allowed:
315
+ return images
316
+ return tuple(
317
+ image for image in images if any(part.lower() in image.name.lower() for part in allowed)
318
+ )
319
+
320
+ async def _export(
321
+ self,
322
+ images: tuple[WimImage, ...],
323
+ install_wim: Path,
324
+ install_sha: str,
325
+ cfg_hash: str,
326
+ tool_version: str,
327
+ workdir: Path,
328
+ ) -> list[tuple[int, str, str]]:
329
+ exported: list[tuple[int, str, str]] = []
330
+ sources_dir = workdir / "sources"
331
+ plan = edition_filenames(images, self._config.output.filename_scheme)
332
+ for image, filename in plan:
333
+ key = stage_key("export", install_sha, image.index, filename, cfg_hash, tool_version)
334
+ dest = sources_dir / filename
335
+ if dest.parent != sources_dir:
336
+ raise BuildError(f"unsafe export filename: {filename!r}")
337
+ if not self._cache.materialize(key, dest):
338
+ dest.parent.mkdir(parents=True, exist_ok=True)
339
+ await self._wimlib.export(install_wim, image.index, dest, compress="LZX")
340
+ sha = await asyncio.to_thread(file_sha256, dest)
341
+ blob = self._cache.put(key, dest, sha, dest.stat().st_size)
342
+ if self._cache.enabled:
343
+ self._cache.materialize(key, dest)
344
+ else:
345
+ dest = blob
346
+ exported.append((image.index, image.name, filename))
347
+ return exported
348
+
349
+ async def _build_winpe(
350
+ self,
351
+ source: Source,
352
+ boot_wim: Path | None,
353
+ cfg_hash: str,
354
+ tool_version: str,
355
+ workdir: Path,
356
+ ) -> Path | None:
357
+ config = self._config
358
+ if config.winpe.source == "file":
359
+ if config.winpe.path is None or config.winpe.sha256 is None:
360
+ raise BuildError('[winpe] source = "file" requires path and sha256')
361
+ base = Path(config.winpe.path)
362
+ if not base.is_file():
363
+ raise BuildError(f"[winpe] path does not exist: {base}")
364
+ if await asyncio.to_thread(file_sha256, base) != config.winpe.sha256.lower():
365
+ raise BuildError(f"[winpe] sha256 mismatch for {base}")
366
+ base_index = 1
367
+ else:
368
+ if boot_wim is None:
369
+ raise BuildError(f"{source.iso_path}: sources/boot.wim missing from ISO")
370
+ base = boot_wim
371
+ base_index = config.winpe.iso_index
372
+ base_sha = await asyncio.to_thread(file_sha256, base)
373
+ overlay = Path(config.winpe.overlay_dir) if config.winpe.overlay_dir else None
374
+ overlay_hash = (
375
+ await asyncio.to_thread(tree_hash, overlay)
376
+ if overlay is not None and overlay.is_dir()
377
+ else ""
378
+ )
379
+ drivers_dir = self._bootpe_drivers_dir(source)
380
+ drivers_hash = (
381
+ await asyncio.to_thread(tree_hash, drivers_dir) if drivers_dir is not None else ""
382
+ )
383
+ key = stage_key(
384
+ "winpe", base_sha, base_index, overlay_hash, drivers_hash, cfg_hash, tool_version
385
+ )
386
+ out = workdir / "boot.wim"
387
+ if not self._cache.materialize(key, out):
388
+ await build_winpe(
389
+ self._wimlib,
390
+ base,
391
+ base_index,
392
+ workdir,
393
+ overlay_dir=overlay,
394
+ drivers_dir=drivers_dir,
395
+ )
396
+ sha = await asyncio.to_thread(file_sha256, out)
397
+ self._cache.put(key, out, sha, out.stat().st_size)
398
+ if self._cache.enabled:
399
+ self._cache.materialize(key, out)
400
+ return out
401
+
402
+ def _bootpe_drivers_dir(self, source: Source) -> Path | None:
403
+ drivers_root = self._config.winpe.drivers_dir
404
+ if drivers_root is None:
405
+ return None
406
+ arch_root = Path(drivers_root) / source.arch
407
+ return arch_root if arch_root.is_dir() else None
408
+
409
+ def _publish(
410
+ self,
411
+ source: Source,
412
+ workdir: Path,
413
+ exported: list[tuple[int, str, str]],
414
+ iso_sha: str,
415
+ tool_version: str,
416
+ backend: str,
417
+ boot_wim: Path | None,
418
+ ) -> set[Path]:
419
+ dest = self._finalized_root / source.arch / source.name
420
+ published = publish_tree(workdir / "boot", dest)
421
+ sources_dest = dest / "sources"
422
+ for _, _, filename in exported:
423
+ published.add(publish_file(workdir / "sources" / filename, sources_dest / filename))
424
+ if boot_wim is not None and boot_wim.is_file():
425
+ published.add(publish_file(boot_wim, sources_dest / "boot.wim"))
426
+ if source.drivers_dir is not None:
427
+ published |= publish_tree(source.drivers_dir, sources_dest / "drivers")
428
+ write_images_ini(
429
+ sources_dest / "images.ini",
430
+ tuple((name, filename) for _, name, filename in exported),
431
+ )
432
+ published.add(sources_dest / "images.ini")
433
+ return published
434
+
435
+ def _published_artifacts(self, source: Source) -> tuple[Artifact, ...]:
436
+ base = self._finalized_root / source.arch / source.name
437
+ artifacts: list[Artifact] = []
438
+ for path in sorted(base.rglob("*.wim")):
439
+ artifacts.append(
440
+ Artifact(path=str(path), sha256=file_sha256(path), size=path.stat().st_size)
441
+ )
442
+ return tuple(artifacts)
443
+
444
+ async def _verify(self, source: Source, exported: list[tuple[int, str, str]]) -> None:
445
+ base = self._finalized_root / source.arch / source.name
446
+ for wim in sorted(base.rglob("*.wim")):
447
+ try:
448
+ await self._wimlib.verify(wim)
449
+ except WimError as exc:
450
+ raise BuildError(f"verify failed for {wim}: {exc}") from exc
451
+ for artifact in self._published_artifacts(source):
452
+ if artifact.sha256 != await asyncio.to_thread(file_sha256, Path(artifact.path)):
453
+ raise BuildError(f"hash mismatch after publish: {artifact.path}")