diff --git a/crates/tiff/README.md b/crates/tiff/README.md index c51f3d45a..13fa56e15 100644 --- a/crates/tiff/README.md +++ b/crates/tiff/README.md @@ -13,6 +13,8 @@ on no other Craft crate. - IFD chain + `SubIFDs` (330) + Exif (34665) / GPS (34853) / Interoperability (40965) pointer IFDs, resolved into a tree (`Ifd::sub_ifds`, `exif`, `gps`, `interop`). `Entry::offset` records the absolute position of each value (needed for maker notes and embedded blobs). +- `Tiff::parse_with_status` returns the best-effort tree with a completeness flag for callers that must + distinguish a clean absence from an IFD, entry, or pointer that could not be inspected. - `parse_ifd_at(data, offset, order, base, bigtiff, opts)` parses an IFD whose offsets are relative to an arbitrary base; `makernote::parse_makernote` knows the header / byte-order / offset-base conventions of Canon, Nikon (types 1 and 3), Sony, Fujifilm, Olympus (old and new), Panasonic and Pentax notes. @@ -36,7 +38,9 @@ overlap at distinct offsets, and an IFD past that bound ends the chain or is ski the first; a next-IFD pointer is followed only when every declared entry was read, with the counts compared as 64-bit numbers (a BigTIFF declaring 4,294,967,297 entries but holding one can't pass for a whole directory on a 32-bit target); the total bytes of decoded values are capped at -`8 × input + 1 MiB` so a file cannot make us allocate far more than its own size. +`8 × input + 1 MiB` so a file cannot make us allocate far more than its own size. `parse_with_status` reports +truncated tables, skipped values, unresolved pointers and incomplete IFD chains without changing the parser's +best-effort tree. ## Sources diff --git a/crates/tiff/src/lib.rs b/crates/tiff/src/lib.rs index 04ca9b8d7..bf8247355 100644 --- a/crates/tiff/src/lib.rs +++ b/crates/tiff/src/lib.rs @@ -174,7 +174,8 @@ impl FieldType { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum Value { Byte(Vec), - /// ASCII text (bytes up to the first NUL, decoded lossily as UTF-8). + /// ASCII text decoded lossily as UTF-8, without the terminating NULs. A field holding several strings keeps + /// them NUL-separated ([`Value::as_strings`]). Ascii(String), Short(Vec), Long(Vec), @@ -307,11 +308,11 @@ impl Value { } } - /// Text for ASCII values (trimmed of trailing NULs / spaces); `Byte`/`Undefined` are decoded as UTF-8 (lossy) - /// up to the first NUL, which covers XMP packets and UCS-less Exif strings. + /// Text for ASCII values (the first string, trimmed of trailing spaces); `Byte`/`Undefined` are decoded as + /// UTF-8 (lossy) up to the first NUL, which covers XMP packets and UCS-less Exif strings. pub fn as_str(&self) -> Option { match self { - Value::Ascii(s) => Some(s.trim_end_matches([' ', '\0']).to_string()), + Value::Ascii(s) => Some(s.split('\0').next().unwrap_or_default().trim_end().to_string()), Value::Byte(b) | Value::Undefined(b) => { let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); Some(String::from_utf8_lossy(&b[..end]).trim_end().to_string()) @@ -320,11 +321,20 @@ impl Value { } } - /// Raw bytes for `Byte` / `Undefined` / `SByte` / `Ascii`. + /// Every NUL-separated string of an ASCII value, each trimmed of trailing spaces (empty ones dropped); other + /// types give what [`Self::as_str`] gives. + pub fn as_strings(&self) -> Vec { + match self { + Value::Ascii(s) => s.split('\0').map(|p| p.trim_end().to_string()).filter(|p| !p.is_empty()).collect(), + _ => self.as_str().into_iter().filter(|p| !p.is_empty()).collect(), + } + } + + /// Raw bytes for `Byte` / `Undefined` / `SByte` / `Ascii` (an ASCII value's first string only). pub fn as_bytes(&self) -> Option<&[u8]> { match self { Value::Byte(b) | Value::Undefined(b) => Some(b), - Value::Ascii(s) => Some(s.as_bytes()), + Value::Ascii(s) => s.as_bytes().split(|&c| c == 0).next(), _ => None, } } @@ -440,6 +450,13 @@ impl Tiff { pub fn parse_with(data: &[u8], opts: &ParseOptions) -> Result { reader::parse(data, opts) } + /// Parse a TIFF stream and report whether the complete reachable IFD structure was read. + /// + /// The parsed value retains the reader's best-effort behavior. `complete` is false when an + /// IFD, entry value, pointer target, or IFD-chain link could not be fully inspected. + pub fn parse_with_status(data: &[u8], opts: &ParseOptions) -> Result<(Tiff, bool)> { + reader::parse_with_status(data, opts) + } /// Header check only: returns the byte order and BigTIFF flag. pub fn sniff(data: &[u8]) -> Option<(ByteOrder, bool)> { reader::header(data).ok().map(|(o, b, _)| (o, b)) diff --git a/crates/tiff/src/reader.rs b/crates/tiff/src/reader.rs index 70c66c5a7..3c4c95a9c 100644 --- a/crates/tiff/src/reader.rs +++ b/crates/tiff/src/reader.rs @@ -1,7 +1,7 @@ //! Bounds-checked IFD parsing. use crate::{ByteOrder, Entry, FieldType, Ifd, Result, Tiff, TiffError, Value, tags}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; /// Limits applied while parsing. The defaults are generous for real files and safe for hostile ones. #[derive(Clone, Debug)] @@ -173,11 +173,22 @@ fn header_with(data: &[u8], raw_magic: bool) -> Result<(ByteOrder, bool, u64)> { } pub(crate) fn parse(data: &[u8], opts: &ParseOptions) -> Result { + parse_with_status(data, opts).map(|(tiff, _)| tiff) +} + +pub(crate) fn parse_with_status(data: &[u8], opts: &ParseOptions) -> Result<(Tiff, bool)> { let (order, big, first) = header_with(data, opts.accept_raw_magic)?; let mut ctx = Ctx::new(data, order, big, opts); let mut ifds = Vec::new(); let mut off = first; + // the top-level chain stops at an IFD it already holds (a `next` pointer looping back) + let mut chain = HashSet::new(); while off != 0 { + if !chain.insert(off) { + // a next pointer looping back: the chain as written has no end + ctx.complete = false; + break; + } match ctx.ifd(off, 0, 0) { Ok((ifd, next)) => { ifds.push(ifd); @@ -187,6 +198,7 @@ pub(crate) fn parse(data: &[u8], opts: &ParseOptions) -> Result { if ifds.is_empty() { return Err(e); } + ctx.complete = false; break; } } @@ -194,7 +206,7 @@ pub(crate) fn parse(data: &[u8], opts: &ParseOptions) -> Result { if ifds.is_empty() { return Err(TiffError::Invalid("no IFDs".into())); } - Ok(Tiff { order, bigtiff: big, ifds }) + Ok((Tiff { order, bigtiff: big, ifds }, ctx.complete)) } /// Parse one IFD at absolute `offset` whose value offsets (and child IFD pointers) are relative to @@ -210,15 +222,41 @@ struct Ctx<'a> { order: ByteOrder, big: bool, opts: &'a ParseOptions, - visited: HashSet, + /// Offsets of the IFDs being parsed (the current path from the root): a child pointing at one of them is a + /// cycle. + ancestors: Vec, + /// IFDs produced so far, counting every copy of a shared one (bounded by `max_ifds`). + visits: usize, + /// IFDs already parsed, by (offset, base, depth): a second pointer to one (two `SubIFDs` entries, or an Exif + /// pointer shared by two directories) gets a copy instead of being dropped. Keyed by depth too, so a copy has + /// exactly the children a fresh parse at that depth would have; a copy is charged the IFDs, entries and value + /// bytes of the first parse, so copies stay inside every limit. + parsed: HashMap<(u64, u64, usize), Parsed>, budget: u64, entries_total: usize, + /// False once an IFD, entry, pointer target or chain link could not be fully inspected. + complete: bool, +} + +/// What parsing one IFD and its children used up: a copy of it is charged the same. +#[derive(Clone, Copy)] +struct Cost { + ifds: usize, + entries: usize, + bytes: u64, +} + +/// An IFD parsed once, kept for later pointers to it. +struct Parsed { + ifd: Ifd, + next: u64, + cost: Cost, } impl<'a> Ctx<'a> { fn new(data: &'a [u8], order: ByteOrder, big: bool, opts: &'a ParseOptions) -> Self { let budget = opts.budget_for(data.len()); - Self { data, order, big, opts, visited: HashSet::new(), budget, entries_total: 0 } + Self { data, order, big, opts, ancestors: Vec::new(), visits: 0, parsed: HashMap::new(), budget, entries_total: 0, complete: true } } fn read_off(&self, pos: usize) -> Option { @@ -230,13 +268,51 @@ impl<'a> Ctx<'a> { if depth > self.opts.max_depth { return Err(TiffError::Limit("IFD nesting depth")); } - if self.visited.len() >= self.opts.max_ifds { - return Err(TiffError::Limit("IFD count")); - } - if !self.visited.insert(offset) { + if self.ancestors.contains(&offset) { return Err(TiffError::Invalid(format!("IFD loop at {offset}"))); } + let key = (offset, base, depth); + if let Some(c) = self.parsed.get(&key) { + // a copy costs what the first parse did: its IFDs, entries and value bytes + let visits = self.visits.saturating_add(c.cost.ifds); + if visits > self.opts.max_ifds { + return Err(TiffError::Limit("IFD count")); + } + let entries = self.entries_total.saturating_add(c.cost.entries); + if entries > self.opts.max_entries_total { + return Err(TiffError::Limit("entry count")); + } + if c.cost.bytes > self.budget { + return Err(TiffError::Limit("value bytes")); + } + self.visits = visits; + self.entries_total = entries; + self.budget -= c.cost.bytes; + return Ok((c.ifd.clone(), c.next)); + } + if self.visits >= self.opts.max_ifds { + return Err(TiffError::Limit("IFD count")); + } + let (visits0, entries0, budget0) = (self.visits, self.entries_total, self.budget); + self.visits = self.visits.saturating_add(1); + self.ancestors.push(offset); + let r = self.parse_dir(offset, base, depth); + self.ancestors.pop(); + let (ifd, next) = r?; + let cost = Cost { + ifds: self.visits.saturating_sub(visits0), + entries: self.entries_total.saturating_sub(entries0), + bytes: budget0.saturating_sub(self.budget), + }; + self.parsed.insert(key, Parsed { ifd: ifd.clone(), next, cost }); + Ok((ifd, next)) + } + + fn parse_dir(&mut self, offset: u64, base: u64, depth: usize) -> Result<(Ifd, u64)> { let dir = directory_entries(self.data, offset, self.order, self.big, self.opts.max_entries)?; + if !reads_every_entry(dir.declared, dir.read) { + self.complete = false; + } // Bound total entries across the whole parse so overlapping IFD tables (SubIFD pointers // shifted by one entry) cannot allocate millions of `Entry`s from a small input. self.entries_total = self.entries_total.saturating_add(dir.read); @@ -247,23 +323,41 @@ impl<'a> Ctx<'a> { for p in dir.positions() { if let Some(e) = self.entry(p, base) { entries.push(e); + } else { + self.complete = false; } } entries.sort_by_key(|e| e.tag); + if entries.windows(2).any(|pair| pair[0].tag == pair[1].tag) { + self.complete = false; + } entries.dedup_by_key(|e| e.tag); // the next-IFD pointer follows the last entry, read only when every declared entry was (checked: a BigTIFF // count can be anything up to u64::MAX) let next = if reads_every_entry(dir.declared, dir.read) { match dir.read.checked_mul(dir.entry_size).and_then(|n| n.checked_add(dir.table)).and_then(|p| self.read_off(p)) { - Some(0) | None => 0, - Some(v) => base.checked_add(v).unwrap_or(0), + Some(0) => 0, + None => { + self.complete = false; + 0 + } + Some(v) => match base.checked_add(v) { + Some(next) => next, + None => { + self.complete = false; + 0 + } + }, } } else { + self.complete = false; 0 }; let mut ifd = Ifd { offset, entries, ..Default::default() }; if self.opts.follow_children { self.children(&mut ifd, base, depth); + } else if [tags::SUB_IFDS, tags::EXIF_IFD, tags::GPS_IFD, tags::INTEROP_IFD].into_iter().any(|tag| ifd.contains(tag)) { + self.complete = false; } Ok((ifd, next)) } @@ -271,27 +365,46 @@ impl<'a> Ctx<'a> { fn children(&mut self, ifd: &mut Ifd, base: u64, depth: usize) { if let Some(v) = ifd.value(tags::SUB_IFDS) { let offs = v.to_u64_vec(); + if offs.len() != v.count() { + self.complete = false; + } for o in offs { - if let Some(abs) = base.checked_add(o) - && let Ok((sub, _)) = self.ifd(abs, base, depth + 1) - { - ifd.sub_ifds.push(sub); + match base.checked_add(o).and_then(|abs| self.ifd(abs, base, depth + 1).ok().map(|(sub, _)| sub)) { + Some(sub) => ifd.sub_ifds.push(sub), + None => self.complete = false, } } } - let child = |tag: u16, ctx: &mut Self| -> Option> { - let o = ifd.value(tag)?.get_u64(0)?; - let abs = base.checked_add(o)?; - ctx.ifd(abs, base, depth + 1).ok().map(|(i, _)| Box::new(i)) - }; - let exif = child(tags::EXIF_IFD, self); - let gps = child(tags::GPS_IFD, self); - let interop = child(tags::INTEROP_IFD, self); + let exif = self.child(ifd, tags::EXIF_IFD, base, depth); + let gps = self.child(ifd, tags::GPS_IFD, base, depth); + let interop = self.child(ifd, tags::INTEROP_IFD, base, depth); ifd.exif = exif; ifd.gps = gps; ifd.interop = interop; } + /// Follow one singleton child pointer and mark malformed or unreadable targets incomplete. + fn child(&mut self, ifd: &Ifd, tag: u16, base: u64, depth: usize) -> Option> { + if !ifd.contains(tag) { + return None; + } + let Some(value) = ifd.value(tag) else { + self.complete = false; + return None; + }; + if value.count() != 1 { + self.complete = false; + } + let parsed = value + .get_u64(0) + .and_then(|offset| base.checked_add(offset)) + .and_then(|offset| self.ifd(offset, base, depth + 1).ok().map(|(child, _)| Box::new(child))); + if parsed.is_none() { + self.complete = false; + } + parsed + } + fn entry(&mut self, p: usize, base: u64) -> Option { let span = entry_span(self.data, p, self.order, self.big, base)?; if span.len > self.budget { @@ -314,8 +427,10 @@ pub fn decode(o: ByteOrder, typ: FieldType, b: &[u8]) -> Value { FieldType::Byte => Value::Byte(b.to_vec()), FieldType::Undefined => Value::Undefined(b.to_vec()), FieldType::Ascii => { - let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); - Value::Ascii(String::from_utf8_lossy(&b[..end]).into_owned()) + // every NUL-separated string is kept (TIFF 6.0 allows several in one field); only the + // terminating NULs go + let end = b.iter().rposition(|&c| c != 0).map_or(0, |i| i.saturating_add(1)); + Value::Ascii(String::from_utf8_lossy(b.get(..end).unwrap_or_default()).into_owned()) } FieldType::SByte => Value::SByte(b.iter().map(|&x| x as i8).collect()), FieldType::Short => Value::Short(b.as_chunks::<2>().0.iter().map(|c| o.u16(arr(c))).collect()), @@ -356,6 +471,47 @@ mod tests { let tiff = Tiff::parse_with(&file, &opts).unwrap(); assert_eq!(tiff.ifds[0].string(t::MAKE), Some("Keep".to_string()), "a fitting entry is read"); assert!(tiff.ifds[0].get(0x9000).is_none(), "the over-budget entry is dropped"); + let (_, complete) = Tiff::parse_with_status(&file, &opts).unwrap(); + assert!(!complete, "dropping an entry must be reported as incomplete"); + } + + #[test] + fn parse_status_reports_truncated_tables_and_unreadable_children() { + let truncated = bigtiff_chain(ByteOrder::Little, 2, 0); + let (_, complete) = Tiff::parse_with_status(&truncated, &ParseOptions::default()).unwrap(); + assert!(!complete, "a declared entry omitted by a truncated table must be reported"); + + let mut unreadable_gps = b"II\x2a\0\x08\0\0\0".to_vec(); + unreadable_gps.extend_from_slice(&1u16.to_le_bytes()); + unreadable_gps.extend_from_slice(&tags::GPS_IFD.to_le_bytes()); + unreadable_gps.extend_from_slice(&4u16.to_le_bytes()); + unreadable_gps.extend_from_slice(&1u32.to_le_bytes()); + unreadable_gps.extend_from_slice(&u32::MAX.to_le_bytes()); + unreadable_gps.extend_from_slice(&0u32.to_le_bytes()); + let (_, complete) = Tiff::parse_with_status(&unreadable_gps, &ParseOptions::default()).unwrap(); + assert!(!complete, "a dangling GPS pointer must be reported"); + + let valid_ifd = IfdBuilder::new().with(tags::MAKE, Value::Ascii("Camera".into())); + let valid = TiffWriter::default().write(&[valid_ifd]).unwrap(); + let (_, complete) = Tiff::parse_with_status(&valid, &ParseOptions::default()).unwrap(); + assert!(complete, "a fully read TIFF must report complete"); + } + + /// Completeness is false when callers skip child metadata or a singleton pointer is malformed. + #[test] + fn parse_status_reports_skipped_and_ambiguous_child_pointers() { + let child = IfdBuilder::new().with(t::GPS_LATITUDE, Value::Rational(vec![(45, 1), (0, 1), (0, 1)])); + let mut root = IfdBuilder::new(); + root.set_child(t::GPS_IFD, child); + let with_child = TiffWriter::default().write(&[root]).unwrap(); + let opts = ParseOptions { follow_children: false, ..Default::default() }; + let (_, complete) = Tiff::parse_with_status(&with_child, &opts).unwrap(); + assert!(!complete, "an intentionally unvisited GPS IFD is incomplete"); + + let ambiguous = IfdBuilder::new().with(t::GPS_IFD, Value::Long(vec![8, 8])); + let ambiguous = TiffWriter::default().write(&[ambiguous]).unwrap(); + let (_, complete) = Tiff::parse_with_status(&ambiguous, &ParseOptions::default()).unwrap(); + assert!(!complete, "a pointer field with multiple offsets is ambiguous"); } /// A BigTIFF whose first IFD, at 80, declares `declared` entries but holds one, followed by a next-IFD pointer @@ -381,6 +537,101 @@ mod tests { d } + /// A classic little-endian TIFF: IFD0 at 8 with `SubIFDs` = [`a`, `b`] and Exif → `exif`; a one-entry IFD + /// (tag 0x0101 = its offset) at 38 and at 56; `IFD0`'s next pointer is `next`. + fn shared_children(a: u32, b: u32, exif: u32, next: u32) -> Vec { + let o = ByteOrder::Little; + let mut d = b"II*\0\x08\0\0\0".to_vec(); + o.put_u16(&mut d, 2); + // SubIFDs: two LONGs (8 bytes) stored out of line at 74 + o.put_u16(&mut d, 330); + o.put_u16(&mut d, 4); + d.extend(2u32.to_le_bytes()); + d.extend(74u32.to_le_bytes()); + o.put_u16(&mut d, 34665); + o.put_u16(&mut d, 4); + d.extend(1u32.to_le_bytes()); + d.extend(exif.to_le_bytes()); + d.extend(next.to_le_bytes()); + for at in [38u32, 56] { + d.resize(at as usize, 0); + o.put_u16(&mut d, 1); + o.put_u16(&mut d, 0x0101); + o.put_u16(&mut d, 4); + d.extend(1u32.to_le_bytes()); + d.extend(at.to_le_bytes()); + d.extend(0u32.to_le_bytes()); + } + d.resize(74, 0); + d.extend(a.to_le_bytes()); + d.extend(b.to_le_bytes()); + d + } + + #[test] + fn a_child_ifd_referenced_twice_is_kept_both_times() { + let t = Tiff::parse(&shared_children(38, 38, 38, 0)).unwrap(); + let ifd = &t.ifds[0]; + assert_eq!(ifd.sub_ifds.len(), 2, "the second SubIFDs pointer to the same IFD used to be dropped"); + assert!(ifd.sub_ifds.iter().all(|s| s.u64(0x0101) == Some(38))); + assert_eq!(ifd.exif.as_ref().and_then(|e| e.u64(0x0101)), Some(38)); + // distinct children still parse as before + let t = Tiff::parse(&shared_children(38, 56, 56, 0)).unwrap(); + let subs: Vec<_> = t.ifds[0].sub_ifds.iter().map(|s| s.u64(0x0101)).collect(); + assert_eq!(subs, vec![Some(38), Some(56)]); + // a chain IFD that is also a child is parsed in the chain too + let t = Tiff::parse(&shared_children(38, 56, 56, 56)).unwrap(); + assert_eq!(t.ifds.len(), 2); + assert_eq!(t.ifds[1].u64(0x0101), Some(56)); + } + + #[test] + fn parse_status_counts_a_shared_child_as_read_and_a_looping_chain_as_incomplete() { + let (_, complete) = Tiff::parse_with_status(&shared_children(38, 38, 38, 0), &ParseOptions::default()).unwrap(); + assert!(complete, "every pointer to the shared child was followed"); + let (t, complete) = Tiff::parse_with_status(&shared_children(38, 56, 56, 8), &ParseOptions::default()).unwrap(); + assert_eq!(t.ifds.len(), 1); + assert!(!complete, "a next pointer back to the first IFD is a chain without an end"); + } + + #[test] + fn a_child_pointing_at_its_parent_is_still_a_loop() { + let t = Tiff::parse(&shared_children(8, 38, 8, 8)).unwrap(); + assert_eq!(t.ifds.len(), 1); + assert_eq!(t.ifds[0].sub_ifds.len(), 1); + assert!(t.ifds[0].exif.is_none()); + } + + #[test] + fn shared_children_count_against_the_ifd_limit() { + let opts = ParseOptions { max_ifds: 2, ..Default::default() }; + let t = Tiff::parse_with(&shared_children(38, 38, 38, 0), &opts).unwrap(); + assert_eq!(t.ifds[0].sub_ifds.len(), 1); + assert!(t.ifds[0].exif.is_none()); + } + + /// Hostile input: many pointers to one shared IFD must not clone past the entry limit (each child has one + /// entry, so three copies need three entries plus the root's two). + #[test] + fn shared_children_count_against_the_entry_limit() { + let opts = ParseOptions { max_entries_total: 4, ..Default::default() }; + let t = Tiff::parse_with(&shared_children(38, 38, 38, 0), &opts).unwrap(); + assert_eq!(t.ifds[0].sub_ifds.len(), 2); + assert!(t.ifds[0].exif.is_none(), "the third copy would pass the entry limit"); + } + + #[test] + fn ascii_keeps_every_nul_separated_string() { + let v = decode(ByteOrder::Little, FieldType::Ascii, b"one\0two\0\0"); + assert_eq!(v, Value::Ascii("one\0two".into())); + assert_eq!(v.as_str().as_deref(), Some("one")); + assert_eq!(v.as_strings(), vec!["one".to_string(), "two".to_string()]); + assert_eq!(v.as_bytes(), Some(&b"one"[..]), "byte readers see the first string, as before"); + assert_eq!(decode(ByteOrder::Little, FieldType::Ascii, b"\0\0"), Value::Ascii(String::new())); + assert!(decode(ByteOrder::Little, FieldType::Ascii, b"\0\0").as_strings().is_empty()); + assert_eq!(decode(ByteOrder::Little, FieldType::Ascii, b"abc").as_str().as_deref(), Some("abc")); + } + #[test] fn only_a_directory_read_whole_has_a_next_pointer() { // the comparison itself, on every target: a declared count past u32::MAX never matches a small read count diff --git a/crates/tiff/src/tags.rs b/crates/tiff/src/tags.rs index 458aefe53..14ce2fb82 100644 --- a/crates/tiff/src/tags.rs +++ b/crates/tiff/src/tags.rs @@ -44,6 +44,8 @@ pub const CFA_PATTERN_EP: u16 = 33422; pub const COPYRIGHT: u16 = 33432; pub const EXPOSURE_TIME_EP: u16 = 33434; pub const IPTC_NAA: u16 = 33723; +/// Photoshop image resources embedded in TIFF tag 34377. +pub const PHOTOSHOP_IMAGE_RESOURCES: u16 = 34377; pub const EXIF_IFD: u16 = 34665; pub const ICC_PROFILE: u16 = 34675; pub const GPS_IFD: u16 = 34853; @@ -100,6 +102,8 @@ pub const GPS_ALTITUDE: u16 = 6; pub const GPS_TIME_STAMP: u16 = 7; pub const GPS_IMG_DIRECTION_REF: u16 = 16; pub const GPS_IMG_DIRECTION: u16 = 17; +pub const GPS_DEST_LATITUDE: u16 = 20; +pub const GPS_DEST_LONGITUDE: u16 = 22; pub const GPS_DATE_STAMP: u16 = 29; // --- DNG