3737import subprocess
3838import sys
3939import warnings
40+ from pathlib import Path
4041from typing import (
4142 Any ,
4243 Callable ,
@@ -771,9 +772,15 @@ def __init__(
771772 self .usr_lib_dir , _OS_RELEASE_BASENAME
772773 )
773774
775+ def __isfile (path : str ) -> bool :
776+ try :
777+ return os .path .isfile (self ._resolve_path_relatively_to_chroot (path ))
778+ except FileNotFoundError :
779+ return False
780+
774781 # NOTE: The idea is to respect order **and** have it set
775782 # at all times for API backwards compatibility.
776- if os . path . isfile (etc_dir_os_release_file ) or not os . path . isfile (
783+ if __isfile (etc_dir_os_release_file ) or not __isfile (
777784 usr_lib_os_release_file
778785 ):
779786 self .os_release_file = etc_dir_os_release_file
@@ -1091,6 +1098,101 @@ def uname_attr(self, attribute: str) -> str:
10911098 """
10921099 return self ._uname_info .get (attribute , "" )
10931100
1101+ @staticmethod
1102+ def __abs_path_join (root_path : Path , abs_path : Path ) -> Path :
1103+ rel_path = os .path .splitdrive (abs_path )[1 ].lstrip (os .sep )
1104+ if os .altsep is not None :
1105+ rel_path = rel_path .lstrip (os .altsep )
1106+
1107+ return root_path / Path (rel_path )
1108+
1109+ def _resolve_path_relatively_to_chroot (self , path : str ) -> Path :
1110+ """
1111+ Resolves any encountered symbolic links in ``path`` relatively to
1112+ ``self.root_dir``, if defined. Otherwise it would simply return
1113+ original ``path``.
1114+ This function could be considered as a "soft-chroot" implementation.
1115+ We're doing this check at a central place, to make calling code more readable
1116+ and to de-duplicate.
1117+
1118+ Raises:
1119+
1120+ * :py:exc:`FileNotFoundError`: ``path`` doesn't resolve in chroot, or resolving
1121+ it lead to symbolic links loop
1122+
1123+ Examples :
1124+
1125+ * if root_dir="/path/to/chroot" and path="folder/../../../../etc/os-release"
1126+ with "etc" resolving to "/mnt/disk/etc" and "os-release" to
1127+ "../../usr/lib/os-release", this function returns
1128+ "/path/to/chroot/mnt/usr/lib/os-release"
1129+
1130+ * if root_dir=None and path="/path/to/os-release", this function returns
1131+ "/path/to/os-release"
1132+ """
1133+ path_to_resolve = Path (path )
1134+
1135+ if self .root_dir is None :
1136+ return path_to_resolve
1137+
1138+ # resolve `self.root_dir` once and for all
1139+ chroot_path = Path (self .root_dir ).resolve ()
1140+
1141+ # consider non-absolute `path_to_resolve` relative to chroot
1142+ if not path_to_resolve .is_absolute ():
1143+ path_to_resolve = chroot_path / path_to_resolve
1144+
1145+ seen_paths = set ()
1146+ while True :
1147+ # although `path_to_resolve` _should_ be relative to chroot (either
1148+ # passed from trusted code or already resolved by previous loop
1149+ # iteration), we enforce this check as some inputs are available through API
1150+ try :
1151+ relative_parts = path_to_resolve .relative_to (chroot_path ).parts
1152+ except ValueError :
1153+ raise FileNotFoundError
1154+
1155+ # iterate over (relative) path segments and try to resolve each one of them
1156+ for i , part in enumerate (relative_parts , start = 1 ):
1157+ if part == os .pardir :
1158+ # normalize path parts up to this segment (relatively to chroot)
1159+ path_to_resolve = self .__abs_path_join (
1160+ chroot_path ,
1161+ Path (os .path .normpath ("/" / Path (* relative_parts [:i ]))),
1162+ ) / Path (* relative_parts [i :])
1163+ break # restart path resolution as path has just been normalized
1164+
1165+ # attempt symbolic link resolution on current path segment
1166+ symlink_candidate = chroot_path / Path (* relative_parts [:i ])
1167+ try :
1168+ symlink_resolved = Path (os .readlink (symlink_candidate ))
1169+ except (
1170+ AttributeError , # `readlink` isn't supported by system
1171+ OSError , # not a symlink, go to next path segment
1172+ ):
1173+ continue
1174+
1175+ # "bend" **absolute** resolved path inside the chroot
1176+ # consider **non-absolute** resolved path relatively to chroot
1177+ if symlink_resolved .is_absolute ():
1178+ path_to_resolve = self .__abs_path_join (
1179+ chroot_path , symlink_resolved
1180+ )
1181+ else :
1182+ path_to_resolve = symlink_candidate .parent / symlink_resolved
1183+
1184+ # append remaining path segments to resolved path
1185+ path_to_resolve /= Path (* relative_parts [i :])
1186+ break # restart path resolution as a symlink has just been resolved
1187+ else :
1188+ # `path_to_resolve` can be considered resolved, return it
1189+ return path_to_resolve
1190+
1191+ # prevent symlinks infinite loop by tracking successive resolutions
1192+ if path_to_resolve in seen_paths :
1193+ raise FileNotFoundError
1194+ seen_paths .add (path_to_resolve )
1195+
10941196 @cached_property
10951197 def _os_release_info (self ) -> Dict [str , str ]:
10961198 """
@@ -1099,10 +1201,14 @@ def _os_release_info(self) -> Dict[str, str]:
10991201 Returns:
11001202 A dictionary containing all information items.
11011203 """
1102- if os .path .isfile (self .os_release_file ):
1103- with open (self .os_release_file , encoding = "utf-8" ) as release_file :
1204+ try :
1205+ with open (
1206+ self ._resolve_path_relatively_to_chroot (self .os_release_file ),
1207+ encoding = "utf-8" ,
1208+ ) as release_file :
11041209 return self ._parse_os_release_content (release_file )
1105- return {}
1210+ except FileNotFoundError :
1211+ return {}
11061212
11071213 @staticmethod
11081214 def _parse_os_release_content (lines : TextIO ) -> Dict [str , str ]:
@@ -1225,7 +1331,10 @@ def _oslevel_info(self) -> str:
12251331 def _debian_version (self ) -> str :
12261332 try :
12271333 with open (
1228- os .path .join (self .etc_dir , "debian_version" ), encoding = "ascii"
1334+ self ._resolve_path_relatively_to_chroot (
1335+ os .path .join (self .etc_dir , "debian_version" )
1336+ ),
1337+ encoding = "ascii" ,
12291338 ) as fp :
12301339 return fp .readline ().rstrip ()
12311340 except FileNotFoundError :
@@ -1235,7 +1344,10 @@ def _debian_version(self) -> str:
12351344 def _armbian_version (self ) -> str :
12361345 try :
12371346 with open (
1238- os .path .join (self .etc_dir , "armbian-release" ), encoding = "ascii"
1347+ self ._resolve_path_relatively_to_chroot (
1348+ os .path .join (self .etc_dir , "armbian-release" )
1349+ ),
1350+ encoding = "ascii" ,
12391351 ) as fp :
12401352 return self ._parse_os_release_content (fp ).get ("version" , "" )
12411353 except FileNotFoundError :
@@ -1287,9 +1399,10 @@ def _distro_release_info(self) -> Dict[str, str]:
12871399 try :
12881400 basenames = [
12891401 basename
1290- for basename in os .listdir (self .etc_dir )
1402+ for basename in os .listdir (
1403+ self ._resolve_path_relatively_to_chroot (self .etc_dir )
1404+ )
12911405 if basename not in _DISTRO_RELEASE_IGNORE_BASENAMES
1292- and os .path .isfile (os .path .join (self .etc_dir , basename ))
12931406 ]
12941407 # We sort for repeatability in cases where there are multiple
12951408 # distro specific files; e.g. CentOS, Oracle, Enterprise all
@@ -1305,12 +1418,13 @@ def _distro_release_info(self) -> Dict[str, str]:
13051418 match = _DISTRO_RELEASE_BASENAME_PATTERN .match (basename )
13061419 if match is None :
13071420 continue
1308- filepath = os .path .join (self .etc_dir , basename )
1309- distro_info = self ._parse_distro_release_file (filepath )
1421+ # NOTE: _parse_distro_release_file below will be resolving for us
1422+ unresolved_filepath = os .path .join (self .etc_dir , basename )
1423+ distro_info = self ._parse_distro_release_file (unresolved_filepath )
13101424 # The name is always present if the pattern matches.
13111425 if "name" not in distro_info :
13121426 continue
1313- self .distro_release_file = filepath
1427+ self .distro_release_file = unresolved_filepath
13141428 break
13151429 else : # the loop didn't "break": no candidate.
13161430 return {}
@@ -1344,7 +1458,10 @@ def _parse_distro_release_file(self, filepath: str) -> Dict[str, str]:
13441458 A dictionary containing all information items.
13451459 """
13461460 try :
1347- with open (filepath , encoding = "utf-8" ) as fp :
1461+ with open (
1462+ self ._resolve_path_relatively_to_chroot (filepath ),
1463+ encoding = "utf-8" ,
1464+ ) as fp :
13481465 # Only parse the first line. For instance, on SLES there
13491466 # are multiple lines. We don't want them...
13501467 return self ._parse_distro_release_content (fp .readline ())
0 commit comments