File Coverage

blib/lib/Doit/Deb.pm
Criterion Covered Total %
statement 82 181 45.3
branch 26 106 24.5
condition 3 33 9.0
subroutine 9 13 69.2
pod 5 7 71.4
total 125 340 36.7


line stmt bran cond sub pod time code
1             # -*- perl -*-
2              
3             #
4             # Author: Slaven Rezic
5             #
6             # Copyright (C) 2017,2018,2020,2025,2026 Slaven Rezic. All rights reserved.
7             # This package is free software; you can redistribute it and/or
8             # modify it under the same terms as Perl itself.
9             #
10             # WWW: https://github.com/eserte/Doit
11             #
12              
13             package Doit::Deb; # Convention: all commands here should be prefixed with 'deb_'
14              
15 3     3   397 use strict;
  3         5  
  3         99  
16 3     3   12 use warnings;
  3         6  
  3         171  
17             our $VERSION = '0.031';
18              
19 3     3   13 use Doit::Log;
  3         4  
  3         227  
20 3     3   15 use Doit::Util 'get_sudo_cmd', 'get_os_release';
  3         4  
  3         5798  
21              
22 2     2 0 19 sub new { bless {}, shift }
23 2     2 0 7 sub functions { qw(deb_install_packages deb_missing_packages deb_install_key deb_add_repository deb_upgradeable_packages) }
24              
25             sub deb_install_packages {
26 0     0 1 0 my($self, @packages) = @_;
27 0         0 my @missing_packages = $self->deb_missing_packages(@packages); # XXX cmd vs. info???
28 0 0       0 if (@missing_packages) {
29 0         0 $self->system(get_sudo_cmd(), 'apt-get', '-y', 'install', @missing_packages);
30             }
31 0         0 @missing_packages;
32             }
33              
34              
35             sub deb_missing_packages {
36 2     2 1 3 my($self, @packages) = @_;
37              
38 2         3 my @missing_packages;
39              
40 2 50       4 if (@packages) {
41 2         490 require IPC::Open3;
42 2         2373 require Symbol;
43              
44 2         3 my %seen_packages;
45             my %required_version;
46 2         5 for my $package (@packages) {
47 2 50       7 if (ref $package eq 'ARRAY') {
48 0         0 my($package_name, $package_version) = @$package;
49 0         0 $required_version{$package_name} = $package_version;
50 0         0 $package = $package_name;
51             }
52             }
53 2         4 my @cmd = ('dpkg-query', '-W', '-f=${Package}\t${Status}\t${Version}\n', @packages);
54 2         12 my $err = Symbol::gensym();
55 2         33 my $fh;
56 2 50       5 my $pid = IPC::Open3::open3(undef, $fh, $err, @cmd)
57             or error "Error running '@cmd': $!";
58 2         19377 while(<$fh>) {
59 1         27 chomp;
60 1 50       29 if (m{^([^\t]+)\t([^\t]+)\t([^\t]*)$}) {
61 1 50       21 if ($2 ne 'install ok installed') {
62 0         0 push @missing_packages, $1;
63             }
64 1 50 33     25 if ($required_version{$1} && $required_version{$1} ne $3) {
65 0         0 push @missing_packages, $1;
66             }
67 1         161 $seen_packages{$1} = 1;
68             } else {
69 0         0 warning "cannot parse '$_', ignore line...";
70             }
71             }
72 2         43 waitpid $pid, 0;
73 2         11 for my $package (@packages) {
74 2 100       49 if (!$seen_packages{$package}) {
75 1         40 push @missing_packages, $package;
76             }
77             }
78             }
79 2         37 @missing_packages;
80             }
81              
82             sub deb_upgradeable_packages {
83 1     1 1 7 my($self) = @_;
84              
85 1         2 my @upgradeable_packages;
86              
87 1         12 require IPC::Open3;
88 1         3 require Symbol;
89              
90 1         3 my @cmd = ('apt-get', '-s', 'upgrade');
91 1         10 my $err = Symbol::gensym();
92 1         21 my $fh;
93 1 50       3 my $pid = IPC::Open3::open3(undef, $fh, $err, @cmd)
94             or error "Error running '@cmd': $!";
95 1         47302 while(<$fh>) {
96 5         37 chomp;
97             # Parse lines like: Inst package_name [old_version] (new_version ...)
98 5 50       19667 if (m{^Inst\s+(\S+)}) {
99 0         0 push @upgradeable_packages, $1;
100             }
101             }
102 1         26 waitpid $pid, 0;
103 1         14 my $exit_code = $? >> 8;
104 1 50       12 if ($exit_code != 0) {
105 0         0 error "Command '@cmd' failed with exit code $exit_code";
106             }
107              
108 1         59 @upgradeable_packages;
109             }
110              
111             sub deb_install_key {
112 0     0 1 0 my($self, %opts) = @_;
113 0         0 my $url = delete $opts{url};
114 0         0 my $keyserver = delete $opts{keyserver};
115 0         0 my $key = delete $opts{key};
116 0         0 my $file = delete $opts{file};
117 0 0       0 error "Unhandled options: " . join(" ", %opts) if %opts;
118              
119 0 0       0 if (!$url) {
120 0 0       0 if (!$keyserver) {
121 0         0 error "keyserver is missing";
122             }
123 0 0       0 if (!$key) {
124 0         0 error "key is missing";
125             }
126             } else {
127 0 0       0 if ($keyserver) {
128 0         0 error "Don't define both url and keyserver";
129             }
130             }
131              
132 0         0 my $is_modern = _missing_apt_key();
133              
134 0 0       0 if ($is_modern) {
135 0 0       0 if (!defined $file) {
136 0         0 error "For Debian >= 13 and Ubuntu >= 24.04 the 'file' option must be set (to something like /etc/apt/keyrings/...gpg)";
137             }
138             }
139              
140 0         0 my $found_key;
141 0 0       0 if ($key) {
142 0         0 $key =~ s{\s}{}g; # convenience: strip spaces from key ('apt-key finger' returns them with spaces)
143 0         0 local $ENV{LC_ALL} = 'C';
144             # XXX If run with $sudo, then this will emit warnings in the form
145             # gpg: WARNING: unsafe ownership on configuration file `$HOME/.gnupg/gpg.conf'
146             # Annoying, but harmless. Could be workarounded by specifying
147             # '--homedir=/root/.gpg', but this would create gpg files under ~root. Similar
148             # if using something like
149             # local $ENV{HOME} = (getpwuid($<))[7];
150             # Probably better would be to work with privilege escalation and run
151             # this command as normal user (to be implemented).
152             #
153             # Older Debian (jessie and older?) have only /etc/apt/trusted.gpg,
154             # newer ones (stretch and newer?) have /etc/apt/trusted.gpg.d/*.gpg
155             SEARCH_FOR_KEY: {
156 0         0 my @gpg_files;
  0         0  
157 0 0       0 if (defined $file) {
158 0         0 @gpg_files = $file;
159             } else {
160 0         0 require File::Glob;
161 0         0 @gpg_files = ('/etc/apt/trusted.gpg', File::Glob::bsd_glob('/etc/apt/trusted.gpg.d/*.gpg'));
162             }
163 0         0 for my $keyfile (@gpg_files) {
164 0 0       0 if (-r $keyfile) {
165 0         0 my @cmd = ('gpg', '--keyring', $keyfile, '--list-keys', '--fingerprint', '--with-colons');
166 0 0       0 open my $fh, '-|', @cmd
167             or error "Running '@cmd' failed: $!";
168 0         0 while(<$fh>) {
169 0 0       0 if (m{^fpr:::::::::\Q$key\E:$}) {
170 0         0 $found_key = 1;
171 0         0 last SEARCH_FOR_KEY;
172             }
173             }
174 0 0       0 close $fh
175             or error "Running '@cmd' failed: $!";
176             }
177             }
178             }
179             }
180              
181 0         0 my $changed = 0;
182              
183 0 0       0 if (!$found_key) {
184 0 0       0 if ($keyserver) {
    0          
185 0         0 $self->system(get_sudo_cmd(), 'apt-key', 'adv', '--keyserver', $keyserver, '--recv-keys', $key);
186             } elsif ($url) {
187 0         0 my @fetch_cmd;
188 0 0       0 if ($self->which('curl')) {
189 0         0 @fetch_cmd = ('curl', '-fsSL', $url);
190             } else {
191 0         0 @fetch_cmd = ('wget', '-O-', $url); # other alternative would be lwp-request
192             }
193 0         0 my @add_cmd;
194 0 0       0 if ($is_modern) {
195 0         0 @add_cmd = (get_sudo_cmd(), 'gpg', '--dearmor', '-o', $file);
196             } else {
197 0         0 @add_cmd = (get_sudo_cmd(), 'apt-key', 'add', '-');
198             }
199 0 0       0 if ($self->is_dry_run) {
200 0         0 info "Fetch key using '@fetch_cmd' and add using '@add_cmd' (dry-run)";
201             } else {
202 0 0       0 open my $ifh, '-|', @fetch_cmd
203             or error "Failed to start '@fetch_cmd': $!";
204 0 0       0 open my $ofh, '|-', @add_cmd
205             or error "Failed to start '@add_cmd': $!";
206 0         0 local $/ = \1024;
207 0         0 while(<$ifh>) {
208 0         0 print $ofh $_;
209             }
210 0 0       0 close $ofh
211             or error "Running '@add_cmd' failed: $!";
212 0 0       0 close $ifh
213             or error "Running '@fetch_cmd' failed: $!";
214             }
215             } else {
216 0         0 error "Shouldn't happen (either url or keyserver has to be specified)";
217             }
218 0         0 $changed = 1;
219             }
220 0         0 $changed;
221             }
222              
223             sub _missing_apt_key {
224 0     0   0 my $os_release = get_os_release();
225 0 0 0     0 if (($os_release->{ID} eq 'debian' && (($os_release->{VERSION_ID}||0) >= 13 || $os_release->{VERSION_CODENAME} =~ m{^(trixie|sid)$})) ||
      0        
      0        
      0        
226             ($os_release->{ID} eq 'ubuntu' && $os_release->{VERSION_ID} >= 24.04)) {
227 0         0 return 1;
228             } else {
229 0         0 return 0;
230             }
231             }
232              
233             sub deb_add_repository {
234 0     0 1 0 my($self, $name, $contents, %opts) = @_;
235 0         0 my $update = delete $opts{update};
236 0 0       0 error "Unhandled options: " . join(" ", %opts) if %opts;
237              
238 0         0 my $os_release = get_os_release();
239 0 0       0 my $debian_ver = $os_release->{ID} eq 'debian' ? $os_release->{VERSION_ID} : undef;
240 0 0       0 my $ubuntu_ver = $os_release->{ID} eq 'ubuntu' ? $os_release->{VERSION_ID} : undef;
241              
242 0         0 my $sources_file = "/etc/apt/sources.list.d/$name.sources";
243 0         0 my $list_file = "/etc/apt/sources.list.d/$name.list";
244              
245 0         0 my ($target_file, $final_contents);
246              
247 0 0 0     0 if (($debian_ver && $debian_ver >= 13) ||
      0        
      0        
248             ($ubuntu_ver && $ubuntu_ver >= 24.04)) {
249             # Use new deb822 format directly
250 0         0 $target_file = $sources_file;
251 0         0 $final_contents = $contents;
252              
253             # If a legacy .list file exists, remove it
254 0 0       0 if (-e $list_file) {
255 0         0 $self->unlink($list_file);
256             }
257             } else {
258             # Convert to old-style .list format
259 0         0 $target_file = $list_file;
260 0         0 $final_contents = _convert_sources_to_list($contents);
261              
262             # If a .sources file exists, remove it
263 0 0       0 if (-e $sources_file) {
264 0         0 $self->unlink($sources_file);
265             }
266             }
267              
268 0         0 my $changed = $self->write_binary($target_file, $final_contents);
269 0 0 0     0 if ($changed && $update) {
270 0         0 $self->system('apt-get', 'update', '-qq');
271             }
272 0         0 $changed;
273             }
274              
275             sub _convert_sources_to_list {
276 6     6   125298 my($contents) = @_;
277              
278 6         11 my %stanza;
279             my $current_key;
280              
281 6         24 for my $line (split /\n/, $contents) {
282 29 50 33     109 next if $line =~ /^\s*#/ || $line =~ /^\s*$/;
283              
284 29 100 33     115 if ($line =~ /^(\S+):\s*(.*)$/) {
    50          
285 28         41 $current_key = lc $1;
286 28 50       44 push @{ $stanza{$current_key} }, split /\s+/, $2 if $2 ne '';
  28         93  
287             }
288             elsif ($line =~ /^\s+(\S.*)$/ && $current_key) {
289 1         1 push @{ $stanza{$current_key} }, split /\s+/, $1;
  1         3  
290             }
291             }
292              
293 6         8 my @lines;
294 6 50       7 for my $type (@{ $stanza{types} || ['deb'] }) {
  6         14  
295 8 50       5 for my $uri (@{ $stanza{uris} || [] }) {
  8         16  
296 8 50       9 for my $suite (@{ $stanza{suites} || [] }) {
  8         14  
297 9 50       9 my $components = join " ", @{ $stanza{components} || [] };
  9         49  
298 9         11 my @opts;
299              
300 9 100       15 if ($stanza{architectures}) {
301 3         4 push @opts, "arch=" . join(",", @{ $stanza{architectures} });
  3         6  
302             }
303 9 100       14 if ($stanza{'signed-by'}) {
304 3         5 push @opts, map { "signed-by=$_"} @{ $stanza{'signed-by'} };
  4         11  
  3         6  
305             }
306              
307 9 100       20 my $options = @opts ? "[" . join(" ", @opts) . "] " : "";
308 9         27 push @lines, "$type ${options}$uri $suite $components";
309             }
310             }
311             }
312              
313 6         46 return join("\n", @lines) . "\n";
314             }
315              
316             1;
317              
318             __END__