@@ -1091,6 +1091,80 @@ def uname_attr(self, attribute: str) -> str:
1091
1091
"""
1092
1092
return self ._uname_info .get (attribute , "" )
1093
1093
1094
+ @staticmethod
1095
+ def __abs_path_join (root_path : str , abs_path : str ) -> str :
1096
+ rel_path = os .path .splitdrive (abs_path )[1 ].lstrip (os .sep )
1097
+ if os .altsep is not None :
1098
+ rel_path = rel_path .lstrip (os .altsep )
1099
+
1100
+ return os .path .join (root_path , rel_path )
1101
+
1102
+ def __resolve_chroot_symlink_as_needed (self , link_location : str ) -> str :
1103
+ """
1104
+ Resolves a potential symlink in ``link_location`` against
1105
+ ``self.root_dir`` if inside the chroot, else just return the original
1106
+ path.
1107
+ We're doing this check at a central place, to making the calling code
1108
+ more readable and to de-duplicate.
1109
+ """
1110
+ if self .root_dir is None :
1111
+ return link_location
1112
+
1113
+ # resolve `self.root_dir`, once and for all.
1114
+ root_dir = os .path .realpath (self .root_dir )
1115
+
1116
+ # consider non-absolute `link_location` relative to `root_dir` (as
1117
+ # `os.path.commonpath` does not support mixing absolute and relative
1118
+ # paths).
1119
+ if not os .path .isabs (link_location ):
1120
+ link_location = self .__abs_path_join (root_dir , link_location )
1121
+
1122
+ seen_paths = set ()
1123
+ while True :
1124
+ # while `link_location` _should_ be relative to chroot (either
1125
+ # passed from trusted code or already resolved by previous loop
1126
+ # iteration), we enforce this check as `self.os_release_file` and
1127
+ # `self.distro_release_file` may be user-supplied.
1128
+ if os .path .commonpath ([root_dir , link_location ]) != root_dir :
1129
+ raise FileNotFoundError
1130
+
1131
+ if not os .path .islink (link_location ):
1132
+ # assert _final_ path is actually inside chroot (this is
1133
+ # required to address `..` usages, potentially leading to
1134
+ # outside, after subsequent link resolutions).
1135
+ if (
1136
+ os .path .commonpath ([root_dir , os .path .realpath (link_location )])
1137
+ != root_dir
1138
+ ):
1139
+ raise FileNotFoundError
1140
+
1141
+ return link_location
1142
+
1143
+ resolved = os .readlink (link_location )
1144
+ if not os .path .isabs (resolved ):
1145
+ # compute resolved path relatively to previous `link_location`
1146
+ # and accordingly to chroot. We also canonize "top" `..`
1147
+ # components (relatively to `root_dir`), as they would
1148
+ # legitimately resolve to chroot itself).
1149
+ resolved = os .path .relpath (
1150
+ os .path .join (os .path .dirname (link_location ), resolved ),
1151
+ start = root_dir ,
1152
+ ).lstrip (
1153
+ os .pardir
1154
+ + os .pathsep
1155
+ + (os .altsep if os .altsep is not None else "" )
1156
+ )
1157
+
1158
+ # "move" back (absolute) path inside the chroot
1159
+ resolved = self .__abs_path_join (root_dir , resolved )
1160
+
1161
+ # prevent symlinks infinite loop
1162
+ if resolved in seen_paths :
1163
+ raise FileNotFoundError
1164
+
1165
+ seen_paths .add (link_location )
1166
+ link_location = resolved
1167
+
1094
1168
@cached_property
1095
1169
def _os_release_info (self ) -> Dict [str , str ]:
1096
1170
"""
@@ -1099,10 +1173,14 @@ def _os_release_info(self) -> Dict[str, str]:
1099
1173
Returns:
1100
1174
A dictionary containing all information items.
1101
1175
"""
1102
- if os .path .isfile (self .os_release_file ):
1103
- with open (self .os_release_file , encoding = "utf-8" ) as release_file :
1176
+ try :
1177
+ with open (
1178
+ self .__resolve_chroot_symlink_as_needed (self .os_release_file ),
1179
+ encoding = "utf-8" ,
1180
+ ) as release_file :
1104
1181
return self ._parse_os_release_content (release_file )
1105
- return {}
1182
+ except FileNotFoundError :
1183
+ return {}
1106
1184
1107
1185
@staticmethod
1108
1186
def _parse_os_release_content (lines : TextIO ) -> Dict [str , str ]:
@@ -1223,7 +1301,10 @@ def _oslevel_info(self) -> str:
1223
1301
def _debian_version (self ) -> str :
1224
1302
try :
1225
1303
with open (
1226
- os .path .join (self .etc_dir , "debian_version" ), encoding = "ascii"
1304
+ self .__resolve_chroot_symlink_as_needed (
1305
+ os .path .join (self .etc_dir , "debian_version" )
1306
+ ),
1307
+ encoding = "ascii" ,
1227
1308
) as fp :
1228
1309
return fp .readline ().rstrip ()
1229
1310
except FileNotFoundError :
@@ -1233,7 +1314,10 @@ def _debian_version(self) -> str:
1233
1314
def _armbian_version (self ) -> str :
1234
1315
try :
1235
1316
with open (
1236
- os .path .join (self .etc_dir , "armbian-release" ), encoding = "ascii"
1317
+ self .__resolve_chroot_symlink_as_needed (
1318
+ os .path .join (self .etc_dir , "armbian-release" )
1319
+ ),
1320
+ encoding = "ascii" ,
1237
1321
) as fp :
1238
1322
return self ._parse_os_release_content (fp ).get ("version" , "" )
1239
1323
except FileNotFoundError :
@@ -1285,9 +1369,10 @@ def _distro_release_info(self) -> Dict[str, str]:
1285
1369
try :
1286
1370
basenames = [
1287
1371
basename
1288
- for basename in os .listdir (self .etc_dir )
1372
+ for basename in os .listdir (
1373
+ self .__resolve_chroot_symlink_as_needed (self .etc_dir )
1374
+ )
1289
1375
if basename not in _DISTRO_RELEASE_IGNORE_BASENAMES
1290
- and os .path .isfile (os .path .join (self .etc_dir , basename ))
1291
1376
]
1292
1377
# We sort for repeatability in cases where there are multiple
1293
1378
# distro specific files; e.g. CentOS, Oracle, Enterprise all
@@ -1303,12 +1388,13 @@ def _distro_release_info(self) -> Dict[str, str]:
1303
1388
match = _DISTRO_RELEASE_BASENAME_PATTERN .match (basename )
1304
1389
if match is None :
1305
1390
continue
1306
- filepath = os .path .join (self .etc_dir , basename )
1307
- distro_info = self ._parse_distro_release_file (filepath )
1391
+ # NOTE: _parse_distro_release_file below will be resolving for us
1392
+ unresolved_filepath = os .path .join (self .etc_dir , basename )
1393
+ distro_info = self ._parse_distro_release_file (unresolved_filepath )
1308
1394
# The name is always present if the pattern matches.
1309
1395
if "name" not in distro_info :
1310
1396
continue
1311
- self .distro_release_file = filepath
1397
+ self .distro_release_file = unresolved_filepath
1312
1398
break
1313
1399
else : # the loop didn't "break": no candidate.
1314
1400
return {}
@@ -1342,7 +1428,9 @@ def _parse_distro_release_file(self, filepath: str) -> Dict[str, str]:
1342
1428
A dictionary containing all information items.
1343
1429
"""
1344
1430
try :
1345
- with open (filepath , encoding = "utf-8" ) as fp :
1431
+ with open (
1432
+ self .__resolve_chroot_symlink_as_needed (filepath ), encoding = "utf-8"
1433
+ ) as fp :
1346
1434
# Only parse the first line. For instance, on SLES there
1347
1435
# are multiple lines. We don't want them...
1348
1436
return self ._parse_distro_release_content (fp .readline ())
0 commit comments