dicom_anonymization/actions/
hash.rs

1use dicom_core::header::Header;
2use dicom_core::{DataElement, PrimitiveValue};
3use dicom_object::mem::InMemElement;
4use dicom_object::DefaultDicomObject;
5use std::borrow::Cow;
6use thiserror::Error;
7
8use crate::actions::errors::ActionError;
9use crate::actions::utils::{is_empty_element, truncate_to};
10use crate::actions::DataElementAction;
11use crate::config::{Config, ConfigError};
12use crate::dicom;
13use crate::hasher::HashFn;
14
15pub const HASH_LENGTH_MINIMUM: usize = 8;
16
17#[derive(Error, Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
18#[error("{0}")]
19pub struct HashLengthError(String);
20
21/// A newtype wrapper for specifying the length of a hash value.
22/// The internal value represents the number of characters the hash should be.
23#[derive(Debug, Clone, Copy, PartialEq)]
24pub struct HashLength(pub usize);
25
26impl HashLength {
27    /// Creates a new [`HashLength`] instance.
28    ///
29    /// # Arguments
30    /// * `length` - The desired length of the hash in characters
31    ///
32    /// # Returns
33    /// * `Ok(HashLength)` if length is valid (>= `HASH_LENGTH_MINIMUM`, which is `8`)
34    /// * `Err(HashLengthError)` if length is too short
35    pub fn new(length: usize) -> Result<Self, HashLengthError> {
36        if length < HASH_LENGTH_MINIMUM {
37            return Err(HashLengthError(format!(
38                "hash length must be at least {}",
39                HASH_LENGTH_MINIMUM
40            )));
41        }
42        Ok(HashLength(length))
43    }
44}
45
46impl From<HashLengthError> for ConfigError {
47    fn from(err: HashLengthError) -> Self {
48        ConfigError::InvalidHashLength(err.0)
49    }
50}
51
52impl TryFrom<usize> for HashLength {
53    type Error = HashLengthError;
54
55    fn try_from(value: usize) -> Result<Self, HashLengthError> {
56        let hash_length = HashLength::new(value)?;
57        Ok(hash_length)
58    }
59}
60
61#[derive(Debug, Clone, PartialEq)]
62pub struct Hash {
63    length: Option<HashLength>,
64}
65
66impl Hash {
67    pub fn new(length: Option<HashLength>) -> Self {
68        Self { length }
69    }
70
71    fn anonymize(
72        &self,
73        hash_fn: HashFn,
74        value: &str,
75        max_length: Option<usize>,
76    ) -> Result<String, ActionError> {
77        let anonymized_value = hash_fn(value)?;
78
79        let length = match self.length {
80            Some(length) => match max_length {
81                Some(max_length) if max_length < length.0 => Some(HashLength(max_length)),
82                _ => Some(HashLength(length.0)),
83            },
84            None => max_length.map(HashLength),
85        };
86
87        let result = match length {
88            Some(length) => truncate_to(length.0, &anonymized_value.to_string()),
89            None => anonymized_value.to_string(),
90        };
91
92        Ok(result)
93    }
94}
95
96impl Default for Hash {
97    fn default() -> Self {
98        Self::new(None)
99    }
100}
101
102impl DataElementAction for Hash {
103    fn process<'a>(
104        &'a self,
105        config: &Config,
106        _obj: &DefaultDicomObject,
107        elem: &'a InMemElement,
108    ) -> Result<Option<Cow<'a, InMemElement>>, ActionError> {
109        if is_empty_element(elem) {
110            return Ok(Some(Cow::Borrowed(elem)));
111        }
112
113        let hash_fn = config.get_hash_fn();
114        let max_length = dicom::max_length_for_vr(elem.vr());
115        let elem_value = elem.value().string()?;
116        let anonymized_value = self.anonymize(hash_fn, elem_value, max_length)?;
117
118        let new_elem = DataElement::new::<PrimitiveValue>(
119            elem.tag(),
120            elem.vr(),
121            PrimitiveValue::from(anonymized_value),
122        );
123        Ok(Some(Cow::Owned(new_elem)))
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    use dicom_core::header::HasLength;
132    use dicom_core::value::Value;
133    use dicom_core::{header, VR};
134    use dicom_object::FileDicomObject;
135
136    use crate::hasher::blake3_hash_fn;
137    use crate::tags;
138    use crate::test_utils::make_file_meta;
139
140    #[test]
141    fn test_process_without_length() {
142        let mut obj = FileDicomObject::new_empty_with_meta(make_file_meta());
143        let elem = InMemElement::new(
144            tags::ACCESSION_NUMBER,
145            VR::SH,
146            Value::from("0123456789ABCDEF"),
147        );
148        obj.put(elem.clone());
149
150        let action_struct = Hash::default();
151        let config = Config::default();
152        let processed = action_struct.process(&config, &obj, &elem).unwrap();
153
154        // no length given, so 16 should been used for length, because that is the maximum length for VR `SH`
155        assert_eq!(processed.unwrap().value().length(), header::Length(16));
156    }
157
158    #[test]
159    fn test_process_with_length() {
160        let mut obj = FileDicomObject::new_empty_with_meta(make_file_meta());
161        let elem = InMemElement::new(
162            tags::ACCESSION_NUMBER,
163            VR::SH,
164            Value::from("0123456789ABCDEF"),
165        );
166        obj.put(elem.clone());
167
168        let action_struct = Hash::new(Some(HashLength(10)));
169        let config = Config::default();
170        let processed = action_struct.process(&config, &obj, &elem).unwrap();
171        assert_eq!(processed.unwrap().value().length(), header::Length(10));
172    }
173
174    #[test]
175    fn test_process_with_length_exceeding_max_length() {
176        let mut obj = FileDicomObject::new_empty_with_meta(make_file_meta());
177        let elem = InMemElement::new(
178            tags::ACCESSION_NUMBER,
179            VR::SH,
180            Value::from("0123456789ABCDEF"),
181        );
182        obj.put(elem.clone());
183
184        let action_struct = Hash::new(Some(HashLength(32)));
185        let config = Config::default();
186        let processed = action_struct.process(&config, &obj, &elem).unwrap();
187        assert_eq!(processed.unwrap().value().length(), header::Length(16));
188    }
189
190    #[test]
191    fn test_process_empty_element() {
192        let mut obj = FileDicomObject::new_empty_with_meta(make_file_meta());
193        let elem = InMemElement::new(
194            tags::ACCESSION_NUMBER,
195            VR::SH,
196            Value::Primitive(PrimitiveValue::Empty),
197        );
198        obj.put(elem.clone());
199
200        let action_struct = Hash::new(Some(HashLength(8)));
201        let config = Config::default();
202        let processed = action_struct.process(&config, &obj, &elem).unwrap();
203        assert_eq!(processed.unwrap().into_owned(), elem);
204    }
205
206    #[test]
207    fn test_anonymize_no_length() {
208        let value = "203087";
209        let hash_fn = blake3_hash_fn;
210        let action_struct = Hash::default();
211        let result = action_struct.anonymize(hash_fn, value, None);
212        assert!(!result.unwrap().is_empty());
213    }
214
215    #[test]
216    fn test_anonymize_with_length() {
217        let value = "203087";
218        let hash_fn = blake3_hash_fn;
219        let action_struct = Hash::new(Some(HashLength(10)));
220        let result = action_struct.anonymize(hash_fn, value, None);
221        assert_eq!(result.unwrap().len(), 10);
222    }
223
224    #[test]
225    fn test_hash_length() {
226        assert_eq!(HashLength::new(9).unwrap().0, 9);
227    }
228
229    #[test]
230    fn test_hash_length_new() {
231        assert!(HashLength::new(9).is_ok());
232        assert!(HashLength::new(8).is_ok());
233        assert!(HashLength::new(7).is_err());
234    }
235
236    #[test]
237    fn test_hash_length_try_into() {
238        assert!(<usize as TryInto<HashLength>>::try_into(9).is_ok());
239        assert!(<usize as TryInto<HashLength>>::try_into(8).is_ok());
240        assert!(<usize as TryInto<HashLength>>::try_into(7).is_err());
241    }
242
243    #[test]
244    fn test_hash_length_error() {
245        let result = HashLength::new(7);
246        assert!(result.is_err());
247        let error = result.unwrap_err();
248        assert_eq!(error.to_string(), "hash length must be at least 8");
249    }
250}