pyaxml_rs/
axml.rs

1use std::collections::HashMap;
2
3use quick_xml::events::{BytesStart, Event};
4use quick_xml::{Decoder, Reader};
5
6use crate::error::AxmlError;
7use crate::proto;
8use crate::public;
9use crate::resource_map::ResourceMap;
10use crate::string_pool::{StringBlocks, StringPool};
11use crate::typed_value::{self, TYPE_STRING};
12use crate::xml_element::{Attribute, XmlElement};
13
14pub(crate) const RES_XML_TYPE: u16 = 0x0003;
15// RES_TABLE_TYPE is defined in arsc : use crate::arsc::RES_TABLE_TYPE where needed.
16
17const ANDROID_NS: &str = "http://schemas.android.com/apk/res/android";
18
19/// The main AXML object representing a binary Android XML file.
20///
21/// # Dual-state synchronization
22///
23/// `Axml` carries two representations of the same data that must be kept in sync:
24///
25/// 1. **Decoded state**: `stringblocks`, `resource_map`, `elements`. These fields
26///    are the ground truth during parsing and editing.
27/// 2. **Proto state**: `proto` is a `prost`-generated message used for protobuf
28///    serialization. It is populated lazily by `update_proto()`.
29///
30/// **Rule**: after any mutation to the decoded state (e.g. modifying `elements` or
31/// calling `import_stringblocks_json`), callers **must** invoke `update_proto()`
32/// before calling `get_proto()` or any proto-based serialization path.
33/// Failing to do so will return a stale proto that does not reflect the current state.
34pub struct Axml {
35    /// Prost-generated protobuf representation; populated by `update_proto()`.
36    pub(crate) proto: proto::Axml,
37    /// String pool, wraps both the binary representation and the proto field.
38    pub(crate) stringblocks: StringBlocks,
39    pub(crate) resource_map: Option<ResourceMap>,
40    pub(crate) elements: Vec<XmlElement>,
41    pub(crate) file_type: u16,
42    pub(crate) file_header_size: u16,
43    /// Stored total file size (bytes 4..8 of the AXML chunk header).
44    /// `pack()` writes this value directly, set it to produce intentionally
45    /// malformed files.  `compute()` recalculates and stores the correct value.
46    pub(crate) total_size: u32,
47}
48
49impl Axml {
50    /// Create a new empty AXML document.
51    pub fn new() -> Self {
52        Axml {
53            proto: proto::Axml::default(),
54            stringblocks: StringBlocks::new(),
55            resource_map: None,
56            elements: Vec::new(),
57            file_type: RES_XML_TYPE,
58            file_header_size: 8,
59            total_size: 0,
60        }
61    }
62
63    /// Return a reference to the cached prost-generated protobuf message.
64    /// The proto is populated during `from_axml()` / `from_xml()`.
65    /// Call `update_proto()` first if the internal state has been modified.
66    pub fn get_proto(&self) -> &proto::Axml {
67        &self.proto
68    }
69
70    /// Number of strings in the string pool.
71    pub fn string_count(&self) -> usize {
72        self.stringblocks.inner.strings.len()
73    }
74
75    /// Serialize the string pool as a JSON object for use with `--stringblocks-file`.
76    pub fn export_stringblocks_json(&self) -> String {
77        crate::json_serde::export_stringblocks(&self.stringblocks.inner)
78    }
79
80    /// Pre-populate the string pool from a JSON object previously produced by
81    /// `export_stringblocks_json`.
82    pub fn import_stringblocks_json(&mut self, json: &str) {
83        crate::json_serde::import_stringblocks(json, &mut self.stringblocks.inner);
84    }
85
86    // ─────────────────────────────── Parsing ────────────────────────────────
87
88    /// Parse binary AXML data into an `Axml` object.
89    pub fn from_axml(data: &[u8]) -> Result<Self, AxmlError> {
90        if data.len() < 8 {
91            return Err(AxmlError::UnexpectedEof);
92        }
93
94        let file_type = u16::from_le_bytes([data[0], data[1]]);
95        let file_header_size = u16::from_le_bytes([data[2], data[3]]);
96
97        // Accept non-standard type values (some real-world AXML files have wrong magic).
98        // Only reject clearly non-XML types.
99        if file_type == crate::arsc::RES_TABLE_TYPE {
100            return Err(AxmlError::InvalidMagic(file_type));
101        }
102
103        let file_size = u32::from_le_bytes([data[4], data[5], data[6], data[7]]) as usize;
104        if file_size > data.len() {
105            return Err(AxmlError::UnexpectedEof);
106        }
107
108        let mut pos = file_header_size as usize;
109        if pos > data.len() {
110            return Err(AxmlError::UnexpectedEof);
111        }
112
113        let (string_pool, consumed) = StringPool::parse(&data[pos..])?;
114        pos += consumed;
115
116        let (resource_map, rmap_consumed) = ResourceMap::parse(&data[pos..])?;
117        pos += rmap_consumed;
118
119        let elements = XmlElement::parse_all(&data[pos..])?;
120
121        let total_size = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
122
123        let mut axml = Axml {
124            proto: proto::Axml::default(),
125            stringblocks: StringBlocks::from_pool_and_proto(
126                string_pool,
127                proto::StringBlocks::default(),
128            ),
129            resource_map,
130            elements,
131            file_type,
132            file_header_size,
133            total_size,
134        };
135        axml.update_proto();
136        Ok(axml)
137    }
138
139    // ─────────────────────────────── Packing ────────────────────────────────
140
141    /// Serialize the AXML object back to binary format.
142    ///
143    /// Bytes 4..8 of the output (the chunk total-size field) come from
144    /// `self.total_size` verbatim.  Set that field to an arbitrary value
145    /// before calling `pack()` to produce intentionally malformed output.
146    /// Call `compute()` first to write the correct size.
147    pub fn pack(&self) -> Vec<u8> {
148        let sp = self.stringblocks.inner.pack();
149        let rm = self
150            .resource_map
151            .as_ref()
152            .map(|r| r.pack())
153            .unwrap_or_default();
154        let mut xml = Vec::new();
155        for e in &self.elements {
156            xml.extend_from_slice(&e.pack());
157        }
158
159        let body_len = sp.len() + rm.len() + xml.len();
160        let mut out = Vec::with_capacity(8 + body_len);
161
162        out.extend_from_slice(&self.file_type.to_le_bytes());
163        out.extend_from_slice(&self.file_header_size.to_le_bytes());
164        out.extend_from_slice(&self.total_size.to_le_bytes());
165        out.extend_from_slice(&sp);
166        out.extend_from_slice(&rm);
167        out.extend_from_slice(&xml);
168        out
169    }
170
171    // ─────────────────────────────── to_xml ─────────────────────────────────
172
173    /// Decode the AXML to a human-readable XML string.
174    pub fn to_xml(&self) -> Result<String, AxmlError> {
175        // Collect namespace declarations in declaration order (preserving order for stable output).
176        // Use Vec to maintain insertion order; HashMap for O(1) lookup.
177        let mut ns_order: Vec<(String, String)> = Vec::new(); // (uri, prefix) in order
178        let mut ns_map: HashMap<String, String> = HashMap::new(); // uri → prefix
179        for elt in &self.elements {
180            if let XmlElement::StartNamespace { prefix, uri, .. } = elt {
181                let p = self.decode_safe(*prefix);
182                let u = self.decode_safe(*uri);
183                if !p.is_empty() && !u.is_empty() && !ns_map.contains_key(&u) {
184                    ns_map.insert(u.clone(), p.clone());
185                    ns_order.push((u, p));
186                }
187            }
188        }
189
190        let mut output = String::from("<?xml version='1.0' encoding='utf-8'?>\n");
191        let mut depth = 0usize;
192        let mut first_element = true;
193        let mut tag_stack: Vec<String> = Vec::new();
194
195        for elt in &self.elements {
196            match elt {
197                XmlElement::StartElement {
198                    namespace_uri,
199                    name,
200                    at_size: _,
201                    attributes,
202                    ..
203                } => {
204                    let ns = self.decode_safe(*namespace_uri);
205                    let tag = self.resolve_attr_name(*name)?;
206                    let qualified = qualify_name(&ns, &tag, &ns_map);
207
208                    indent(&mut output, depth);
209                    output.push('<');
210                    output.push_str(&qualified);
211
212                    if first_element {
213                        for (uri, prefix) in &ns_order {
214                            output.push_str(" xmlns:");
215                            output.push_str(prefix);
216                            output.push_str("=\"");
217                            xml_escape_into(&mut output, uri);
218                            output.push('"');
219                        }
220                        first_element = false;
221                    }
222
223                    for attr in attributes {
224                        let attr_ns = self.decode_safe(attr.namespace_uri);
225                        let attr_name = self.resolve_attr_name(attr.name)?;
226                        let qname = qualify_name(&attr_ns, &attr_name, &ns_map);
227
228                        let type_byte = typed_value::get_type(attr.type_);
229                        let value_str = if type_byte == TYPE_STRING {
230                            self.decode_safe(attr.value)
231                        } else if let Some(s) = typed_value::coerce_to_string(type_byte, attr.data)
232                        {
233                            s
234                        } else {
235                            attr.data.to_string()
236                        };
237
238                        output.push(' ');
239                        output.push_str(&qname);
240                        output.push_str("=\"");
241                        xml_escape_into(&mut output, &value_str);
242                        output.push('"');
243                    }
244
245                    output.push('>');
246                    output.push('\n');
247                    depth += 1;
248                    tag_stack.push(qualified);
249                }
250
251                XmlElement::EndElement { .. } => {
252                    depth = depth.saturating_sub(1);
253                    let tag = tag_stack.pop().unwrap_or_default();
254                    indent(&mut output, depth);
255                    output.push_str("</");
256                    output.push_str(&tag);
257                    output.push_str(">\n");
258                }
259
260                XmlElement::CData { name, .. } => {
261                    if *name != 0xffff_ffff {
262                        let text = self.decode_safe(*name);
263                        if !text.is_empty() {
264                            indent(&mut output, depth);
265                            xml_escape_into(&mut output, &text);
266                            output.push('\n');
267                        }
268                    }
269                }
270
271                XmlElement::StartNamespace { .. } | XmlElement::EndNamespace { .. } => {}
272            }
273        }
274
275        Ok(output)
276    }
277
278    fn decode_safe(&self, idx: u32) -> String {
279        if idx == 0xffff_ffff {
280            return String::new();
281        }
282        self.stringblocks.inner.decode(idx).unwrap_or_default()
283    }
284
285    /// Resolve attribute name using resource map → system resources if possible.
286    fn resolve_attr_name(&self, name_idx: u32) -> Result<String, AxmlError> {
287        if name_idx == 0xffff_ffff {
288            return Ok(String::new());
289        }
290        if let Some(ref rmap) = self.resource_map {
291            if let Some(res_id) = rmap.get_id(name_idx) {
292                if let Some(name) = public::attr_inverse(res_id) {
293                    return Ok(name.to_string());
294                }
295            }
296        }
297        self.stringblocks.inner.decode(name_idx)
298    }
299
300    // ─────────────────────────────── from_xml ────────────────────────────────
301
302    /// Build binary AXML from a plain XML string.
303    ///
304    /// Two-pass implementation: the first pass collects all xmlns: declarations so
305    /// that StartNamespace elements can be emitted before any StartElement.  The
306    /// second pass builds the binary elements.  Android attribute → resource-ID
307    /// mappings are collected inline and the resource map is assembled after the loop.
308    pub fn from_xml(&mut self, xml: &str) -> Result<(), AxmlError> {
309        self.stringblocks.inner = StringPool::new(false);
310        self.resource_map = None;
311        self.elements = Vec::new();
312
313        // Pre-scan: collect every xmlns: declaration in document order.
314        // The android namespace is always guaranteed to be first.
315        let mut all_xmlns: Vec<(String, String)> = Vec::new(); // (prefix, uri)
316        all_xmlns.push(("android".to_string(), ANDROID_NS.to_string()));
317        {
318            let mut pre_reader = Reader::from_str(xml);
319            pre_reader.config_mut().trim_text(true);
320            let mut pre_buf = Vec::new();
321            loop {
322                match pre_reader.read_event_into(&mut pre_buf) {
323                    Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
324                        for attr in e.attributes().flatten() {
325                            let k = String::from_utf8_lossy(attr.key.as_ref());
326                            if let Some(prefix) = k.strip_prefix("xmlns:") {
327                                if prefix != "android"
328                                    && !all_xmlns.iter().any(|(p, _)| p == prefix)
329                                {
330                                    let uri = String::from_utf8_lossy(&attr.value).into_owned();
331                                    all_xmlns.push((prefix.to_string(), uri));
332                                }
333                            }
334                        }
335                    }
336                    Ok(Event::Eof) | Err(_) => break,
337                    _ => {}
338                }
339                pre_buf.clear();
340            }
341        }
342
343        // Emit one StartNamespace for every declared namespace, in document order.
344        // Track (prefix_idx, uri_idx) for matching EndNamespace at the end.
345        let mut ns_indices: Vec<(u32, u32)> = Vec::with_capacity(all_xmlns.len());
346        for (prefix, uri) in &all_xmlns {
347            let p_idx = self.stringblocks.inner.add(prefix.as_str());
348            let u_idx = self.stringblocks.inner.add(uri.as_str());
349            self.elements.push(XmlElement::StartNamespace {
350                header_size: 16,
351                chunk_size: 0,
352                line_number: 0,
353                comment: 0xffff_ffff,
354                prefix: p_idx,
355                uri: u_idx,
356            });
357            ns_indices.push((p_idx, u_idx));
358        }
359
360        // Collect (pool_index, resource_id) pairs inline as android attrs are seen.
361        let mut res_map_entries: Vec<(usize, u32)> = Vec::new();
362
363        // Namespace prefix → URI map built from the pre-scan.
364        let mut xmlns_map: HashMap<String, String> = all_xmlns.iter().cloned().collect();
365
366        let mut reader = Reader::from_str(xml);
367        reader.config_mut().trim_text(true);
368        let decoder = reader.decoder();
369
370        // Stack of (namespace_uri_idx, name_idx) for matching end elements.
371        let mut open_stack: Vec<(u32, u32)> = Vec::new();
372        let mut buf = Vec::new();
373        // MED-4: reject pathologically deep XML to prevent unbounded stack growth.
374        const MAX_DEPTH: usize = 512;
375        const MAX_ELEMENTS: usize = 100_000;
376        let mut element_count: usize = 0;
377
378        loop {
379            match reader.read_event_into(&mut buf) {
380                Ok(Event::Start(ref e)) => {
381                    if open_stack.len() >= MAX_DEPTH {
382                        return Err(AxmlError::XmlError(format!(
383                            "XML nesting depth exceeds limit ({})",
384                            MAX_DEPTH
385                        )));
386                    }
387                    element_count += 1;
388                    if element_count > MAX_ELEMENTS {
389                        return Err(AxmlError::XmlError(format!(
390                            "XML element count exceeds limit ({})",
391                            MAX_ELEMENTS
392                        )));
393                    }
394                    collect_xmlns(e, &mut xmlns_map);
395                    let (ns_idx, name_idx) = self.parse_element_name(e, &xmlns_map)?;
396                    open_stack.push((ns_idx, name_idx));
397                    self.push_start_element(
398                        e,
399                        ns_idx,
400                        name_idx,
401                        &xmlns_map,
402                        decoder,
403                        &mut res_map_entries,
404                    )?;
405                }
406                Ok(Event::Empty(ref e)) => {
407                    collect_xmlns(e, &mut xmlns_map);
408                    let (ns_idx, name_idx) = self.parse_element_name(e, &xmlns_map)?;
409                    self.push_start_element(
410                        e,
411                        ns_idx,
412                        name_idx,
413                        &xmlns_map,
414                        decoder,
415                        &mut res_map_entries,
416                    )?;
417                    self.elements.push(XmlElement::EndElement {
418                        header_size: 16,
419                        chunk_size: 0,
420                        line_number: 0,
421                        comment: 0xffff_ffff,
422                        namespace_uri: 0xffff_ffff,
423                        name: name_idx,
424                    });
425                }
426                Ok(Event::End(_)) => {
427                    let (_, name_idx) = open_stack.pop().unwrap_or((0xffff_ffff, 0xffff_ffff));
428                    self.elements.push(XmlElement::EndElement {
429                        header_size: 16,
430                        chunk_size: 0,
431                        line_number: 0,
432                        comment: 0xffff_ffff,
433                        namespace_uri: 0xffff_ffff,
434                        name: name_idx,
435                    });
436                }
437                Ok(Event::Text(ref e)) => {
438                    let text = e
439                        .decode()
440                        .map_err(|_err| AxmlError::XmlError("Failed to decode text".to_string()))?;
441                    let trimmed = text.trim();
442                    if !trimmed.is_empty() {
443                        let idx = self.stringblocks.inner.add(trimmed);
444                        self.elements.push(XmlElement::CData {
445                            header_size: 16,
446                            chunk_size: 0,
447                            line_number: 0,
448                            comment: 0xffff_ffff,
449                            name: idx,
450                            res_size: 8,
451                            res_res0: 0,
452                            res_data_type: 0x03,
453                            res_data: 0,
454                        });
455                    }
456                }
457                Ok(Event::Eof) => break,
458                Err(e) => return Err(AxmlError::XmlError(e.to_string())),
459                _ => {}
460            }
461            buf.clear();
462        }
463
464        // EndNamespace in reverse declaration order.
465        for (p_idx, u_idx) in ns_indices.into_iter().rev() {
466            self.elements.push(XmlElement::EndNamespace {
467                header_size: 16,
468                chunk_size: 0,
469                line_number: 0,
470                comment: 0xffff_ffff,
471                prefix: p_idx,
472                uri: u_idx,
473            });
474        }
475
476        // Build resource map from the inline-collected entries.
477        if let Some(&(max_idx, _)) = res_map_entries.iter().max_by_key(|&&(i, _)| i) {
478            let mut ids = vec![0u32; max_idx + 1];
479            for (idx, id) in res_map_entries {
480                ids[idx] = id;
481            }
482            while ids.last() == Some(&0) {
483                ids.pop();
484            }
485            if !ids.is_empty() {
486                self.resource_map = Some(ResourceMap::new_with_ids(ids));
487            }
488        }
489
490        self.compute();
491        self.update_proto();
492        Ok(())
493    }
494
495    /// Extract namespace URI index and local name index from a start element.
496    fn parse_element_name(
497        &mut self,
498        e: &BytesStart,
499        xmlns_map: &HashMap<String, String>,
500    ) -> Result<(u32, u32), AxmlError> {
501        let full = String::from_utf8_lossy(e.name().as_ref()).to_string();
502        let (ns, local) = split_clark_name(&full);
503        let resolved_ns = if ns.is_empty() {
504            resolve_prefix_ns(&full, xmlns_map)
505        } else {
506            ns.to_string()
507        };
508        let ns_idx = if resolved_ns.is_empty() {
509            0xffff_ffff
510        } else {
511            self.stringblocks.inner.add(&resolved_ns)
512        };
513        let name_idx = self.stringblocks.inner.add(local);
514        Ok((ns_idx, name_idx))
515    }
516
517    /// Build and push a `StartElement`, given pre-computed ns/name indices.
518    ///
519    /// `res_map_entries` is populated with `(pool_idx, resource_id)` pairs for
520    /// any android-namespace attribute that has a known system resource ID.
521    fn push_start_element(
522        &mut self,
523        e: &BytesStart,
524        ns_uri_idx: u32,
525        name_idx: u32,
526        xmlns_map: &HashMap<String, String>,
527        decoder: Decoder,
528        res_map_entries: &mut Vec<(usize, u32)>,
529    ) -> Result<(), AxmlError> {
530        let mut attributes = Vec::new();
531
532        for attr in e.attributes().flatten() {
533            let key = String::from_utf8_lossy(attr.key.as_ref()).to_string();
534
535            // Skip xmlns declarations (already collected by collect_xmlns).
536            if key.starts_with("xmlns") {
537                continue;
538            }
539
540            // Decode and unescape XML entities so that `&gt;` → `>`, `&amp;` → `&`,
541            // etc. are stored as decoded characters in the binary string pool.
542            let val = attr
543                .decode_and_unescape_value(decoder)
544                .map(|v| v.into_owned())
545                .unwrap_or_else(|_| String::from_utf8_lossy(&attr.value).into_owned());
546            let (attr_ns, attr_local) = split_clark_name(&key);
547
548            // Resolve prefix:local notation via the xmlns map when Clark notation
549            // is not available (i.e., when the input was generated by to_xml()).
550            let resolved_ns = if attr_ns.is_empty() {
551                resolve_prefix_ns(&key, xmlns_map)
552            } else {
553                attr_ns.to_string()
554            };
555
556            let attr_ns_idx = if resolved_ns.is_empty() {
557                0xffff_ffff
558            } else {
559                self.stringblocks.inner.add(&resolved_ns)
560            };
561
562            // Add attribute name to pool (find() is now O(1) via the HashMap index).
563            let attr_name_idx = self.stringblocks.inner.add(attr_local);
564
565            // If this is an android-namespace attr with a known resource ID, record it.
566            if !resolved_ns.is_empty() || key.starts_with("android:") {
567                if let Some(id) = public::attr_forward(attr_local) {
568                    res_map_entries.push((attr_name_idx as usize, id));
569                }
570            }
571
572            let (type_, value_idx, data) =
573                typed_value::encode_attribute_value(&val, attr_local, &mut self.stringblocks.inner);
574
575            attributes.push(Attribute {
576                namespace_uri: attr_ns_idx,
577                name: attr_name_idx,
578                value: value_idx,
579                type_,
580                data,
581                padding: Vec::new(),
582            });
583        }
584
585        self.elements.push(XmlElement::StartElement {
586            header_size: 16,
587            chunk_size: 0,
588            line_number: 0,
589            comment: 0xffff_ffff,
590            namespace_uri: ns_uri_idx,
591            name: name_idx,
592            at_start: 0x14,
593            at_size: 0x14,
594            style_attribute: 0,
595            class_attribute: 0,
596            attributes,
597        });
598
599        Ok(())
600    }
601
602    // ─────────────────────────── compute / no-compute ───────────────────────
603
604    /// Recalculate all chunk-size header fields and store them in the struct.
605    ///
606    /// Updates `self.total_size` (AXML outer header) and marks the string pool
607    /// dirty so `pack()` produces a fully correct binary.
608    ///
609    /// Without calling `compute()`, `pack()` uses whatever sizes were last stored
610    /// either the original parsed values, values set manually, or a stale size
611    /// left by `apply_stringblocks_no_compute()`.
612    pub fn compute(&mut self) {
613        self.stringblocks.inner.mark_dirty();
614        let sp = self.stringblocks.inner.pack();
615
616        if let Some(ref mut rm) = self.resource_map {
617            let chunk_size = (8 + rm.ids.len() * 4) as u32;
618            rm.set_chunk_size(chunk_size);
619        }
620        let rm = self
621            .resource_map
622            .as_ref()
623            .map(|r| r.pack())
624            .unwrap_or_default();
625
626        for elem in &mut self.elements {
627            let body = elem.pack_body();
628            let chunk_size = (8 + body.len()) as u32;
629            elem.set_chunk_size(chunk_size);
630        }
631
632        let xml_len: usize = self.elements.iter().map(|e| e.pack().len()).sum();
633        self.total_size = (8 + sp.len() + rm.len() + xml_len) as u32;
634    }
635
636    /// Apply new string pool content without updating the pool's chunk-size header.
637    ///
638    /// `new_strings` must be the `data` bytes from each `StringBlock` proto entry
639    /// (raw UTF-8 or UTF-16-LE bytes, no length prefix, no null terminator).
640    ///
641    /// After this call `pack()` returns a binary where string content reflects
642    /// the new values but the chunk total-size field is stale, an intentionally
643    /// corrupted file useful for security testing.  Call `compute()` before
644    /// `pack()` to produce a correctly-sized output instead.
645    pub fn apply_stringblocks_no_compute(&mut self, new_strings: Vec<Vec<u8>>) {
646        self.stringblocks.inner.apply_no_compute(new_strings);
647    }
648
649    // ─────────────────────────── to_proto_text ───────────────────────────────
650
651    /// Serialize to proto text format using the native prost-reflect formatter.
652    /// Requires `update_proto()` to have been called (automatically done by
653    /// `from_axml()` and `from_xml()`).
654    pub fn to_proto_text(&self) -> String {
655        use prost_reflect::ReflectMessage;
656        self.proto.transcode_to_dynamic().to_text_format()
657    }
658
659    /// Serialize to indented proto text format (one field per line).
660    pub fn to_proto_text_pretty(&self) -> String {
661        use prost_reflect::{text_format::FormatOptions, ReflectMessage};
662        self.proto
663            .transcode_to_dynamic()
664            .to_text_format_with_options(&FormatOptions::default().pretty(true))
665    }
666
667    /// Write the binary proto representation of this AXML file to `path`.
668    pub fn export_proto_file(&self, path: &str) -> Result<(), crate::error::AxmlError> {
669        std::fs::write(path, self.to_proto_bytes())?;
670        Ok(())
671    }
672
673    /// Load an AXML from a binary proto file written by [`export_proto_file`].
674    pub fn from_proto_file(path: &str) -> Result<Self, crate::error::AxmlError> {
675        let data = std::fs::read(path)?;
676        Self::from_proto_bytes(&data)
677    }
678
679    /// Write the binary proto representation of this file's string pool to `path`.
680    pub fn export_stringblocks_proto_file(
681        &self,
682        path: &str,
683    ) -> Result<(), crate::error::AxmlError> {
684        use prost::Message as _;
685        let bytes = self.stringblocks.proto.encode_to_vec();
686        std::fs::write(path, bytes)?;
687        Ok(())
688    }
689
690    /// Replace the string pool from a binary proto file written by
691    /// [`export_stringblocks_proto_file`].  Marks the pool dirty so that the
692    /// next `pack()` / `compute()` recalculates chunk sizes.
693    pub fn import_stringblocks_proto_file(
694        &mut self,
695        path: &str,
696    ) -> Result<(), crate::error::AxmlError> {
697        use prost::Message as _;
698        let data = std::fs::read(path)?;
699        let sb_proto = crate::proto::StringBlocks::decode(data.as_slice())?;
700        let pool = crate::proto_conv::proto_to_string_pool(sb_proto.clone());
701        self.stringblocks = crate::string_pool::StringBlocks::from_pool_and_proto(pool, sb_proto);
702        self.update_proto();
703        Ok(())
704    }
705}
706
707impl Default for Axml {
708    fn default() -> Self {
709        Self::new()
710    }
711}
712
713// ─────────────────────────── Helper functions ────────────────────────────────
714
715fn indent(out: &mut String, depth: usize) {
716    for _ in 0..depth {
717        out.push_str("  ");
718    }
719}
720
721fn qualify_name(ns_uri: &str, local: &str, ns_map: &HashMap<String, String>) -> String {
722    if ns_uri.is_empty() {
723        return local.to_string();
724    }
725    match ns_map.get(ns_uri) {
726        Some(prefix) => format!("{}:{}", prefix, local),
727        None => local.to_string(),
728    }
729}
730
731fn xml_escape_into(out: &mut String, s: &str) {
732    for c in s.chars() {
733        match c {
734            '&' => out.push_str("&amp;"),
735            '<' => out.push_str("&lt;"),
736            '>' => out.push_str("&gt;"),
737            '"' => out.push_str("&quot;"),
738            c => out.push(c),
739        }
740    }
741}
742
743/// Scan element attributes for `xmlns:prefix="uri"` declarations and add them
744/// to `xmlns_map`.  Called before processing any other attribute on the element.
745fn collect_xmlns(e: &BytesStart, xmlns_map: &mut HashMap<String, String>) {
746    for attr in e.attributes().flatten() {
747        let key = String::from_utf8_lossy(attr.key.as_ref());
748        if let Some(prefix) = key.strip_prefix("xmlns:") {
749            let uri = String::from_utf8_lossy(&attr.value).into_owned();
750            xmlns_map.insert(prefix.to_string(), uri);
751        } else if key == "xmlns" {
752            let uri = String::from_utf8_lossy(&attr.value).into_owned();
753            xmlns_map.insert(String::new(), uri);
754        }
755    }
756}
757
758/// Given an attribute or element key in `prefix:local` notation, return the
759/// namespace URI by looking up `prefix` in `xmlns_map`.  Returns an empty
760/// string when no mapping is found or the key has no prefix.
761fn resolve_prefix_ns(key: &str, xmlns_map: &HashMap<String, String>) -> String {
762    if let Some(colon) = key.find(':') {
763        let prefix = &key[..colon];
764        if prefix != "xmlns" {
765            if let Some(uri) = xmlns_map.get(prefix) {
766                return uri.clone();
767            }
768        }
769    }
770    String::new()
771}
772
773/// Split `{ns}local` or `prefix:local` into `(ns_or_empty, local)`.
774fn split_clark_name(name: &str) -> (&str, &str) {
775    if name.starts_with('{') {
776        if let Some(end) = name.find('}') {
777            return (&name[1..end], &name[end + 1..]);
778        }
779    }
780    // Prefix notation like "android:name" → treat prefix as nothing (android NS already stored)
781    if let Some(colon) = name.find(':') {
782        let prefix = &name[..colon];
783        if prefix != "xmlns" {
784            return ("", &name[colon + 1..]);
785        }
786    }
787    ("", name)
788}