dicom_anonymization/actions/
hash.rs1use 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#[derive(Debug, Clone, Copy, PartialEq)]
24pub struct HashLength(pub usize);
25
26impl HashLength {
27 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 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}