OSDN Git Service

8a5194843c1e1149dce1a0455e09400afb7a4c86
[alterlinux/alterlinux-calamares.git] / src / modules / unpackfs / main.py
1 #!/usr/bin/env python3
2 # -*- coding: utf-8 -*-
3 #
4 # === This file is part of Calamares - <https://calamares.io> ===
5 #
6 #   SPDX-FileCopyrightText: 2014 Teo Mrnjavac <teo@kde.org>
7 #   SPDX-FileCopyrightText: 2014 Daniel Hillenbrand <codeworkx@bbqlinux.org>
8 #   SPDX-FileCopyrightText: 2014 Philip Müller <philm@manjaro.org>
9 #   SPDX-FileCopyrightText: 2017 Alf Gaida <agaida@siduction.org>
10 #   SPDX-FileCopyrightText: 2019 Kevin Kofler <kevin.kofler@chello.at>
11 #   SPDX-FileCopyrightText: 2020 Adriaan de Groot <groot@kde.org>
12 #   SPDX-FileCopyrightText: 2020 Gabriel Craciunescu <crazy@frugalware.org>
13 #   SPDX-License-Identifier: GPL-3.0-or-later
14 #
15 #   Calamares is Free Software: see the License-Identifier above.
16 #
17
18 import os
19 import re
20 import shutil
21 import subprocess
22 import sys
23 import tempfile
24
25 from libcalamares import *
26 from libcalamares.utils import mount
27
28 import gettext
29 _ = gettext.translation("calamares-python",
30                         localedir=utils.gettext_path(),
31                         languages=utils.gettext_languages(),
32                         fallback=True).gettext
33
34 def pretty_name():
35     return _("Filling up filesystems.")
36
37 # This is going to be changed from various methods
38 status = pretty_name()
39
40 def pretty_status_message():
41     return status
42
43 class UnpackEntry:
44     """
45     Extraction routine using rsync.
46
47     :param source:
48     :param sourcefs:
49     :param destination:
50     """
51     __slots__ = ('source', 'sourcefs', 'destination', 'copied', 'total', 'exclude', 'excludeFile',
52                  'mountPoint', 'weight')
53
54     def __init__(self, source, sourcefs, destination):
55         """
56         @p source is the source file name (might be an image file, or
57             a directory, too)
58         @p sourcefs is a type indication; "file" is special, as is
59             "squashfs".
60         @p destination is where the files from the source go. This is
61             **already** prefixed by rootMountPoint, so should be a
62             valid absolute path within the host system.
63
64         The members copied and total are filled in by the copying process.
65         """
66         self.source = source
67         self.sourcefs = sourcefs
68         self.destination = destination
69         self.exclude = None
70         self.excludeFile = None
71         self.copied = 0
72         self.total = 0
73         self.mountPoint = None
74         self.weight = 1
75
76     def is_file(self):
77         return self.sourcefs == "file"
78
79     def do_count(self):
80         """
81         Counts the number of files this entry has.
82         """
83         fslist = ""
84
85         if self.sourcefs == "squashfs":
86             fslist = subprocess.check_output(
87                 ["unsquashfs", "-l", self.source]
88                 )
89
90         elif self.sourcefs == "ext4":
91             fslist = subprocess.check_output(
92                 ["find", self.mountPoint, "-type", "f"]
93                 )
94
95         elif self.is_file():
96             # Hasn't been mounted, copy directly; find handles both
97             # files and directories.
98             fslist = subprocess.check_output(["find", self.source, "-type", "f"])
99
100         self.total = len(fslist.splitlines())
101         return self.total
102
103     def do_mount(self, base):
104         """
105         Mount given @p entry as loop device underneath @p base
106
107         A *file* entry (e.g. one with *sourcefs* set to *file*)
108         is not mounted and just ignored.
109
110         :param base: directory to place all the mounts in.
111
112         :returns: None, but throws if the mount failed
113         """
114         imgbasename = os.path.splitext(
115             os.path.basename(self.source))[0]
116         imgmountdir = os.path.join(base, imgbasename)
117         os.makedirs(imgmountdir, exist_ok=True)
118
119         # This is where it *would* go (files bail out before actually mounting)
120         self.mountPoint = imgmountdir
121
122         if self.is_file():
123             return
124
125         if os.path.isdir(self.source):
126             r = mount(self.source, imgmountdir, "", "--bind")
127         elif os.path.isfile(self.source):
128             r = mount(self.source, imgmountdir, self.sourcefs, "loop")
129         else: # self.source is a device
130             r = mount(self.source, imgmountdir, self.sourcefs, "")
131
132         if r != 0:
133             utils.debug("Failed to mount '{}' (fs={}) (target={})".format(self.source, self.sourcefs, imgmountdir))
134             raise subprocess.CalledProcessError(r, "mount")
135
136
137 ON_POSIX = 'posix' in sys.builtin_module_names
138
139
140 def global_excludes():
141     """
142     List excludes for rsync.
143     """
144     lst = []
145     extra_mounts = globalstorage.value("extraMounts")
146     if extra_mounts is None:
147         extra_mounts = []
148
149     for extra_mount in extra_mounts:
150         mount_point = extra_mount["mountPoint"]
151
152         if mount_point:
153             lst.extend(['--exclude', mount_point + '/'])
154
155     return lst
156
157 def file_copy(source, entry, progress_cb):
158     """
159     Extract given image using rsync.
160
161     :param source: Source file. This may be the place the entry's
162         image is mounted, or if it's a single file, the entry's source value.
163     :param entry: The UnpackEntry being copied.
164     :param progress_cb: A callback function for progress reporting.
165         Takes a number and a total-number.
166     """
167     import time
168
169     dest = entry.destination
170
171     # Environment used for executing rsync properly
172     # Setting locale to C (fix issue with tr_TR locale)
173     at_env = os.environ
174     at_env["LC_ALL"] = "C"
175
176     # `source` *must* end with '/' otherwise a directory named after the source
177     # will be created in `dest`: ie if `source` is "/foo/bar" and `dest` is
178     # "/dest", then files will be copied in "/dest/bar".
179     if not source.endswith("/") and not os.path.isfile(source):
180         source += "/"
181
182     num_files_total_local = 0
183     num_files_copied = 0  # Gets updated through rsync output
184
185     args = ['rsync', '-aHAXr', '--filter=-x trusted.overlay.*']
186     args.extend(global_excludes())
187     if entry.excludeFile:
188         args.extend(["--exclude-from=" + entry.excludeFile])
189     if entry.exclude:
190         for f in entry.exclude:
191             args.extend(["--exclude", f])
192     args.extend(['--progress', source, dest])
193     process = subprocess.Popen(
194         args, env=at_env,
195         stdout=subprocess.PIPE, close_fds=ON_POSIX
196         )
197     # last_num_files_copied trails num_files_copied, and whenever at least 107 more
198     # files (file_count_chunk) have been copied, progress is reported and
199     # last_num_files_copied is updated. The chunk size isn't "tidy"
200     # so that all the digits of the progress-reported number change.
201     #
202     last_num_files_copied = 0
203     last_timestamp_reported = time.time()
204     file_count_chunk = 107
205
206     for line in iter(process.stdout.readline, b''):
207         # rsync outputs progress in parentheses. Each line will have an
208         # xfer and a chk item (either ir-chk or to-chk) as follows:
209         #
210         # - xfer#x => Interpret it as 'file copy try no. x'
211         # - ir-chk=x/y, where:
212         #     - x = number of files yet to be checked
213         #     - y = currently calculated total number of files.
214         # - to-chk=x/y, which is similar and happens once the ir-chk
215         #   phase (collecting total files) is over.
216         #
217         # If you're copying directory with some links in it, the xfer#
218         # might not be a reliable counter (for one increase of xfer, many
219         # files may be created).
220         m = re.findall(r'xfr#(\d+), ..-chk=(\d+)/(\d+)', line.decode())
221
222         if m:
223             # we've got a percentage update
224             num_files_remaining = int(m[0][1])
225             num_files_total_local = int(m[0][2])
226             # adjusting the offset so that progressbar can be continuesly drawn
227             num_files_copied = num_files_total_local - num_files_remaining
228
229             now = time.time()
230             if (num_files_copied - last_num_files_copied >= file_count_chunk) or (now - last_timestamp_reported > 0.5):
231                 last_num_files_copied = num_files_copied
232                 last_timestamp_reported = now
233                 progress_cb(num_files_copied, num_files_total_local)
234
235     process.wait()
236     progress_cb(num_files_copied, num_files_total_local)  # Push towards 100%
237
238     # Mark this entry as really done
239     entry.copied = entry.total
240
241     # 23 is the return code rsync returns if it cannot write extended
242     # attributes (with -X) because the target file system does not support it,
243     # e.g., the FAT EFI system partition. We need -X because distributions
244     # using file system capabilities and/or SELinux require the extended
245     # attributes. But distributions using SELinux may also have SELinux labels
246     # set on files under /boot/efi, and rsync complains about those. The only
247     # clean way would be to split the rsync into one with -X and
248     # --exclude /boot/efi and a separate one without -X for /boot/efi, but only
249     # if /boot/efi is actually an EFI system partition. For now, this hack will
250     # have to do. See also:
251     # https://bugzilla.redhat.com/show_bug.cgi?id=868755#c50
252     # for the same issue in Anaconda, which uses a similar workaround.
253     if process.returncode != 0 and process.returncode != 23:
254         utils.warning("rsync failed with error code {}.".format(process.returncode))
255         return _("rsync failed with error code {}.").format(process.returncode)
256
257     return None
258
259
260 class UnpackOperation:
261     """
262     Extraction routine using unsquashfs.
263
264     :param entries:
265     """
266
267     def __init__(self, entries):
268         self.entries = entries
269         self.entry_for_source = dict((x.source, x) for x in self.entries)
270         self.total_weight = sum([e.weight for e in entries])
271
272     def report_progress(self):
273         """
274         Pass progress to user interface
275         """
276         progress = float(0)
277
278         current_total = 0
279         current_done = 0  # Files count in the current entry
280         complete_count = 0
281         complete_weight = 0  # This much weight already finished
282         for entry in self.entries:
283             if entry.total == 0:
284                 # Total 0 hasn't counted yet
285                 continue
286             if entry.total == entry.copied:
287                 complete_weight += entry.weight
288                 complete_count += 1
289             else:
290                 # There is at most *one* entry in-progress
291                 current_total = entry.total
292                 current_done = entry.copied
293                 complete_weight += entry.weight * ( 1.0 * current_done ) / current_total
294                 break
295
296         if current_total > 0:
297             progress = ( 1.0 * complete_weight ) / self.total_weight
298
299         global status
300         status = _("Unpacking image {}/{}, file {}/{}").format((complete_count+1), len(self.entries), current_done, current_total)
301         job.setprogress(progress)
302
303     def run(self):
304         """
305         Extract given image using unsquashfs.
306
307         :return:
308         """
309         global status
310         source_mount_path = tempfile.mkdtemp()
311
312         try:
313             complete = 0
314             for entry in self.entries:
315                 status = _("Starting to unpack {}").format(entry.source)
316                 job.setprogress( ( 1.0 * complete ) / len(self.entries) )
317                 entry.do_mount(source_mount_path)
318                 entry.do_count()  # Fill in the entry.total
319
320                 self.report_progress()
321                 error_msg = self.unpack_image(entry, entry.mountPoint)
322
323                 if error_msg:
324                     return (_("Failed to unpack image \"{}\"").format(entry.source),
325                             error_msg)
326                 complete += 1
327
328             return None
329         finally:
330             shutil.rmtree(source_mount_path, ignore_errors=True, onerror=None)
331
332
333     def unpack_image(self, entry, imgmountdir):
334         """
335         Unpacks image.
336
337         :param entry:
338         :param imgmountdir:
339         :return:
340         """
341         def progress_cb(copied, total):
342             """ Copies file to given destination target.
343
344             :param copied:
345             """
346             entry.copied = copied
347             if total > entry.total:
348                 entry.total = total
349             self.report_progress()
350
351         try:
352             if entry.is_file():
353                 source = entry.source
354             else:
355                 source = imgmountdir
356
357             return file_copy(source, entry, progress_cb)
358         finally:
359             if not entry.is_file():
360                 subprocess.check_call(["umount", "-l", imgmountdir])
361
362
363 def get_supported_filesystems_kernel():
364     """
365     Reads /proc/filesystems (the list of supported filesystems
366     for the current kernel) and returns a list of (names of)
367     those filesystems.
368     """
369     PATH_PROCFS = '/proc/filesystems'
370
371     if os.path.isfile(PATH_PROCFS) and os.access(PATH_PROCFS, os.R_OK):
372         with open(PATH_PROCFS, 'r') as procfile:
373             filesystems = procfile.read()
374             filesystems = filesystems.replace(
375                 "nodev", "").replace("\t", "").splitlines()
376             return filesystems
377
378     return []
379
380
381 def get_supported_filesystems():
382     """
383     Returns a list of all the supported filesystems
384     (valid values for the *sourcefs* key in an item.
385     """
386     return ["file"] + get_supported_filesystems_kernel()
387
388
389 def repair_root_permissions(root_mount_point):
390     """
391     If the / of the system gets permission 777, change it down
392     to 755. Any other permission is left alone. This
393     works around standard behavior from squashfs where
394     permissions are (easily, accidentally) set to 777.
395     """
396     existing_root_mode = os.stat(root_mount_point).st_mode & 0o777
397     if existing_root_mode == 0o777:
398         try:
399             os.chmod(root_mount_point, 0o755)  # Want / to be rwxr-xr-x
400         except OSError as e:
401             utils.warning("Could not set / to safe permissions: {}".format(e))
402             # But ignore it
403
404
405 def extract_weight(entry):
406     """
407     Given @p entry, a dict representing a single entry in
408     the *unpack* list, returns its weight (1, or whatever is
409     set if it is sensible).
410     """
411     w =  entry.get("weight", None)
412     if w:
413         try:
414             wi = int(w)
415             return wi if wi > 0 else 1
416         except ValueError:
417             utils.warning("*weight* setting {!r} is not valid.".format(w))
418         except TypeError:
419             utils.warning("*weight* setting {!r} must be number.".format(w))
420     return 1
421
422
423 def run():
424     """
425     Unsquash filesystem.
426     """
427     root_mount_point = globalstorage.value("rootMountPoint")
428
429     if not root_mount_point:
430         utils.warning("No mount point for root partition")
431         return (_("No mount point for root partition"),
432                 _("globalstorage does not contain a \"rootMountPoint\" key, "
433                 "doing nothing"))
434
435     if not os.path.exists(root_mount_point):
436         utils.warning("Bad root mount point \"{}\"".format(root_mount_point))
437         return (_("Bad mount point for root partition"),
438                 _("rootMountPoint is \"{}\", which does not "
439                 "exist, doing nothing").format(root_mount_point))
440
441     supported_filesystems = get_supported_filesystems()
442
443     # Bail out before we start when there are obvious problems
444     #   - unsupported filesystems
445     #   - non-existent sources
446     #   - missing tools for specific FS
447     for entry in job.configuration["unpack"]:
448         source = os.path.abspath(entry["source"])
449         sourcefs = entry["sourcefs"]
450
451         if sourcefs not in supported_filesystems:
452             utils.warning("The filesystem for \"{}\" ({}) is not supported by your current kernel".format(source, sourcefs))
453             utils.warning(" ... modprobe {} may solve the problem".format(sourcefs))
454             return (_("Bad unsquash configuration"),
455                     _("The filesystem for \"{}\" ({}) is not supported by your current kernel").format(source, sourcefs))
456         if not os.path.exists(source):
457             utils.warning("The source filesystem \"{}\" does not exist".format(source))
458             return (_("Bad unsquash configuration"),
459                     _("The source filesystem \"{}\" does not exist").format(source))
460         if sourcefs == "squashfs":
461             if shutil.which("unsquashfs") is None:
462                 utils.warning("Failed to find unsquashfs")
463
464                 return (_("Failed to unpack image \"{}\"").format(self.source),
465                         _("Failed to find unsquashfs, make sure you have the squashfs-tools package installed"))
466
467     unpack = list()
468
469     is_first = True
470     for entry in job.configuration["unpack"]:
471         source = os.path.abspath(entry["source"])
472         sourcefs = entry["sourcefs"]
473         destination = os.path.abspath(root_mount_point + entry["destination"])
474
475         if not os.path.isdir(destination) and sourcefs != "file":
476             utils.warning(("The destination \"{}\" in the target system is not a directory").format(destination))
477             if is_first:
478                 return (_("Bad unsquash configuration"),
479                         _("The destination \"{}\" in the target system is not a directory").format(destination))
480             else:
481                 utils.debug(".. assuming that the previous targets will create that directory.")
482
483         unpack.append(UnpackEntry(source, sourcefs, destination))
484         # Optional settings
485         if entry.get("exclude", None):
486             unpack[-1].exclude = entry["exclude"]
487         if entry.get("excludeFile", None):
488             unpack[-1].excludeFile = entry["excludeFile"]
489         unpack[-1].weight = extract_weight(entry)
490
491         is_first = False
492
493     repair_root_permissions(root_mount_point)
494     try:
495         unpackop = UnpackOperation(unpack)
496         return unpackop.run()
497     finally:
498         repair_root_permissions(root_mount_point)