dicom_anonymization/
processor.rs

1use dicom_core::header::Header;
2use dicom_core::value::{CastValueError, DataSetSequence};
3use dicom_core::{DicomValue, VR};
4use dicom_object::mem::{InMemDicomObject, InMemElement};
5use dicom_object::{AccessError, DefaultDicomObject};
6use log::warn;
7use std::borrow::Cow;
8use thiserror::Error;
9
10use crate::actions::errors::ActionError;
11use crate::config::Config;
12
13#[derive(Error, Debug, PartialEq)]
14pub enum Error {
15    #[error("Value error: {}", .0.to_lowercase())]
16    ValueError(String),
17
18    #[error("Element error: {}", .0.to_lowercase())]
19    ElementError(String),
20
21    #[error("Anonymization error: {}", .0.to_lowercase())]
22    AnonymizationError(String),
23}
24
25impl From<CastValueError> for Error {
26    fn from(err: CastValueError) -> Self {
27        Error::ValueError(format!("{err}"))
28    }
29}
30
31impl From<AccessError> for Error {
32    fn from(err: AccessError) -> Self {
33        Error::ElementError(format!("{err}"))
34    }
35}
36
37impl From<ActionError> for Error {
38    fn from(err: ActionError) -> Self {
39        Error::AnonymizationError(format!("{err}"))
40    }
41}
42
43pub type Result<T, E = Error> = std::result::Result<T, E>;
44
45pub trait Processor {
46    fn process_element<'a>(
47        &'a self,
48        obj: &DefaultDicomObject,
49        elem: &'a InMemElement,
50    ) -> Result<Option<Cow<'a, InMemElement>>>;
51}
52
53/// DefaultProcessor is responsible for applying anonymization rules to DICOM elements
54///
55/// This processor uses a provided configuration to determine which anonymization
56/// actions should be applied to each DICOM element. It can process both individual
57/// elements and recursively handle sequence elements.
58#[derive(Debug, Clone, PartialEq)]
59pub struct DefaultProcessor {
60    config: Config,
61}
62
63impl DefaultProcessor {
64    /// Creates a new instance of DefaultProcessor
65    ///
66    /// # Arguments
67    ///
68    /// * `config` - Configuration containing anonymization rules
69    ///
70    /// # Returns
71    ///
72    /// A new DefaultProcessor instance initialized with the provided configuration
73    pub fn new(config: Config) -> Self {
74        Self { config }
75    }
76
77    /// Process a sequence element by recursively processing each item in the sequence
78    ///
79    /// Takes a DICOM sequence element and applies the configured anonymization rules to each
80    /// element within each item of the sequence.
81    ///
82    /// # Arguments
83    ///
84    /// * `obj` - Reference to the parent DICOM object
85    /// * `seq_elem` - Reference to the sequence element to be processed
86    ///
87    /// # Returns
88    ///
89    /// Returns a `Result` containing:
90    /// * `Some(Cow<InMemElement>)` - The processed sequence element
91    /// * `None` - If the sequence should be removed (all items were empty after processing)
92    /// * `Err` - If there was an error processing the sequence
93    fn process_sequence<'a>(
94        &self,
95        obj: &DefaultDicomObject,
96        seq_elem: &InMemElement,
97    ) -> Result<Option<Cow<'a, InMemElement>>> {
98        let DicomValue::Sequence(sequence) = seq_elem.value() else {
99            // not a sequence apparently, return as is
100            return Ok(Some(Cow::Owned(seq_elem.clone())));
101        };
102
103        let mut new_items: Vec<InMemDicomObject> = Vec::with_capacity(sequence.items().len());
104
105        for item in sequence.items() {
106            let mut new_item = InMemDicomObject::new_empty();
107
108            for elem in item {
109                if let Some(processed_elem) = self.process_element(obj, elem)? {
110                    new_item.put(processed_elem.into_owned());
111                }
112            }
113
114            if new_item.iter().count() > 0 {
115                new_items.push(new_item);
116            }
117        }
118
119        match new_items.is_empty() {
120            true => Ok(None),
121            false => Ok(Some(Cow::Owned(InMemElement::new(
122                seq_elem.tag(),
123                VR::SQ,
124                DataSetSequence::from(new_items),
125            )))),
126        }
127    }
128}
129
130impl Processor for DefaultProcessor {
131    /// Process a DICOM data element according to the configured anonymization rules
132    ///
133    /// Takes a DICOM object and one of its elements, applies the appropriate anonymization
134    /// action based on the configuration, and returns the result.
135    ///
136    /// # Arguments
137    ///
138    /// * `obj` - Reference to the DICOM object containing the element
139    /// * `elem` - Reference to the element to be processed
140    ///
141    /// # Returns
142    ///
143    /// Returns a `Result` containing:
144    /// * `Some(Cow<InMemElement>)` - The processed element
145    /// * `None` - If the element should be removed
146    /// * `Err` - If there was an error processing the element
147    fn process_element<'a>(
148        &'a self,
149        obj: &DefaultDicomObject,
150        elem: &'a InMemElement,
151    ) -> Result<Option<Cow<'a, InMemElement>>> {
152        let action = self.config.get_action(&elem.tag());
153        let action_struct = action.get_action_struct();
154        let process_result = action_struct.process(&self.config, obj, elem);
155
156        match process_result {
157            Ok(None) => Ok(None),
158            Ok(Some(processed_elem)) => match processed_elem.vr() {
159                VR::SQ => self.process_sequence(obj, &processed_elem),
160                _ => Ok(Some(Cow::Owned(processed_elem.into_owned()))),
161            },
162            Err(ActionError::InvalidHashDateTag(e)) => {
163                // return the element as is, but log a warning for this error
164                warn!("{}", e);
165                Ok(Some(Cow::Borrowed(elem)))
166            }
167            Err(e) => Err(Error::from(e)),
168        }
169    }
170}
171
172#[derive(Debug, Clone, PartialEq)]
173struct NoopProcessor;
174
175impl NoopProcessor {
176    fn new() -> Self {
177        Self {}
178    }
179}
180
181impl Default for NoopProcessor {
182    fn default() -> Self {
183        Self::new()
184    }
185}
186
187impl Processor for NoopProcessor {
188    fn process_element<'a>(
189        &'a self,
190        _obj: &DefaultDicomObject,
191        elem: &'a InMemElement,
192    ) -> Result<Option<Cow<'a, InMemElement>>> {
193        // just return it as is, without any changes
194        Ok(Some(Cow::Borrowed(elem)))
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    use dicom_core::header::HasLength;
203    use dicom_core::value::DataSetSequence;
204    use dicom_core::value::Value;
205    use dicom_core::Tag;
206    use dicom_core::{header, PrimitiveValue, VR};
207    use dicom_object::FileDicomObject;
208
209    use crate::actions::Action;
210    use crate::config::builder::ConfigBuilder;
211    use crate::tags;
212    use crate::test_utils::make_file_meta;
213
214    #[test]
215    fn test_process_element_hash_length() {
216        let meta = make_file_meta();
217        let mut obj = FileDicomObject::new_empty_with_meta(meta);
218
219        obj.put(InMemElement::new(
220            tags::ACCESSION_NUMBER,
221            VR::SH,
222            Value::from("0123456789ABCDEF"),
223        ));
224
225        let config = ConfigBuilder::new()
226            .tag_action(tags::ACCESSION_NUMBER, Action::Hash { length: None })
227            .build();
228
229        let elem = obj.element(tags::ACCESSION_NUMBER).unwrap();
230        let processor = DefaultProcessor::new(config);
231        let processed = processor.process_element(&obj, elem).unwrap();
232        assert_eq!(processed.unwrap().value().length(), header::Length(16));
233    }
234
235    #[test]
236    fn test_process_element_hash_max_length() {
237        let meta = make_file_meta();
238        let mut obj = FileDicomObject::new_empty_with_meta(meta);
239
240        obj.put(InMemElement::new(
241            tags::ACCESSION_NUMBER,
242            VR::SH,
243            Value::from("0123456789ABCDEF"),
244        ));
245
246        let config = ConfigBuilder::new()
247            .tag_action(tags::ACCESSION_NUMBER, Action::Hash { length: Some(32) })
248            .build();
249
250        let elem = obj.element(tags::ACCESSION_NUMBER).unwrap();
251        let processor = DefaultProcessor::new(config);
252        let processed = processor.process_element(&obj, elem).unwrap();
253        // new value length should have been cut off at the max length for SH VR, which is 16
254        assert_eq!(processed.unwrap().value().length(), header::Length(16));
255    }
256
257    #[test]
258    fn test_process_element_hash_length_with_value() {
259        let meta = make_file_meta();
260        let mut obj = FileDicomObject::new_empty_with_meta(meta);
261
262        obj.put(InMemElement::new(
263            tags::ACCESSION_NUMBER,
264            VR::SH,
265            Value::from("0123456789ABCDEF"),
266        ));
267
268        let config = ConfigBuilder::new()
269            .tag_action(tags::ACCESSION_NUMBER, Action::Hash { length: Some(8) })
270            .build();
271
272        let elem = obj.element(tags::ACCESSION_NUMBER).unwrap();
273        let processor = DefaultProcessor::new(config);
274        let processed = processor.process_element(&obj, elem).unwrap();
275        assert_eq!(processed.unwrap().value().length(), header::Length(8));
276    }
277
278    #[test]
279    fn test_process_element_hash_date_invalid_hash_date_tag_error() {
280        let meta = make_file_meta();
281        let mut obj = FileDicomObject::new_empty_with_meta(meta);
282
283        obj.put(InMemElement::new(
284            tags::STUDY_DATE,
285            VR::DA,
286            Value::from("20010102"),
287        ));
288
289        let config = ConfigBuilder::new()
290            .tag_action(tags::STUDY_DATE, Action::HashDate)
291            .build();
292
293        let elem = obj.element(tags::STUDY_DATE).unwrap();
294        let processor = DefaultProcessor::new(config);
295        let processed = processor.process_element(&obj, elem).unwrap();
296
297        // element should be returned as is because the `PatientID` tag is not in the DICOM object
298        assert_eq!(&processed.unwrap().into_owned(), elem);
299    }
300
301    #[test]
302    fn test_process_element_replace() {
303        let meta = make_file_meta();
304        let mut obj = FileDicomObject::new_empty_with_meta(meta);
305
306        obj.put(InMemElement::new(
307            tags::PATIENT_NAME,
308            VR::PN,
309            Value::from("John Doe"),
310        ));
311
312        let config = ConfigBuilder::new()
313            .tag_action(
314                tags::PATIENT_NAME,
315                Action::Replace {
316                    value: "Jane Doe".into(),
317                },
318            )
319            .build();
320
321        let elem = obj.element(tags::PATIENT_NAME).unwrap();
322        let processor = DefaultProcessor::new(config);
323        let processed = processor.process_element(&obj, elem).unwrap();
324        assert_eq!(processed.unwrap().value(), &Value::from("Jane Doe"));
325    }
326
327    #[test]
328    fn test_process_element_keep() {
329        let meta = make_file_meta();
330        let mut obj = FileDicomObject::new_empty_with_meta(meta);
331
332        obj.put(InMemElement::new(
333            tags::PATIENT_NAME,
334            VR::PN,
335            Value::from("John Doe"),
336        ));
337
338        let config = ConfigBuilder::new()
339            .tag_action(tags::PATIENT_NAME, Action::Keep)
340            .build();
341
342        let elem = obj.element(tags::PATIENT_NAME).unwrap();
343        let processor = DefaultProcessor::new(config);
344        let processed = processor.process_element(&obj, elem).unwrap();
345        assert_eq!(&processed.unwrap().into_owned(), elem);
346    }
347
348    #[test]
349    fn test_process_element_empty() {
350        let meta = make_file_meta();
351        let mut obj = FileDicomObject::new_empty_with_meta(meta);
352
353        obj.put(InMemElement::new(
354            tags::PATIENT_NAME,
355            VR::PN,
356            Value::from("John Doe"),
357        ));
358
359        let config = ConfigBuilder::new()
360            .tag_action(tags::PATIENT_NAME, Action::Empty)
361            .build();
362
363        let elem = obj.element(tags::PATIENT_NAME).unwrap();
364        let processor = DefaultProcessor::new(config);
365        let processed = processor.process_element(&obj, elem).unwrap();
366        assert_eq!(
367            processed.unwrap().value(),
368            &Value::Primitive(PrimitiveValue::Empty)
369        );
370    }
371
372    #[test]
373    fn test_process_element_remove() {
374        let meta = make_file_meta();
375        let mut obj = FileDicomObject::new_empty_with_meta(meta);
376
377        obj.put(InMemElement::new(
378            tags::PATIENT_NAME,
379            VR::PN,
380            Value::from("John Doe"),
381        ));
382
383        let config = ConfigBuilder::new()
384            .tag_action(tags::PATIENT_NAME, Action::Remove)
385            .build();
386
387        let elem = obj.element(tags::PATIENT_NAME).unwrap();
388        let processor = DefaultProcessor::new(config);
389        let processed = processor.process_element(&obj, elem).unwrap();
390        assert_eq!(processed, None);
391    }
392
393    #[test]
394    fn test_noop_processor() {
395        let meta = make_file_meta();
396        let mut obj = FileDicomObject::new_empty_with_meta(meta);
397
398        obj.put(InMemElement::new(
399            tags::PATIENT_NAME,
400            VR::PN,
401            Value::from("John Doe"),
402        ));
403
404        let elem = obj.element(tags::PATIENT_NAME).unwrap();
405        let processor = NoopProcessor::new();
406        let processed = processor.process_element(&obj, elem).unwrap();
407        assert_eq!(processed.unwrap().into_owned(), elem.clone());
408    }
409
410    #[test]
411    fn test_process_sequence_replace_action_inside_item() {
412        let meta = make_file_meta();
413        let mut obj = FileDicomObject::new_empty_with_meta(meta);
414
415        let mut item = InMemDicomObject::new_empty();
416        item.put(InMemElement::new(
417            tags::PATIENT_NAME,
418            VR::PN,
419            Value::from("John Doe"),
420        ));
421
422        let seq_tag = Tag(0x0008, 0x1110);
423        let seq_value = Value::Sequence(DataSetSequence::from(vec![item]));
424        obj.put(InMemElement::new(seq_tag, VR::SQ, seq_value));
425
426        let config = ConfigBuilder::new()
427            .tag_action(
428                tags::PATIENT_NAME,
429                Action::Replace {
430                    value: "Jane Doe".into(),
431                },
432            )
433            .build();
434
435        let elem = obj.element(seq_tag).unwrap();
436        let processor = DefaultProcessor::new(config);
437        let processed = processor
438            .process_element(&obj, elem)
439            .unwrap()
440            .unwrap()
441            .into_owned();
442
443        if let Value::Sequence(seq) = processed.value() {
444            let pn_elem = seq.items()[0].element(tags::PATIENT_NAME).unwrap();
445            assert_eq!(pn_elem.value(), &Value::from("Jane Doe"));
446        } else {
447            panic!("Expected a sequence element");
448        }
449    }
450
451    #[test]
452    fn test_process_sequence_remove_whole_sequence_if_no_items_left() {
453        let meta = make_file_meta();
454        let mut obj = FileDicomObject::new_empty_with_meta(meta);
455
456        let mut item = InMemDicomObject::new_empty();
457        item.put(InMemElement::new(
458            tags::PATIENT_NAME,
459            VR::PN,
460            Value::from("John Doe"),
461        ));
462
463        let seq_tag = Tag(0x0008, 0x1110);
464        let seq_value = Value::Sequence(DataSetSequence::from(vec![item]));
465        obj.put(InMemElement::new(seq_tag, VR::SQ, seq_value));
466
467        let config = ConfigBuilder::new()
468            .tag_action(tags::PATIENT_NAME, Action::Remove)
469            .build();
470
471        let elem = obj.element(seq_tag).unwrap();
472        let processor = DefaultProcessor::new(config);
473        let processed = processor.process_element(&obj, elem).unwrap();
474
475        assert!(processed.is_none(), "Sequence should have been removed");
476    }
477}