| // Copyright 2026 The Fuchsia Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| use anyhow::Error; |
| use diagnostics_data::InspectData; |
| use diagnostics_hierarchy::SelectResult; |
| use fidl::endpoints::Proxy; |
| use fidl_fuchsia_diagnostics as fdiagnostics; |
| use fidl_fuchsia_diagnostics_persistence as fdiagnostics_persistence; |
| use fidl_fuchsia_io as fio; |
| use fidl_fuchsia_logger as flogger; |
| use fidl_fuchsia_power_battery as fbattery; |
| use fidl_fuchsia_sys2 as fsys2; |
| use fidl_fuchsia_update as fupdate; |
| use fuchsia_component_test::{Capability, ChildOptions, RealmBuilder, RealmInstance, Ref, Route}; |
| use futures::channel::mpsc; |
| use futures::{FutureExt, SinkExt, StreamExt, TryStreamExt}; |
| use std::path::Path; |
| use std::sync::Arc; |
| use std::sync::atomic::{AtomicU64, Ordering}; |
| |
| fn extract_token(content: &str) -> Option<u64> { |
| let data_vec: Vec<InspectData> = serde_json::from_str(content).ok()?; |
| let selector = selectors::parse_verbose("*:root:token").ok()?; |
| |
| for data in data_vec { |
| if data.moniker.to_string().contains("publisher") { |
| let prop = diagnostics_hierarchy::select_from_hierarchy( |
| data.payload.as_ref().unwrap(), |
| &selector, |
| ) |
| .unwrap(); |
| let SelectResult::Properties(props) = prop else { |
| panic!("malformed/unexpected test data") |
| }; |
| assert_eq!(props.len(), 1); |
| return props[0].uint(); |
| } |
| } |
| None |
| } |
| |
| struct TestRealm { |
| instance: RealmInstance, |
| current_token: Arc<AtomicU64>, |
| temp_dir: tempfile::TempDir, |
| } |
| |
| async fn make_realm( |
| interval: i64, |
| mock_battery_tx: Option<mpsc::Sender<fbattery::BatteryInfoWatcherProxy>>, |
| mock_update_tx: Option<mpsc::Sender<fupdate::NotifierProxy>>, |
| ) -> Result<TestRealm, Error> { |
| let skip_update_check = mock_update_tx.is_none(); |
| let builder = RealmBuilder::new().await?; |
| |
| let temp_dir = tempfile::TempDir::new_in("/tmp")?; |
| let temp_path = temp_dir.path().to_path_buf(); |
| |
| // Create a local child component that serves a temporary directory (`temp_dir`) |
| // containing a `"cache"` subdirectory. This directory will act as the backing |
| // storage for the Persistence component under test, allowing the test to directly |
| // inspect persisted Inspect snapshot files on the host filesystem. |
| let storage_provider = builder |
| .add_local_child( |
| "storage-provider", |
| move |handles| { |
| let temp_path = temp_path.clone(); |
| Box::pin(async move { |
| let dir_proxy = fuchsia_fs::directory::open_in_namespace( |
| temp_path.to_str().unwrap(), |
| fio::PERM_READABLE | fio::PERM_WRITABLE, |
| )?; |
| let _ = fuchsia_fs::directory::create_directory_recursive( |
| &dir_proxy, |
| "cache", |
| fio::PERM_READABLE | fio::PERM_WRITABLE, |
| ) |
| .await?; |
| // Serve temp_path (which contains the "cache" subdir) as the component's outgoing directory. |
| fuchsia_fs::directory::clone_onto(&dir_proxy, handles.outgoing_dir)?; |
| // Stay pending so the component continues serving the directory for the duration of the test realm. |
| futures::future::pending::<()>().await; |
| Ok(()) |
| }) |
| }, |
| ChildOptions::new().eager(), |
| ) |
| .await?; |
| |
| builder |
| .add_route( |
| Route::new() |
| .capability(Capability::directory("cache").rights(fio::RW_STAR_DIR).path("/cache")) |
| .from(&storage_provider) |
| .to(Ref::parent()), |
| ) |
| .await?; |
| |
| let current_token = Arc::new(AtomicU64::new(0)); |
| |
| let publisher_token = current_token.clone(); |
| let archivist = builder |
| .add_child("archivist", "#meta/archivist-for-embedding.cm", ChildOptions::new().eager()) |
| .await?; |
| |
| let publisher = builder |
| .add_local_child( |
| "publisher", |
| move |handles| { |
| let token = publisher_token.clone(); |
| Box::pin(async move { |
| let inspector = fuchsia_inspect::Inspector::default(); |
| inspector.root().record_lazy_values("", move || { |
| let token_val = token.load(Ordering::SeqCst); |
| let inspector = fuchsia_inspect::Inspector::default(); |
| inspector.root().record_uint("token", token_val); |
| async move { Ok(inspector) }.boxed() |
| }); |
| let mut options = inspect_runtime::PublishOptions::default(); |
| if let Ok(proxy) = |
| handles.connect_to_protocol::<fidl_fuchsia_inspect::InspectSinkProxy>() |
| { |
| if let Ok(client_end) = proxy.into_client_end() { |
| options = options.on_inspect_sink_client(client_end); |
| } |
| } |
| let _inspect_server = inspect_runtime::publish(&inspector, options); |
| let mut fs = fuchsia_component::server::ServiceFs::new(); |
| fs.serve_connection(handles.outgoing_dir)?; |
| fs.collect::<()>().await; |
| Ok(()) |
| }) |
| }, |
| ChildOptions::new().eager(), |
| ) |
| .await?; |
| |
| let persistence = builder |
| .add_child("persistence", "#meta/diagnostics-persistence.cm", ChildOptions::new()) |
| .await?; |
| |
| builder |
| .add_capability(cm_rust::CapabilityDecl::Config(cm_rust::ConfigurationDecl { |
| name: "fuchsia.diagnostics.persist.PersistencePeriodSeconds".parse().unwrap(), |
| value: cm_rust::ConfigValue::Single(cm_rust::ConfigSingleValue::Int64(interval)), |
| })) |
| .await?; |
| builder |
| .add_capability(cm_rust::CapabilityDecl::Config(cm_rust::ConfigurationDecl { |
| name: "fuchsia.diagnostics.persist.SkipUpdateCheck".parse().unwrap(), |
| value: cm_rust::ConfigValue::Single(cm_rust::ConfigSingleValue::Bool( |
| skip_update_check, |
| )), |
| })) |
| .await?; |
| builder |
| .add_capability(cm_rust::CapabilityDecl::Config(cm_rust::ConfigurationDecl { |
| name: "fuchsia.diagnostics.persist.StopOnIdleTimeoutMillis".parse().unwrap(), |
| value: cm_rust::ConfigValue::Single(cm_rust::ConfigSingleValue::Int64(-1)), |
| })) |
| .await?; |
| builder |
| .add_capability(cm_rust::CapabilityDecl::Config(cm_rust::ConfigurationDecl { |
| name: "fuchsia.diagnostics.persist.LowBatteryThresholdPercent".parse().unwrap(), |
| value: cm_rust::ConfigValue::Single(cm_rust::ConfigSingleValue::Uint64(10)), |
| })) |
| .await?; |
| builder |
| .add_route( |
| Route::new() |
| .capability(Capability::configuration( |
| "fuchsia.diagnostics.persist.PersistencePeriodSeconds", |
| )) |
| .capability(Capability::configuration( |
| "fuchsia.diagnostics.persist.SkipUpdateCheck", |
| )) |
| .capability(Capability::configuration( |
| "fuchsia.diagnostics.persist.StopOnIdleTimeoutMillis", |
| )) |
| .capability(Capability::configuration( |
| "fuchsia.diagnostics.persist.LowBatteryThresholdPercent", |
| )) |
| .from(Ref::self_()) |
| .to(&persistence), |
| ) |
| .await?; |
| |
| if let Some(tx) = mock_battery_tx { |
| let battery_manager = builder |
| .add_local_child( |
| "battery-manager", |
| move |handles| { |
| let tx = tx.clone(); |
| Box::pin(async move { |
| let mut fs = fuchsia_component::server::ServiceFs::new(); |
| fs.dir("svc").add_fidl_service( |
| |stream: fbattery::BatteryManagerRequestStream| stream, |
| ); |
| fs.serve_connection(handles.outgoing_dir)?; |
| fs.for_each_concurrent(None, move |mut stream| { |
| let mut tx = tx.clone(); |
| async move { |
| while let Ok(Some(req)) = stream.try_next().await { |
| match req { |
| fbattery::BatteryManagerRequest::Watch { |
| watcher, |
| control_handle: _, |
| } => { |
| let proxy = watcher.into_proxy(); |
| let _ = tx.send(proxy).await; |
| } |
| _ => {} |
| } |
| } |
| } |
| }) |
| .await; |
| Ok(()) |
| }) |
| }, |
| ChildOptions::new().eager(), |
| ) |
| .await?; |
| |
| builder |
| .add_route( |
| Route::new() |
| .capability(Capability::protocol::<fbattery::BatteryManagerMarker>()) |
| .from(&battery_manager) |
| .to(&persistence), |
| ) |
| .await?; |
| } |
| |
| if let Some(tx) = mock_update_tx { |
| let update_listener = builder |
| .add_local_child( |
| "update-listener", |
| move |handles| { |
| let tx = tx.clone(); |
| Box::pin(async move { |
| let mut fs = fuchsia_component::server::ServiceFs::new(); |
| fs.dir("svc") |
| .add_fidl_service(|stream: fupdate::ListenerRequestStream| stream); |
| fs.serve_connection(handles.outgoing_dir)?; |
| fs.for_each_concurrent(None, move |mut stream| { |
| let mut tx = tx.clone(); |
| async move { |
| while let Ok(Some(req)) = stream.try_next().await { |
| match req { |
| fupdate::ListenerRequest::NotifyOnFirstUpdateCheck { |
| payload, |
| control_handle: _, |
| } => { |
| if let Some(notifier) = payload.notifier { |
| let proxy = notifier.into_proxy(); |
| let _ = tx.send(proxy).await; |
| } |
| } |
| _ => {} |
| } |
| } |
| } |
| }) |
| .await; |
| Ok(()) |
| }) |
| }, |
| ChildOptions::new().eager(), |
| ) |
| .await?; |
| |
| builder |
| .add_route( |
| Route::new() |
| .capability(Capability::protocol::<fupdate::ListenerMarker>()) |
| .from(&update_listener) |
| .to(&persistence), |
| ) |
| .await?; |
| } |
| |
| // Route capability_requested event stream to archivist |
| builder |
| .add_route( |
| Route::new() |
| .capability(Capability::event_stream("capability_requested")) |
| .from(Ref::parent()) |
| .to(&archivist), |
| ) |
| .await?; |
| |
| // Route LogSink from parent to archivist, publisher, and persistence |
| builder |
| .add_route( |
| Route::new() |
| .capability(Capability::protocol::<flogger::LogSinkMarker>()) |
| .from(Ref::parent()) |
| .to(&archivist) |
| .to(&publisher) |
| .to(&persistence), |
| ) |
| .await?; |
| |
| // Route InspectSink from archivist to publisher, persistence |
| builder |
| .add_route( |
| Route::new() |
| .capability(Capability::protocol::<fidl_fuchsia_inspect::InspectSinkMarker>()) |
| .from(&archivist) |
| .to(&publisher) |
| .to(&persistence), |
| ) |
| .await?; |
| |
| // Route diagnostics-accessors dictionary from archivist as ArchiveAccessor.previous_boot to persistence |
| builder |
| .add_route( |
| Route::new() |
| .capability( |
| Capability::protocol::<fdiagnostics::ArchiveAccessorMarker>() |
| .as_("fuchsia.diagnostics.ArchiveAccessor.previous_boot"), |
| ) |
| .from(Ref::dictionary(&archivist, "diagnostics-accessors")) |
| .to(&persistence), |
| ) |
| .await?; |
| |
| // Route PreviousBootDataProvider from persistence to parent |
| builder |
| .add_route( |
| Route::new() |
| .capability(Capability::protocol::< |
| fdiagnostics_persistence::PreviousBootDataProviderMarker, |
| >()) |
| .from(&persistence) |
| .to(Ref::parent()), |
| ) |
| .await?; |
| |
| // Route LifecycleController from framework to parent |
| builder |
| .add_route( |
| Route::new() |
| .capability(Capability::protocol::<fsys2::LifecycleControllerMarker>()) |
| .from(Ref::framework()) |
| .to(Ref::parent()), |
| ) |
| .await?; |
| |
| // Diagnostics Persistence consumes a `storage: "cache"` capability. |
| // Because RealmBuilder's route API does not support defining a new Storage capability |
| // backed by a child directory capability, we manually update the root realm's decl: |
| // 1. Declare a Storage capability "cache" backed by child `storage-provider`'s "cache" directory. |
| // 2. Offer the "cache" Storage capability from `self` to the `persistence` child. |
| let mut realm_decl = builder.get_realm_decl().await?; |
| let mut capabilities = Vec::from(realm_decl.capabilities); |
| capabilities.push(cm_rust::CapabilityDecl::Storage(cm_rust::StorageDecl { |
| name: "cache".parse().unwrap(), |
| source: cm_rust::StorageDirectorySource::Child("storage-provider".to_string()), |
| backing_dir: "cache".parse().unwrap(), |
| subdir: Default::default(), |
| storage_id: fidl_fuchsia_component_decl::StorageId::StaticInstanceIdOrMoniker, |
| })); |
| realm_decl.capabilities = capabilities.into_boxed_slice(); |
| |
| let mut offers = Vec::from(realm_decl.offers); |
| offers.push(cm_rust::OfferDecl::Storage(cm_rust::OfferStorageDecl { |
| source: cm_rust::OfferSource::Self_, |
| source_name: "cache".parse().unwrap(), |
| target: cm_rust::OfferTarget::Child(cm_rust::ChildRef { |
| name: "persistence".parse().unwrap(), |
| collection: None, |
| }), |
| target_name: "cache".parse().unwrap(), |
| availability: cm_rust::Availability::Required, |
| })); |
| realm_decl.offers = offers.into_boxed_slice(); |
| builder.replace_realm_decl(realm_decl).await?; |
| |
| Ok(TestRealm { instance: builder.build().await?, current_token, temp_dir }) |
| } |
| |
| fn find_active_dir(dir: &Path) -> Option<std::path::PathBuf> { |
| if let Ok(entries) = std::fs::read_dir(dir) { |
| for entry in entries.flatten() { |
| let path = entry.path(); |
| if path.is_dir() { |
| if entry.file_name() == "active" { |
| return Some(path); |
| } |
| if let Some(found) = find_active_dir(&path) { |
| return Some(found); |
| } |
| } |
| } |
| } |
| None |
| } |
| |
| async fn wait_for_active_token(temp_dir: &tempfile::TempDir, expected: u64) -> Result<(), Error> { |
| loop { |
| if let Some(active_dir) = find_active_dir(temp_dir.path()) { |
| if let Ok(entries) = std::fs::read_dir(&active_dir) { |
| let file_names: Vec<String> = |
| entries.flatten().filter_map(|e| e.file_name().into_string().ok()).collect(); |
| |
| if file_names.len() == 2 |
| && file_names.iter().any(|f| f == "active.json") |
| && file_names.iter().any(|f| f == "metadata.json") |
| { |
| let active_path = active_dir.join("active.json"); |
| let meta_path = active_dir.join("metadata.json"); |
| if let (Ok(content), Ok(meta_content)) = |
| (std::fs::read_to_string(&active_path), std::fs::read_to_string(&meta_path)) |
| { |
| if !meta_content.is_empty() |
| && serde_json::from_str::<serde_json::Value>(&meta_content).is_ok() |
| { |
| if let Some(token) = extract_token(&content) { |
| if token == expected { |
| return Ok(()); |
| } |
| } |
| } |
| } |
| } |
| } |
| } |
| fuchsia_async::Timer::new(zx::MonotonicInstant::after(zx::MonotonicDuration::from_millis( |
| 5, |
| ))) |
| .await; |
| } |
| } |
| |
| async fn restart_persistence(lifecycle: &fsys2::LifecycleControllerProxy) -> Result<(), Error> { |
| lifecycle |
| .stop_instance("./persistence") |
| .await? |
| .map_err(|e| anyhow::anyhow!("stop_instance error: {e:?}"))?; |
| |
| let (_, binder_server) = fidl::endpoints::create_endpoints(); |
| lifecycle |
| .start_instance("./persistence", binder_server) |
| .await? |
| .map_err(|e| anyhow::anyhow!("start_instance error: {e:?}"))?; |
| Ok(()) |
| } |
| |
| async fn wait_for_snapshot( |
| instance: &RealmInstance, |
| ) -> Result<fdiagnostics_persistence::PreviousBootData, Error> { |
| loop { |
| let provider: fdiagnostics_persistence::PreviousBootDataProviderProxy = |
| instance.root.connect_to_protocol_at_exposed_dir()?; |
| let data = provider |
| .watch_previous_boot_data( |
| &fdiagnostics_persistence::PreviousBootDataProviderOptions::default(), |
| ) |
| .await?; |
| if data.inspect.is_some() { |
| return Ok(data); |
| } |
| fuchsia_async::Timer::new(zx::MonotonicInstant::after(zx::MonotonicDuration::from_millis( |
| 10, |
| ))) |
| .await; |
| } |
| } |
| |
| impl TestRealm { |
| async fn destroy(self) -> Result<(), Error> { |
| let res = self.instance.destroy().await; |
| drop(self.temp_dir); |
| Ok(res?) |
| } |
| } |
| |
| async fn read_snapshot_token(instance: &RealmInstance) -> Result<u64, Error> { |
| let data = wait_for_snapshot(instance).await?; |
| let inspect_file = data.inspect.expect("Expected inspect file in PreviousBootData"); |
| let file_proxy = inspect_file.into_proxy(); |
| let content = fuchsia_fs::file::read_to_string(&file_proxy).await?; |
| extract_token(&content) |
| .ok_or_else(|| anyhow::anyhow!("Failed to extract token from JSON:\n{content}")) |
| } |
| |
| #[fuchsia::test] |
| async fn test_persistence_rotation() -> Result<(), Error> { |
| const INTERVAL: i64 = 1; |
| let realm = make_realm(INTERVAL, None, None).await?; |
| |
| // Boot 1 Check: Connect to PreviousBootDataProvider, verify data.inspect is None |
| let provider: fdiagnostics_persistence::PreviousBootDataProviderProxy = |
| realm.instance.root.connect_to_protocol_at_exposed_dir()?; |
| let data = provider.watch_previous_boot_data(&Default::default()).await?; |
| assert!(data.inspect.is_none(), "Expected no previous boot data on initial boot"); |
| |
| // Set Token 1 and wait until Persistence captures it in active snapshot on disk |
| const TOKEN_1: u64 = 0xAAAA_1111_2222; |
| realm.current_token.store(TOKEN_1, Ordering::SeqCst); |
| wait_for_active_token(&realm.temp_dir, TOKEN_1).await?; |
| |
| let lifecycle: fsys2::LifecycleControllerProxy = |
| realm.instance.root.connect_to_protocol_at_exposed_dir()?; |
| |
| // Boot 2 (First Rotation): Restart persistence and verify previous boot data contains Token 1 |
| restart_persistence(&lifecycle).await?; |
| let token = read_snapshot_token(&realm.instance).await?; |
| assert_eq!(token, TOKEN_1, "Expected previous boot data to contain Token 1"); |
| |
| // Set Token 2 and wait until Persistence captures it |
| const TOKEN_2: u64 = 0xBBBB_3333_4444; |
| realm.current_token.store(TOKEN_2, Ordering::SeqCst); |
| wait_for_active_token(&realm.temp_dir, TOKEN_2).await?; |
| |
| // Boot 3 (Second Rotation): Restart persistence and verify previous boot data contains Token 2 |
| restart_persistence(&lifecycle).await?; |
| let token = read_snapshot_token(&realm.instance).await?; |
| assert_eq!(token, TOKEN_2, "Expected previous boot data to contain Token 2"); |
| |
| realm.destroy().await?; |
| Ok(()) |
| } |
| |
| #[fuchsia::test] |
| async fn test_low_battery_trigger() -> Result<(), Error> { |
| const INTERVAL: i64 = 300; |
| let (tx, mut rx) = mpsc::channel(1); |
| let realm = make_realm(INTERVAL, Some(tx), None).await?; |
| |
| // Boot 1 Check: Connect to PreviousBootDataProvider, verify data.inspect is None |
| let provider: fdiagnostics_persistence::PreviousBootDataProviderProxy = |
| realm.instance.root.connect_to_protocol_at_exposed_dir()?; |
| let data = provider.watch_previous_boot_data(&Default::default()).await?; |
| assert!(data.inspect.is_none(), "Expected no previous boot data on initial boot"); |
| |
| // Set distinctive low-battery token |
| const LOW_BATTERY_TOKEN: u64 = 0xCAFE_BABE_BEEF; |
| realm.current_token.store(LOW_BATTERY_TOKEN, Ordering::SeqCst); |
| |
| // Wait for persistence to connect to mock BatteryManager and send watcher proxy |
| let watcher_proxy = rx |
| .next() |
| .await |
| .ok_or_else(|| anyhow::anyhow!("Failed to receive BatteryInfoWatcherProxy"))?; |
| |
| // Emit a low battery event (8.0% <= threshold 10.0%) |
| let low_battery_info = fbattery::BatteryInfo { |
| level_percent: Some(8.0), |
| level_status: Some(fbattery::LevelStatus::Low), |
| ..Default::default() |
| }; |
| watcher_proxy.on_change_battery_info(&low_battery_info, None).await?; |
| |
| // Wait until Persistence captures LOW_BATTERY_TOKEN on disk |
| wait_for_active_token(&realm.temp_dir, LOW_BATTERY_TOKEN).await?; |
| |
| let lifecycle: fsys2::LifecycleControllerProxy = |
| realm.instance.root.connect_to_protocol_at_exposed_dir()?; |
| |
| // Restart persistence to rotate active snapshot to previous_boot |
| restart_persistence(&lifecycle).await?; |
| |
| // Verify inspect snapshot generated by low-battery trigger is present with LOW_BATTERY_TOKEN |
| let token = read_snapshot_token(&realm.instance).await?; |
| assert_eq!(token, LOW_BATTERY_TOKEN, "Expected low battery snapshot to contain token"); |
| |
| realm.destroy().await?; |
| Ok(()) |
| } |
| |
| #[fuchsia::test] |
| async fn test_update_check_gating() -> Result<(), Error> { |
| const INTERVAL: i64 = 5; |
| let (tx, mut rx) = mpsc::channel(1); |
| let realm = make_realm(INTERVAL, None, Some(tx)).await?; |
| |
| let provider: fdiagnostics_persistence::PreviousBootDataProviderProxy = |
| realm.instance.root.connect_to_protocol_at_exposed_dir()?; |
| |
| // Wait for persistence to connect to mock update Listener and send notifier proxy |
| let notifier_proxy = |
| rx.next().await.ok_or_else(|| anyhow::anyhow!("Failed to receive NotifierProxy"))?; |
| |
| const UPDATE_TOKEN: u64 = 0x1234_5678_9ABC; |
| realm.current_token.store(UPDATE_TOKEN, Ordering::SeqCst); |
| |
| // Signal that the post-boot update check is complete |
| notifier_proxy.notify()?; |
| |
| let data = provider.watch_previous_boot_data(&Default::default()).await?; |
| assert!(data.inspect.is_none(), "Expected no previous boot data on initial boot"); |
| |
| // Wait deterministically for persistence to collect an active snapshot with UPDATE_TOKEN on disk |
| wait_for_active_token(&realm.temp_dir, UPDATE_TOKEN).await?; |
| |
| let lifecycle: fsys2::LifecycleControllerProxy = |
| realm.instance.root.connect_to_protocol_at_exposed_dir()?; |
| restart_persistence(&lifecycle).await?; |
| |
| // On Boot 2 restart, persistence connects to update Listener again |
| let notifier_proxy_2 = rx |
| .next() |
| .await |
| .ok_or_else(|| anyhow::anyhow!("Failed to receive NotifierProxy on Boot 2"))?; |
| notifier_proxy_2.notify()?; |
| |
| // Verify inspect snapshot is served by PreviousBootDataProvider with UPDATE_TOKEN |
| let token = read_snapshot_token(&realm.instance).await?; |
| assert_eq!(token, UPDATE_TOKEN, "Expected previous boot data to contain UPDATE_TOKEN"); |
| |
| realm.destroy().await?; |
| Ok(()) |
| } |