Skip to main content

tzif_codec/
interop.rs

1use crate::{common::TimeSize, validate::validate_file, DataBlock, TzifError, TzifFile, Version};
2
3#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
4pub enum InteroperabilityWarning {
5    VersionOneDataMayBeIncomplete,
6    VersionThreeOrLaterFooterMayConfuseVersionTwoReaders,
7    VersionFourLeapSecondTableMayConfuseStrictRfc8536Readers,
8    FooterMayBeIgnoredByReaders,
9    MissingEarlyNoOpTransition {
10        block: &'static str,
11    },
12    FirstTransitionAfterRecommendedCompatibilityPoint {
13        block: &'static str,
14        timestamp: i64,
15    },
16    TransitionBeforeRecommendedLowerBound {
17        block: &'static str,
18        index: usize,
19        timestamp: i64,
20    },
21    MinimumI64Transition {
22        block: &'static str,
23        index: usize,
24    },
25    NegativeTransition {
26        block: &'static str,
27        index: usize,
28        timestamp: i64,
29    },
30    FooterContainsAngleBracket,
31    DesignationNonAscii {
32        block: &'static str,
33        index: usize,
34        designation: Vec<u8>,
35    },
36    DesignationLengthOutsideRecommendedRange {
37        block: &'static str,
38        index: usize,
39        designation: String,
40    },
41    DesignationContainsNonRecommendedAscii {
42        block: &'static str,
43        index: usize,
44        designation: String,
45    },
46    UnspecifiedLocalTimeDesignation {
47        block: &'static str,
48        index: usize,
49    },
50    DaylightOffsetLessThanStandardOffset {
51        block: &'static str,
52        daylight_offset: i32,
53        standard_offset: i32,
54    },
55    LeapSecondWithSubMinuteOffset {
56        block: &'static str,
57        offset: i32,
58    },
59    OffsetOutsideConventionalRange {
60        block: &'static str,
61        index: usize,
62        offset: i32,
63    },
64    OffsetOutsideRecommendedRange {
65        block: &'static str,
66        index: usize,
67        offset: i32,
68    },
69    NegativeSubHourOffset {
70        block: &'static str,
71        index: usize,
72        offset: i32,
73    },
74    OffsetNotMultipleOfMinute {
75        block: &'static str,
76        index: usize,
77        offset: i32,
78    },
79    OffsetNotMultipleOfQuarterHour {
80        block: &'static str,
81        index: usize,
82        offset: i32,
83    },
84    UnusedLocalTimeType {
85        block: &'static str,
86        index: usize,
87    },
88    UnusedDesignationOctet {
89        block: &'static str,
90        index: usize,
91    },
92}
93
94impl TzifFile {
95    /// Returns interoperability warnings for readers with common legacy `TZif` limitations.
96    ///
97    /// # Errors
98    ///
99    /// Returns an error if the file is not structurally valid.
100    pub fn interoperability_warnings(&self) -> Result<Vec<InteroperabilityWarning>, TzifError> {
101        validate_file(self)?;
102        let mut warnings = Vec::new();
103        push_file_warnings(self, &mut warnings);
104        push_block_warnings("v1", &self.v1, TimeSize::ThirtyTwo, &mut warnings);
105        if let Some(block) = &self.v2_plus {
106            push_block_warnings("v2_plus", block, TimeSize::SixtyFour, &mut warnings);
107        }
108        warnings.sort();
109        warnings.dedup();
110        Ok(warnings)
111    }
112}
113
114pub fn designation_at(block: &DataBlock, designation_index: u8) -> Option<&[u8]> {
115    let start = usize::from(designation_index);
116    let bytes = block.designations.get(start..)?;
117    let len = bytes
118        .iter()
119        .position(|&byte| byte == 0)
120        .unwrap_or(bytes.len());
121    bytes.get(..len)
122}
123
124pub fn is_placeholder_type(block: &DataBlock, type_index: usize) -> bool {
125    let Some(local_time_type) = block.local_time_types.get(type_index) else {
126        return false;
127    };
128    designation_at(block, local_time_type.designation_index) == Some(b"-00")
129}
130
131fn push_file_warnings(file: &TzifFile, warnings: &mut Vec<InteroperabilityWarning>) {
132    if let Some(v2_plus) = &file.v2_plus {
133        if !v2_plus.transition_times.is_empty() {
134            let v1_count = file.v1.transition_times.len();
135            let representable_v2_count = v2_plus
136                .transition_times
137                .iter()
138                .filter(|&&timestamp| i32::try_from(timestamp).is_ok())
139                .count();
140            if v1_count < representable_v2_count {
141                warnings.push(InteroperabilityWarning::VersionOneDataMayBeIncomplete);
142            }
143        }
144    }
145
146    if file.version >= Version::V3
147        && file
148            .footer
149            .as_deref()
150            .is_some_and(|footer| !footer.is_empty())
151    {
152        warnings
153            .push(InteroperabilityWarning::VersionThreeOrLaterFooterMayConfuseVersionTwoReaders);
154    }
155
156    if file.version == Version::V4
157        && file
158            .v2_plus
159            .as_ref()
160            .is_some_and(|block| !block.leap_seconds.is_empty())
161    {
162        warnings.push(
163            InteroperabilityWarning::VersionFourLeapSecondTableMayConfuseStrictRfc8536Readers,
164        );
165    }
166
167    if file
168        .footer
169        .as_deref()
170        .is_some_and(|footer| !footer.is_empty())
171    {
172        warnings.push(InteroperabilityWarning::FooterMayBeIgnoredByReaders);
173        if file
174            .footer
175            .as_deref()
176            .is_some_and(|footer| footer.contains(['<', '>']))
177        {
178            warnings.push(InteroperabilityWarning::FooterContainsAngleBracket);
179        }
180    }
181}
182
183fn push_block_warnings(
184    block_name: &'static str,
185    block: &DataBlock,
186    time_size: TimeSize,
187    warnings: &mut Vec<InteroperabilityWarning>,
188) {
189    if let Some((&first, &first_type)) = block
190        .transition_times
191        .first()
192        .zip(block.transition_types.first())
193    {
194        if first_type != 0 {
195            warnings
196                .push(InteroperabilityWarning::MissingEarlyNoOpTransition { block: block_name });
197        }
198        if first > i64::from(i32::MIN) {
199            warnings.push(
200                InteroperabilityWarning::FirstTransitionAfterRecommendedCompatibilityPoint {
201                    block: block_name,
202                    timestamp: first,
203                },
204            );
205        }
206    }
207
208    for (index, &timestamp) in block.transition_times.iter().enumerate() {
209        if timestamp < 0 {
210            warnings.push(InteroperabilityWarning::NegativeTransition {
211                block: block_name,
212                index,
213                timestamp,
214            });
215        }
216        if matches!(time_size, TimeSize::SixtyFour) && timestamp < -(1_i64 << 59) {
217            warnings.push(
218                InteroperabilityWarning::TransitionBeforeRecommendedLowerBound {
219                    block: block_name,
220                    index,
221                    timestamp,
222                },
223            );
224        }
225        if matches!(time_size, TimeSize::SixtyFour) && timestamp == i64::MIN {
226            warnings.push(InteroperabilityWarning::MinimumI64Transition {
227                block: block_name,
228                index,
229            });
230        }
231    }
232
233    for (index, local_time_type) in block.local_time_types.iter().enumerate() {
234        push_designation_warnings(
235            block_name,
236            block,
237            index,
238            local_time_type.designation_index,
239            warnings,
240        );
241        push_offset_warnings(block_name, index, local_time_type.utc_offset, warnings);
242    }
243    push_negative_dst_warnings(block_name, block, warnings);
244    push_leap_second_offset_warnings(block_name, block, warnings);
245    push_unused_local_time_type_warnings(block_name, block, warnings);
246    push_unused_designation_octet_warnings(block_name, block, warnings);
247}
248
249fn push_designation_warnings(
250    block_name: &'static str,
251    block: &DataBlock,
252    index: usize,
253    designation_index: u8,
254    warnings: &mut Vec<InteroperabilityWarning>,
255) {
256    let Some(designation) = designation_at(block, designation_index) else {
257        return;
258    };
259    if designation == b"-00" {
260        warnings.push(InteroperabilityWarning::UnspecifiedLocalTimeDesignation {
261            block: block_name,
262            index,
263        });
264        return;
265    }
266    let Ok(designation_string) = std::str::from_utf8(designation) else {
267        warnings.push(InteroperabilityWarning::DesignationNonAscii {
268            block: block_name,
269            index,
270            designation: designation.to_vec(),
271        });
272        return;
273    };
274    if !designation_string.is_ascii() {
275        warnings.push(InteroperabilityWarning::DesignationNonAscii {
276            block: block_name,
277            index,
278            designation: designation.to_vec(),
279        });
280        return;
281    }
282    if designation_string.len() < 3 || designation_string.len() > 6 {
283        warnings.push(
284            InteroperabilityWarning::DesignationLengthOutsideRecommendedRange {
285                block: block_name,
286                index,
287                designation: designation_string.to_string(),
288            },
289        );
290    }
291    if !designation_string
292        .bytes()
293        .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'+')
294    {
295        warnings.push(
296            InteroperabilityWarning::DesignationContainsNonRecommendedAscii {
297                block: block_name,
298                index,
299                designation: designation_string.to_string(),
300            },
301        );
302    }
303}
304
305fn push_offset_warnings(
306    block_name: &'static str,
307    index: usize,
308    offset: i32,
309    warnings: &mut Vec<InteroperabilityWarning>,
310) {
311    if !(-12 * 3600..=12 * 3600).contains(&offset) {
312        warnings.push(InteroperabilityWarning::OffsetOutsideConventionalRange {
313            block: block_name,
314            index,
315            offset,
316        });
317    }
318    if !(-89_999..=93_599).contains(&offset) {
319        warnings.push(InteroperabilityWarning::OffsetOutsideRecommendedRange {
320            block: block_name,
321            index,
322            offset,
323        });
324    }
325    if (-3599..=-1).contains(&offset) {
326        warnings.push(InteroperabilityWarning::NegativeSubHourOffset {
327            block: block_name,
328            index,
329            offset,
330        });
331    }
332    if offset % 60 != 0 {
333        warnings.push(InteroperabilityWarning::OffsetNotMultipleOfMinute {
334            block: block_name,
335            index,
336            offset,
337        });
338    } else if offset % (15 * 60) != 0 {
339        warnings.push(InteroperabilityWarning::OffsetNotMultipleOfQuarterHour {
340            block: block_name,
341            index,
342            offset,
343        });
344    }
345}
346
347fn push_unused_local_time_type_warnings(
348    block_name: &'static str,
349    block: &DataBlock,
350    warnings: &mut Vec<InteroperabilityWarning>,
351) {
352    let mut used = vec![false; block.local_time_types.len()];
353    if let Some(first) = used.first_mut() {
354        *first = true;
355    }
356    for &transition_type in &block.transition_types {
357        if let Some(slot) = used.get_mut(usize::from(transition_type)) {
358            *slot = true;
359        }
360    }
361    for (index, is_used) in used.into_iter().enumerate() {
362        if !is_used {
363            warnings.push(InteroperabilityWarning::UnusedLocalTimeType {
364                block: block_name,
365                index,
366            });
367        }
368    }
369}
370
371fn push_unused_designation_octet_warnings(
372    block_name: &'static str,
373    block: &DataBlock,
374    warnings: &mut Vec<InteroperabilityWarning>,
375) {
376    let mut used = vec![false; block.designations.len()];
377    for local_time_type in &block.local_time_types {
378        let start = usize::from(local_time_type.designation_index);
379        let Some(bytes) = block.designations.get(start..) else {
380            continue;
381        };
382        let Some(end) = bytes.iter().position(|&byte| byte == 0) else {
383            continue;
384        };
385        for is_used in used.iter_mut().skip(start).take(end + 1) {
386            *is_used = true;
387        }
388    }
389    for (index, is_used) in used.into_iter().enumerate() {
390        if !is_used {
391            warnings.push(InteroperabilityWarning::UnusedDesignationOctet {
392                block: block_name,
393                index,
394            });
395        }
396    }
397}
398
399fn push_negative_dst_warnings(
400    block_name: &'static str,
401    block: &DataBlock,
402    warnings: &mut Vec<InteroperabilityWarning>,
403) {
404    let standard_offsets: Vec<i32> = block
405        .local_time_types
406        .iter()
407        .filter(|local_time_type| !local_time_type.is_dst)
408        .map(|local_time_type| local_time_type.utc_offset)
409        .collect();
410    for daylight in block
411        .local_time_types
412        .iter()
413        .filter(|local_time_type| local_time_type.is_dst)
414    {
415        if let Some(&standard_offset) = standard_offsets
416            .iter()
417            .filter(|&&standard| daylight.utc_offset < standard)
418            .max()
419        {
420            warnings.push(
421                InteroperabilityWarning::DaylightOffsetLessThanStandardOffset {
422                    block: block_name,
423                    daylight_offset: daylight.utc_offset,
424                    standard_offset,
425                },
426            );
427        }
428    }
429}
430
431fn push_leap_second_offset_warnings(
432    block_name: &'static str,
433    block: &DataBlock,
434    warnings: &mut Vec<InteroperabilityWarning>,
435) {
436    if block.leap_seconds.is_empty() {
437        return;
438    }
439    for offset in block
440        .local_time_types
441        .iter()
442        .map(|local_time_type| local_time_type.utc_offset)
443        .filter(|offset| offset % 60 != 0)
444    {
445        warnings.push(InteroperabilityWarning::LeapSecondWithSubMinuteOffset {
446            block: block_name,
447            offset,
448        });
449    }
450}