Skip to main content

max / alloy

Decide the recovery by the acceptance predicate, not by encryption The recovery branched on whether the install was encrypted, so an unencrypted one got nothing. The justification was that a machine with no account is visibly broken at the greeter. It is not: it boots, it shows tuigreet, and it refuses every name typed into it, which reads as a forgotten password rather than as an install that never finished. That is the same class of trap as a TPM-only volume, only cheaper to escape. Encryption was never the right question. What the recovery cares about is whether the disk holds a machine, and there is already a definition of that: the checks the install asserts at its end. So the checks become values rather than stages, and the two callers wrap them differently. The install treats a false answer as a failure. The recovery treats it as the reason to stop pretending there is a machine here. - Check + acceptance_checks hold the definition once, so the install and the recovery cannot come to disagree about what "installed" means. - deployed_disk_plan is the install's own discovery chain, extracted so the recovery asks its question with the same one that built the disk. Its dead-end hook routes a chain that cannot go on to the wreck. - The wreck is wipefs either way. A filesystem signature and a LUKS signature are both signatures, which is why the unencrypted case stops being a special case, and it says what it is clearing before it clears.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-06 17:11 UTC
Signed with PGP, not checked
Commit: 54ded45b6e4f7f30b9bd5698eb6dcf81bbb03a06
Parent: 1311167
1 file changed, +318 insertions, -96 deletions
@@ -1107,7 +1107,7 @@
1107 1107 /// resolvers, where the partition path finally exists. This one is what the
1108 1108 /// caller hands over, and `Option` is what carries the user's answer: `None` is
1109 1109 /// an unencrypted install, which is why nothing downstream needs a bool.
1110 - #[derive(Debug)]
1110 + #[derive(Debug, Clone)]
1111 1111 struct EncryptionChoice {
1112 1112 passphrase: String,
1113 1113 recovery: String,
@@ -1167,27 +1167,46 @@
1167 1167 Invocation::new("cryptsetup").arg("luksDump").arg(partition)
1168 1168 }
1169 1169
1170 - /// Remove the LUKS signature from `partition`.
1170 + /// Make `target` read as blank to everything that looks at it.
1171 1171 ///
1172 - /// The honest-wreck branch of [`recover_plan`]. What it leaves is a partition
1173 - /// nothing recognizes as anything, which is the point: an install that failed
1174 - /// should leave a disk that is obviously unusable rather than one that is
1175 - /// subtly unusable. A volume whose only key is a TPM slot, on a machine with no
1176 - /// account, looks like a working install right up until the day the TPM stops
1177 - /// answering, and by then the eight words that would have opened it are long
1178 - /// thrown away.
1172 + /// The last resort of [`recover_plan`], and the reason it exists: an install
1173 + /// that failed should leave a disk that is obviously unusable rather than one
1174 + /// that is subtly unusable. Both of the subtle wrecks this installer can
1175 + /// produce look like a working machine until the day they do not.
1176 + ///
1177 + /// An encrypted volume whose only key is a TPM slot opens every time until the
1178 + /// board is replaced or the TPM is cleared, and by then the eight words that
1179 + /// would have opened it were thrown away by someone who was told their install
1180 + /// had failed. A machine with no account boots to a greeter that refuses every
1181 + /// name typed into it, which reads as a forgotten password rather than as an
1182 + /// install that did not finish.
1179 1183 ///
1180 1184 /// `wipefs` rather than `cryptsetup luksErase`: erasing the keyslots leaves a
1181 1185 /// header that still says LUKS, so the disk still presents as an encrypted
1182 1186 /// volume, and "encrypted volume that refuses every key" is exactly the subtle
1183 - /// wreck this is here to avoid. Removing the signature makes the partition read
1184 - /// as empty to everything that looks at it.
1187 + /// wreck this is here to avoid. Removing the signatures makes the target read
1188 + /// as empty instead. It is also the same command either way, which is why the
1189 + /// unencrypted case is no longer a hole: a filesystem signature and a LUKS
1190 + /// signature are both signatures.
1185 1191 ///
1186 - /// The data under it is not recoverable either way. It never was: the key was
1187 - /// in the keyslots this destroys, and the user consented to erasing this disk
1188 - /// before the install began.
1189 - fn wipe_luks_header(partition: &str) -> Invocation {
1190 - Invocation::new("wipefs").arg("--all").arg(partition)
1192 + /// Nothing recoverable is destroyed. The target is a disk the user consented to
1193 + /// erasing before the install began, and the install had already erased it.
1194 + fn wipe_signatures(target: &str) -> Invocation {
1195 + Invocation::new("wipefs").arg("--all").arg(target)
1196 + }
1197 +
1198 + /// Say what is about to be destroyed, then destroy it.
1199 + ///
1200 + /// The line matters as much as the command. A user watching the run screen sees
1201 + /// their install fail and then sees a disk being wiped, and without the reason
1202 + /// between them the second reads as the installer compounding the first.
1203 + fn wreck_plan(target: &str, why: &str) -> Vec<Stage> {
1204 + vec![
1205 + Stage::Run(Invocation::new("echo").arg(format!(
1206 + "{why}. Clearing {target} so this disk cannot be mistaken for an install"
1207 + ))),
1208 + Stage::Run(wipe_signatures(target)),
1209 + ]
1191 1210 }
1192 1211
1193 1212 /// Whether the header in `dump` has a keyslot that is not the TPM's.
@@ -1247,15 +1266,27 @@
1247 1266 slots.iter().any(|slot| !tpm_slots.contains(slot))
1248 1267 }
1249 1268
1269 + /// One fact that must be true of a finished machine, and how to ask the disk.
1270 + ///
1271 + /// A value rather than a [`Stage`] because two callers want different things
1272 + /// from the same question. The install treats a false answer as a failure; the
1273 + /// recovery treats it as the reason to stop pretending there is a machine here.
1274 + /// Writing it once means those two can never come to disagree about what a
1275 + /// finished machine is.
1276 + struct Check {
1277 + invocation: Invocation,
1278 + verdict: Box<dyn FnOnce(&str) -> Result<(), String>>,
1279 + }
1280 +
1250 1281 /// The unit that presents a login on a client-profile machine.
1251 1282 const GREETER_UNIT: &str = "greetd.service";
1252 1283
1253 - /// The checks that decide whether what was built is a machine.
1284 + /// What has to be true of the disk for it to be a machine.
1254 1285 ///
1255 - /// Every stage before these does a piece of work and reports whether the
1286 + /// Every stage of the install does a piece of work and reports whether the
1256 1287 /// command worked. That is a different question from whether the result is
1257 1288 /// usable, and the gap between the two is where this installer's shipped bugs
1258 - /// have lived. Four of them, and each is one assertion here:
1289 + /// have lived. Four of them, and each is one check here:
1259 1290 ///
1260 1291 /// - `fb967fd2`, an install with no usable account,
1261 1292 /// - `a2dfb319`, a volume openable only by its TPM,
@@ -1266,53 +1297,50 @@
1266 1297 /// possible way to learn them and the only one available while the install
1267 1298 /// finished by saying the last command exited 0.
1268 1299 ///
1269 - /// Run before finalize and umount, because every one of them reads the target
1270 - /// through the mount. They are the last thing the install asserts and the first
1271 - /// thing it would have wanted to know.
1272 - fn acceptance_plan(username: &str, encryption: Option<&Encryption<'_>>, root: &str) -> Vec<Stage> {
1300 + /// `root` is the ostree deployment directory, so every one of these reads the
1301 + /// target through the mount and none of them can run before it.
1302 + fn acceptance_checks(
1303 + username: &str,
1304 + encryption: Option<&Encryption<'_>>,
1305 + root: &str,
1306 + ) -> Vec<Check> {
1273 1307 let username = username.to_string();
1274 - let mut stages = vec![
1308 + let mut checks = vec![
1275 1309 // An account that exists, with the ids to prove it. The install's own
1276 1310 // useradd exited 0 in the case this catches.
1277 - Stage::Resolve {
1311 + Check {
1278 1312 invocation: Invocation::new("cat").arg(format!("{root}/etc/passwd")),
1279 - then: Box::new(move |listing| {
1280 - passwd_ids(listing, &username)?;
1281 - Ok(Vec::new())
1282 - }),
1313 + verdict: Box::new(move |listing| passwd_ids(listing, &username).map(|_| ())),
1283 1314 },
1284 1315 // An update address the updater could act on. Writing the file worked
1285 1316 // in the case this catches; what was written was unusable.
1286 - Stage::Resolve {
1317 + //
1318 + // `<deployment>.origin` is ostree's own layout, not a guess: libostree
1319 + // writes `ostree/deploy/%s/deploy/%s.%d.origin`.
1320 + Check {
1287 1321 invocation: Invocation::new("cat").arg(format!("{root}.origin")),
1288 - then: Box::new(move |origin| {
1289 - update_reference_parses(origin)?;
1290 - Ok(Vec::new())
1291 - }),
1322 + verdict: Box::new(update_reference_parses),
1292 1323 },
1293 1324 // A way in on first boot.
1294 - Stage::Resolve {
1325 + Check {
1295 1326 invocation: Invocation::new("systemctl")
1296 1327 .arg(format!("--root={root}"))
1297 1328 .arg("list-unit-files")
1298 1329 .arg(GREETER_UNIT),
1299 - then: Box::new(move |listing| {
1300 - greeter_ready(listing, GREETER_UNIT)?;
1301 - Ok(Vec::new())
1302 - }),
1330 + verdict: Box::new(|listing| greeter_ready(listing, GREETER_UNIT)),
1303 1331 },
1304 1332 ];
1305 1333
1306 - // The invariant, asserted rather than assumed: the enrollment above ran
1307 - // two commands and both exited 0, which is not the same as the header
1308 - // having a slot a person can use.
1334 + // The invariant, asserted rather than assumed: the enrollment ran two
1335 + // commands and both exited 0, which is not the same as the header having a
1336 + // slot a person can use.
1309 1337 if let Some(encryption) = encryption {
1310 1338 let partition = encryption.partition.to_string();
1311 - stages.push(Stage::Resolve {
1339 + checks.push(Check {
1312 1340 invocation: luks_dump(&partition),
1313 - then: Box::new(move |dump| {
1341 + verdict: Box::new(move |dump| {
1314 1342 if non_tpm_slot_exists(dump) {
1315 - return Ok(Vec::new());
1343 + return Ok(());
1316 1344 }
1317 1345 Err(format!(
1318 1346 "{partition} has no keyslot but the TPM's; \
@@ -1322,7 +1350,66 @@
1322 1350 });
1323 1351 }
1324 1352
1325 - stages
1353 + checks
1354 + }
1355 +
1356 + /// The acceptance checks as the install runs them: a false answer fails it.
1357 + ///
1358 + /// Run before finalize and umount, because every one of them reads the target
1359 + /// through the mount. They are the last thing the install asserts and the first
1360 + /// thing it would have wanted to know.
1361 + fn acceptance_plan(username: &str, encryption: Option<&Encryption<'_>>, root: &str) -> Vec<Stage> {
1362 + acceptance_checks(username, encryption, root)
1363 + .into_iter()
1364 + .map(
1365 + |Check {
1366 + invocation,
1367 + verdict,
1368 + }| Stage::Resolve {
1369 + invocation,
1370 + then: Box::new(move |output| {
1371 + verdict(output)?;
1372 + Ok(Vec::new())
1373 + }),
1374 + },
1375 + )
1376 + .collect()
1377 + }
1378 +
1379 + /// The same checks as the recovery runs them: a false answer wrecks the disk.
1380 + ///
1381 + /// Each check decides whether the next one is asked, so the first fact that is
1382 + /// not true ends it. `intact` is set only when every check has answered yes,
1383 + /// which is the one state in which the disk is left alone.
1384 + ///
1385 + /// The recursion happens inside a resolver rather than while the plan is built,
1386 + /// because a check's verdict is not knowable until its command has run.
1387 + fn verify_or_wreck(
1388 + mut checks: std::collections::VecDeque<Check>,
1389 + target: String,
1390 + intact: Arc<AtomicBool>,
1391 + ) -> Vec<Stage> {
1392 + let Some(Check {
1393 + invocation,
1394 + verdict,
1395 + }) = checks.pop_front()
1396 + else {
1397 + return Vec::new();
1398 + };
1399 +
1400 + vec![Stage::Resolve {
1401 + invocation,
1402 + then: Box::new(move |output| {
1403 + if let Err(message) = verdict(output) {
1404 + return Ok(wreck_plan(&target, &message));
1405 + }
1406 + if checks.is_empty() {
1407 + intact.store(true, Ordering::Relaxed);
1408 + return Ok(Vec::new());
1409 + }
1410 + Ok(verify_or_wreck(checks, target, intact))
1411 + }),
1412 + }]
1326 1413 }
1327 1414
1328 1415 /// Whether the deployment origin names an image that can be fetched.
@@ -1399,70 +1486,77 @@
1399 1486 /// have Alloy on, and walking away from that is how three of this installer's
1400 1487 /// four shipped bugs reached a person. Each was found by booting the result.
1401 1488 ///
1402 - /// Two branches, in order, and the second only because the first did not take.
1489 + /// Three phases, and each only because the one before it did not settle the
1490 + /// matter.
1403 1491 ///
1404 - /// **Repair forward.** The enrollment is the one piece of remaining work that
1405 - /// is still possible after an arbitrary failure: it needs the LUKS header and a
1406 - /// TPM that will authorize, and neither depends on the mount, the deployment or
1407 - /// the account. Running it turns the worst outcome (a volume only a TPM can
1408 - /// open) into a recoverable one, and it is what makes the recovery phrase on
1409 - /// screen mean something. It runs as [`Stage::Attempt`]s because a repair that
1410 - /// fails must not stop the check that follows it.
1492 + /// **Repair forward.** Find the disk again and mount it, through
1493 + /// [`deployed_disk_plan`], the same chain the install used, and then enroll the
1494 + /// keyslots bootc wipes. The repair runs as [`Stage::Attempt`]s: one that fails
1495 + /// must not stop the checks, because what decides this is the state of the disk
1496 + /// and not the exit status of a retry.
1411 1497 ///
1412 - /// **Leave an honest wreck.** If the header still has no slot but the TPM's,
1413 - /// the volume is destroyed rather than left looking installable. See
1414 - /// [`wipe_luks_header`].
1498 + /// **Ask whether it is a machine.** [`acceptance_checks`], the same four facts
1499 + /// the install asserts at its end. Using the install's own definition is the
1500 + /// point: a recovery that judged by its own standard would be a second opinion
1501 + /// about what "installed" means, free to drift from the first.
1415 1502 ///
1416 - /// The choice between them is made by reading the header, not by whether the
1417 - /// repair reported success. That is the same rule the deploy is held to, and it
1418 - /// matters more here: the branch it guards destroys data.
1503 + /// **Leave an honest wreck.** If any of those facts is false, the disk is
1504 + /// cleared. See [`wipe_signatures`] for why that is kinder than it sounds, and
1505 + /// why it is the same answer whether the volume was encrypted or not.
1419 1506 ///
1420 - /// Empty for an unencrypted install. The trap this exists to prevent is a
1421 - /// volume with one TPM slot, and there is no such volume to answer for. A
1422 - /// failed unencrypted install leaves a machine with no account, which is
1423 - /// visibly broken at the greeter rather than silently broken years later.
1507 + /// A dead end anywhere in the chain lands in the third phase too. If the disk
1508 + /// cannot even be shown to hold a deployment, it certainly cannot be shown to
1509 + /// hold a machine, and the whole disk is cleared rather than a partition that
1510 + /// was never identified.
1424 1511 fn recover_plan(
1425 1512 disk: &str,
1513 + username: &str,
1426 1514 encryption: Option<&EncryptionChoice>,
1427 - openable: &Arc<AtomicBool>,
1515 + intact: &Arc<AtomicBool>,
1428 1516 ) -> Vec<Stage> {
1429 - let Some(choice) = encryption else {
1430 - return Vec::new();
1431 - };
1432 - let passphrase = choice.passphrase.clone();
1433 - let recovery = choice.recovery.clone();
1434 - let openable = Arc::clone(openable);
1517 + let username = username.to_string();
1518 + let choice = encryption.cloned();
1519 + let intact = Arc::clone(intact);
1435 1520
1436 - vec![Stage::Resolve {
1437 - invocation: partition_types(disk),
1438 - then: Box::new(move |listing| {
1439 - let partition = root_partition(listing)?;
1440 - let mut stages: Vec<Stage> = enroll_plan(&partition, &passphrase, &recovery)
1441 - .into_iter()
1442 - .map(|stage| match stage {
1443 - Stage::Run(invocation) => Stage::Attempt(invocation),
1444 - other => other,
1445 - })
1446 - .collect();
1521 + // Before the root partition is known there is nothing narrower to clear.
1522 + let whole_disk = disk.to_string();
1523 + let dead_end: DeadEnd = std::rc::Rc::new(move |message| wreck_plan(&whole_disk, &message));
1447 1524
1448 - stages.push(Stage::Resolve {
1449 - invocation: luks_dump(&partition),
1450 - then: Box::new(move |dump| {
1451 - // Recorded either way, because the run screen has a
1452 - // different thing to say in each case: a phrase worth
1453 - // copying down, or a disk that no longer exists.
1454 - let repaired = non_tpm_slot_exists(dump);
1455 - openable.store(repaired, Ordering::Relaxed);
1456 - if repaired {
1457 - return Ok(Vec::new());
1458 - }
1459 - Ok(vec![Stage::Run(wipe_luks_header(&partition))])
1460 - }),
1525 + deployed_disk_plan(
1526 + disk,
1527 + Some(dead_end),
1528 + Box::new(move |partition, root| {
1529 + let mut stages = Vec::new();
1530 +
1531 + // The one repair that does not depend on anything the checks are
1532 + // about to test, and the one whose absence is irreversible: a
1533 + // volume with no slot but the TPM's cannot be given one later by
1534 + // anybody, where an account can be added from any live medium.
1535 + if let Some(choice) = &choice {
1536 + stages.extend(
1537 + enroll_plan(partition, &choice.passphrase, &choice.recovery)
1538 + .into_iter()
1539 + .map(|stage| match stage {
1540 + Stage::Run(invocation) => Stage::Attempt(invocation),
1541 + other => other,
1542 + }),
1543 + );
1544 + }
1545 +
1546 + let encryption = choice.as_ref().map(|choice| Encryption {
1547 + partition,
1548 + passphrase: &choice.passphrase,
1549 + recovery: &choice.recovery,
1461 1550 });
1551 + stages.extend(verify_or_wreck(
1552 + acceptance_checks(&username, encryption.as_ref(), root).into(),
1553 + partition.to_string(),
1554 + intact,
1555 + ));
1462 1556
1463 1557 Ok(stages)
1464 1558 }),
1465 - }]
1559 + )
1466 1560 }
1467 1561
1468 1562 /// What lands in the new home besides the skeleton.
@@ -1959,6 +2053,146 @@
1959 2053 .then(|| format!("oci:{}:{LIVE_SOURCE_TAG}", path.display()))
1960 2054 }
1961 2055
2056 + /// What to do once the disk has been found to hold a deployment.
2057 + ///
2058 + /// Takes the root partition and the deployment directory, the two values that
2059 + /// cannot be known before the deploy has run. The install configures; the
2060 + /// recovery checks.
2061 + type Deployed = Box<dyn FnOnce(&str, &str) -> Result<Vec<Stage>, String>>;
2062 +
2063 + /// What to do when the chain cannot go on.
2064 + ///
2065 + /// The install has no answer to that and says so, which fails the run. The
2066 + /// recovery does: a disk that cannot be shown to hold a machine is a disk that
2067 + /// must not look like one. Shared rather than owned because every resolver in
2068 + /// the chain needs it and only one of them will ever call it.
2069 + type DeadEnd = std::rc::Rc<dyn Fn(String) -> Vec<Stage>>;
2070 +
2071 + /// Route a dead end to the stages that answer for it, or report it.
2072 + fn or_dead_end(
2073 + outcome: Result<Vec<Stage>, String>,
2074 + dead_end: Option<&DeadEnd>,
2075 + ) -> Result<Vec<Stage>, String> {
2076 + match (outcome, dead_end) {
2077 + (Err(message), Some(answer)) => Ok(answer(message)),
2078 + (outcome, _) => outcome,
2079 + }
2080 + }
2081 +
2082 + /// Find the deployment on `disk`, mount it, and hand it to `then`.
2083 + ///
2084 + /// The three discoveries the install rests on, in the only order they can
2085 + /// happen: bootc decides which partition holds the new root while it
2086 + /// partitions, whether that partition is a LUKS container is a fact about what
2087 + /// it built rather than about what was asked for, and the ostree deployment is
2088 + /// named after a checksum that does not exist until the deploy finishes.
2089 + ///
2090 + /// Shared by the install and by [`recover_plan`], which is the point. The
2091 + /// recovery's job is to decide whether the disk holds a machine, and asking
2092 + /// that question with a different chain than the one that built it would be two
2093 + /// definitions of the same thing, drifting.
2094 + fn deployed_disk_plan(disk: &str, dead_end: Option<DeadEnd>, then: Deployed) -> Vec<Stage> {
2095 + let settle = dead_end.clone();
2096 + let contents_end = dead_end.clone();
2097 + let mount_end = dead_end.clone();
2098 + let deployment_end = dead_end;
2099 +
2100 + vec![
2101 + // bootc returns when the install is done, not when the kernel and
2102 + // udev have caught up with the partition table it wrote. In that
2103 + // window `lsblk` answers with a `parttype` of null for every
2104 + // partition, so the discovery below finds no root and fails with
2105 + // "disk not deployed" about a disk that deployed perfectly. Observed
2106 + // against a real bootc 1.16.3 install: null for all three partitions
2107 + // immediately after, the correct GUIDs a moment later.
2108 + //
2109 + // Waiting for the queue to drain is the fix rather than a retry loop:
2110 + // there is a specific event to wait for, and a retry would just be
2111 + // this wait spelled less precisely.
2112 + Stage::Run(Invocation::new("udevadm").arg("settle")),
2113 + Stage::Run(Invocation::new("mkdir").args(["-p", TARGET_MOUNT])),
2114 + // First discovery: which partition bootc made the root.
2115 + Stage::Resolve {
2116 + invocation: partition_types(disk),
2117 + then: Box::new(move |listing| {
2118 + let partition = match root_partition(listing) {
2119 + Ok(partition) => partition,
2120 + Err(message) => return or_dead_end(Err(message), settle.as_ref()),
2121 + };
2122 + // Second discovery: whether that partition holds the filesystem
2123 + // or a LUKS container with the filesystem inside it. Asked of
2124 + // the disk rather than inferred from the answers, so the mount
2125 + // follows what bootc actually built. See [`filesystem_device`].
2126 + Ok(vec![Stage::Resolve {
2127 + invocation: partition_contents(&partition),
2128 + then: Box::new(move |contents| {
2129 + let device = match filesystem_device(contents, &partition) {
2130 + Ok(device) => device,
2131 + Err(message) => {
2132 + return or_dead_end(Err(message), contents_end.as_ref());
2133 + }
2134 + };
2135 + Ok(vec![
2136 + // Release whatever bootc left mounted on the device
2137 + // before mounting it. Its own leftover is read-only,
2138 + // and a second mount of a filesystem that is already
2139 + // mounted shares the first one's superblock rather
2140 + // than getting a fresh one, so the read-only travels
2141 + // to this mount too.
2142 + Stage::Resolve {
2143 + invocation: mounts_of(&device),
2144 + then: Box::new(move |listing| {
2145 + Ok(leftover_mounts(listing)
2146 + .iter()
2147 + .map(|target| Stage::Run(unmount(target)))
2148 + .collect())
2149 + }),
2150 + },
2151 + Stage::Run(
2152 + Invocation::new("mount")
2153 + .args(["-o", "rw"])
2154 + .arg(&device)
2155 + .arg(TARGET_MOUNT),
2156 + ),
2157 + // Prove it took. `mount` warns and exits 0 when it
2158 + // falls back to read-only, so success here is not
2159 + // evidence.
2160 + Stage::Resolve {
2161 + invocation: mount_options(TARGET_MOUNT),
2162 + then: Box::new(move |options| {
2163 + if let Err(message) = writable_mount(options) {
2164 + return or_dead_end(Err(message), mount_end.as_ref());
2165 + }
2166 + // Third discovery, only possible once
2167 + // mounted: where the deployment is inside
2168 + // the sysroot.
2169 + Ok(vec![Stage::Resolve {
2170 + invocation: deployment_dir(TARGET_MOUNT),
2171 + then: Box::new(move |printed| {
2172 + let deployment = printed.trim();
2173 + if deployment.is_empty() {
2174 + return or_dead_end(
2175 + Err("ostree reported no current deployment"
2176 + .into()),
2177 + deployment_end.as_ref(),
2178 + );
2179 + }
2180 + or_dead_end(
2181 + then(&partition, deployment),
2182 + deployment_end.as_ref(),
2183 + )
Lines truncated