Skip to main content

bootc_lib/bootc_composefs/
aboot.rs

1//! Persistent Aboot state relative to the physical root:
2//!
3//! ```text
4//! state/boot/aboot/
5//!   slots/a                 observed deployment ID for slot A
6//!   slots/b                 observed deployment ID for slot B
7//!   pending                 staged deployment, payload hashes, and finalization lock
8//!   attempted               deployment attempt and the boot ID which initiated it
9//!   rollback                queued rollback deployment and originating boot ID
10//!
11//! state/deploy/<deployment ID>/aboot/
12//!   aboot.img               staged boot partition payload
13//!   vbmeta.img              optional staged vbmeta payload
14//! ```
15//!
16//! A missing slot file means that slot has no known valid mapping. Pending and
17//! attempted state retain an update until a later boot observes its result.
18//! `/run/composefs/staged-deployment` is a transient version of pending state
19//! used by the common status and finalization code.
20//!
21//! Update staging first creates the deployment state, then writes and hashes the boot
22//! (and optional vbmeta payloads) below that deployment. Only after those writes are
23//! durable does it publish `pending` and the transient staged view. No boot partitions
24//! are written during staging.
25//!
26//! At shutdown the finalizer verifies the staged payload hashes, writes `attempted`,
27//! invalidates the non-booted slot mapping, and invokes aboot-deploy. On the next boot,
28//! observing the candidate consumes `pending`, its staged payload directory, and
29//! `attempted`. Observing another deployment retains them without automatically retrying
30//! the write; an explicit update or switch can discard them after that reboot.
31//! An unattempted pending update is projected back into `/run` and rearmed
32//! unless it was staged download-only.
33
34use anyhow::{Context, Result, ensure};
35use camino::{Utf8Path, Utf8PathBuf};
36use canon_json::CanonJsonSerialize;
37use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt};
38use composefs_ctl::composefs::fsverity::{FsVerityHashValue, Sha512HashValue};
39use linux_kernel_cmdline::utf8::Cmdline;
40use rustix::fs::fsync;
41use serde::{Deserialize, Serialize};
42
43use crate::composefs_consts::{
44    ABOOT_ARTIFACT_DIR, ABOOT_ATTEMPTED_FNAME, ABOOT_IMAGE_FNAME, ABOOT_PENDING_FNAME,
45    ABOOT_ROLLBACK_FNAME, ABOOT_STATE_DIR, ABOOT_VBMETA_FNAME, STATE_DIR_RELATIVE,
46};
47use crate::spec::Bootloader;
48
49use super::{
50    boot::BootType,
51    state::{read_boot_type, remove_staged_deployment, write_staged_deployment},
52    status::StagedDeployment,
53};
54
55const SLOT_SUFFIX: &str = "androidboot.slot_suffix";
56const ABOOT_CONFIG: &str = "/etc/aboot.cfg";
57
58/// Determine the host bootloader from the running system's aboot configuration.
59pub(crate) fn get_bootloader() -> Result<Bootloader> {
60    let config = match std::fs::read_to_string(ABOOT_CONFIG) {
61        Ok(config) => config,
62        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Bootloader::None),
63        Err(err) => return Err(err).with_context(|| format!("Reading {ABOOT_CONFIG}")),
64    };
65    Ok(bootloader_from_config(&config))
66}
67
68fn bootloader_from_config(config: &str) -> Bootloader {
69    // Match aboot_config_load(): trim keys and values, keep the last assignment,
70    // and treat quotes and inline comments literally rather than as shell syntax.
71    let boot_type = config
72        .lines()
73        .filter(|line| !line.starts_with('#'))
74        .filter_map(|line| line.split_once('='))
75        .filter(|(key, _)| key.trim_ascii() == "BOOT_TYPE")
76        .map(|(_, value)| value.trim_ascii())
77        .next_back();
78    match boot_type {
79        Some("ukiboot") => Bootloader::Ukiboot,
80        _ => Bootloader::None,
81    }
82}
83
84/// Require update and rollback tooling in every installed or staged aboot image.
85pub(crate) fn require_aboot_deploy_in_image(root: &Dir) -> Result<()> {
86    ensure!(
87        crate::utils::have_executable_in_root(root, "aboot-deploy")?,
88        "Target image must contain aboot-deploy for aboot updates and rollbacks"
89    );
90    Ok(())
91}
92
93/// Logical A/B slot observed from the running kernel command line.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub(crate) enum Slot {
96    A,
97    B,
98}
99
100impl Slot {
101    pub(crate) fn from_cmdline(cmdline: &Cmdline<'_>) -> Option<Self> {
102        let mut suffixes = cmdline
103            .iter()
104            .filter(|param| param.key() == SLOT_SUFFIX.into());
105        let suffix = suffixes.next()?;
106        if suffixes.next().is_some() {
107            return None;
108        }
109        match suffix.value()? {
110            "_a" => Some(Self::A),
111            "_b" => Some(Self::B),
112            _ => None,
113        }
114    }
115
116    fn name(self) -> &'static str {
117        match self {
118            Self::A => "a",
119            Self::B => "b",
120        }
121    }
122}
123
124/// Durable staged-deployment state and hashes of the payloads to flash to the boot partitions.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub(crate) struct PendingDeployment {
127    pub(crate) depl_id: String,
128    pub(crate) finalization_locked: bool,
129    boot_image_sha256: String,
130    vbmeta_image_sha256: Option<String>,
131}
132
133impl PendingDeployment {
134    pub(crate) fn staged(&self) -> StagedDeployment {
135        StagedDeployment {
136            depl_id: self.depl_id.clone(),
137            finalization_locked: self.finalization_locked,
138        }
139    }
140}
141
142/// Records an attempted deployment and the system boot id which initiated it.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub(crate) struct AttemptedDeployment {
145    pub(crate) depl_id: String,
146    boot_id: String,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150struct QueuedRollback {
151    depl_id: String,
152    boot_id: String,
153}
154
155/// Absolute paths to staged payloads after their hashes have been verified.
156pub(crate) struct ArtifactPaths {
157    pub(crate) boot: Utf8PathBuf,
158    pub(crate) vbmeta: Option<Utf8PathBuf>,
159}
160
161fn boot_id() -> Result<String> {
162    Ok(std::fs::read_to_string("/proc/sys/kernel/random/boot_id")?
163        .trim()
164        .to_string())
165}
166
167enum ReconcileAction {
168    None,
169    RemoveTransient,
170    Publish(PendingDeployment),
171}
172
173/// An open fd to the persistent Aboot state
174pub(crate) struct AbootState<'a> {
175    sysroot: &'a Dir,
176    state: Option<Dir>,
177    slots: Option<Dir>,
178}
179
180impl<'a> AbootState<'a> {
181    pub(crate) fn open(sysroot: &'a Dir) -> Result<Self> {
182        let state = sysroot.open_dir_optional(ABOOT_STATE_DIR)?;
183        let slots = match state.as_ref() {
184            Some(state) => state.open_dir_optional("slots")?,
185            None => None,
186        };
187        Ok(Self {
188            sysroot,
189            state,
190            slots,
191        })
192    }
193
194    fn ensure_state(&mut self) -> Result<&Dir> {
195        if self.state.is_none() {
196            let mut dir = self.sysroot.try_clone()?;
197            for component in ABOOT_STATE_DIR.split('/') {
198                dir.create_dir_all(component)?;
199                fsync(dir.reopen_as_ownedfd()?).context("Syncing aboot state directory")?;
200                dir = dir.open_dir(component)?;
201            }
202            self.state = Some(dir);
203        }
204        Ok(self.state.as_ref().unwrap())
205    }
206
207    fn ensure_slots(&mut self) -> Result<&Dir> {
208        if self.slots.is_none() {
209            {
210                let state = self.ensure_state()?;
211                state.create_dir_all("slots")?;
212                fsync(state.reopen_as_ownedfd()?).context("Syncing aboot state directory")?;
213            }
214            self.slots = Some(self.state.as_ref().unwrap().open_dir("slots")?);
215        }
216        Ok(self.slots.as_ref().unwrap())
217    }
218
219    fn read_json<T: for<'de> Deserialize<'de>>(&self, name: &str) -> Result<Option<T>> {
220        let Some(state) = self.state.as_ref() else {
221            return Ok(None);
222        };
223        let Some(contents) = state.read_optional(name)? else {
224            return Ok(None);
225        };
226        serde_json::from_slice(&contents)
227            .with_context(|| format!("Parsing aboot {name}"))
228            .map(Some)
229    }
230
231    fn write_json(&mut self, name: &str, value: &impl CanonJsonSerialize) -> Result<()> {
232        let state = self.ensure_state()?;
233        state.atomic_write(name, value.to_canon_json_vec()?)?;
234        fsync(state.reopen_as_ownedfd()?).context("Syncing aboot state")?;
235        Ok(())
236    }
237
238    fn remove_state_file(&self, name: &str) -> Result<()> {
239        let Some(state) = self.state.as_ref() else {
240            return Ok(());
241        };
242        state.remove_file_optional(name)?;
243        fsync(state.reopen_as_ownedfd()?).context("Syncing aboot state")?;
244        Ok(())
245    }
246
247    pub(crate) fn read_pending(&self) -> Result<Option<PendingDeployment>> {
248        self.read_json(ABOOT_PENDING_FNAME)
249    }
250
251    /// Publish or replace a pending deployment unless one was already attempted.
252    pub(crate) fn write_pending(&mut self, pending: &PendingDeployment) -> Result<()> {
253        ensure!(
254            self.read_attempted()?.is_none(),
255            "Cannot replace an attempted aboot deployment"
256        );
257        self.write_json(ABOOT_PENDING_FNAME, pending)
258    }
259
260    pub(crate) fn read_attempted(&self) -> Result<Option<AttemptedDeployment>> {
261        self.read_json(ABOOT_ATTEMPTED_FNAME)
262    }
263
264    fn read_rollback(&self) -> Result<Option<QueuedRollback>> {
265        self.read_json(ABOOT_ROLLBACK_FNAME)
266    }
267
268    pub(crate) fn queued_rollback(&self) -> Result<Option<String>> {
269        let Some(rollback) = self.read_rollback()? else {
270            return Ok(None);
271        };
272        if rollback.boot_id != boot_id()? {
273            return Ok(None);
274        }
275        Ok(Some(rollback.depl_id))
276    }
277
278    pub(crate) fn queue_rollback(&mut self, deployment: &str) -> Result<()> {
279        let digest = Sha512HashValue::from_hex(deployment).context("Invalid rollback digest")?;
280        ensure!(
281            self.slot_deployments()?.contains(&digest),
282            "Rollback deployment is not mapped to an aboot slot"
283        );
284        self.write_json(
285            ABOOT_ROLLBACK_FNAME,
286            &QueuedRollback {
287                depl_id: deployment.to_string(),
288                boot_id: boot_id()?,
289            },
290        )
291    }
292
293    pub(crate) fn clear_rollback(&self) -> Result<()> {
294        self.remove_state_file(ABOOT_ROLLBACK_FNAME)
295    }
296
297    /// Make a download-only pending deployment eligible for finalization.
298    pub(crate) fn unlock_pending(&mut self, deployment: &str) -> Result<PendingDeployment> {
299        let mut pending = self
300            .read_pending()?
301            .ok_or_else(|| anyhow::anyhow!("No pending aboot deployment"))?;
302        ensure!(
303            pending.depl_id == deployment,
304            "Pending aboot deployment changed"
305        );
306        pending.finalization_locked = false;
307        self.write_pending(&pending)?;
308        Ok(pending)
309    }
310
311    /// Store the exact partition payloads before publishing the pending deployment.
312    pub(crate) fn stage_artifacts(
313        &self,
314        deployment: &str,
315        finalization_locked: bool,
316        boot: &[u8],
317        vbmeta: Option<&[u8]>,
318    ) -> Result<PendingDeployment> {
319        Sha512HashValue::from_hex(deployment).context("Invalid staged deployment digest")?;
320        ensure!(
321            read_boot_type(self.sysroot, deployment)? == Some(BootType::Aboot),
322            "Staged deployment is not aboot"
323        );
324        let deployment_dir = self
325            .sysroot
326            .open_dir(format!("{STATE_DIR_RELATIVE}/{deployment}"))
327            .context("Opening staged deployment")?;
328        deployment_dir.create_dir_all(ABOOT_ARTIFACT_DIR)?;
329        fsync(deployment_dir.reopen_as_ownedfd()?).context("Syncing staged deployment")?;
330        let artifacts = deployment_dir.open_dir(ABOOT_ARTIFACT_DIR)?;
331        artifacts.atomic_write(ABOOT_IMAGE_FNAME, boot)?;
332        match vbmeta {
333            Some(vbmeta) => artifacts.atomic_write(ABOOT_VBMETA_FNAME, vbmeta)?,
334            None => {
335                artifacts.remove_file_optional(ABOOT_VBMETA_FNAME)?;
336            }
337        }
338        fsync(artifacts.reopen_as_ownedfd()?).context("Syncing staged aboot artifacts")?;
339        Ok(PendingDeployment {
340            depl_id: deployment.to_string(),
341            finalization_locked,
342            boot_image_sha256: hex::encode(openssl::sha::sha256(boot)),
343            vbmeta_image_sha256: vbmeta.map(|data| hex::encode(openssl::sha::sha256(data))),
344        })
345    }
346
347    /// Verify staged payloads and return paths suitable for passing to aboot-deploy.
348    pub(crate) fn verify_artifacts(
349        &self,
350        root_path: &Utf8Path,
351        pending: &PendingDeployment,
352    ) -> Result<ArtifactPaths> {
353        let relative = Utf8PathBuf::from(STATE_DIR_RELATIVE)
354            .join(&pending.depl_id)
355            .join(ABOOT_ARTIFACT_DIR);
356        let artifacts = self.sysroot.open_dir(&relative)?;
357        let boot = artifacts.read(ABOOT_IMAGE_FNAME)?;
358        ensure!(
359            hex::encode(openssl::sha::sha256(&boot)) == pending.boot_image_sha256,
360            "Staged aboot image was modified"
361        );
362        let vbmeta = match pending.vbmeta_image_sha256.as_ref() {
363            Some(expected) => {
364                let data = artifacts.read(ABOOT_VBMETA_FNAME)?;
365                ensure!(
366                    hex::encode(openssl::sha::sha256(&data)) == *expected,
367                    "Staged vbmeta image was modified"
368                );
369                Some(root_path.join(&relative).join(ABOOT_VBMETA_FNAME))
370            }
371            None => None,
372        };
373        Ok(ArtifactPaths {
374            boot: root_path.join(relative).join(ABOOT_IMAGE_FNAME),
375            vbmeta,
376        })
377    }
378
379    fn remove_artifacts(&self, deployment: &str) -> Result<()> {
380        let deployment_dir = self
381            .sysroot
382            .open_dir(format!("{STATE_DIR_RELATIVE}/{deployment}"))
383            .context("Opening deployed state")?;
384        if deployment_dir
385            .open_dir_optional(ABOOT_ARTIFACT_DIR)?
386            .is_some()
387        {
388            deployment_dir
389                .remove_dir_all(ABOOT_ARTIFACT_DIR)
390                .context("Removing staged aboot artifacts")?;
391        }
392        fsync(deployment_dir.reopen_as_ownedfd()?).context("Syncing deployed state")?;
393        Ok(())
394    }
395
396    pub(crate) fn discard_pending(&self) -> Result<()> {
397        if let Some(attempted) = self.read_attempted()? {
398            ensure!(
399                attempted.boot_id != boot_id()?,
400                "Cannot discard an aboot update attempted during this boot"
401            );
402        }
403        // Remove pending before attempted so interrupted cleanup cannot rearm the payload.
404        if let Some(pending) = self.read_pending()? {
405            self.remove_state_file(ABOOT_PENDING_FNAME)?;
406            self.remove_artifacts(&pending.depl_id)?;
407        }
408        self.remove_state_file(ABOOT_ATTEMPTED_FNAME)?;
409        remove_staged_deployment()?;
410        Ok(())
411    }
412
413    /// Discard a failed attempt only when an explicit update replaces it after reboot.
414    pub(crate) fn discard_failed_attempt(&self, booted: &str) -> Result<bool> {
415        let Some(attempted) = self.read_attempted()? else {
416            return Ok(false);
417        };
418        if attempted.depl_id == booted {
419            return Ok(false);
420        }
421        self.discard_pending()?;
422        Ok(true)
423    }
424
425    /// Mark an update attempted before invalidating a slot or writing partitions.
426    pub(crate) fn record_attempt(&mut self, deployment: &str) -> Result<()> {
427        ensure!(
428            self.read_attempted()?.is_none(),
429            "Aboot deployment was already attempted"
430        );
431        let pending = self
432            .read_pending()?
433            .ok_or_else(|| anyhow::anyhow!("No pending aboot deployment"))?;
434        ensure!(
435            pending.depl_id == deployment,
436            "Pending aboot deployment changed"
437        );
438        self.write_json(
439            ABOOT_ATTEMPTED_FNAME,
440            &AttemptedDeployment {
441                depl_id: deployment.to_string(),
442                boot_id: boot_id()?,
443            },
444        )
445    }
446
447    fn reconcile_state(&mut self, booted: &str, current_boot_id: &str) -> Result<ReconcileAction> {
448        if self
449            .read_rollback()?
450            .is_some_and(|rollback| rollback.boot_id != current_boot_id)
451        {
452            self.remove_state_file(ABOOT_ROLLBACK_FNAME)?;
453        }
454        if let Some(attempted) = self.read_attempted()? {
455            if attempted.boot_id == current_boot_id {
456                // The still-running system cannot tell us whether the new payload will boot.
457                return Ok(ReconcileAction::None);
458            }
459            if attempted.depl_id == booted {
460                // Remove pending first so interruption cannot make a successful write retryable.
461                self.remove_state_file(ABOOT_PENDING_FNAME)?;
462                self.remove_artifacts(&attempted.depl_id)?;
463                self.remove_state_file(ABOOT_ATTEMPTED_FNAME)?;
464                return Ok(ReconcileAction::RemoveTransient);
465            }
466            // Firmware booted another deployment; retain the candidate without retrying it.
467            return Ok(ReconcileAction::None);
468        }
469
470        let Some(pending) = self.read_pending()? else {
471            return Ok(ReconcileAction::RemoveTransient);
472        };
473        Ok(ReconcileAction::Publish(pending))
474    }
475
476    /// Restore the transient staged view and return whether finalization should be armed.
477    pub(crate) fn reconcile(&mut self, booted: &str) -> Result<bool> {
478        match self.reconcile_state(booted, &boot_id()?)? {
479            ReconcileAction::None => Ok(false),
480            ReconcileAction::RemoveTransient => {
481                remove_staged_deployment()?;
482                Ok(false)
483            }
484            ReconcileAction::Publish(pending) => {
485                write_staged_deployment(&pending.staged())?;
486                Ok(!pending.finalization_locked)
487            }
488        }
489    }
490
491    pub(crate) fn slot_deployments(&self) -> Result<Vec<Sha512HashValue>> {
492        let mut deployments = Vec::new();
493        for slot in [Slot::A, Slot::B] {
494            let Some(deployment) = self.read_slot(slot)? else {
495                continue;
496            };
497            if !deployments.contains(&deployment) {
498                deployments.push(deployment);
499            }
500        }
501        Ok(deployments)
502    }
503
504    fn read_slot(&self, slot: Slot) -> Result<Option<Sha512HashValue>> {
505        let path = format!("{ABOOT_STATE_DIR}/slots/{}", slot.name());
506        let Some(slots) = self.slots.as_ref() else {
507            return Ok(None);
508        };
509        let Some(contents) = slots.read_optional(slot.name())? else {
510            return Ok(None);
511        };
512        let digest = match Sha512HashValue::from_hex(contents.trim_ascii()) {
513            Ok(digest) => digest,
514            Err(err) => {
515                tracing::warn!(%path, %err, "Invalid aboot slot mapping");
516                return Ok(None);
517            }
518        };
519        if read_boot_type(self.sysroot, &digest.to_hex())? != Some(BootType::Aboot) {
520            tracing::warn!(%path, "Aboot slot mapping has no corresponding aboot deployment");
521            return Ok(None);
522        }
523        Ok(Some(digest))
524    }
525
526    /// Record a slot mapping only after that deployment has been observed running.
527    pub(crate) fn record_booted(&mut self, cmdline: &Cmdline<'_>, digest: &str) -> Result<()> {
528        let digest =
529            Sha512HashValue::from_hex(digest).context("Invalid booted deployment digest")?;
530        ensure!(
531            read_boot_type(self.sysroot, &digest.to_hex())? == Some(BootType::Aboot),
532            "Booted deployment is not aboot"
533        );
534        let Some(slot) = Slot::from_cmdline(cmdline) else {
535            tracing::warn!("No unambiguous aboot slot suffix; leaving slot mappings unchanged");
536            return Ok(());
537        };
538        if self.read_slot(slot)?.as_ref() == Some(&digest) {
539            return Ok(());
540        }
541
542        self.ensure_slots()?
543            .atomic_write(slot.name(), format!("{}\n", digest.to_hex()))
544            .context("Recording booted aboot slot")?;
545        Ok(())
546    }
547
548    /// Invalidate only the non-booted slot mapping before deployment.
549    pub(crate) fn invalidate_other(&self, booted_slot: Slot) -> Result<()> {
550        let other = match booted_slot {
551            Slot::A => Slot::B,
552            Slot::B => Slot::A,
553        };
554        let Some(slots) = self.slots.as_ref() else {
555            return Ok(());
556        };
557        slots.remove_file_optional(other.name())?;
558        fsync(slots.reopen_as_ownedfd()?).context("Syncing invalidated aboot slot")?;
559        Ok(())
560    }
561
562    pub(crate) fn delete_deployment(&self, deployment: &str) -> Result<()> {
563        let digest = Sha512HashValue::from_hex(deployment).context("Invalid deployment digest")?;
564        let current_boot_id = boot_id()?;
565
566        if let Some(rollback) = self.read_rollback()? {
567            if rollback.boot_id == current_boot_id {
568                ensure!(
569                    rollback.depl_id != deployment,
570                    "Cannot delete a queued aboot rollback deployment"
571                );
572            } else {
573                self.clear_rollback()?;
574            }
575        }
576
577        if let Some(attempted) = self
578            .read_attempted()?
579            .filter(|attempted| attempted.depl_id == deployment)
580        {
581            ensure!(
582                attempted.boot_id != current_boot_id,
583                "Cannot delete an aboot deployment attempted during this boot"
584            );
585            if self
586                .read_pending()?
587                .is_some_and(|pending| pending.depl_id == deployment)
588            {
589                self.remove_state_file(ABOOT_PENDING_FNAME)?;
590                remove_staged_deployment()?;
591                self.remove_artifacts(deployment)?;
592            }
593            self.remove_state_file(ABOOT_ATTEMPTED_FNAME)?;
594        } else if self
595            .read_pending()?
596            .is_some_and(|pending| pending.depl_id == deployment)
597        {
598            self.remove_state_file(ABOOT_PENDING_FNAME)?;
599            remove_staged_deployment()?;
600            self.remove_artifacts(deployment)?;
601        }
602
603        let mut removed = false;
604        for slot in [Slot::A, Slot::B] {
605            if self.read_slot(slot)?.as_ref() == Some(&digest) {
606                self.slots.as_ref().unwrap().remove_file(slot.name())?;
607                removed = true;
608            }
609        }
610        if removed {
611            fsync(self.slots.as_ref().unwrap().reopen_as_ownedfd()?)
612                .context("Syncing aboot slot mappings")?;
613        }
614        Ok(())
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use crate::composefs_consts::STATE_DIR_RELATIVE;
622    use cap_std_ext::{cap_std::ambient_authority, cap_tempfile::tempdir};
623
624    #[test]
625    fn bootloader_config() {
626        for (config, expected) in [
627            ("", Bootloader::None),
628            (
629                "PARTITION_A=/dev/disk/by-partlabel/boot_a\n",
630                Bootloader::None,
631            ),
632            ("BOOT_TYPE=aboot\n", Bootloader::None),
633            ("BOOT_TYPE=unknown\n", Bootloader::None),
634            ("BOOT_TYPE=\n", Bootloader::None),
635            ("BOOT_TYPE=ukiboot", Bootloader::Ukiboot),
636            (" \tBOOT_TYPE \t= \tukiboot \t\r\n", Bootloader::Ukiboot),
637            (
638                "#BOOT_TYPE=ukiboot\n #BOOT_TYPE=ukiboot\n",
639                Bootloader::None,
640            ),
641            ("BOOT_TYPE\nCMDLINE=a=b\n", Bootloader::None),
642            ("BOOT_TYPE=ukiboot\nBOOT_TYPE=aboot\n", Bootloader::None),
643            ("BOOT_TYPE=aboot\nBOOT_TYPE=ukiboot\n", Bootloader::Ukiboot),
644            ("BOOT_TYPE=\"ukiboot\"\n", Bootloader::None),
645            ("BOOT_TYPE='ukiboot'\n", Bootloader::None),
646            ("BOOT_TYPE=ukiboot # comment\n", Bootloader::None),
647        ] {
648            assert_eq!(bootloader_from_config(config), expected, "{config:?}");
649        }
650    }
651
652    fn deployment(root: &Dir, digest: &str, boot_type: &str) -> Result<()> {
653        let path = format!("{STATE_DIR_RELATIVE}/{digest}");
654        root.create_dir_all(&path)?;
655        root.atomic_write(
656            format!("{path}/{digest}.origin"),
657            format!("[boot]\nboot_type={boot_type}\n"),
658        )?;
659        Ok(())
660    }
661
662    #[test]
663    fn slot_suffix() {
664        for (cmdline, expected) in [
665            ("androidboot.slot_suffix=_a", Some(Slot::A)),
666            ("quiet androidboot.slot_suffix=_b rw", Some(Slot::B)),
667            ("", None),
668            ("androidboot.slot_suffix", None),
669            ("androidboot.slot_suffix=", None),
670            ("androidboot.slot_suffix=a", None),
671            ("androidboot.slot_suffix=_c", None),
672            (
673                "androidboot.slot_suffix=_a androidboot.slot_suffix=_b",
674                None,
675            ),
676            (
677                "androidboot.slot_suffix=_a androidboot.slot_suffix=_a",
678                None,
679            ),
680        ] {
681            assert_eq!(
682                Slot::from_cmdline(&Cmdline::from(cmdline)),
683                expected,
684                "{cmdline}"
685            );
686        }
687    }
688
689    #[test]
690    fn slot_lifecycle() -> Result<()> {
691        let root = tempdir(ambient_authority())?;
692        let a = "aa".repeat(64);
693        let b = "bb".repeat(64);
694        deployment(&root, &a, "aboot")?;
695        deployment(&root, &b, "aboot")?;
696        let cmdline_a = Cmdline::from("androidboot.slot_suffix=_a");
697        let cmdline_b = Cmdline::from("androidboot.slot_suffix=_b");
698        let mut state = AbootState::open(&root)?;
699
700        assert_eq!(state.read_slot(Slot::A)?, None);
701        state.invalidate_other(Slot::A)?;
702        state.record_booted(&cmdline_a, &a)?;
703        state.record_booted(&cmdline_a, &a)?;
704        assert_eq!(state.read_slot(Slot::B)?, None);
705        state.record_booted(&cmdline_b, &b)?;
706        assert_eq!(
707            state.read_slot(Slot::A)?,
708            Some(Sha512HashValue::from_hex(&a)?)
709        );
710        assert_eq!(
711            state.read_slot(Slot::B)?,
712            Some(Sha512HashValue::from_hex(&b)?)
713        );
714
715        for slot in [Slot::A, Slot::B] {
716            state.record_booted(&cmdline_a, &a)?;
717            state.record_booted(&cmdline_b, &b)?;
718            state.invalidate_other(slot)?;
719            state.invalidate_other(slot)?;
720            assert!(state.read_slot(slot)?.is_some());
721            let other = if slot == Slot::A { Slot::B } else { Slot::A };
722            assert_eq!(state.read_slot(other)?, None);
723            assert!(!root.try_exists(format!("{ABOOT_STATE_DIR}/slots/{}", other.name()))?);
724        }
725
726        state.record_booted(&cmdline_a, &a)?;
727        state.record_booted(&Cmdline::from(""), &b)?;
728        assert_eq!(
729            state.read_slot(Slot::A)?,
730            Some(Sha512HashValue::from_hex(&a)?)
731        );
732        state.record_booted(&cmdline_a, &b)?;
733        assert_eq!(
734            state.read_slot(Slot::A)?,
735            Some(Sha512HashValue::from_hex(&b)?)
736        );
737        Ok(())
738    }
739
740    #[test]
741    fn invalid_mappings() -> Result<()> {
742        let root = tempdir(ambient_authority())?;
743        let slots = format!("{ABOOT_STATE_DIR}/slots");
744        root.create_dir_all(&slots)?;
745        let mut state = AbootState::open(&root)?;
746        for contents in [String::new(), "invalid".into(), "aa".repeat(64)] {
747            root.atomic_write(format!("{slots}/a"), contents)?;
748            assert_eq!(state.read_slot(Slot::A)?, None);
749        }
750        root.atomic_write(format!("{slots}/a"), [0xff; 128])?;
751        assert_eq!(state.read_slot(Slot::A)?, None);
752        let digest = "aa".repeat(64);
753        root.atomic_write(format!("{slots}/a"), &digest)?;
754        deployment(&root, &digest, "uki")?;
755        assert_eq!(state.read_slot(Slot::A)?, None);
756        assert!(
757            state
758                .record_booted(&Cmdline::from("androidboot.slot_suffix=_a"), &digest)
759                .is_err()
760        );
761        assert!(
762            state
763                .record_booted(&Cmdline::from(""), "../invalid")
764                .is_err()
765        );
766        Ok(())
767    }
768
769    #[test]
770    fn deployments_deduplicates_slots() -> Result<()> {
771        let root = tempdir(ambient_authority())?;
772        let digest = "aa".repeat(64);
773        deployment(&root, &digest, "aboot")?;
774        let mut state = AbootState::open(&root)?;
775        state.record_booted(&Cmdline::from("androidboot.slot_suffix=_a"), &digest)?;
776        state.record_booted(&Cmdline::from("androidboot.slot_suffix=_b"), &digest)?;
777        assert_eq!(
778            state.slot_deployments()?,
779            vec![Sha512HashValue::from_hex(digest)?]
780        );
781        Ok(())
782    }
783
784    #[test]
785    fn pending_artifacts_and_attempt() -> Result<()> {
786        let root = tempdir(ambient_authority())?;
787        let digest = "aa".repeat(64);
788        deployment(&root, &digest, "aboot")?;
789        let mut state = AbootState::open(&root)?;
790        let pending = state.stage_artifacts(&digest, true, b"boot", Some(b"vbmeta"))?;
791        state.write_pending(&pending)?;
792        assert_eq!(state.read_pending()?, Some(pending.clone()));
793
794        let paths = state.verify_artifacts(Utf8Path::new("/sysroot"), &pending)?;
795        assert_eq!(
796            paths.boot,
797            Utf8PathBuf::from(format!(
798                "/sysroot/{STATE_DIR_RELATIVE}/{digest}/{ABOOT_ARTIFACT_DIR}/{ABOOT_IMAGE_FNAME}"
799            ))
800        );
801        assert!(paths.vbmeta.is_some());
802
803        let pending = state.unlock_pending(&digest)?;
804        assert!(!pending.finalization_locked);
805        state.record_attempt(&digest)?;
806        assert_eq!(state.read_attempted()?.unwrap().depl_id, digest);
807        assert!(state.write_pending(&pending).is_err());
808
809        let artifacts = root.open_dir(format!(
810            "{STATE_DIR_RELATIVE}/{digest}/{ABOOT_ARTIFACT_DIR}"
811        ))?;
812        artifacts.atomic_write(ABOOT_IMAGE_FNAME, b"modified")?;
813        assert!(
814            state
815                .verify_artifacts(Utf8Path::new("/sysroot"), &pending)
816                .is_err()
817        );
818        Ok(())
819    }
820
821    #[test]
822    fn reconcile_attempt() -> Result<()> {
823        let root = tempdir(ambient_authority())?;
824        let digest = "aa".repeat(64);
825        let artifact_dir = format!("{STATE_DIR_RELATIVE}/{digest}/{ABOOT_ARTIFACT_DIR}");
826        deployment(&root, &digest, "aboot")?;
827        let mut state = AbootState::open(&root)?;
828        let pending = state.stage_artifacts(&digest, false, b"boot", None)?;
829        state.write_pending(&pending)?;
830        assert!(root.try_exists(&artifact_dir)?);
831        assert!(matches!(
832            state.reconcile_state("booted", "new-boot")?,
833            ReconcileAction::Publish(_)
834        ));
835
836        state.write_json(
837            ABOOT_ATTEMPTED_FNAME,
838            &AttemptedDeployment {
839                depl_id: digest.clone(),
840                boot_id: "old-boot".into(),
841            },
842        )?;
843        for current_boot in ["old-boot", "new-boot", "another-boot"] {
844            assert!(matches!(
845                state.reconcile_state("booted", current_boot)?,
846                ReconcileAction::None
847            ));
848            assert_eq!(state.read_pending()?, Some(pending.clone()));
849            assert_eq!(state.read_attempted()?.unwrap().boot_id, "old-boot");
850        }
851        assert!(root.try_exists(&artifact_dir)?);
852        assert!(matches!(
853            state.reconcile_state(&digest, "new-boot")?,
854            ReconcileAction::RemoveTransient
855        ));
856        assert!(state.read_pending()?.is_none());
857        assert!(state.read_attempted()?.is_none());
858        assert!(!root.try_exists(&artifact_dir)?);
859        Ok(())
860    }
861
862    #[test]
863    fn replace_failed_attempt_after_reboot() -> Result<()> {
864        let root = tempdir(ambient_authority())?;
865        let digest = "aa".repeat(64);
866        let artifact_dir = format!("{STATE_DIR_RELATIVE}/{digest}/{ABOOT_ARTIFACT_DIR}");
867        deployment(&root, &digest, "aboot")?;
868        let mut state = AbootState::open(&root)?;
869        let pending = state.stage_artifacts(&digest, false, b"failed", None)?;
870        state.write_pending(&pending)?;
871        state.record_attempt(&digest)?;
872
873        assert!(state.discard_failed_attempt("booted").is_err());
874        assert!(state.discard_pending().is_err());
875        assert_eq!(state.read_pending()?, Some(pending));
876        assert!(root.try_exists(&artifact_dir)?);
877
878        state.write_json(
879            ABOOT_ATTEMPTED_FNAME,
880            &AttemptedDeployment {
881                depl_id: digest.clone(),
882                boot_id: "previous-boot".into(),
883            },
884        )?;
885        // Leave successful attempts for normal boot reconciliation.
886        assert!(!state.discard_failed_attempt(&digest)?);
887        assert!(state.read_attempted()?.is_some());
888        assert!(state.discard_failed_attempt("booted")?);
889        assert!(state.read_pending()?.is_none());
890        assert!(state.read_attempted()?.is_none());
891        assert!(!root.try_exists(&artifact_dir)?);
892
893        // The same image can be staged and attempted again explicitly.
894        let pending = state.stage_artifacts(&digest, false, b"retry", None)?;
895        state.write_pending(&pending)?;
896        state.record_attempt(&digest)?;
897        assert_eq!(state.read_attempted()?.unwrap().boot_id, boot_id()?);
898
899        // Rollback can also discard an older failed attempt.
900        state.write_json(
901            ABOOT_ATTEMPTED_FNAME,
902            &AttemptedDeployment {
903                depl_id: digest,
904                boot_id: "previous-boot".into(),
905            },
906        )?;
907        state.discard_pending()?;
908        assert!(state.read_pending()?.is_none());
909        assert!(state.read_attempted()?.is_none());
910        assert!(!root.try_exists(&artifact_dir)?);
911        Ok(())
912    }
913
914    #[test]
915    fn rollback_and_deletion() -> Result<()> {
916        let root = tempdir(ambient_authority())?;
917        let a = "aa".repeat(64);
918        let b = "bb".repeat(64);
919        let staged = "cc".repeat(64);
920        for digest in [&a, &b, &staged] {
921            deployment(&root, digest, "aboot")?;
922        }
923        let mut state = AbootState::open(&root)?;
924        state.record_booted(&Cmdline::from("androidboot.slot_suffix=_a"), &a)?;
925        state.record_booted(&Cmdline::from("androidboot.slot_suffix=_b"), &b)?;
926
927        state.queue_rollback(&b)?;
928        assert_eq!(state.queued_rollback()?.as_deref(), Some(b.as_str()));
929        assert!(state.delete_deployment(&b).is_err());
930        state.clear_rollback()?;
931
932        let pending = state.stage_artifacts(&staged, false, b"boot", None)?;
933        state.write_pending(&pending)?;
934        state.delete_deployment(&staged)?;
935        assert!(state.read_pending()?.is_none());
936        assert!(!root.try_exists(format!(
937            "{STATE_DIR_RELATIVE}/{staged}/{ABOOT_ARTIFACT_DIR}"
938        ))?);
939
940        state.delete_deployment(&b)?;
941        assert_eq!(state.read_slot(Slot::B)?, None);
942        assert_eq!(
943            state.read_slot(Slot::A)?,
944            Some(Sha512HashValue::from_hex(&a)?)
945        );
946
947        let pending = state.stage_artifacts(&staged, false, b"boot", None)?;
948        state.write_pending(&pending)?;
949        state.write_json(
950            ABOOT_ATTEMPTED_FNAME,
951            &AttemptedDeployment {
952                depl_id: staged.clone(),
953                boot_id: boot_id()?,
954            },
955        )?;
956        assert!(state.delete_deployment(&staged).is_err());
957        state.write_json(
958            ABOOT_ATTEMPTED_FNAME,
959            &AttemptedDeployment {
960                depl_id: staged.clone(),
961                boot_id: "previous-boot".into(),
962            },
963        )?;
964        state.delete_deployment(&staged)?;
965        assert!(state.read_pending()?.is_none());
966        assert!(state.read_attempted()?.is_none());
967        Ok(())
968    }
969
970    #[test]
971    fn rollback_marker_expires_after_boot() -> Result<()> {
972        let root = tempdir(ambient_authority())?;
973        let a = "aa".repeat(64);
974        let b = "bb".repeat(64);
975        deployment(&root, &a, "aboot")?;
976        deployment(&root, &b, "aboot")?;
977        let mut state = AbootState::open(&root)?;
978        state.record_booted(&Cmdline::from("androidboot.slot_suffix=_a"), &a)?;
979        state.record_booted(&Cmdline::from("androidboot.slot_suffix=_b"), &b)?;
980        state.queue_rollback(&b)?;
981
982        let current_boot_id = boot_id()?;
983        assert!(matches!(
984            state.reconcile_state(&a, &current_boot_id)?,
985            ReconcileAction::RemoveTransient
986        ));
987        assert_eq!(state.queued_rollback()?.as_deref(), Some(b.as_str()));
988        assert!(matches!(
989            state.reconcile_state(&b, "next-boot")?,
990            ReconcileAction::RemoveTransient
991        ));
992        assert!(state.read_rollback()?.is_none());
993        Ok(())
994    }
995}