File CVE-2026-32711.patch of Package python-pydicom

From 6414f01a053dff925578799f5a7208d2ae585e82 Mon Sep 17 00:00:00 2001
From: Darcy Mason <darcymason@gmail.com>
Date: Thu, 19 Mar 2026 14:33:11 -0400
Subject: [PATCH] Merge from fork - security fix in `FileSet`s * Raise
 PermissionError for bad file id * Add fixture to Ignore unneded warnings in
 tests ---------

Co-authored-by: mrbean-bremen
---
 doc/release_notes/index.rst  |   1 +
 doc/release_notes/v3.0.2.rst |   8 ++
 pyproject.toml               |  27 ++++++-
 src/pydicom/fileset.py       | 141 ++++++++++++++++++++++++++---------
 tests/conftest.py            |   8 ++
 tests/test_fileset.py        | 127 +++++++++++++++++++++++++++----
 6 files changed, 260 insertions(+), 52 deletions(-)
 create mode 100644 doc/release_notes/v3.0.2.rst

Index: pydicom-2.3.1/pydicom/fileset.py
===================================================================
--- pydicom-2.3.1.orig/pydicom/fileset.py
+++ pydicom-2.3.1/pydicom/fileset.py
@@ -344,8 +344,7 @@ class RecordNode(Iterable["RecordNode"])
 
         return len(fp.getvalue())
 
-    @property
-    def _file_id(self) -> Optional[Path]:
+    def file_id_path(self, root_path: Path) -> Optional[Path]:
         """Return the *Referenced File ID* as a :class:`~pathlib.Path`.
 
         Returns
@@ -356,12 +355,22 @@ class RecordNode(Iterable["RecordNode"])
         """
         if "ReferencedFileID" in self._record:
             elem = self._record["ReferencedFileID"]
+            if elem.VM < 1:
+                return None
             if elem.VM == 1:
-                return Path(cast(str, self._record.ReferencedFileID))
-            if elem.VM > 1:
-                return Path(*cast(List[str], self._record.ReferencedFileID))
+                path = Path(cast(str, self._record.ReferencedFileID))
+            else:
+                path = Path(*cast(List[str], self._record.ReferencedFileID))
 
-            return None
+            if path is not None:
+                pp = (root_path / path).resolve()
+                if path.anchor or not (
+                    pp == root_path or root_path in pp.parents
+                ):
+                    raise PermissionError(
+                        f"ReferencedFileID ('{path}') must be inside the DICOMDIR root path"
+                    )
+            return path
 
         raise AttributeError("No 'Referenced File ID' in the directory record")
 
@@ -371,7 +380,7 @@ class RecordNode(Iterable["RecordNode"])
         return self.root.file_set
 
     def __getitem__(self, key: Union[str, "RecordNode"]) -> "RecordNode":
-        """Return the current node's child using it's
+        """Return the current node's child using its
         :attr:`~pydicom.fileset.RecordNode.key`
         """
         if isinstance(key, RecordNode):
@@ -931,9 +940,8 @@ class FileInstance:
             return os.fspath(cast(Path, self._stage_path))
 
         # If not staged for addition then File Set must exist on file system
-        return os.fspath(
-            cast(Path, self.file_set.path) / cast(Path, self.node._file_id)
-        )
+        root_path = self.file_set.root_path
+        return os.fspath(root_path / cast(Path, self.node.file_id_path(root_path)))
 
     @property
     def SOPClassUID(self) -> UID:
@@ -966,7 +974,7 @@ class FileSet:
             to the DICOMDIR file.
         """
         # The nominal path to the root of the File-set
-        self._path: Optional[Path] = None
+        self._root_path: Optional[Path] = None
         # The root node of the record tree used to fill out the DICOMDIR's
         #   *Directory Record Sequence*.
         # The tree for instances currently in the File-set
@@ -1212,7 +1220,7 @@ class FileSet:
         """Clear the File-set."""
         self._tree.children = []
         self._instances = []
-        self._path = None
+        self._root_path = None
         self._ds = Dataset()
         self._id = None
         self._uid = generate_uid()
@@ -1661,7 +1669,7 @@ class FileSet:
             )
 
         try:
-            path = Path(cast(str, ds.filename)).resolve(strict=True)
+            path = Path(ds.filename).resolve(strict=True)
         except FileNotFoundError:
             raise FileNotFoundError(
                 "Unable to load the File-set as the 'filename' attribute "
@@ -1692,7 +1700,7 @@ class FileSet:
             Optional[str],
             ds.get("SpecificCharacterSetOfFileSetDescriptorFile", None)
         )
-        self._path = path.parent
+        self._root_path = path.parent
         self._ds = ds
 
         # Create the record tree
@@ -1701,20 +1709,17 @@ class FileSet:
         bad_instances = []
         for instance in self:
             # Check that the referenced file exists
-            file_id = instance.node._file_id
-            if file_id is None:
-                bad_instances.append(instance)
-                continue
-
+            file_id = self._file_id_path(instance.node)
+            assert file_id is not None
             try:
                 # self.path is already set at this point
-                (cast(Path, self.path) / file_id).resolve(strict=True)
+                (self.root_path / file_id).resolve(strict=True)
             except FileNotFoundError:
                 bad_instances.append(instance)
                 warnings.warn(
                     "The referenced SOP Instance for the directory record at "
                     f"offset {instance.node._offset} does not exist: "
-                    f"{cast(Path, self.path) / file_id}"
+                    f"{self.root_path / file_id}"
                 )
                 continue
 
@@ -1726,6 +1731,31 @@ class FileSet:
         for instance in bad_instances:
             self._instances.remove(instance)
 
+    def _file_id_path(self, node: RecordNode) -> Optional[Path]:
+        """Return the *Referenced File ID* from the given node
+        as a :class:`~pathlib.Path`.
+
+        Parameters
+        ----------
+        node: RecordNode
+            The node where the *Referenced File ID* resides.
+
+        Returns
+        -------
+        pathlib.Path or None
+            The *Referenced File ID* from the directory record as a
+            :class:`pathlib.Path` or ``None`` if the element value is null.
+
+        Raises
+        ------
+        PermissionError
+            If the file ID points to a path outside the fileset root path.
+
+        AttributeError
+            If the Referenced File ID is missing in the directory record.
+        """
+        return node.file_id_path(self.root_path)
+
     def _parse_records(
         self,
         ds: Dataset,
@@ -1782,7 +1812,10 @@ class FileSet:
                 del node.parent[node]
 
             # The leaf node references the FileInstance
-            if "ReferencedFileID" in node._record:
+            if (
+                "ReferencedFileID" in node._record
+                and self._file_id_path(node) is not None
+            ):
                 node.instance = FileInstance(node)
                 self._instances.append(node.instance)
 
@@ -1817,12 +1850,12 @@ class FileSet:
         for node in missing:
             # Get the path to the orphaned instance
             original_value = node._record.ReferencedFileID
-            file_id = node._file_id
+            file_id = self._file_id_path(node)
             if file_id is None:
                 continue
 
             # self.path is set for an existing File Set
-            path = cast(Path, self.path) / file_id
+            path = self.root_path / file_id
             if node.record_type == "PRIVATE":
                 instance = self.add_custom(path, node)
             else:
@@ -1830,16 +1863,31 @@ class FileSet:
 
             # Because the record is new the Referenced File ID isn't set
             instance.node._record.ReferencedFileID = original_value
+            
+    @property
+    def root_path(self) -> Path:
+        """Return the absolute path to the File-set root directory as
+        :class:`pathlib.Path`.
+
+        Raises
+        ------
+        AttributeError
+            If the root path is not set.
+        """
+        if self._root_path is None:
+            raise AttributeError("No root path set in the File-set")
+
+        return self._root_path
 
     @property
     def path(self) -> Optional[str]:
         """Return the absolute path to the File-set root directory as
         :class:`str` (if set) or ``None`` otherwise.
         """
-        if self._path is not None:
-            return os.fspath(self._path)
+        if self._root_path is not None:
+            return os.fspath(self._root_path)
 
-        return self._path
+        return self._root_path
 
     def _recordify(self, ds: Dataset) -> Iterator[Dataset]:
         """Yield directory records for a SOP Instance.
@@ -2105,14 +2153,15 @@ class FileSet:
             )
 
         if path:
-            self._path = Path(path)
+            self._root_path = Path(path)
 
         # Don't write unless changed or new
         if not self.is_staged:
             return
 
         # Path to the DICOMDIR file
-        p = cast(Path, self._path) / 'DICOMDIR'
+        root = self.root_path
+        p = root / 'DICOMDIR'
 
         # Re-use the existing directory structure if only moves or removals
         #   are required and `use_existing` is True
@@ -2159,26 +2208,27 @@ class FileSet:
         #   and copy any to the stage
         fout = {Path(ii.FileID) for ii in self}
         fin = {
-            ii.node._file_id for ii in self
+            self._file_id_path(ii.node)
+            for ii in self
             if ii.SOPInstanceUID not in self._stage['+']
         }
         collisions = fout & fin
-        for instance in [ii for ii in self if ii.node._file_id in collisions]:
+        for instance in [ii for ii in self if self._file_id_path(ii.node) in collisions]:
             self._stage['+'][instance.SOPInstanceUID] = instance
             instance._apply_stage('+')
             shutil.copyfile(
-                self._path / instance.node._file_id, instance.path
+                root / cast(Path, self._file_id_path(instance.node)), instance.path
             )
 
         for instance in self:
-            dst = self._path / instance.FileID
+            dst = self._root_path / instance.FileID
             dst.parent.mkdir(parents=True, exist_ok=True)
             fn: Callable
             if instance.SOPInstanceUID in self._stage['+']:
                 src = instance.path
                 fn = shutil.copyfile
             else:
-                src = self._path / instance.node._file_id
+                src = root / cast(Path, self._file_id_path(instance.node))
                 fn = shutil.move
 
             fn(os.fspath(src), os.fspath(dst))
Index: pydicom-2.3.1/pydicom/tests/test_fileset.py
===================================================================
--- pydicom-2.3.1.orig/pydicom/tests/test_fileset.py
+++ pydicom-2.3.1/pydicom/tests/test_fileset.py
@@ -1651,7 +1651,7 @@ class TestFileSet:
         assert "ISO 1" == fs.descriptor_character_set
         assert [] != fs._instances
         assert fs._id is not None
-        assert fs._path is not None
+        assert fs._root_path is not None
         uid = fs._uid
         assert fs._uid is not None
         assert fs._ds is not None
@@ -1662,7 +1662,7 @@ class TestFileSet:
         fs.clear()
         assert [] == fs._instances
         assert fs._id is None
-        assert fs._path is None
+        assert fs._root_path is None
         assert uid != fs._uid
         assert fs._uid.is_valid
         assert fs._ds == Dataset()
@@ -2321,14 +2321,14 @@ class TestFileSet_Modify:
         tdir, ds = dicomdir_copy
         assert 52 == len(ds.DirectoryRecordSequence)
         fs = FileSet(ds)
-        orig_paths = [p for p in fs._path.glob('**/*') if p.is_file()]
+        orig_paths = [p for p in fs._root_path.glob('**/*') if p.is_file()]
         instance = fs._instances[0]
         assert Path(instance.path) in orig_paths
         fs.remove(instance)
         orig_file_ids = [ii.ReferencedFileID for ii in fs]
         fs.write(use_existing=True)
         assert 50 == len(fs._ds.DirectoryRecordSequence)
-        paths = [p for p in fs._path.glob('**/*') if p.is_file()]
+        paths = [p for p in fs._root_path.glob('**/*') if p.is_file()]
         assert orig_file_ids == [ii.ReferencedFileID for ii in fs]
         assert Path(instance.path) not in paths
         assert sorted(orig_paths)[1:] == sorted(paths)
openSUSE Build Service is sponsored by