@@ -1091,6 +1091,76 @@ 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 (os .pardir + os .pathsep )
1153
+
1154
+ # "move" back (absolute) path inside the chroot
1155
+ resolved = self .__abs_path_join (root_dir , resolved )
1156
+
1157
+ # prevent symlinks infinite loop
1158
+ if resolved in seen_paths :
1159
+ raise FileNotFoundError
1160
+
1161
+ seen_paths .add (link_location )
1162
+ link_location = resolved
1163
+
1094
1164
@cached_property
1095
1165
def _os_release_info (self ) -> Dict [str , str ]:
1096
1166
"""
@@ -1099,10 +1169,14 @@ def _os_release_info(self) -> Dict[str, str]:
1099
1169
Returns:
1100
1170
A dictionary containing all information items.
1101
1171
"""
1102
- if os .path .isfile (self .os_release_file ):
1103
- with open (self .os_release_file , encoding = "utf-8" ) as release_file :
1172
+ try :
1173
+ with open (
1174
+ self .__resolve_chroot_symlink_as_needed (self .os_release_file ),
1175
+ encoding = "utf-8" ,
1176
+ ) as release_file :
1104
1177
return self ._parse_os_release_content (release_file )
1105
- return {}
1178
+ except FileNotFoundError :
1179
+ return {}
1106
1180
1107
1181
@staticmethod
1108
1182
def _parse_os_release_content (lines : TextIO ) -> Dict [str , str ]:
@@ -1223,7 +1297,10 @@ def _oslevel_info(self) -> str:
1223
1297
def _debian_version (self ) -> str :
1224
1298
try :
1225
1299
with open (
1226
- os .path .join (self .etc_dir , "debian_version" ), encoding = "ascii"
1300
+ self .__resolve_chroot_symlink_as_needed (
1301
+ os .path .join (self .etc_dir , "debian_version" )
1302
+ ),
1303
+ encoding = "ascii" ,
1227
1304
) as fp :
1228
1305
return fp .readline ().rstrip ()
1229
1306
except FileNotFoundError :
@@ -1233,7 +1310,10 @@ def _debian_version(self) -> str:
1233
1310
def _armbian_version (self ) -> str :
1234
1311
try :
1235
1312
with open (
1236
- os .path .join (self .etc_dir , "armbian-release" ), encoding = "ascii"
1313
+ self .__resolve_chroot_symlink_as_needed (
1314
+ os .path .join (self .etc_dir , "armbian-release" )
1315
+ ),
1316
+ encoding = "ascii" ,
1237
1317
) as fp :
1238
1318
return self ._parse_os_release_content (fp ).get ("version" , "" )
1239
1319
except FileNotFoundError :
@@ -1285,9 +1365,10 @@ def _distro_release_info(self) -> Dict[str, str]:
1285
1365
try :
1286
1366
basenames = [
1287
1367
basename
1288
- for basename in os .listdir (self .etc_dir )
1368
+ for basename in os .listdir (
1369
+ self .__resolve_chroot_symlink_as_needed (self .etc_dir )
1370
+ )
1289
1371
if basename not in _DISTRO_RELEASE_IGNORE_BASENAMES
1290
- and os .path .isfile (os .path .join (self .etc_dir , basename ))
1291
1372
]
1292
1373
# We sort for repeatability in cases where there are multiple
1293
1374
# distro specific files; e.g. CentOS, Oracle, Enterprise all
@@ -1303,12 +1384,13 @@ def _distro_release_info(self) -> Dict[str, str]:
1303
1384
match = _DISTRO_RELEASE_BASENAME_PATTERN .match (basename )
1304
1385
if match is None :
1305
1386
continue
1306
- filepath = os .path .join (self .etc_dir , basename )
1307
- distro_info = self ._parse_distro_release_file (filepath )
1387
+ # NOTE: _parse_distro_release_file below will be resolving for us
1388
+ unresolved_filepath = os .path .join (self .etc_dir , basename )
1389
+ distro_info = self ._parse_distro_release_file (unresolved_filepath )
1308
1390
# The name is always present if the pattern matches.
1309
1391
if "name" not in distro_info :
1310
1392
continue
1311
- self .distro_release_file = filepath
1393
+ self .distro_release_file = unresolved_filepath
1312
1394
break
1313
1395
else : # the loop didn't "break": no candidate.
1314
1396
return {}
@@ -1342,7 +1424,9 @@ def _parse_distro_release_file(self, filepath: str) -> Dict[str, str]:
1342
1424
A dictionary containing all information items.
1343
1425
"""
1344
1426
try :
1345
- with open (filepath , encoding = "utf-8" ) as fp :
1427
+ with open (
1428
+ self .__resolve_chroot_symlink_as_needed (filepath ), encoding = "utf-8"
1429
+ ) as fp :
1346
1430
# Only parse the first line. For instance, on SLES there
1347
1431
# are multiple lines. We don't want them...
1348
1432
return self ._parse_distro_release_content (fp .readline ())
0 commit comments