Skip to main content

viva_genapi_xml/
fetch.rs

1//! URL parsing and XML document retrieval utilities.
2
3use std::future::Future;
4
5use crate::XmlError;
6use crate::util::parse_u64;
7
8/// Address of the first URL register in the GigE Vision bootstrap register map.
9/// GigE Vision spec: GevFirstURL at 0x0200 (512 bytes max).
10const FIRST_URL_ADDRESS: u64 = 0x0200;
11/// Address of the second URL register (GevSecondURL at 0x0400, 512 bytes max).
12/// Used as a fallback when the first URL is empty or cannot be retrieved.
13const SECOND_URL_ADDRESS: u64 = 0x0400;
14/// Maximum length of a URL register string.
15const URL_MAX_LEN: usize = 512;
16
17/// ZIP local file header magic: `PK\x03\x04`.
18const ZIP_MAGIC: &[u8; 4] = b"PK\x03\x04";
19
20/// Upper bound on the decompressed XML size: real GenICam XML documents are
21/// well under 10 MiB, and the cap bounds allocation driven by
22/// device-controlled ZIP metadata.
23const MAX_XML_SIZE: u64 = 64 * 1024 * 1024;
24
25/// If `data` starts with a ZIP signature, extract the first file's contents.
26/// Otherwise return `data` unchanged.
27///
28/// GenICam devices commonly serve their register-description XML as a ZIP
29/// archive (the URL then ends in `.zip`), which the standard requires
30/// consumers to handle transparently.
31pub fn decompress_if_zip(data: Vec<u8>) -> Result<Vec<u8>, XmlError> {
32    use std::io::Read;
33
34    if data.len() < 4 || &data[..4] != ZIP_MAGIC {
35        return Ok(data);
36    }
37    let cursor = std::io::Cursor::new(&data);
38    let mut archive = zip::ZipArchive::new(cursor)
39        .map_err(|e| XmlError::Invalid(format!("bad XML ZIP archive: {e}")))?;
40    if archive.is_empty() {
41        return Err(XmlError::Invalid("XML ZIP archive is empty".into()));
42    }
43    let file = archive
44        .by_index(0)
45        .map_err(|e| XmlError::Invalid(format!("cannot read XML ZIP entry: {e}")))?;
46    if file.size() > MAX_XML_SIZE {
47        return Err(XmlError::Invalid(format!(
48            "XML ZIP entry declares {} bytes, exceeding the {MAX_XML_SIZE}-byte cap",
49            file.size()
50        )));
51    }
52    let mut xml = Vec::with_capacity(file.size().min(MAX_XML_SIZE) as usize);
53    // The declared size can lie and DEFLATE can expand past it, so bound the
54    // read itself as well.
55    file.take(MAX_XML_SIZE + 1)
56        .read_to_end(&mut xml)
57        .map_err(|e| XmlError::Invalid(format!("XML ZIP decompression failed: {e}")))?;
58    if xml.len() as u64 > MAX_XML_SIZE {
59        return Err(XmlError::Invalid(format!(
60            "XML ZIP entry decompressed past the {MAX_XML_SIZE}-byte cap"
61        )));
62    }
63    Ok(xml)
64}
65
66/// Fetch the GenICam XML document using the provided memory reader closure.
67///
68/// The closure must return the requested number of bytes starting at the
69/// provided address. It can internally perform chunked transfers.
70///
71/// Reads `GevFirstURL`, falling back to `GevSecondURL` when the first URL is
72/// empty or its document cannot be retrieved. ZIP-compressed XML documents
73/// are decompressed transparently.
74pub async fn fetch_and_load_xml<F, Fut>(mut read_mem: F) -> Result<String, XmlError>
75where
76    F: FnMut(u64, usize) -> Fut,
77    Fut: Future<Output = Result<Vec<u8>, XmlError>>,
78{
79    match fetch_from_url_register(&mut read_mem, FIRST_URL_ADDRESS).await {
80        Ok(xml) => Ok(xml),
81        Err(first_err) => {
82            tracing::debug!(error = %first_err, "first URL failed, trying GevSecondURL");
83            match fetch_from_url_register(&mut read_mem, SECOND_URL_ADDRESS).await {
84                Ok(xml) => Ok(xml),
85                // The first URL's error is the more useful diagnostic: the
86                // second URL is typically absent on devices where the first
87                // one was already the problem.
88                Err(second_err) => {
89                    tracing::debug!(error = %second_err, "second URL failed as well");
90                    Err(first_err)
91                }
92            }
93        }
94    }
95}
96
97/// Fetch and decode the XML document referenced by the URL register at `url_addr`.
98async fn fetch_from_url_register<F, Fut>(
99    read_mem: &mut F,
100    url_addr: u64,
101) -> Result<String, XmlError>
102where
103    F: FnMut(u64, usize) -> Fut,
104    Fut: Future<Output = Result<Vec<u8>, XmlError>>,
105{
106    let url_bytes = read_mem(url_addr, URL_MAX_LEN).await?;
107    let url = first_cstring(&url_bytes)
108        .ok_or_else(|| XmlError::Invalid("URL register is empty".into()))?;
109    let location = UrlLocation::parse(&url)?;
110    match location {
111        UrlLocation::Local { address, length } => {
112            let xml_bytes = read_mem(address, length).await?;
113            let xml_bytes = decompress_if_zip(xml_bytes)?;
114            String::from_utf8(xml_bytes)
115                .map_err(|err| XmlError::Xml(format!("invalid UTF-8: {err}")))
116        }
117        UrlLocation::LocalNamed(name) => Err(XmlError::Unsupported(format!(
118            "named local URL '{name}' is not supported"
119        ))),
120        UrlLocation::Http(url) => Err(XmlError::Unsupported(format!(
121            "HTTP retrieval is not implemented ({url})"
122        ))),
123        UrlLocation::File(path) => Err(XmlError::Unsupported(format!(
124            "file URL '{path}' is not supported"
125        ))),
126    }
127}
128
129/// Extract the first null-terminated C string from a byte buffer.
130fn first_cstring(bytes: &[u8]) -> Option<String> {
131    let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
132    let slice = &bytes[..end];
133    let value = String::from_utf8_lossy(slice).trim().to_string();
134    if value.is_empty() { None } else { Some(value) }
135}
136
137/// Parsed URL location variants.
138#[derive(Debug)]
139enum UrlLocation {
140    /// Memory-mapped local XML at a fixed address.
141    Local { address: u64, length: usize },
142    /// Named local XML resource (unsupported).
143    #[allow(dead_code)]
144    LocalNamed(String),
145    /// HTTP(S) remote URL (unsupported).
146    Http(String),
147    /// File system URL (unsupported).
148    File(String),
149}
150
151impl UrlLocation {
152    fn parse(url: &str) -> Result<Self, XmlError> {
153        let lower = url.to_ascii_lowercase();
154        if let Some(rest) = lower.strip_prefix("local:") {
155            // Use original URL (case-preserved) for the rest, at same offset.
156            let rest_original = &url[url.len() - rest.len()..];
157            parse_local_url(rest_original)
158        } else if lower.starts_with("http://") || lower.starts_with("https://") {
159            Ok(UrlLocation::Http(url.to_string()))
160        } else if lower.starts_with("file://") {
161            Ok(UrlLocation::File(url.to_string()))
162        } else {
163            Err(XmlError::Unsupported(format!("unknown URL scheme: {url}")))
164        }
165    }
166}
167
168/// Parse a `local:` URL into its components.
169///
170/// Supports two formats:
171///
172/// 1. **Key-value**: `local:address=0x10;length=0x3`
173/// 2. **GenICam standard**: `Local:///filename;hex_address;hex_length`
174///
175/// In format 2, the filename is optional (can be just `///;addr;len`).
176fn parse_local_url(rest: &str) -> Result<UrlLocation, XmlError> {
177    // Strip the `///` prefix used by the standard GenICam URL format.
178    let trimmed = rest.strip_prefix("///").unwrap_or(rest).trim();
179    if trimmed.is_empty() {
180        return Err(XmlError::Invalid("empty local URL".into()));
181    }
182
183    let parts: Vec<&str> = trimmed.split(';').collect();
184
185    // GenICam standard format: filename;hex_address;hex_length (3 semicolon-separated parts).
186    if parts.len() >= 3 {
187        let addr_str = parts[parts.len() - 2].trim();
188        let len_str = parts[parts.len() - 1].trim();
189        if let (Ok(address), Ok(length)) = (
190            u64::from_str_radix(addr_str, 16),
191            u64::from_str_radix(len_str, 16),
192        ) {
193            return Ok(UrlLocation::Local {
194                address,
195                length: length as usize,
196            });
197        }
198    }
199
200    // Fall back to key-value parsing.
201    let mut address = None;
202    let mut length = None;
203    for part in parts {
204        let token = part.trim();
205        if token.is_empty() {
206            continue;
207        }
208        if let Some((key, value)) = token.split_once('=') {
209            let key = key.trim().to_ascii_lowercase();
210            let value = value.trim();
211            match key.as_str() {
212                "address" | "addr" | "offset" => {
213                    address = Some(parse_u64(value)?);
214                }
215                "length" | "size" => {
216                    let len = parse_u64(value)?;
217                    length = Some(
218                        len.try_into()
219                            .map_err(|_| XmlError::Invalid("length does not fit usize".into()))?,
220                    );
221                }
222                _ => {}
223            }
224        } else if token.starts_with("0x") {
225            address = Some(parse_u64(token)?);
226        }
227        // Ignore unrecognized tokens (like the filename).
228    }
229    match (address, length) {
230        (Some(address), Some(length)) => Ok(UrlLocation::Local { address, length }),
231        _ => Err(XmlError::Invalid(format!("unsupported local URL: {rest}"))),
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    /// Compress `data` into a single-entry ZIP archive (deflate).
240    fn zip_bytes(name: &str, data: &[u8]) -> Vec<u8> {
241        use std::io::Write;
242        let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
243        let options = zip::write::SimpleFileOptions::default()
244            .compression_method(zip::CompressionMethod::Deflated);
245        writer.start_file(name, options).expect("start zip entry");
246        writer.write_all(data).expect("write zip entry");
247        writer.finish().expect("finish zip").into_inner()
248    }
249
250    #[tokio::test]
251    async fn fetch_local_zipped_xml() {
252        let url = b"Local:camera.zip;10;9999\0".to_vec();
253        let zipped = zip_bytes("camera.xml", b"<a/>");
254        let mut url_reg = url.clone();
255        url_reg.resize(URL_MAX_LEN, 0);
256        let expected_len = zipped.len();
257        let loaded = fetch_and_load_xml(|addr, len| {
258            let url_reg = url_reg.clone();
259            let zipped = zipped.clone();
260            async move {
261                if addr == FIRST_URL_ADDRESS {
262                    Ok(url_reg)
263                } else if addr == 0x10 && len == 0x9999 {
264                    // Devices serve the advertised length; extra bytes past
265                    // the archive end are padding.
266                    let mut data = zipped;
267                    data.resize(len.max(expected_len), 0);
268                    Ok(data)
269                } else {
270                    Err(XmlError::Transport("unexpected read".into()))
271                }
272            }
273        })
274        .await
275        .expect("load zipped xml");
276        assert_eq!(loaded, "<a/>");
277    }
278
279    #[tokio::test]
280    async fn falls_back_to_second_url() {
281        let url = b"local:address=0x20;length=0x4\0".to_vec();
282        let loaded = fetch_and_load_xml(|addr, len| {
283            let url = url.clone();
284            async move {
285                if addr == FIRST_URL_ADDRESS {
286                    // Empty first URL register.
287                    Ok(vec![0u8; URL_MAX_LEN])
288                } else if addr == SECOND_URL_ADDRESS {
289                    Ok(url)
290                } else if addr == 0x20 && len == 0x4 {
291                    Ok(b"<b/>".to_vec())
292                } else {
293                    Err(XmlError::Transport("unexpected read".into()))
294                }
295            }
296        })
297        .await
298        .expect("load xml via second URL");
299        assert_eq!(loaded, "<b/>");
300    }
301
302    #[tokio::test]
303    async fn first_url_error_is_reported_when_both_fail() {
304        let err = fetch_and_load_xml(|_, _| async { Ok(vec![0u8; URL_MAX_LEN]) })
305            .await
306            .expect_err("both URLs empty");
307        assert!(matches!(err, XmlError::Invalid(_)));
308    }
309
310    #[test]
311    fn decompress_rejects_oversized_declared_xml() {
312        let mut zipped = zip_bytes("camera.xml", b"<a/>");
313        // Patch the uncompressed-size field in both the local file header
314        // (offset 22 from `PK\x03\x04`) and the central directory header
315        // (offset 24 from `PK\x01\x02`) to claim ~4 GiB.
316        let huge = 0xFFFF_FFF0u32.to_le_bytes();
317        for (magic, offset) in [(b"PK\x03\x04", 22usize), (b"PK\x01\x02", 24usize)] {
318            let pos = zipped
319                .windows(4)
320                .position(|w| w == magic)
321                .expect("zip header signature");
322            zipped[pos + offset..pos + offset + 4].copy_from_slice(&huge);
323        }
324        let err = decompress_if_zip(zipped).expect_err("oversized declared XML");
325        assert!(matches!(err, XmlError::Invalid(_)), "got: {err:?}");
326    }
327
328    #[test]
329    fn decompress_passes_through_plain_xml() {
330        let data = b"<plain/>".to_vec();
331        assert_eq!(decompress_if_zip(data.clone()).unwrap(), data);
332    }
333
334    #[tokio::test]
335    async fn fetch_local_xml() {
336        let data = b"local:address=0x10;length=0x3\0".to_vec();
337        let xml_payload = b"<a/>".to_vec();
338        let loaded = fetch_and_load_xml(|addr, len| {
339            let data = data.clone();
340            let xml_payload = xml_payload.clone();
341            async move {
342                if addr == FIRST_URL_ADDRESS {
343                    Ok(data)
344                } else if addr == 0x10 && len == 0x3 {
345                    Ok(xml_payload)
346                } else {
347                    Err(XmlError::Transport("unexpected read".into()))
348                }
349            }
350        })
351        .await
352        .expect("load xml");
353        assert_eq!(loaded, "<a/>");
354    }
355}