Skip to main content

attribution_processing/
digest.rs

1// Copyright 2025 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5use crate::{ProcessedAttributionData, ZXName};
6use anyhow::Result;
7use bstr::ByteSlice;
8use fidl_fuchsia_kernel_common as fkernel;
9use fidl_fuchsia_memory_attribution_plugin_common as fplugin;
10use regex_lite::Regex;
11use rustc_hash::FxHashMap;
12use serde::de::Error;
13use serde::{Deserialize, Deserializer, Serialize};
14use smallvec::SmallVec;
15use std::collections::hash_map::Entry::Occupied;
16#[cfg(target_os = "fuchsia")]
17use {crate::CATEGORY_MEMORY_CAPTURE, fuchsia_trace::duration};
18
19const UNDIGESTED: &str = "Undigested";
20const ORPHANED: &str = "Orphaned";
21const KERNEL: &str = "Kernel";
22const FREE: &str = "Free";
23const PAGER_TOTAL: &str = "[Addl]PagerTotal";
24const PAGER_NEWEST: &str = "[Addl]PagerNewest";
25const PAGER_OLDEST: &str = "[Addl]PagerOldest";
26const DISCARDABLE_LOCKED: &str = "[Addl]DiscardableLocked";
27const DISCARDABLE_UNLOCKED: &str = "[Addl]DiscardableUnlocked";
28const ZRAM_COMPRESSED_BYTES: &str = "[Addl]ZramCompressedBytes";
29const POPULATED_ANONYMOUS_BYTES: &str = "[Addl]PopulatedAnonymousBytes";
30
31/// Represents a specification for aggregating memory usage in meaningful groups.
32///
33/// `name` represents the meaningful name of the group; grouping is done based on process and VMO
34/// names.
35///
36// Note: This needs to mirror `//src/lib/assembly/memory_buckets/src/memory_buckets.rs`, but cannot
37// reuse it directly because it is an host-only library.
38#[derive(Clone, Debug, Deserialize)]
39pub struct BucketDefinition {
40    pub name: String,
41    #[serde(deserialize_with = "deserialize_regex")]
42    pub process: Option<Regex>,
43    #[serde(deserialize_with = "deserialize_regex")]
44    pub vmo: Option<Regex>,
45    #[serde(default, deserialize_with = "deserialize_regex")]
46    pub principal: Option<Regex>,
47    pub event_code: u64,
48}
49
50impl BucketDefinition {
51    /// Tests whether a process matches this bucket's definition, based on its name.
52    fn process_match(&self, process: &ZXName) -> bool {
53        self.process.as_ref().is_none_or(|process_regex| {
54            process
55                .as_bstr()
56                .to_str()
57                .is_ok_and(|process_name| process_regex.is_match(process_name))
58        })
59    }
60
61    /// Tests whether a VMO matches this bucket's definition, based on its name.
62    fn vmo_match(&self, vmo: &ZXName) -> bool {
63        self.vmo.as_ref().is_none_or(|vmo_regex| {
64            vmo.as_bstr().to_str().is_ok_and(|vmo_name| vmo_regex.is_match(vmo_name))
65        })
66    }
67
68    /// Tests whether any of the specified principal names match this bucket's definition.
69    fn principals_match(&self, principals: &[&str]) -> bool {
70        self.principal.as_ref().is_none_or(|a| principals.iter().any(|name| a.is_match(name)))
71    }
72}
73
74// Teach serde to deserialize an optional regex.
75fn deserialize_regex<'de, D>(d: D) -> Result<Option<Regex>, D::Error>
76where
77    D: Deserializer<'de>,
78{
79    // Deserialize as Option<&str>
80    Option::<String>::deserialize(d)
81        // If the parsing failed, return the error, otherwise transform the value
82        .and_then(|os| {
83            os
84                // If there is a value, try to parse it as a Regex.
85                .map(|s| {
86                    Regex::new(&s)
87                        // If the regex compilation failed, wrap the error in the error type expected
88                        // by serde.
89                        .map_err(D::Error::custom)
90                })
91                // If there was a value but it failed to compile, return an error, otherwise return
92                // the potentially parsed option.
93                .transpose()
94        })
95}
96
97/// Aggregates bytes in categories with human readable names.
98#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
99pub struct Bucket {
100    pub name: String,
101    pub populated_size: u64,
102    pub committed_size: u64,
103    pub vmos: Option<Vec<NamedVmo>>,
104}
105
106/// Contains a view of the system's memory usage, aggregated in groups called buckets, which are
107/// configurable.
108#[derive(Debug, Default, PartialEq, Eq, Serialize)]
109pub struct Digest {
110    pub buckets: Vec<Bucket>,
111}
112
113/// Non-owning structure to keep track of known undigested VMOs.
114struct UndigestedVmo<'a> {
115    populated_size: u64,
116    committed_size: u64,
117    name: &'a ZXName,
118    principals: &'a [&'a str],
119}
120
121#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
122/// Owning structure to report known VMOs.
123pub struct NamedVmo {
124    pub name: ZXName,
125    pub populated_size: u64,
126    pub committed_size: u64,
127    pub principals: Vec<String>,
128}
129
130impl Digest {
131    /// Given means to query the system for memory usage, and a specification, this function
132    /// aggregates the current memory usage into human displayable units we call buckets.
133    pub fn compute(
134        attribution_data: &ProcessedAttributionData,
135        kmem_stats: &fkernel::MemoryStats,
136        kmem_stats_compression: &fkernel::MemoryStatsCompression,
137        bucket_definitions: &[BucketDefinition],
138        detailed_vmos: bool,
139    ) -> Result<Digest> {
140        #[cfg(target_os = "fuchsia")]
141        duration!(CATEGORY_MEMORY_CAPTURE, c"Digest::compute");
142
143        // Maps resources' (VMO, Process, Job. See Resource) ids
144        // to their owner, i.e. the principal they have been
145        // attributed to.
146        //
147        // On a test run (2026-08), we found that the number of VMOs for each number of owners
148        // distributes as follows:
149        // # of owners : # of VMOs
150        // - 01 : 62072
151        // - 02 :  3112
152        // - 03 :  0096
153        // - 04 :  0011
154        // - 05 :  0002
155        // - 06 :  0001
156        // - .... (each subsequent entry has 1 VMO at most)
157        let owners: FxHashMap<u64, SmallVec<[&str; 1]>> = {
158            // `owners` are only needed when detailed_vmos is true, or when one bucket definition
159            // uses a principal matcher, which is the case on all the products where memory monitor
160            // 2 is deployed. Therefore we always compute it.
161            let mut owners: FxHashMap<u64, SmallVec<[&str; 1]>> = FxHashMap::default();
162            for (_, p) in &attribution_data.principals {
163                let p_name = p.name();
164                for r in &p.resources {
165                    owners.entry(*r).or_default().push(p_name);
166                }
167            }
168            owners
169        };
170
171        let mut populated_reclaimable_bytes = 0;
172        let mut undigested_vmos: FxHashMap<u64, UndigestedVmo<'_>> = attribution_data
173            .resources
174            .iter()
175            .filter_map(|(koid, r)| match &r.resource.resource_type {
176                fplugin::ResourceType::Vmo(vmo) => {
177                    attribution_data.resource_names.get(r.resource.name_index).and_then(|name| {
178                        let populated_size = vmo.scaled_populated_bytes?;
179                        let committed_size = vmo.scaled_committed_bytes?;
180                        if vmo.flags.map_or(false, |flags| {
181                            flags
182                                & (zx_types::ZX_INFO_VMO_PAGER_BACKED
183                                    | zx_types::ZX_INFO_VMO_DISCARDABLE)
184                                != 0
185                        }) {
186                            populated_reclaimable_bytes += populated_size;
187                        }
188                        Some((
189                            *koid,
190                            UndigestedVmo {
191                                name,
192                                populated_size,
193                                committed_size,
194                                principals: owners.get(koid).map_or(&[], |v| v.as_slice()),
195                            },
196                        ))
197                    })
198                }
199                _ => None,
200            })
201            .collect();
202        let processes: Vec<(&ZXName, &fplugin::Process)> = attribution_data
203            .resources
204            .values()
205            .filter_map(|r| match &r.resource.resource_type {
206                fplugin::ResourceType::Process(process) => attribution_data
207                    .resource_names
208                    .get(r.resource.name_index)
209                    .map(|name| (name, process)),
210                _ => None,
211            })
212            .collect();
213
214        let mut buckets: Vec<Bucket> = bucket_definitions
215            .iter()
216            .map(|bd| {
217                let mut bucket = Bucket {
218                    name: bd.name.to_owned(),
219                    populated_size: 0,
220                    committed_size: 0,
221                    vmos: None,
222                };
223                processes.iter().for_each(|(process_name, process)| {
224                    if bd.process_match(process_name) {
225                        for koid in process.vmos.iter().flatten() {
226                            let (populated_size, committed_size) = match undigested_vmos
227                                .entry(*koid)
228                            {
229                                Occupied(e) => {
230                                    let UndigestedVmo { name, principals, .. } = e.get();
231                                    if bd.vmo_match(&name) && bd.principals_match(principals) {
232                                        let (_, vmo) = e.remove_entry();
233                                        if detailed_vmos {
234                                            bucket.vmos.get_or_insert_default().push(NamedVmo {
235                                                name: vmo.name.clone(),
236                                                populated_size: vmo.populated_size,
237                                                committed_size: vmo.committed_size,
238                                                principals: vmo
239                                                    .principals
240                                                    .iter()
241                                                    .map(|&name| name.to_owned())
242                                                    .collect(),
243                                            });
244                                        }
245                                        (vmo.populated_size, vmo.committed_size)
246                                    } else {
247                                        (0, 0)
248                                    }
249                                }
250                                _ => (0, 0),
251                            };
252                            bucket.committed_size += committed_size;
253                            bucket.populated_size += populated_size;
254                        }
255                    };
256                });
257                bucket
258            })
259            .collect();
260
261        // This bucket contains the total size of the known VMOs that have not been covered
262        // by any other bucket.
263        let undigested = {
264            let (populated_size, committed_size) = undigested_vmos
265                .values()
266                .map(|UndigestedVmo { populated_size, committed_size, .. }| {
267                    (*populated_size, *committed_size)
268                })
269                .fold((0, 0), |(total_populated, total_committed), (populated, committed)| {
270                    (total_populated + populated, total_committed + committed)
271                });
272
273            Bucket {
274                name: UNDIGESTED.to_string(),
275                populated_size: populated_size,
276                committed_size,
277                vmos: if detailed_vmos {
278                    Some(
279                        undigested_vmos
280                            .values()
281                            .map(|vmo| NamedVmo {
282                                name: vmo.name.clone(),
283                                populated_size: vmo.populated_size,
284                                committed_size: vmo.committed_size,
285                                principals: vmo
286                                    .principals
287                                    .iter()
288                                    .map(|&name| name.to_owned())
289                                    .collect(),
290                            })
291                            .collect(),
292                    )
293                } else {
294                    None
295                },
296            }
297        };
298
299        let total_vmo_size: u64 = undigested.committed_size
300            + buckets.iter().map(|Bucket { committed_size, .. }| committed_size).sum::<u64>();
301
302        // Extend the configured aggregation with a number of additional, occasionally useful meta
303        // aggregations.
304        buckets.extend([
305            undigested,
306            // This bucket accounts for VMO bytes that have been allocated by the kernel, but not
307            // claimed by any VMO (anymore).
308            {
309                let size = kmem_stats.vmo_bytes.unwrap_or(0).saturating_sub(total_vmo_size);
310                Bucket {
311                    name: ORPHANED.to_string(),
312                    populated_size: size,
313                    committed_size: size,
314                    vmos: None,
315                }
316            },
317            // This bucket aggregates overall kernel memory usage.
318            {
319                let size = (|| {
320                    Some(
321                        kmem_stats.wired_bytes?
322                            + kmem_stats.total_heap_bytes?
323                            + kmem_stats.mmu_overhead_bytes?
324                            + kmem_stats.ipc_bytes?
325                            + kmem_stats.other_bytes?
326                            + kmem_stats.slab_bytes?
327                            + kmem_stats.cache_bytes?,
328                    )
329                })()
330                .unwrap_or(0);
331                Bucket {
332                    name: KERNEL.to_string(),
333                    populated_size: size,
334                    committed_size: size,
335                    vmos: None,
336                }
337            },
338            // This bucket contains the amount of free memory in the system.
339            {
340                let size = kmem_stats.free_bytes.unwrap_or(0);
341                Bucket {
342                    name: FREE.to_string(),
343                    populated_size: size,
344                    committed_size: size,
345                    vmos: None,
346                }
347            },
348            // Those buckets contain pager related information.
349            {
350                let size = kmem_stats.vmo_reclaim_total_bytes.unwrap_or(0);
351                Bucket {
352                    name: PAGER_TOTAL.to_string(),
353                    populated_size: size,
354                    committed_size: size,
355                    vmos: None,
356                }
357            },
358            {
359                let size = kmem_stats.vmo_reclaim_newest_bytes.unwrap_or(0);
360                Bucket {
361                    name: PAGER_NEWEST.to_string(),
362                    populated_size: size,
363                    committed_size: size,
364                    vmos: None,
365                }
366            },
367            {
368                let size = kmem_stats.vmo_reclaim_oldest_bytes.unwrap_or(0);
369                Bucket {
370                    name: PAGER_OLDEST.to_string(),
371                    populated_size: size,
372                    committed_size: size,
373                    vmos: None,
374                }
375            },
376            // Those buckets account for discardable memory.
377            {
378                let size = kmem_stats.vmo_discardable_locked_bytes.unwrap_or(0);
379                Bucket {
380                    name: DISCARDABLE_LOCKED.to_string(),
381                    populated_size: size,
382                    committed_size: size,
383                    vmos: None,
384                }
385            },
386            {
387                let size = kmem_stats.vmo_discardable_unlocked_bytes.unwrap_or(0);
388                Bucket {
389                    name: DISCARDABLE_UNLOCKED.to_string(),
390                    populated_size: size,
391                    committed_size: size,
392                    vmos: None,
393                }
394            },
395            // This bucket accounts for compressed memory.
396            {
397                let size = kmem_stats_compression.compressed_storage_bytes.unwrap_or(0);
398                Bucket {
399                    name: ZRAM_COMPRESSED_BYTES.to_string(),
400                    populated_size: size,
401                    committed_size: size,
402                    vmos: None,
403                }
404            },
405            // This bucket accounts for all populated anonymous memory (non-reclaimable).
406            {
407                let size = (kmem_stats.total_bytes.unwrap_or(0)
408                    + kmem_stats_compression.uncompressed_storage_bytes.unwrap_or(0))
409                .saturating_sub(kmem_stats.free_bytes.unwrap_or(0))
410                .saturating_sub(kmem_stats.zram_bytes.unwrap_or(0))
411                .saturating_sub(populated_reclaimable_bytes);
412
413                Bucket {
414                    name: POPULATED_ANONYMOUS_BYTES.to_string(),
415                    populated_size: size,
416                    committed_size: size,
417                    vmos: None,
418                }
419            },
420        ]);
421        Ok(Digest { buckets })
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use crate::{
429        Attribution, AttributionData, GlobalPrincipalIdentifier, Principal, PrincipalDescription,
430        PrincipalType, ProcessedAttributionData, Resource, ResourceReference, attribute_vmos,
431    };
432    use fidl_fuchsia_memory_attribution_plugin_common as fplugin;
433    use regex_lite::Regex;
434
435    fn get_attribution_data() -> ProcessedAttributionData {
436        attribute_vmos(AttributionData {
437            principals_vec: vec![
438                Principal {
439                    identifier: GlobalPrincipalIdentifier::new_for_test(1),
440                    description: Some(PrincipalDescription::Component("principal".to_owned())),
441                    principal_type: PrincipalType::Runnable,
442                    parent: Some(GlobalPrincipalIdentifier::new_for_test(2)),
443                },
444                Principal {
445                    identifier: GlobalPrincipalIdentifier::new_for_test(2),
446                    description: Some(PrincipalDescription::Component("parent".to_owned())),
447                    principal_type: PrincipalType::Runnable,
448                    parent: None,
449                },
450            ],
451            resources_vec: vec![
452                Resource {
453                    koid: 10,
454                    name_index: 0,
455                    resource_type: fplugin::ResourceType::Vmo(fplugin::Vmo {
456                        parent: None,
457                        private_committed_bytes: Some(1024),
458                        private_populated_bytes: Some(2048),
459                        scaled_committed_bytes: Some(512),
460                        scaled_populated_bytes: Some(2048),
461                        total_committed_bytes: Some(1024),
462                        total_populated_bytes: Some(2048),
463                        ..Default::default()
464                    }),
465                },
466                Resource {
467                    koid: 20,
468                    name_index: 1,
469                    resource_type: fplugin::ResourceType::Vmo(fplugin::Vmo {
470                        parent: None,
471                        private_committed_bytes: Some(1024),
472                        private_populated_bytes: Some(2048),
473                        scaled_committed_bytes: Some(512),
474                        scaled_populated_bytes: Some(2048),
475                        total_committed_bytes: Some(1024),
476                        total_populated_bytes: Some(2048),
477                        ..Default::default()
478                    }),
479                },
480                Resource {
481                    koid: 30,
482                    name_index: 1,
483                    resource_type: fplugin::ResourceType::Process(fplugin::Process {
484                        vmos: Some(vec![10, 20]),
485                        ..Default::default()
486                    }),
487                },
488            ],
489            resource_names: vec![
490                ZXName::try_from_bytes(b"resource").unwrap(),
491                ZXName::try_from_bytes(b"matched").unwrap(),
492            ],
493            attributions: vec![Attribution {
494                source: GlobalPrincipalIdentifier::new_for_test(1),
495                subject: GlobalPrincipalIdentifier::new_for_test(1),
496                resources: vec![ResourceReference::KernelObject(20)],
497            }],
498        })
499    }
500
501    fn get_kernel_stats() -> (fkernel::MemoryStats, fkernel::MemoryStatsCompression) {
502        (
503            fkernel::MemoryStats {
504                total_bytes: Some(20),
505                free_bytes: Some(2),
506                wired_bytes: Some(3),
507                total_heap_bytes: Some(4),
508                free_heap_bytes: Some(5),
509                vmo_bytes: Some(10000),
510                mmu_overhead_bytes: Some(7),
511                ipc_bytes: Some(8),
512                other_bytes: Some(9),
513                free_loaned_bytes: Some(10),
514                cache_bytes: Some(11),
515                slab_bytes: Some(12),
516                zram_bytes: Some(13),
517                vmo_reclaim_total_bytes: Some(14),
518                vmo_reclaim_newest_bytes: Some(15),
519                vmo_reclaim_oldest_bytes: Some(16),
520                vmo_reclaim_disabled_bytes: Some(17),
521                vmo_discardable_locked_bytes: Some(18),
522                vmo_discardable_unlocked_bytes: Some(19),
523                ..Default::default()
524            },
525            fkernel::MemoryStatsCompression {
526                uncompressed_storage_bytes: Some(1),
527                compressed_storage_bytes: Some(21),
528                compressed_fragmentation_bytes: Some(22),
529                compression_time: Some(23),
530                decompression_time: Some(24),
531                total_page_compression_attempts: Some(25),
532                failed_page_compression_attempts: Some(26),
533                total_page_decompressions: Some(27),
534                compressed_page_evictions: Some(28),
535                eager_page_compressions: Some(29),
536                memory_pressure_page_compressions: Some(30),
537                critical_memory_page_compressions: Some(31),
538                pages_decompressed_unit_ns: Some(32),
539                pages_decompressed_within_log_time: Some([40, 41, 42, 43, 44, 45, 46, 47]),
540                ..Default::default()
541            },
542        )
543    }
544
545    fn sort_buckets_for_assert(digest: &mut Digest) {
546        for bucket in digest.buckets.iter_mut() {
547            for vmos in bucket.vmos.iter_mut() {
548                vmos.sort_by(|vmo1, vmo2| vmo1.name.cmp(&vmo2.name));
549            }
550        }
551    }
552
553    #[test]
554    fn test_digest_no_definitions() {
555        let (kernel_stats, kernel_stats_compression) = get_kernel_stats();
556        let digest = {
557            let mut digest = Digest::compute(
558                &get_attribution_data(),
559                &kernel_stats,
560                &kernel_stats_compression,
561                &vec![],
562                true,
563            )
564            .unwrap();
565            sort_buckets_for_assert(&mut digest);
566            digest
567        };
568        let expected_buckets = vec![
569            // The two VMOs are unmatched, 512 + 512
570            Bucket {
571                name: UNDIGESTED.to_string(),
572                populated_size: 4096,
573                committed_size: 1024,
574                vmos: Some(vec![
575                    NamedVmo {
576                        name: ZXName::from_string_lossy("matched"),
577                        populated_size: 2048,
578                        committed_size: 512,
579                        principals: vec!["principal".to_string()],
580                    },
581                    NamedVmo {
582                        name: ZXName::from_string_lossy("resource"),
583                        populated_size: 2048,
584                        committed_size: 512,
585                        principals: vec![],
586                    },
587                ]),
588            },
589            // No matched VMOs, one UNDIGESTED VMO => 10000 - 1024 = 8976
590            Bucket {
591                name: ORPHANED.to_string(),
592                populated_size: 8976,
593                committed_size: 8976,
594                vmos: None,
595            },
596            // wired + heap + mmu + ipc + other + slab + cache => 3 + 4 + 7 + 8 + 9 + 12 + 11 = 54
597            Bucket { name: KERNEL.to_string(), populated_size: 54, committed_size: 54, vmos: None },
598            Bucket { name: FREE.to_string(), populated_size: 2, committed_size: 2, vmos: None },
599            Bucket {
600                name: PAGER_TOTAL.to_string(),
601                populated_size: 14,
602                committed_size: 14,
603                vmos: None,
604            },
605            Bucket {
606                name: PAGER_NEWEST.to_string(),
607                populated_size: 15,
608                committed_size: 15,
609                vmos: None,
610            },
611            Bucket {
612                name: PAGER_OLDEST.to_string(),
613                populated_size: 16,
614                committed_size: 16,
615                vmos: None,
616            },
617            Bucket {
618                name: DISCARDABLE_LOCKED.to_string(),
619                populated_size: 18,
620                committed_size: 18,
621                vmos: None,
622            },
623            Bucket {
624                name: DISCARDABLE_UNLOCKED.to_string(),
625                populated_size: 19,
626                committed_size: 19,
627                vmos: None,
628            },
629            Bucket {
630                name: ZRAM_COMPRESSED_BYTES.to_string(),
631                populated_size: 21,
632                committed_size: 21,
633                vmos: None,
634            },
635            Bucket {
636                name: POPULATED_ANONYMOUS_BYTES.to_string(),
637                populated_size: 6,
638                committed_size: 6,
639                vmos: None,
640            },
641        ];
642
643        assert_eq!(digest.buckets, expected_buckets);
644    }
645
646    #[test]
647    fn test_digest_with_matching_vmo() -> Result<(), anyhow::Error> {
648        let (kernel_stats, kernel_stats_compression) = get_kernel_stats();
649        let digest = {
650            let mut digest = Digest::compute(
651                &get_attribution_data(),
652                &kernel_stats,
653                &kernel_stats_compression,
654                &vec![BucketDefinition {
655                    name: "matched".to_string(),
656                    process: None,
657                    vmo: Some(Regex::new("matched")?),
658                    principal: None,
659                    event_code: Default::default(),
660                }],
661                true,
662            )
663            .unwrap();
664            sort_buckets_for_assert(&mut digest);
665            digest
666        };
667        let expected_buckets = vec![
668            // One VMO is matched, the other is not
669            Bucket {
670                name: "matched".to_string(),
671                populated_size: 2048,
672                committed_size: 512,
673                vmos: Some(vec![NamedVmo {
674                    name: ZXName::from_string_lossy("matched"),
675                    populated_size: 2048,
676                    committed_size: 512,
677                    principals: vec!["principal".to_owned()],
678                }]),
679            },
680            // One unmatched VMO
681            Bucket {
682                name: UNDIGESTED.to_string(),
683                populated_size: 2048,
684                committed_size: 512,
685                vmos: Some(vec![NamedVmo {
686                    name: ZXName::from_string_lossy("resource"),
687                    populated_size: 2048,
688                    committed_size: 512,
689                    principals: vec![],
690                }]),
691            },
692            // One matched VMO, one unmatched VMO //=> 10000 - 512 - 512 = 8976
693            Bucket {
694                name: ORPHANED.to_string(),
695                populated_size: 8976,
696                committed_size: 8976,
697                vmos: None,
698            },
699            // wired + heap + mmu + ipc + other + slab + cache => 3 + 4 + 7 + 8 + 9 + 12 + 11 = 54
700            Bucket { name: KERNEL.to_string(), populated_size: 54, committed_size: 54, vmos: None },
701            Bucket { name: FREE.to_string(), populated_size: 2, committed_size: 2, vmos: None },
702            Bucket {
703                name: PAGER_TOTAL.to_string(),
704                populated_size: 14,
705                committed_size: 14,
706                vmos: None,
707            },
708            Bucket {
709                name: PAGER_NEWEST.to_string(),
710                populated_size: 15,
711                committed_size: 15,
712                vmos: None,
713            },
714            Bucket {
715                name: PAGER_OLDEST.to_string(),
716                populated_size: 16,
717                committed_size: 16,
718                vmos: None,
719            },
720            Bucket {
721                name: DISCARDABLE_LOCKED.to_string(),
722                populated_size: 18,
723                committed_size: 18,
724                vmos: None,
725            },
726            Bucket {
727                name: DISCARDABLE_UNLOCKED.to_string(),
728                populated_size: 19,
729                committed_size: 19,
730                vmos: None,
731            },
732            Bucket {
733                name: ZRAM_COMPRESSED_BYTES.to_string(),
734                populated_size: 21,
735                committed_size: 21,
736                vmos: None,
737            },
738            Bucket {
739                name: POPULATED_ANONYMOUS_BYTES.to_string(),
740                populated_size: 6,
741                committed_size: 6,
742                vmos: None,
743            },
744        ];
745
746        assert_eq!(digest.buckets, expected_buckets);
747        Ok(())
748    }
749
750    #[test]
751    fn test_digest_with_matching_process() -> Result<(), anyhow::Error> {
752        let (kernel_stats, kernel_stats_compression) = get_kernel_stats();
753        let digest = {
754            let mut digest = Digest::compute(
755                &get_attribution_data(),
756                &kernel_stats,
757                &kernel_stats_compression,
758                &vec![BucketDefinition {
759                    name: "matched".to_string(),
760                    process: Some(Regex::new("matched")?),
761                    vmo: None,
762                    principal: None,
763                    event_code: Default::default(),
764                }],
765                true,
766            )
767            .unwrap();
768            sort_buckets_for_assert(&mut digest);
769            digest
770        };
771        let expected_buckets = vec![
772            // Both VMOs are matched => 512 + 512 = 1024
773            Bucket {
774                name: "matched".to_string(),
775                populated_size: 4096,
776                committed_size: 1024,
777                vmos: Some(vec![
778                    NamedVmo {
779                        name: ZXName::from_string_lossy("matched"),
780                        populated_size: 2048,
781                        committed_size: 512,
782                        principals: vec!["principal".to_owned()],
783                    },
784                    NamedVmo {
785                        name: ZXName::from_string_lossy("resource"),
786                        populated_size: 2048,
787                        committed_size: 512,
788                        principals: vec![],
789                    },
790                ]),
791            },
792            // No unmatched VMO
793            Bucket {
794                name: UNDIGESTED.to_string(),
795                populated_size: 0,
796                committed_size: 0,
797                vmos: Some(vec![]),
798            },
799            // Two matched VMO => 10000 - 512 - 512 = 8976
800            Bucket {
801                name: ORPHANED.to_string(),
802                populated_size: 8976,
803                committed_size: 8976,
804                vmos: None,
805            },
806            // wired + heap + mmu + ipc + other + slab + cache => 3 + 4 + 7 + 8 + 9 + 12 + 11 = 54
807            Bucket { name: KERNEL.to_string(), populated_size: 54, committed_size: 54, vmos: None },
808            Bucket { name: FREE.to_string(), populated_size: 2, committed_size: 2, vmos: None },
809            Bucket {
810                name: PAGER_TOTAL.to_string(),
811                populated_size: 14,
812                committed_size: 14,
813                vmos: None,
814            },
815            Bucket {
816                name: PAGER_NEWEST.to_string(),
817                populated_size: 15,
818                committed_size: 15,
819                vmos: None,
820            },
821            Bucket {
822                name: PAGER_OLDEST.to_string(),
823                populated_size: 16,
824                committed_size: 16,
825                vmos: None,
826            },
827            Bucket {
828                name: DISCARDABLE_LOCKED.to_string(),
829                populated_size: 18,
830                committed_size: 18,
831                vmos: None,
832            },
833            Bucket {
834                name: DISCARDABLE_UNLOCKED.to_string(),
835                populated_size: 19,
836                committed_size: 19,
837                vmos: None,
838            },
839            Bucket {
840                name: ZRAM_COMPRESSED_BYTES.to_string(),
841                populated_size: 21,
842                committed_size: 21,
843                vmos: None,
844            },
845            Bucket {
846                name: POPULATED_ANONYMOUS_BYTES.to_string(),
847                populated_size: 6,
848                committed_size: 6,
849                vmos: None,
850            },
851        ];
852
853        assert_eq!(digest.buckets, expected_buckets);
854        Ok(())
855    }
856
857    #[test]
858    fn test_digest_with_matching_principal() -> Result<(), anyhow::Error> {
859        let (kernel_stats, kernel_stats_compression) = get_kernel_stats();
860        let digest = {
861            let mut digest = Digest::compute(
862                &get_attribution_data(),
863                &kernel_stats,
864                &kernel_stats_compression,
865                &vec![BucketDefinition {
866                    name: "matched".to_string(),
867                    process: None,
868                    vmo: None,
869                    principal: Some(Regex::new("principal")?),
870                    event_code: Default::default(),
871                }],
872                true,
873            )
874            .unwrap();
875            sort_buckets_for_assert(&mut digest);
876            digest
877        };
878        let expected_buckets = vec![
879            // One VMO is matched, the other is not
880            Bucket {
881                name: "matched".to_string(),
882                populated_size: 2048,
883                committed_size: 512,
884                vmos: Some(vec![NamedVmo {
885                    name: ZXName::from_string_lossy("matched"),
886                    populated_size: 2048,
887                    committed_size: 512,
888                    principals: vec!["principal".to_owned()],
889                }]),
890            },
891            // One unmatched VMO
892            Bucket {
893                name: UNDIGESTED.to_string(),
894                populated_size: 2048,
895                committed_size: 512,
896                vmos: Some(vec![NamedVmo {
897                    name: ZXName::from_string_lossy("resource"),
898                    populated_size: 2048,
899                    committed_size: 512,
900                    principals: vec![],
901                }]),
902            },
903            // One matched VMO, one unmatched VMO //=> 10000 - 512 - 512 = 8976
904            Bucket {
905                name: ORPHANED.to_string(),
906                populated_size: 8976,
907                committed_size: 8976,
908                vmos: None,
909            },
910            // wired + heap + mmu + ipc + other + slab + cache => 3 + 4 + 7 + 8 + 9 + 12 + 11 = 54
911            Bucket { name: KERNEL.to_string(), populated_size: 54, committed_size: 54, vmos: None },
912            Bucket { name: FREE.to_string(), populated_size: 2, committed_size: 2, vmos: None },
913            Bucket {
914                name: PAGER_TOTAL.to_string(),
915                populated_size: 14,
916                committed_size: 14,
917                vmos: None,
918            },
919            Bucket {
920                name: PAGER_NEWEST.to_string(),
921                populated_size: 15,
922                committed_size: 15,
923                vmos: None,
924            },
925            Bucket {
926                name: PAGER_OLDEST.to_string(),
927                populated_size: 16,
928                committed_size: 16,
929                vmos: None,
930            },
931            Bucket {
932                name: DISCARDABLE_LOCKED.to_string(),
933                populated_size: 18,
934                committed_size: 18,
935                vmos: None,
936            },
937            Bucket {
938                name: DISCARDABLE_UNLOCKED.to_string(),
939                populated_size: 19,
940                committed_size: 19,
941                vmos: None,
942            },
943            Bucket {
944                name: ZRAM_COMPRESSED_BYTES.to_string(),
945                populated_size: 21,
946                committed_size: 21,
947                vmos: None,
948            },
949            Bucket {
950                name: POPULATED_ANONYMOUS_BYTES.to_string(),
951                populated_size: 6,
952                committed_size: 6,
953                vmos: None,
954            },
955        ];
956
957        assert_eq!(digest.buckets, expected_buckets);
958        Ok(())
959    }
960
961    #[test]
962    fn test_digest_with_matching_principal_process_and_vmo() -> Result<(), anyhow::Error> {
963        let (kernel_stats, kernel_stats_compression) = get_kernel_stats();
964        let digest = {
965            let mut digest = Digest::compute(
966                &get_attribution_data(),
967                &kernel_stats,
968                &kernel_stats_compression,
969                &vec![BucketDefinition {
970                    name: "matched".to_string(),
971                    process: Some(Regex::new("matched")?),
972                    vmo: Some(Regex::new("matched")?),
973                    principal: Some(Regex::new("principal")?),
974                    event_code: Default::default(),
975                }],
976                true,
977            )
978            .unwrap();
979            sort_buckets_for_assert(&mut digest);
980            digest
981        };
982        let expected_buckets = vec![
983            // One VMO is matched, the other is not
984            Bucket {
985                name: "matched".to_string(),
986                populated_size: 2048,
987                committed_size: 512,
988                vmos: Some(vec![NamedVmo {
989                    name: ZXName::from_string_lossy("matched"),
990                    populated_size: 2048,
991                    committed_size: 512,
992                    principals: vec!["principal".to_owned()],
993                }]),
994            },
995            // One unmatched VMO
996            Bucket {
997                name: UNDIGESTED.to_string(),
998                populated_size: 2048,
999                committed_size: 512,
1000                vmos: Some(vec![NamedVmo {
1001                    name: ZXName::from_string_lossy("resource"),
1002                    populated_size: 2048,
1003                    committed_size: 512,
1004                    principals: vec![],
1005                }]),
1006            },
1007            // One matched VMO, one unmatched VMO => 10000 - 512 - 512 = 8976
1008            Bucket {
1009                name: ORPHANED.to_string(),
1010                populated_size: 8976,
1011                committed_size: 8976,
1012                vmos: None,
1013            },
1014            // wired + heap + mmu + ipc + other + slab + cache => 3 + 4 + 7 + 8 + 9 + 12 + 11 = 54
1015            Bucket { name: KERNEL.to_string(), populated_size: 54, committed_size: 54, vmos: None },
1016            Bucket { name: FREE.to_string(), populated_size: 2, committed_size: 2, vmos: None },
1017            Bucket {
1018                name: PAGER_TOTAL.to_string(),
1019                populated_size: 14,
1020                committed_size: 14,
1021                vmos: None,
1022            },
1023            Bucket {
1024                name: PAGER_NEWEST.to_string(),
1025                populated_size: 15,
1026                committed_size: 15,
1027                vmos: None,
1028            },
1029            Bucket {
1030                name: PAGER_OLDEST.to_string(),
1031                populated_size: 16,
1032                committed_size: 16,
1033                vmos: None,
1034            },
1035            Bucket {
1036                name: DISCARDABLE_LOCKED.to_string(),
1037                populated_size: 18,
1038                committed_size: 18,
1039                vmos: None,
1040            },
1041            Bucket {
1042                name: DISCARDABLE_UNLOCKED.to_string(),
1043                populated_size: 19,
1044                committed_size: 19,
1045                vmos: None,
1046            },
1047            Bucket {
1048                name: ZRAM_COMPRESSED_BYTES.to_string(),
1049                populated_size: 21,
1050                committed_size: 21,
1051                vmos: None,
1052            },
1053            Bucket {
1054                name: POPULATED_ANONYMOUS_BYTES.to_string(),
1055                populated_size: 6,
1056                committed_size: 6,
1057                vmos: None,
1058            },
1059        ];
1060
1061        assert_eq!(digest.buckets, expected_buckets);
1062        Ok(())
1063    }
1064
1065    /// What is tested: `Digest::compute` with `detailed_vmos: false` (the production periodic
1066    /// monitoring path) and skipping of VMO list population.
1067    ///
1068    /// Expectations verified:
1069    /// - When `detailed_vmos == false`, every bucket in `digest.buckets` (including matched buckets
1070    ///   and `UNDIGESTED`) has `bucket.vmos == None`.
1071    /// - Verifies that `committed_size` and `populated_size` totals match expected values
1072    ///   identically to when `detailed_vmos == true`.
1073    #[test]
1074    fn test_digest_compute_undetailed_vmos_fast_path() -> Result<(), anyhow::Error> {
1075        let (kernel_stats, kernel_stats_compression) = get_kernel_stats();
1076        let digest = Digest::compute(
1077            &get_attribution_data(),
1078            &kernel_stats,
1079            &kernel_stats_compression,
1080            &vec![BucketDefinition {
1081                name: "matched".to_string(),
1082                process: None,
1083                vmo: Some(Regex::new("matched")?),
1084                principal: None,
1085                event_code: Default::default(),
1086            }],
1087            false, // detailed_vmos = false
1088        )?;
1089
1090        for bucket in &digest.buckets {
1091            assert!(bucket.vmos.is_none(), "Bucket '{}' should have vmos == None", bucket.name);
1092        }
1093        let matched_bucket = digest.buckets.iter().find(|b| b.name == "matched").unwrap();
1094        assert_eq!(matched_bucket.committed_size, 512);
1095        assert_eq!(matched_bucket.populated_size, 2048);
1096        let undigested_bucket = digest.buckets.iter().find(|b| b.name == UNDIGESTED).unwrap();
1097        assert_eq!(undigested_bucket.committed_size, 512);
1098        assert_eq!(undigested_bucket.populated_size, 2048);
1099        Ok(())
1100    }
1101
1102    /// What is tested: First-match-wins bucket priority ordering and deduplication across multiple
1103    /// overlapping `BucketDefinition`s in `Digest::compute`.
1104    ///
1105    /// Expectations verified:
1106    /// - When two bucket definitions both match the same VMO (`first_bucket` matches
1107    ///   `vmo="matched"`, and `second_bucket` matches `vmo=".*"`), the earlier bucket claims the
1108    ///   VMO.
1109    /// - Verifies that `second_bucket` does not double-count VMO 20 (`committed_size == 0` for VMO
1110    ///   20), and only claims the remaining unconsumed VMO 10 (`resource`).
1111    #[test]
1112    fn test_digest_bucket_priority_and_deduplication() -> Result<(), anyhow::Error> {
1113        let (kernel_stats, kernel_stats_compression) = get_kernel_stats();
1114        let digest = Digest::compute(
1115            &get_attribution_data(),
1116            &kernel_stats,
1117            &kernel_stats_compression,
1118            &vec![
1119                BucketDefinition {
1120                    name: "first_bucket".to_string(),
1121                    process: None,
1122                    vmo: Some(Regex::new("matched")?),
1123                    principal: None,
1124                    event_code: Default::default(),
1125                },
1126                BucketDefinition {
1127                    name: "second_bucket".to_string(),
1128                    process: None,
1129                    vmo: Some(Regex::new(".*")?),
1130                    principal: None,
1131                    event_code: Default::default(),
1132                },
1133            ],
1134            true,
1135        )?;
1136
1137        let b1 = digest.buckets.iter().find(|b| b.name == "first_bucket").unwrap();
1138        assert_eq!(b1.committed_size, 512); // VMO 20 ("matched")
1139        let b2 = digest.buckets.iter().find(|b| b.name == "second_bucket").unwrap();
1140        assert_eq!(b2.committed_size, 512); // VMO 10 ("resource")
1141        let undigested = digest.buckets.iter().find(|b| b.name == UNDIGESTED).unwrap();
1142        assert_eq!(undigested.committed_size, 0);
1143        Ok(())
1144    }
1145
1146    /// What is tested: Multi-attribute `BucketDefinition` matching where all non-None attributes
1147    /// (`process`, `vmo`, `principal`) must match simultaneously, and partial mismatch fallthrough.
1148    ///
1149    /// Expectations verified:
1150    /// - A bucket with matching `process` ("matched") but non-matching `vmo` ("nonexistent_vmo")
1151    ///   fails to claim the VMO.
1152    /// - Verifies that partial mismatches leave the VMO unclaimed so it is assigned to
1153    ///   `UNDIGESTED`.
1154    #[test]
1155    fn test_digest_multi_attribute_matching_and_partial_mismatch() -> Result<(), anyhow::Error> {
1156        let (kernel_stats, kernel_stats_compression) = get_kernel_stats();
1157        let digest = Digest::compute(
1158            &get_attribution_data(),
1159            &kernel_stats,
1160            &kernel_stats_compression,
1161            &vec![BucketDefinition {
1162                name: "partial_mismatch".to_string(),
1163                process: Some(Regex::new("matched")?),
1164                vmo: Some(Regex::new("nonexistent_vmo")?),
1165                principal: Some(Regex::new("principal")?),
1166                event_code: Default::default(),
1167            }],
1168            true,
1169        )?;
1170
1171        let b = digest.buckets.iter().find(|b| b.name == "partial_mismatch").unwrap();
1172        assert_eq!(b.committed_size, 0);
1173        let undigested = digest.buckets.iter().find(|b| b.name == UNDIGESTED).unwrap();
1174        assert_eq!(undigested.committed_size, 1024); // Both VMO 10 and 20 remain undigested
1175        Ok(())
1176    }
1177
1178    /// What is tested: VMO pager-backed / discardable flags (`ZX_INFO_VMO_PAGER_BACKED` and
1179    /// `ZX_INFO_VMO_DISCARDABLE`) and their impact on `POPULATED_ANONYMOUS_BYTES`.
1180    ///
1181    /// Expectations verified:
1182    /// - VMOs with pager-backed or discardable flags set have their `scaled_populated_bytes`
1183    ///   accumulated in `populated_reclaimable_bytes`.
1184    /// - Verifies that `POPULATED_ANONYMOUS_BYTES` is reduced by the populated reclaimable amount.
1185    #[test]
1186    fn test_digest_compute_reclaimable_vmo_flags_and_anonymous_bytes() -> Result<(), anyhow::Error>
1187    {
1188        use zx_types::{ZX_INFO_VMO_DISCARDABLE, ZX_INFO_VMO_PAGER_BACKED};
1189        let mut attr_data = get_attribution_data();
1190        let vmo_res = attr_data.resources.get_mut(&20).unwrap();
1191        if let fplugin::ResourceType::Vmo(vmo) = &mut vmo_res.resource.resource_type {
1192            vmo.flags = Some(ZX_INFO_VMO_PAGER_BACKED | ZX_INFO_VMO_DISCARDABLE);
1193        }
1194
1195        let (kernel_stats, kernel_stats_compression) = get_kernel_stats();
1196        let digest =
1197            Digest::compute(&attr_data, &kernel_stats, &kernel_stats_compression, &vec![], true)?;
1198
1199        let anon_bucket =
1200            digest.buckets.iter().find(|b| b.name == POPULATED_ANONYMOUS_BYTES).unwrap();
1201        assert_eq!(anon_bucket.populated_size, 0);
1202        Ok(())
1203    }
1204
1205    /// What is tested: `Digest::compute` handling of default/missing (`None`) kernel memory stats
1206    /// and `saturating_sub` underflow protection for the `ORPHANED` bucket.
1207    ///
1208    /// Expectations verified:
1209    /// - Verifies that `Digest::compute` succeeds without panicking when `fkernel::MemoryStats` and
1210    ///   `fkernel::MemoryStatsCompression` have all fields set to `None` (`Default::default()`).
1211    /// - Verifies that `ORPHANED`, `KERNEL`, `FREE`, and all pager/discardable/zram/anonymous
1212    ///   buckets cleanly default to `0` without underflowing.
1213    #[test]
1214    fn test_digest_missing_kernel_stats_and_saturating_orphaned() -> Result<(), anyhow::Error> {
1215        let digest = Digest::compute(
1216            &get_attribution_data(),
1217            &fkernel::MemoryStats::default(),
1218            &fkernel::MemoryStatsCompression::default(),
1219            &vec![],
1220            true,
1221        )?;
1222
1223        let orphaned = digest.buckets.iter().find(|b| b.name == ORPHANED).unwrap();
1224        assert_eq!(orphaned.committed_size, 0);
1225        let kernel = digest.buckets.iter().find(|b| b.name == KERNEL).unwrap();
1226        assert_eq!(kernel.committed_size, 0);
1227        let free = digest.buckets.iter().find(|b| b.name == FREE).unwrap();
1228        assert_eq!(free.committed_size, 0);
1229        let anon = digest.buckets.iter().find(|b| b.name == POPULATED_ANONYMOUS_BYTES).unwrap();
1230        assert_eq!(anon.committed_size, 0);
1231        let zram = digest.buckets.iter().find(|b| b.name == ZRAM_COMPRESSED_BYTES).unwrap();
1232        assert_eq!(zram.committed_size, 0);
1233        Ok(())
1234    }
1235}