#!/usr/bin/perl # vim: shiftwidth=4 tabstop=4 # # Copyright 2014-2022 by Marco d'Itri # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. use warnings; use strict; use autodie; use v5.16; use File::Find::Rule; use Cwd qw(abs_path); use Errno; my $Debug = 0; my $Program_RC = 0; my $Is_Hurd = is_hurd(); # If a file exists both in / and /usr then ignore (delete) the one in / # Justification for these entries: # /lib/udev/hwdb.bin -> systemd-hwdb --usr update [LP: #1930573, Ubuntu-specific] my %Ignore_In_Root = map { $_ => 1 } qw( /lib/udev/hwdb.bin ); $ENV{LC_ALL} = 'C'; check_free_space(); check_overlayfs(); check_uml(); go_faster(); # print the long error message if something fails due to autodie $SIG{__DIE__} = sub { fatal($_[0]); }; { foreach my $name (early_conversion_files()) { next if $name =~ m#^/usr/#; # already converted convert_file($name); } my @dirs = directories_to_merge(); # create any directory which is in / but not in /usr umask(0022); foreach my $dir (@dirs) { next if -e "/usr$dir"; next if not -d $dir; # but only if it actually exists in / mkdir("/usr$dir"); restore_context("/usr$dir"); } my @later; my $rule = File::Find::Rule->mindepth(1)->maxdepth(1)->start(@dirs); while (defined (my $name = $rule->match)) { convert_file($name, \@later); } # symlinks must be converted after the rest to avoid races, because # they may point to binaries which have not been converted yet if ($Is_Hurd) { while (@later) { my @newlater; convert_file($_, \@newlater) foreach @later; @later = @newlater; } } else { convert_file($_) foreach @later; } verify_links_only($_) foreach @dirs; convert_directory($_) foreach @dirs; exit($Program_RC) if $Program_RC; print "The system has been successfully converted.\n"; exit; } ############################################################################## sub convert_file { my ($n, $later) = @_; print "==== $n ====\n" if $Debug; # the source is a broken link if (-l $n and not -e $n and -e "/usr$n") { warn "WARNING: $n is a broken symlink and has been renamed!\n"; rename($n, "$n.usrmerge-broken"); return; } # the destination is a broken link if (-l "/usr$n" and not -e "/usr$n") { warn "WARNING: /usr$n is a broken symlink and has been renamed!\n"; rename("/usr$n", "/usr$n.usrmerge-broken"); # continue and move the source as usual } # is a directory and the destination does not exist if (-d $n and not -e "/usr$n") { mv($n, "/usr$n"); # XXX race symlink("/usr$n", $n); return; } # is a link and the destination does not exist, but defer the conversion if (-l $n and not -e "/usr$n" and $later and not $Is_Hurd) { push(@$later, $n); return; } # the same, but only tested on Hurd systems (see #1020463 for details) if (-l $n and not -e "/usr$n" and $later and $Is_Hurd) { my $l = readlink($n); my ($basedir) = $n =~ m#^(.+)/[^/]+$#; if ($l !~ m#^/# and not -e "/usr$basedir/$l") { fatal("Converted link /usr$n would point to non-existing" . " /usr$basedir/$l but we are not deferring conversion") if not $later; push(@$later, $n); return; } } # is a file or link and the destination does not exist if (not -e "/usr$n") { cp($n, "/usr$n"); symlink("/usr$n", "$n~~tmp~usrmerge~~"); rename("$n~~tmp~usrmerge~~", $n); # XXX alternative implementation: #mv($n, "/usr$n"); # XXX race #symlink("/usr$n", $n); return; } # The other cases are more complex and there are some corner cases that # we do not try to resolve automatically. # both source and dest are links if (-l $n and -l "/usr$n") { my $l1 = readlink($n); my $l2 = readlink("/usr$n"); my ($basedir) = $n =~ m#^(.+)/[^/]+$#; return if $l1 eq $l2; # and they point to the same file return if "$basedir/$l1" eq $l2; # same (the / link is relative) return if $l1 eq "/usr$basedir/$l2";# same (the /usr link is relative) return if $l1 eq "/usr$n"; # and the / link points to the /usr link if ($l2 eq $n) { # and the /usr link points to the / link # the target of the new link will be an absolute path, so it # may be different from the original one even if both point to # the same file symlink(abs_path($n), "/usr$n~~tmp~usrmerge~~"); rename("/usr$n~~tmp~usrmerge~~", "/usr$n"); # convert the /bin link too to make the program idempotent symlink(abs_path($n), "$n~~tmp~usrmerge~~"); rename("$n~~tmp~usrmerge~~", "$n"); return; } fatal("Both $n and /usr$n exist"); } # the source is a link if (-l $n) { my $l = readlink($n); return if $l eq "/usr$n"; # and it points to dest fatal("Both $n and /usr$n exist"); } # the destination is a link if (-l "/usr$n") { my $l = readlink("/usr$n"); if ($l eq $n) { # and it points to source cp($n, "/usr$n~~tmp~usrmerge~~"); rename("/usr$n~~tmp~usrmerge~~", "/usr$n"); symlink("/usr$n", "$n~~tmp~usrmerge~~"); rename("$n~~tmp~usrmerge~~", $n); # XXX alternative implementation: #mv($n, "/usr$n"); # XXX race #symlink("/usr$n", $n); return; } fatal("Both $n and /usr$n exist"); } # both source and dest are directories # this is the second most common case if (-d $n and -d "/usr$n") { # so they have to be merged recursively my $rule = File::Find::Rule->mindepth(1)->maxdepth(1)->start($n); while (defined (my $name = $rule->match)) { convert_file($name, $later); } return; } fatal("$n is a directory and /usr$n is not") if -d $n and -e "/usr$n"; fatal("/usr$n is a directory and $n is not") if -d "/usr$n"; if (-e "/usr$n" and exists $Ignore_In_Root{$n}) { rm($n); return; } fatal("Both $n and /usr$n exist") if -e "/usr$n"; fatal("The status of $n and /usr$n is really unexpected"); } ############################################################################## # To prevent a failure later, the regular files of the libraries used by # cp and mv must be converted before of the symlinks that point to them. sub early_conversion_files { no autodie qw(close); my $bin_cp = '/bin/cp'; $bin_cp = '/usr/bin/cp' unless -x $bin_cp; open(my $fh, '-|', "ldd $bin_cp"); my @ldd = <$fh>; close $fh or fatal("Failed to execute 'ldd $bin_cp'"); # the libraries my @list = grep { $_ } map { /^\s+\S+ => (\/\S+) / and $1 } @ldd; # the dynamic linker push(@list, grep { $_ } map { /^\s+(\/\S+) \(/ and $1 } @ldd); # this is the equivalent of readlink --canonicalize my @newlist; foreach my $name (@list) { my $newname = -l $name ? readlink($name) : $name; if ($newname !~ m#^/#) { my $dir = $name; $dir =~ s#[^/]+$##; $newname = $dir . $newname; } my $topdir = $newname; $topdir =~ s#^(/[^/]+).*#$1#; # this is needed to be idempotent after a complete conversion next if -l $topdir; push(@newlist, $newname); } return @newlist; } ############################################################################## # Safety check: verify that there are no regular files in the directories # that will be deleted by the final pass. sub verify_links_only { my ($dir) = @_; my $link_or_dir = File::Find::Rule->or( File::Find::Rule->symlink, File::Find::Rule->directory, ); my $rule = File::Find::Rule->mindepth(1)->not($link_or_dir)->start($dir); while (defined (my $name = $rule->match)) { print STDERR "$name is not a symlink!\n"; print STDERR "\nSafety check: the conversion has failed!\n"; exit 1; } } sub convert_directory { my ($dir) = @_; return if -l $dir; if (not -d $dir) { # do not create a broken symlink to /usr$dir if it does not exist return if not -e "usr$dir"; symlink("usr$dir", $dir); restore_context($dir); return; } no autodie; if (not rename($dir, "$dir~~delete~usrmerge~~")) { # XXX race if ($!{EBUSY}) { handle_ebusy($dir); return; } die "Can't rename $dir: $!"; } use autodie; symlink("usr$dir", $dir); restore_context($dir); rm('-rf', "$dir~~delete~usrmerge~~"); } sub restore_context { my ($file) = @_; return if not (-x '/sbin/restorecon' or -x '/usr/sbin/restorecon'); safe_system('restorecon', $file); } sub handle_ebusy { my ($dir) = @_; print STDERR <; die "stat / failed" if not defined $root_id; chomp $root_id; # beware: df(1) reports the value of %a, not of %f open($fh, '-|', 'stat --dereference --file-system --format="%f %S %i" /usr/'); my $stat_output = <$fh>; die "stat /usr failed" if not defined $stat_output; chomp $stat_output; my ($free_blocks, $bs, $usr_id) = split(/ /, $stat_output); return if $root_id eq $usr_id; my $free = $free_blocks * ($bs / 1024); my @dirs = grep { -e $_ } directories_to_merge(); my $cmd = "du --summarize --no-dereference --total --block-size=1K @dirs"; open($fh, '-|', $cmd); my $needed; while (<$fh>) { ($needed) = /^(\d+)\s+total$/; } close $fh or fatal("Failed to execute $cmd"); die "$cmd failed" if not defined $needed; say "Free space in /usr: $free KB." if $Debug; say "The origin directories (@dirs) require $needed KB." if $Debug; die "$free KB in /usr but $needed KB are required!\n" if $needed > $free; } # check if we are running under overlayfs, and if so, try and see if directories # are movable - we might be running in a container, thus running under overlayfs # but still able to do the conversion because the chroot is not overlayed. sub check_overlayfs { no autodie qw(close); my $fh; open($fh, '-|', "stat --file-system --format=%T /"); my $fs_type = <$fh>; close $fh or fatal("Failed to execute stat --file-system --format=%T /"); die "stat / failed" if not defined $fs_type; chomp $fs_type; return if $fs_type ne "overlayfs"; # We have detected overlayfs on /. Let's see if we can move the directories # with a (hopefully) quick back-and-forth. This is not risk-free, but it's # the best we can do. my @dirs = directories_to_merge(); foreach my $dir (@dirs) { next if not -e "$dir"; no autodie qw(rename); if ((not rename("$dir", "$dir~usrmerge~")) && $!{EXDEV}) { die("Warning: overlayfs detected, /usr/lib/usrmerge/convert-usrmerge will not be run automatically. See #1008202 for details. If this is a container then it can be converted by unpacking the image, entering it with chroot(8), installing usrmerge and then repacking the image again."); } use autodie qw(rename); rename("$dir~usrmerge~", "$dir"); } } sub is_hurd { my $architecture = qx{dpkg --print-architecture}; return 1 if $architecture =~ /^hurd-/; return 0; } sub is_mount_point { my ($path) = @_; my @cmd = ('mountpoint', '-q', $path); my $rc = system(@cmd); die "Failed to execute @cmd: $!\n" if $rc == -1; return 0 if $rc; return 1; } # Check if something is mounted on /lib/modules/ in User Mode Linux systems. # See #1021180 for details. sub check_uml { return if not is_mount_point('/lib/modules/'); print STDERR < is / and I is /usr. Types of files: =over 4 =item F: file =item D: directory =item L: symbolic link =item B: broken symbolic link =back Actions: =over 4 =item X: unresolvable conflict =item D: recurse and compare the content of the directories =item K: keep the source (if the link matches the destination) =item S: swap source and destination (if the link matches the destination) =item R: rename (source or destination) =back =head1 BUGS Replacing a directory with a symlink is racy unless we do a complex dance of bind mounts. We should decide if this is really needed. Conflicting relative symbolic links are not handled automatically. The libc6-i386 and libc6-x32 packages require to convert the /lib32 and /libx32 directories as well, otherwise they would only contain the symlink to the dynamic linker specified by the architecture ABI. =head1 AUTHOR The program and this man page have been written by Marco d'Itri and are licensed under the terms of the GNU General Public License, version 2 or higher.