Skip to main content

document_svg/document/
netcdf.rs

1//! Bounded NetCDF classic (CDF-1) and 64-bit-offset (CDF-2) preview.
2//!
3//! The classic formats are self-describing big-endian files containing
4//! dimensions, attributes, and homogeneous variables. This reader exposes
5//! that structure as inert tables and never evaluates metadata or follows
6//! references. NetCDF-4/HDF5 and CDF-5 are reported as unsupported.
7
8use std::path::Path;
9
10use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
11use crate::document::html::{HtmlBlock, render_blocks_to_pages};
12use crate::error::{Error, Result};
13use crate::table::{TableAlign, TableData};
14
15const MAX_NETCDF_BYTES: u64 = 512 * 1024 * 1024;
16const MAX_NETCDF_DIMS: usize = 1024;
17const MAX_NETCDF_VARS: usize = 1024;
18const MAX_NETCDF_ATTRIBUTES: usize = 4096;
19const MAX_NETCDF_NAME_BYTES: usize = 4096;
20const MAX_NETCDF_VALUES_PER_VAR: usize = 200_000;
21const MAX_NETCDF_TOTAL_VALUES: usize = 1_000_000;
22const MAX_NETCDF_TEXT_BYTES: usize = 64 * 1024 * 1024;
23const MAX_NETCDF_VALUE_CHARS: usize = 512;
24
25pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
26    bytes.len() >= 4 && bytes[..3] == *b"CDF" && matches!(bytes[3], 1 | 2 | 5)
27}
28
29pub(crate) fn convert(
30    path: &Path,
31    options: &ConvertOptions,
32    sink: &mut dyn PageConsumer,
33) -> Result<Vec<String>> {
34    convert_with_profile(path, options, sink, "netcdf", "NetCDF dataset", None)
35}
36
37pub(crate) fn convert_exodus(
38    path: &Path,
39    options: &ConvertOptions,
40    sink: &mut dyn PageConsumer,
41) -> Result<Vec<String>> {
42    convert_with_profile(
43        path,
44        options,
45        sink,
46        "exodus",
47        "Exodus II dataset",
48        Some(
49            "Exodus-specific mesh topology, element blocks, sets, result semantics, and solver operations are not reconstructed",
50        ),
51    )
52}
53
54fn convert_with_profile(
55    path: &Path,
56    options: &ConvertOptions,
57    sink: &mut dyn PageConsumer,
58    source_format: &'static str,
59    title: &'static str,
60    profile_warning: Option<&'static str>,
61) -> Result<Vec<String>> {
62    let bytes = read_limited_file(
63        path,
64        options.max_input_bytes.min(MAX_NETCDF_BYTES),
65        if source_format == "exodus" {
66            "Exodus II input"
67        } else {
68            "NetCDF input"
69        },
70    )?;
71    let (dataset, mut warnings) = parse_netcdf(bytes)?;
72    if let Some(profile_warning) = profile_warning {
73        warnings.push(profile_warning.into());
74    }
75    let mut blocks = Vec::new();
76    blocks.push(HtmlBlock::Heading {
77        level: 1,
78        text: if source_format == "netcdf" {
79            format!("NetCDF {} dataset", dataset.variant)
80        } else {
81            format!("{title} ({})", dataset.variant)
82        },
83    });
84    if !dataset.dimensions.is_empty() {
85        blocks.push(HtmlBlock::Heading {
86            level: 2,
87            text: "Dimensions".into(),
88        });
89        blocks.push(HtmlBlock::Table(TableData {
90            headers: vec!["Name".into(), "Length".into(), "Role".into()],
91            rows: dataset
92                .dimensions
93                .iter()
94                .map(|dimension| {
95                    vec![
96                        dimension.name.clone(),
97                        if dimension.unlimited {
98                            dataset.num_records.to_string()
99                        } else {
100                            dimension.length.to_string()
101                        },
102                        if dimension.unlimited {
103                            "unlimited record dimension".into()
104                        } else {
105                            "fixed".into()
106                        },
107                    ]
108                })
109                .collect(),
110            alignments: vec![TableAlign::Left, TableAlign::Right, TableAlign::Left],
111            raw_source: String::new(),
112        }));
113    }
114    if !dataset.global_attributes.is_empty() {
115        blocks.push(HtmlBlock::Heading {
116            level: 2,
117            text: "Global attributes".into(),
118        });
119        blocks.push(HtmlBlock::Table(TableData {
120            headers: vec!["Name".into(), "Type".into(), "Value".into()],
121            rows: dataset
122                .global_attributes
123                .iter()
124                .map(|attribute| {
125                    vec![
126                        attribute.name.clone(),
127                        attribute.kind.name().into(),
128                        attribute.display_value(),
129                    ]
130                })
131                .collect(),
132            alignments: vec![TableAlign::Left; 3],
133            raw_source: String::new(),
134        }));
135    }
136    for variable in &dataset.variables {
137        blocks.push(HtmlBlock::Heading {
138            level: 2,
139            text: format!(
140                "{} ({}, {})",
141                variable.name,
142                variable.kind.name(),
143                variable.dimension_label(&dataset.dimensions)
144            ),
145        });
146        if !variable.attributes.is_empty() {
147            blocks.push(HtmlBlock::Table(TableData {
148                headers: vec!["Attribute".into(), "Type".into(), "Value".into()],
149                rows: variable
150                    .attributes
151                    .iter()
152                    .map(|attribute| {
153                        vec![
154                            attribute.name.clone(),
155                            attribute.kind.name().into(),
156                            attribute.display_value(),
157                        ]
158                    })
159                    .collect(),
160                alignments: vec![TableAlign::Left; 3],
161                raw_source: String::new(),
162            }));
163        }
164        let rows = dataset.read_variable_rows(variable)?;
165        if rows.is_empty() {
166            warnings.push(format!(
167                "{source_format} variable '{}' has no values within the preview budget",
168                variable.name
169            ));
170        } else {
171            blocks.push(HtmlBlock::Table(TableData {
172                headers: vec!["Index".into(), "Value".into()],
173                rows,
174                alignments: vec![TableAlign::Right, TableAlign::Left],
175                raw_source: String::new(),
176            }));
177        }
178    }
179    let mut page_sink = NetcdfPageSink {
180        inner: sink,
181        source_format,
182        title,
183        warnings: &warnings,
184    };
185    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
186    warnings.sort();
187    warnings.dedup();
188    Ok(warnings)
189}
190
191struct NetcdfPageSink<'a> {
192    inner: &'a mut dyn PageConsumer,
193    source_format: &'static str,
194    title: &'static str,
195    warnings: &'a [String],
196}
197
198impl PageConsumer for NetcdfPageSink<'_> {
199    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
200        page.source_format = self.source_format.into();
201        page.title = self.title.into();
202        for warning in self.warnings {
203            page.warn(warning.clone());
204        }
205        self.inner.consume(page)
206    }
207}
208
209#[derive(Clone, Debug)]
210struct Dimension {
211    name: String,
212    length: usize,
213    unlimited: bool,
214}
215
216#[derive(Clone, Copy, Debug, Eq, PartialEq)]
217enum NcType {
218    Byte,
219    Char,
220    Short,
221    Int,
222    Float,
223    Double,
224}
225
226impl NcType {
227    fn from_tag(tag: u32) -> Result<Self> {
228        match tag {
229            1 => Ok(Self::Byte),
230            2 => Ok(Self::Char),
231            3 => Ok(Self::Short),
232            4 => Ok(Self::Int),
233            5 => Ok(Self::Float),
234            6 => Ok(Self::Double),
235            _ => Err(Error::Unsupported(format!(
236                "NetCDF type tag {tag} is unsupported"
237            ))),
238        }
239    }
240
241    fn name(self) -> &'static str {
242        match self {
243            Self::Byte => "byte",
244            Self::Char => "char",
245            Self::Short => "short",
246            Self::Int => "int",
247            Self::Float => "float",
248            Self::Double => "double",
249        }
250    }
251
252    fn bytes(self) -> usize {
253        match self {
254            Self::Byte | Self::Char => 1,
255            Self::Short => 2,
256            Self::Int | Self::Float => 4,
257            Self::Double => 8,
258        }
259    }
260}
261
262#[derive(Clone, Debug)]
263struct Attribute {
264    name: String,
265    kind: NcType,
266    values: Vec<u8>,
267    count: usize,
268}
269
270impl Attribute {
271    fn display_value(&self) -> String {
272        if self.kind == NcType::Char {
273            let text = String::from_utf8_lossy(&self.values)
274                .trim_end_matches('\0')
275                .to_string();
276            return truncate(&text);
277        }
278        let mut values = Vec::new();
279        for index in 0..self.count.min(16) {
280            if let Some(value) = decode_value(self.kind, &self.values, index) {
281                values.push(value);
282            }
283        }
284        if self.count > 16 {
285            values.push("…".into());
286        }
287        truncate(&values.join(", "))
288    }
289}
290
291#[derive(Clone, Debug)]
292struct Variable {
293    name: String,
294    dimensions: Vec<usize>,
295    attributes: Vec<Attribute>,
296    kind: NcType,
297    vsize: u64,
298    begin: u64,
299}
300
301impl Variable {
302    fn record(&self, unlimited_dim: Option<usize>) -> bool {
303        self.dimensions.first().copied() == unlimited_dim
304    }
305
306    fn element_count(&self, dimensions: &[Dimension], records: usize) -> Result<usize> {
307        let mut count = 1usize;
308        for (index, dimension_id) in self.dimensions.iter().enumerate() {
309            let dimension = dimensions.get(*dimension_id).ok_or_else(|| {
310                Error::InvalidInput(format!(
311                    "NetCDF variable '{}' references unknown dimension {}",
312                    self.name, dimension_id
313                ))
314            })?;
315            let length = if index == 0 && dimension.unlimited {
316                records
317            } else {
318                dimension.length
319            };
320            count = count
321                .checked_mul(length)
322                .ok_or_else(|| Error::LimitExceeded("NetCDF variable size overflowed".into()))?;
323        }
324        Ok(count)
325    }
326
327    fn dimension_label(&self, dimensions: &[Dimension]) -> String {
328        if self.dimensions.is_empty() {
329            return "scalar".into();
330        }
331        self.dimensions
332            .iter()
333            .filter_map(|id| dimensions.get(*id).map(|dimension| dimension.name.as_str()))
334            .collect::<Vec<_>>()
335            .join(" × ")
336    }
337
338    fn fill_value(&self) -> Option<String> {
339        self.attributes
340            .iter()
341            .find(|attribute| attribute.name == "_FillValue" && attribute.count == 1)
342            .and_then(|attribute| decode_value(attribute.kind, &attribute.values, 0))
343    }
344}
345
346#[derive(Debug)]
347struct Dataset {
348    variant: &'static str,
349    bytes: Vec<u8>,
350    dimensions: Vec<Dimension>,
351    global_attributes: Vec<Attribute>,
352    variables: Vec<Variable>,
353    num_records: usize,
354    record_size: u64,
355}
356
357impl Dataset {
358    fn read_variable_rows(&self, variable: &Variable) -> Result<Vec<Vec<String>>> {
359        let unlimited = self
360            .dimensions
361            .iter()
362            .position(|dimension| dimension.unlimited);
363        let count = variable.element_count(&self.dimensions, self.num_records)?;
364        let wanted = count.min(MAX_NETCDF_VALUES_PER_VAR);
365        let mut rows = Vec::with_capacity(wanted);
366        let fill = variable.fill_value();
367        for index in 0..wanted {
368            let offset = self.value_offset(variable, index, unlimited)?;
369            let value = read_value(variable.kind, &self.bytes, offset)?;
370            let display = if fill.as_deref() == Some(value.as_str()) {
371                "_FillValue".into()
372            } else {
373                truncate(&value)
374            };
375            rows.push(vec![index.to_string(), display]);
376        }
377        Ok(rows)
378    }
379
380    fn value_offset(
381        &self,
382        variable: &Variable,
383        index: usize,
384        unlimited: Option<usize>,
385    ) -> Result<usize> {
386        if !variable.record(unlimited) {
387            let byte_offset = variable
388                .begin
389                .checked_add((index as u64).saturating_mul(variable.kind.bytes() as u64))
390                .ok_or_else(|| Error::LimitExceeded("NetCDF data offset overflowed".into()))?;
391            return usize::try_from(byte_offset).map_err(|_| {
392                Error::LimitExceeded("NetCDF data offset exceeds platform size".into())
393            });
394        }
395        if unlimited.is_none() {
396            return Err(Error::InvalidInput(
397                "NetCDF record variable has no unlimited dimension".into(),
398            ));
399        }
400        let inner_count = variable.element_count(&self.dimensions, 1)?.max(1);
401        let record_index = index / inner_count;
402        let inner_index = index % inner_count;
403        if record_index >= self.num_records {
404            return Err(Error::InvalidInput(
405                "NetCDF record index is outside numrecs".into(),
406            ));
407        }
408        let byte_offset = variable
409            .begin
410            .checked_add((record_index as u64).saturating_mul(self.record_size))
411            .and_then(|value| {
412                value.checked_add((inner_index as u64).saturating_mul(variable.kind.bytes() as u64))
413            })
414            .ok_or_else(|| Error::LimitExceeded("NetCDF record data offset overflowed".into()))?;
415        usize::try_from(byte_offset)
416            .map_err(|_| Error::LimitExceeded("NetCDF record offset exceeds platform size".into()))
417    }
418}
419
420fn parse_netcdf(bytes: Vec<u8>) -> Result<(Dataset, Vec<String>)> {
421    if bytes.len() < 4 || &bytes[..3] != b"CDF" {
422        return Err(Error::InvalidInput("NetCDF CDF magic is missing".into()));
423    }
424    let version = bytes[3];
425    if version == 5 {
426        return Err(Error::Unsupported(
427            "NetCDF CDF-5 uses a different 64-bit header model and is unsupported".into(),
428        ));
429    }
430    if !matches!(version, 1 | 2) {
431        return Err(Error::Unsupported(format!(
432            "NetCDF CDF version {version} is unsupported; expected CDF-1 or CDF-2"
433        )));
434    }
435    let mut reader = Reader::new(&bytes[4..], version == 2);
436    let numrecs_raw = reader.u32("numrecs")?;
437    let dimensions = reader.dimensions()?;
438    let global_attributes = reader.attributes("global attributes")?;
439    let variables = reader.variables()?;
440    let attribute_bytes = global_attributes
441        .iter()
442        .chain(
443            variables
444                .iter()
445                .flat_map(|variable| variable.attributes.iter()),
446        )
447        .map(|attribute| attribute.values.len())
448        .try_fold(0usize, |sum, value| sum.checked_add(value))
449        .ok_or_else(|| Error::LimitExceeded("NetCDF attribute byte size overflowed".into()))?;
450    if attribute_bytes > MAX_NETCDF_TEXT_BYTES {
451        return Err(Error::LimitExceeded(format!(
452            "NetCDF attribute data exceeds {MAX_NETCDF_TEXT_BYTES} bytes"
453        )));
454    }
455    if reader.position > bytes.len() {
456        return Err(Error::InvalidInput(
457            "NetCDF header extends beyond the file".into(),
458        ));
459    }
460    let unlimited = dimensions.iter().position(|dimension| dimension.unlimited);
461    if unlimited.is_none() && numrecs_raw != 0 && numrecs_raw != u32::MAX {
462        return Err(Error::InvalidInput(
463            "NetCDF has a nonzero record count but no unlimited dimension".into(),
464        ));
465    }
466    let num_records = if numrecs_raw == u32::MAX {
467        0
468    } else {
469        usize::try_from(numrecs_raw)
470            .map_err(|_| Error::LimitExceeded("NetCDF numrecs exceeds platform size".into()))?
471    };
472    let record_variables = variables
473        .iter()
474        .filter(|variable| variable.record(unlimited))
475        .collect::<Vec<_>>();
476    let mut record_size = record_variables.iter().try_fold(0u64, |sum, variable| {
477        sum.checked_add(variable.vsize)
478            .ok_or_else(|| Error::LimitExceeded("NetCDF record size overflowed".into()))
479    })?;
480    if record_variables.len() == 1
481        && matches!(
482            record_variables[0].kind,
483            NcType::Byte | NcType::Char | NcType::Short
484        )
485    {
486        let raw_record_values = record_variables[0].element_count(&dimensions, 1)?;
487        record_size =
488            (raw_record_values as u64).saturating_mul(record_variables[0].kind.bytes() as u64);
489    }
490    let mut effective_records = num_records;
491    let mut warnings = Vec::new();
492    if numrecs_raw == u32::MAX {
493        if record_size == 0 {
494            effective_records = 0;
495        } else {
496            let starts = record_variables
497                .iter()
498                .map(|variable| variable.begin)
499                .min()
500                .unwrap_or(bytes.len() as u64);
501            effective_records = ((bytes.len() as u64).saturating_sub(starts) / record_size)
502                .try_into()
503                .map_err(|_| {
504                    Error::LimitExceeded("NetCDF streaming record count is too large".into())
505                })?;
506        }
507        warnings.push(
508            "NetCDF numrecs uses the streaming sentinel; record count was derived from file length"
509                .into(),
510        );
511    }
512    if effective_records > MAX_NETCDF_VALUES_PER_VAR {
513        return Err(Error::LimitExceeded(format!(
514            "NetCDF record count exceeds {MAX_NETCDF_VALUES_PER_VAR}"
515        )));
516    }
517    let mut total_values = 0usize;
518    for variable in &variables {
519        for (index, dimension_id) in variable.dimensions.iter().enumerate() {
520            if dimensions
521                .get(*dimension_id)
522                .is_some_and(|dimension| dimension.unlimited && index != 0)
523            {
524                return Err(Error::Unsupported(format!(
525                    "NetCDF variable '{}' uses an unlimited dimension after its first dimension",
526                    variable.name
527                )));
528            }
529        }
530        let count = variable.element_count(&dimensions, effective_records)?;
531        total_values = total_values
532            .checked_add(count.min(MAX_NETCDF_VALUES_PER_VAR))
533            .ok_or_else(|| Error::LimitExceeded("NetCDF total value count overflowed".into()))?;
534        if total_values > MAX_NETCDF_TOTAL_VALUES {
535            return Err(Error::LimitExceeded(format!(
536                "NetCDF values exceed {MAX_NETCDF_TOTAL_VALUES}"
537            )));
538        }
539        let data_bytes = if variable.record(unlimited) {
540            let per_record = if record_variables.len() == 1
541                && matches!(variable.kind, NcType::Byte | NcType::Char | NcType::Short)
542            {
543                record_size
544            } else {
545                variable.vsize
546            };
547            per_record.saturating_mul(effective_records as u64)
548        } else {
549            variable.vsize
550        };
551        if variable
552            .begin
553            .checked_add(data_bytes)
554            .is_none_or(|end| end > bytes.len() as u64)
555        {
556            return Err(Error::InvalidInput(format!(
557                "NetCDF variable '{}' data extends beyond the file",
558                variable.name
559            )));
560        }
561    }
562    let dataset = Dataset {
563        variant: if version == 1 { "CDF-1" } else { "CDF-2" },
564        bytes,
565        dimensions,
566        global_attributes,
567        variables,
568        num_records: effective_records,
569        record_size,
570    };
571    Ok((dataset, warnings))
572}
573
574struct Reader<'a> {
575    bytes: &'a [u8],
576    position: usize,
577    wide_offsets: bool,
578    unlimited: Option<usize>,
579}
580
581impl<'a> Reader<'a> {
582    fn new(bytes: &'a [u8], wide_offsets: bool) -> Self {
583        Self {
584            bytes,
585            position: 0,
586            wide_offsets,
587            unlimited: None,
588        }
589    }
590
591    fn bytes(&mut self, count: usize, context: &str) -> Result<&'a [u8]> {
592        let end = self
593            .position
594            .checked_add(count)
595            .ok_or_else(|| Error::LimitExceeded(format!("NetCDF {context} offset overflowed")))?;
596        let slice = self.bytes.get(self.position..end).ok_or_else(|| {
597            Error::InvalidInput(format!(
598                "NetCDF {context} ends before the header is complete"
599            ))
600        })?;
601        self.position = end;
602        Ok(slice)
603    }
604
605    fn u32(&mut self, context: &str) -> Result<u32> {
606        let bytes = self.bytes(4, context)?;
607        Ok(u32::from_be_bytes(bytes.try_into().unwrap()))
608    }
609
610    fn u64(&mut self, context: &str) -> Result<u64> {
611        let bytes = self.bytes(8, context)?;
612        Ok(u64::from_be_bytes(bytes.try_into().unwrap()))
613    }
614
615    fn offset(&mut self, context: &str) -> Result<u64> {
616        if self.wide_offsets {
617            self.u64(context)
618        } else {
619            Ok(u64::from(self.u32(context)?))
620        }
621    }
622
623    fn padded_bytes(&mut self, count: usize, context: &str) -> Result<Vec<u8>> {
624        let data = self.bytes(count, context)?.to_vec();
625        let padded = (count + 3) & !3;
626        if padded > count {
627            let _ = self.bytes(padded - count, "padding")?;
628        }
629        Ok(data)
630    }
631
632    fn name(&mut self, context: &str) -> Result<String> {
633        let length = usize::try_from(self.u32(context)?)
634            .map_err(|_| Error::LimitExceeded("NetCDF name length exceeds platform size".into()))?;
635        if length == 0 || length > MAX_NETCDF_NAME_BYTES {
636            return Err(Error::LimitExceeded(format!(
637                "NetCDF {context} name length {length} is outside the supported range"
638            )));
639        }
640        let bytes = self.padded_bytes(length, context)?;
641        if bytes.contains(&0) {
642            return Err(Error::InvalidInput(format!(
643                "NetCDF {context} name contains NUL"
644            )));
645        }
646        String::from_utf8(bytes).map_err(|error| {
647            Error::InvalidInput(format!("NetCDF {context} name is not UTF-8: {error}"))
648        })
649    }
650
651    fn dimensions(&mut self) -> Result<Vec<Dimension>> {
652        let tag = self.u32("dimension list tag")?;
653        if tag == 0 {
654            self.unlimited = None;
655            return Ok(Vec::new());
656        }
657        if tag != 10 {
658            return Err(Error::InvalidInput(format!(
659                "NetCDF dimension list has unexpected tag {tag}"
660            )));
661        }
662        let count = checked_count(self.u32("dimension count")?, MAX_NETCDF_DIMS, "dimensions")?;
663        let mut dimensions = Vec::with_capacity(count);
664        for index in 0..count {
665            let name = self.name("dimension")?;
666            let length = usize::try_from(self.u32("dimension length")?).map_err(|_| {
667                Error::LimitExceeded("NetCDF dimension length exceeds platform size".into())
668            })?;
669            let unlimited = length == 0;
670            if unlimited {
671                if self.unlimited.is_some() {
672                    return Err(Error::InvalidInput(
673                        "NetCDF declares more than one unlimited dimension".into(),
674                    ));
675                }
676                self.unlimited = Some(index);
677            }
678            dimensions.push(Dimension {
679                name,
680                length,
681                unlimited,
682            });
683        }
684        Ok(dimensions)
685    }
686
687    fn attributes(&mut self, context: &str) -> Result<Vec<Attribute>> {
688        let tag = self.u32(&format!("{context} tag"))?;
689        if tag == 0 {
690            return Ok(Vec::new());
691        }
692        if tag != 12 {
693            return Err(Error::InvalidInput(format!(
694                "NetCDF {context} has unexpected tag {tag}"
695            )));
696        }
697        let count = checked_count(
698            self.u32(&format!("{context} count"))?,
699            MAX_NETCDF_ATTRIBUTES,
700            context,
701        )?;
702        let mut attributes = Vec::with_capacity(count);
703        for _ in 0..count {
704            let name = self.name("attribute")?;
705            let kind = NcType::from_tag(self.u32("attribute type")?)?;
706            let count = checked_count(
707                self.u32("attribute value count")?,
708                MAX_NETCDF_VALUES_PER_VAR,
709                "attribute values",
710            )?;
711            let bytes = count.checked_mul(kind.bytes()).ok_or_else(|| {
712                Error::LimitExceeded("NetCDF attribute byte size overflowed".into())
713            })?;
714            if bytes > MAX_NETCDF_TEXT_BYTES {
715                return Err(Error::LimitExceeded(
716                    "NetCDF attribute values exceed the text budget".into(),
717                ));
718            }
719            let values = self.padded_bytes(bytes, "attribute values")?;
720            attributes.push(Attribute {
721                name,
722                kind,
723                values,
724                count,
725            });
726        }
727        Ok(attributes)
728    }
729
730    fn variables(&mut self) -> Result<Vec<Variable>> {
731        let tag = self.u32("variable list tag")?;
732        if tag == 0 {
733            return Ok(Vec::new());
734        }
735        if tag != 11 {
736            return Err(Error::InvalidInput(format!(
737                "NetCDF variable list has unexpected tag {tag}"
738            )));
739        }
740        let count = checked_count(self.u32("variable count")?, MAX_NETCDF_VARS, "variables")?;
741        let mut variables = Vec::with_capacity(count);
742        for _ in 0..count {
743            let name = self.name("variable")?;
744            let rank = checked_count(
745                self.u32("variable rank")?,
746                MAX_NETCDF_DIMS,
747                "variable dimensions",
748            )?;
749            let mut dimensions = Vec::with_capacity(rank);
750            for _ in 0..rank {
751                let dimension_id = checked_index(
752                    self.u32("variable dimension id")?,
753                    MAX_NETCDF_DIMS,
754                    "variable dimension",
755                )?;
756                dimensions.push(dimension_id);
757            }
758            let attributes = self.attributes("variable attributes")?;
759            let kind = NcType::from_tag(self.u32("variable type")?)?;
760            let vsize = u64::from(self.u32("variable vsize")?);
761            let begin = self.offset("variable begin offset")?;
762            variables.push(Variable {
763                name,
764                dimensions,
765                attributes,
766                kind,
767                vsize,
768                begin,
769            });
770        }
771        Ok(variables)
772    }
773}
774
775fn checked_count(raw: u32, maximum: usize, context: &str) -> Result<usize> {
776    let count = usize::try_from(raw).map_err(|_| {
777        Error::LimitExceeded(format!("NetCDF {context} count exceeds platform size"))
778    })?;
779    if count > maximum {
780        return Err(Error::LimitExceeded(format!(
781            "NetCDF {context} count {count} exceeds {maximum}"
782        )));
783    }
784    Ok(count)
785}
786
787fn checked_index(raw: u32, maximum: usize, context: &str) -> Result<usize> {
788    let index = usize::try_from(raw).map_err(|_| {
789        Error::LimitExceeded(format!("NetCDF {context} index exceeds platform size"))
790    })?;
791    if index >= maximum {
792        return Err(Error::InvalidInput(format!(
793            "NetCDF {context} index {index} is out of range"
794        )));
795    }
796    Ok(index)
797}
798
799fn decode_value(kind: NcType, bytes: &[u8], index: usize) -> Option<String> {
800    let width = kind.bytes();
801    let start = index.checked_mul(width)?;
802    let value = bytes.get(start..start + width)?;
803    Some(match kind {
804        NcType::Byte => (value[0] as i8).to_string(),
805        NcType::Char => String::from_utf8_lossy(value).to_string(),
806        NcType::Short => i16::from_be_bytes(value.try_into().ok()?).to_string(),
807        NcType::Int => i32::from_be_bytes(value.try_into().ok()?).to_string(),
808        NcType::Float => f32::from_bits(u32::from_be_bytes(value.try_into().ok()?)).to_string(),
809        NcType::Double => f64::from_bits(u64::from_be_bytes(value.try_into().ok()?)).to_string(),
810    })
811}
812
813fn read_value(kind: NcType, bytes: &[u8], offset: usize) -> Result<String> {
814    let width = kind.bytes();
815    let value = bytes
816        .get(offset..offset.saturating_add(width))
817        .ok_or_else(|| {
818            Error::InvalidInput("NetCDF variable data ends before a complete value".into())
819        })?;
820    decode_value(kind, value, 0)
821        .ok_or_else(|| Error::InvalidInput("NetCDF variable value could not be decoded".into()))
822}
823
824fn truncate(value: &str) -> String {
825    value.chars().take(MAX_NETCDF_VALUE_CHARS).collect()
826}
827
828#[cfg(test)]
829mod tests {
830    use super::{looks_like_prefix, parse_netcdf};
831
832    fn tiny_cdf1() -> Vec<u8> {
833        let mut bytes = Vec::new();
834        bytes.extend_from_slice(b"CDF\x01");
835        bytes.extend_from_slice(&0u32.to_be_bytes()); // numrecs
836        bytes.extend_from_slice(&10u32.to_be_bytes()); // dimensions tag
837        bytes.extend_from_slice(&1u32.to_be_bytes());
838        bytes.extend_from_slice(&1u32.to_be_bytes());
839        bytes.extend_from_slice(b"x\0\0\0");
840        bytes.extend_from_slice(&3u32.to_be_bytes());
841        bytes.extend_from_slice(&0u32.to_be_bytes()); // global attrs absent
842        bytes.extend_from_slice(&11u32.to_be_bytes()); // vars tag
843        bytes.extend_from_slice(&1u32.to_be_bytes());
844        bytes.extend_from_slice(&1u32.to_be_bytes());
845        bytes.extend_from_slice(b"v\0\0\0");
846        bytes.extend_from_slice(&1u32.to_be_bytes()); // rank
847        bytes.extend_from_slice(&0u32.to_be_bytes()); // dim id
848        bytes.extend_from_slice(&0u32.to_be_bytes()); // attrs absent
849        bytes.extend_from_slice(&4u32.to_be_bytes()); // int
850        bytes.extend_from_slice(&12u32.to_be_bytes()); // vsize
851        bytes.extend_from_slice(&72u32.to_be_bytes()); // begin
852        for value in [1i32, 2, 3] {
853            bytes.extend_from_slice(&value.to_be_bytes());
854        }
855        bytes
856    }
857
858    fn single_char_record_cdf1() -> Vec<u8> {
859        let mut bytes = Vec::new();
860        bytes.extend_from_slice(b"CDF\x01");
861        bytes.extend_from_slice(&2u32.to_be_bytes());
862        bytes.extend_from_slice(&10u32.to_be_bytes());
863        bytes.extend_from_slice(&1u32.to_be_bytes());
864        bytes.extend_from_slice(&4u32.to_be_bytes());
865        bytes.extend_from_slice(b"time");
866        bytes.extend_from_slice(&0u32.to_be_bytes());
867        bytes.extend_from_slice(&0u32.to_be_bytes());
868        bytes.extend_from_slice(&11u32.to_be_bytes());
869        bytes.extend_from_slice(&1u32.to_be_bytes());
870        bytes.extend_from_slice(&1u32.to_be_bytes());
871        bytes.extend_from_slice(b"c\0\0\0");
872        bytes.extend_from_slice(&1u32.to_be_bytes());
873        bytes.extend_from_slice(&0u32.to_be_bytes());
874        bytes.extend_from_slice(&0u32.to_be_bytes());
875        bytes.extend_from_slice(&2u32.to_be_bytes());
876        bytes.extend_from_slice(&4u32.to_be_bytes());
877        let begin = bytes.len() + 4;
878        bytes.extend_from_slice(&(begin as u32).to_be_bytes());
879        bytes.extend_from_slice(b"ab");
880        bytes
881    }
882
883    #[test]
884    fn parses_big_endian_classic_dimensions_and_values() {
885        let bytes = tiny_cdf1();
886        let (dataset, warnings) = parse_netcdf(bytes).unwrap();
887        assert!(warnings.is_empty());
888        assert_eq!(dataset.variant, "CDF-1");
889        assert_eq!(dataset.variables[0].name, "v");
890        assert_eq!(
891            dataset.read_variable_rows(&dataset.variables[0]).unwrap()[1][1],
892            "2"
893        );
894    }
895
896    #[test]
897    fn recognizes_only_supported_cdf_magic_versions() {
898        assert!(looks_like_prefix(b"CDF\x01"));
899        assert!(looks_like_prefix(b"CDF\x02"));
900        assert!(looks_like_prefix(b"CDF\x05"));
901        assert!(!looks_like_prefix(b"HDF\x89"));
902    }
903
904    #[test]
905    fn handles_the_unpadded_single_byte_record_special_case() {
906        let bytes = single_char_record_cdf1();
907        let (dataset, _) = parse_netcdf(bytes).unwrap();
908        let rows = dataset.read_variable_rows(&dataset.variables[0]).unwrap();
909        assert_eq!(rows[0][1], "a");
910        assert_eq!(rows[1][1], "b");
911    }
912
913    #[test]
914    fn rejects_unexpected_header_list_tags() {
915        let mut bytes = tiny_cdf1();
916        bytes[8..12].copy_from_slice(&99u32.to_be_bytes());
917        let error = parse_netcdf(bytes).unwrap_err();
918        assert!(error.to_string().contains("unexpected tag"));
919    }
920}