# menu format -- lintian check script -*- perl -*- # Copyright (C) 1998 by Joey Hess # Copyright (C) 2017-2018 Chris Lamb # # 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. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, you can find it on the World Wide # Web at https://www.gnu.org/copyleft/gpl.html, or write to the Free # Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, # MA 02110-1301, USA. # This script also checks desktop entries, since they share quite a bit of # code. At some point, it would make sense to try to refactor this so that # shared code is in libraries. # # Further things that the desktop file validation should be checking: # # - Encoding of the file should be UTF-8. # - Additional Categories should be associated with Main Categories. # - List entries (MimeType, Categories) should end with a semicolon. # - Check for GNOME/GTK/X11/etc. dependencies and require the relevant # Additional Category to be present. # - Check all the escape characters supported by Exec. # - Review desktop-file-validate to see what else we're missing. package Lintian::Check::MenuFormat; use v5.20; use warnings; use utf8; use Const::Fast; use File::Basename; use List::SomeUtils qw(any first_value); use Unicode::UTF8 qw(encode_utf8); use Moo; use namespace::clean; with 'Lintian::Check'; const my $EMPTY => q{}; const my $SPACE => q{ }; const my $SLASH => q{/}; const my $MAXIMUM_SIZE_STANDARD_ICON => 32; const my $MAXIMUM_SIZE_32X32_ICON => 32; const my $MAXIMUM_SIZE_16X16_ICON => 16; # This is a list of all tags that should be in every menu item. my @req_tags = qw(needs section title command); # This is a list of all known tags. my @known_tags = qw( needs section title sort command longtitle icon icon16x16 icon32x32 description hotkey hints ); # These 'needs' tags are always valid, no matter the context, and no other # values are valid outside the Window Managers context (don't include wm here, # in other words). It's case insensitive, use lower case here. my @needs_tag_vals = qw(x11 text vc); has MENU_SECTIONS => ( is => 'rw', lazy => 1, default => sub { my ($self) = @_; my %menu_sections; my $data = $self->data->load('menu-format/menu-sections'); for my $key ($data->all) { my ($root, $under) = split(m{/}, $key, 2); $under //= $EMPTY; # $under is empty if this is just a root section $menu_sections{$root}{$under} = 1; } return \%menu_sections; } ); # Authoritative source of desktop keys: # https://specifications.freedesktop.org/desktop-entry-spec/latest/ # # This is a list of all keys that should be in every desktop entry. my @req_desktop_keys = qw(Type Name); # This is a list of all known keys. has KNOWN_DESKTOP_KEYS => ( is => 'rw', lazy => 1, default => sub { my ($self) = @_; return $self->data->load('menu-format/known-desktop-keys'); } ); has DEPRECATED_DESKTOP_KEYS => ( is => 'rw', lazy => 1, default => sub { my ($self) = @_; return $self->data->load('menu-format/deprecated-desktop-keys'); } ); # KDE uses some additional keys that should start with X-KDE but don't for # historical reasons. has KDE_DESKTOP_KEYS => ( is => 'rw', lazy => 1, default => sub { my ($self) = @_; return $self->data->load('menu-format/kde-desktop-keys'); } ); # Known types of desktop entries. # https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s06.html my %known_desktop_types = map { $_ => 1 } qw( Application Link Directory ); # Authoritative source of desktop categories: # https://specifications.freedesktop.org/menu-spec/latest/apa.html # This is a list of all Main Categories for .desktop files. Application is # added as an exception; it's not listed in the standard, but it's widely used # and used as an example in the GNOME documentation. GNUstep is added as an # exception since it's used by GNUstep packages. my %main_categories = map { $_ => 1 } qw( AudioVideo Audio Video Development Education Game Graphics Network Office Science Settings System Utility Application GNUstep ); # This is a list of all Additional Categories for .desktop files. Ideally we # should be checking to be sure the associated Main Categories are present, # but we don't have support for that yet. has ADD_CATEGORIES => ( is => 'rw', lazy => 1, default => sub { my ($self) = @_; return $self->data->load('menu-format/add-categories'); } ); # This is a list of Reserved Categories for .desktop files. To use one of # these, the desktop entry must also have an OnlyShowIn key limiting the # environment to one that supports this category. my %reserved_categories = map { $_ => 1 } qw( Screensaver TrayIcon Applet Shell ); # Path in which to search for binaries referenced in menu entries. These must # not have leading slashes. my @path = qw(usr/local/bin/ usr/bin/ bin/ usr/games/); my %known_tags_hash = map { $_ => 1 } @known_tags; my %needs_tag_vals_hash = map { $_ => 1 } @needs_tag_vals; # ----------------------------------- sub installable { my ($self) = @_; my $index = $self->processable->installed; my (@menufiles, %desktop_cmds); for my $dirname (qw(usr/share/menu/ usr/lib/menu/)) { if (my $dir = $index->resolve_path($dirname)) { push(@menufiles, $dir->children); } } # Find the desktop files in the package for verification. my @desktop_files; for my $subdir (qw(applications xsessions)) { if (my $dir = $index->lookup("usr/share/$subdir/")) { for my $item ($dir->children) { next unless $item->is_file; next if $item->is_dir; next unless $item->basename =~ /\.desktop$/; $self->pointed_hint('executable-desktop-file', $item->pointer, $item->octal_permissions) if $item->is_executable; push(@desktop_files, $item) unless $item->name =~ / template /msx; } } } # Verify all the desktop files. for my $desktop_file (@desktop_files) { $self->verify_desktop_file($desktop_file, \%desktop_cmds); } # Now all the menu files. for my $menufile (@menufiles) { # Do not try to parse executables next if $menufile->is_executable or not $menufile->is_open_ok; # README is a special case next if $menufile->basename eq 'README' && !$menufile->is_dir; my $menufile_line =$EMPTY; open(my $fd, '<', $menufile->unpacked_path) or die encode_utf8('Cannot open ' . $menufile->unpacked_path); # line below is commented out in favour of the while loop # do { $_=; } while defined && (m/^\s* \#/ || m/^\s*$/); while (my $line = <$fd>) { if ($line =~ /^\s*\#/ || $line =~ /^\s*$/) { next; } else { $menufile_line = $line; last; } } # Check first line of file to see if it matches the new menu # file format. if ($menufile_line =~ m/^!C\s*menu-2/) { # we can't parse that yet close($fd); next; } # Parse entire file as a new format menu file. my $line=$EMPTY; my $lc=0; do { $lc++; # Ignore lines that are comments. if ($menufile_line =~ m/^\s*\#/) { next; } $line .= $menufile_line; # Note that I allow whitespace after the continuation character. # This is caught by verify_line(). if (!($menufile_line =~ m/\\\s*?$/)) { $self->verify_line($menufile, $line,$lc,\%desktop_cmds); $line=$EMPTY; } } while ($menufile_line = <$fd>); $self->verify_line($menufile, $line,$lc,\%desktop_cmds); close($fd); } return; } # ----------------------------------- # Pass this a line of a menu file, it sanitizes it and # verifies that it is correct. sub verify_line { my ($self, $menufile, $line, $position,$desktop_cmds) = @_; my $pointer = $menufile->pointer($position); my %vals; chomp $line; # Replace all line continuation characters with whitespace. # (do not remove them completely, because update-menus doesn't) $line =~ s/\\\n/ /mg; # This is in here to fix a common mistake: whitespace after a '\' # character. if ($line =~ s/\\\s+\n/ /mg) { $self->pointed_hint('whitespace-after-continuation-character', $pointer); } # Ignore lines that are all whitespace or empty. return if $line =~ m/^\s*$/; # Ignore lines that are comments. return if $line =~ m/^\s*\#/; # Start by testing the package check. if (not $line =~ m/^\?package\((.*?)\):/) { $self->pointed_hint('bad-test-in-menu-item', $pointer); return; } my $pkg_test = $1; my %tested_packages = map { $_ => 1 } split(/\s*,\s*/, $pkg_test); my $tested_packages = scalar keys %tested_packages; unless (exists $tested_packages{$self->processable->name}) { $self->pointed_hint('pkg-not-in-package-test', $pointer, $pkg_test); } $line =~ s/^\?package\(.*?\)://; # Now collect all the tag=value pairs. I've heavily commented # the killer regexp that's responsible. # # The basic idea here is we start at the beginning of the line. # Each loop pulls off one tag=value pair and advances to the next # when we have no more matches, there should be no text left on # the line - if there is, it's a parse error. while ( $line =~ m{ \s*? # allow whitespace between pairs ( # capture what follows in $1, it's our tag [^\"\s=] # a non-quote, non-whitespace, character * # match as many as we can ) = ( # capture what follows in $2, it's our value (?: \" # this is a quoted string (?: \\. # any quoted character | # or [^\"] # a non-quote character ) * # repeat as many times as possible \" # end of the quoted value string ) | # the other possibility is a non-quoted string (?: [^\"\s] # a non-quote, non-whitespace character * # match as many times as we can ) ) }gcx ) { my $tag = $1; my $value = $2; if (exists $vals{$tag}) { $self->pointed_hint('duplicate-tag-in-menu', $pointer, $1); } # If the value was quoted, remove those quotes. if ($value =~ m/^\"(.*)\"$/) { $value = $1; } else { $self->pointed_hint('unquoted-string-in-menu-item',$pointer, $1); } # If the value has escaped characters, remove the # escapes. $value =~ s/\\(.)/$1/g; $vals{$tag} = $value; } # This is not really a no-op. Note the use of the /c # switch - this makes perl keep track of the current # search position. Notice, we did it above in the loop, # too. (I have a /g here just so the /c takes affect.) # We use this below when we look at how far along in the # string we matched. So the point of this line is to allow # trailing whitespace on the end of a line. $line =~ m/\s*/gc; # If that loop didn't match up to end of line, we have a # problem.. if (pos($line) < length($line)) { $self->pointed_hint('unparsable-menu-item', $pointer); # Give up now, before things just blow up in our face. return; } # Now validate the data in the menu file. # Test for important tags. for my $tag (@req_tags) { unless (exists($vals{$tag}) && defined($vals{$tag})) { $self->pointed_hint('menu-item-missing-required-tag', $pointer, $tag); # Just give up right away, if such an essential tag is missing, # chance is high the rest doesn't make sense either. And now all # following checks can assume those tags to be there return; } } # Make sure all tags are known. for my $tag (keys %vals) { if (!$known_tags_hash{$tag}) { $self->pointed_hint('menu-item-contains-unknown-tag', $pointer, $tag); } } # Sanitize the section tag my $section = $vals{'section'}; $section =~ tr:/:/:s; # eliminate duplicate slashes. # Hallo emacs ;; $section =~ s{/$}{} # remove trailing slash unless $section eq $SLASH; # - except if $section is '/' # Be sure the command is provided by the package. my ($okay, $command) = $self->verify_cmd($pointer, $vals{'command'}); $self->pointed_hint('menu-command-not-in-package', $pointer, $command) if !$okay && length $command && $tested_packages < 2 && $section !~ m{^(?:WindowManagers/Modules|FVWM Modules|Window Maker)}; if (length $command) { $command =~ s{^(?:usr/)?s?bin/}{}; $command =~ s{^usr/games/}{}; $self->pointed_hint('command-in-menu-file-and-desktop-file', $pointer, $command) if $desktop_cmds->{$command}; } $self->verify_icon('icon', $vals{'icon'},$MAXIMUM_SIZE_STANDARD_ICON, $pointer); $self->verify_icon('icon32x32', $vals{'icon32x32'}, $MAXIMUM_SIZE_32X32_ICON, $pointer); $self->verify_icon('icon16x16', $vals{'icon16x16'}, $MAXIMUM_SIZE_16X16_ICON, $pointer); # needs is case insensitive my $needs = lc($vals{'needs'}); if ($section =~ m{^(WindowManagers/Modules|FVWM Modules|Window Maker)}) { # WM/Modules: needs must not be the regular ones nor wm $self->pointed_hint('non-wm-module-in-wm-modules-menu-section', $pointer, $needs) if $needs_tag_vals_hash{$needs} || $needs eq 'wm'; } elsif ($section =~ m{^Window ?Managers}) { # Other WM sections: needs must be wm $self->pointed_hint('non-wm-in-windowmanager-menu-section', $pointer, $needs) unless $needs eq 'wm'; } else { # Any other section: just only the general ones if ($needs eq 'dwww') { $self->pointed_hint('menu-item-needs-dwww', $pointer); } elsif (!$needs_tag_vals_hash{$needs}) { $self->pointed_hint('menu-item-needs-tag-has-unknown-value', $pointer, $needs); } } # Check the section tag # Check for historical changes in the section tree. if ($section =~ m{^Apps/Games}) { $self->pointed_hint('menu-item-uses-apps-games-section', $pointer); $section =~ s{^Apps/}{}; } if ($section =~ m{^Apps/}) { $self->pointed_hint('menu-item-uses-apps-section', $pointer); $section =~ s{^Apps/}{Applications/}; } if ($section =~ m{^WindowManagers}) { $self->pointed_hint('menu-item-uses-windowmanagers-section', $pointer); $section =~ s{^WindowManagers}{Window Managers}; } # Check for Evil new root sections. my ($rootsec, $sect) = split(m{/}, $section, 2); my $root_data = $self->MENU_SECTIONS->{$rootsec}; if (!defined $root_data) { my $pkg = $self->processable->name; $self->pointed_hint('menu-item-creates-new-root-section', $pointer, $rootsec) unless $rootsec =~ /$pkg/i; } else { $self->pointed_hint('menu-item-creates-new-section', $pointer, $vals{section}) if (length $sect && !exists $root_data->{$sect}) || (!length $sect && !exists $root_data->{$EMPTY}); } return; } sub verify_icon { my ($self, $tag, $name, $size, $pointer)= @_; return unless length $name; if ($name eq 'none') { $self->pointed_hint('menu-item-uses-icon-none', $pointer, $tag); return; } $self->pointed_hint('menu-icon-uses-relative-path', $pointer, $tag, $name) unless $name =~ s{^/+}{}; if ($name !~ /\.xpm$/i) { $self->pointed_hint('menu-icon-not-in-xpm-format', $pointer, $tag, $name); return; } my @packages = ( $self->processable, @{ $self->group->direct_dependencies($self->processable) } ); my @candidates; for my $processable (@packages) { push(@candidates, $processable->installed->resolve_path($name)); push(@candidates, $processable->installed->resolve_path("usr/share/pixmaps/$name")); } my $iconfile = first_value { defined } @candidates; if (!defined $iconfile || !$iconfile->is_open_ok) { $self->pointed_hint('menu-icon-missing', $pointer, $tag, $name); return; } open(my $fd, '<', $iconfile->unpacked_path) or die encode_utf8('Cannot open ' . $iconfile->unpacked_path); my $parse = 'XPM header'; my $line; do { defined($line = <$fd>) or goto PARSE_ERROR; } until ($line =~ /\/\*\s*XPM\s*\*\//); $parse = 'size line'; do { defined($line = <$fd>) or goto PARSE_ERROR; } until ($line =~ /"\s*([0-9]+)\s*([0-9]+)\s*(?:[0-9]+)\s*(?:[0-9]+)\s*"/); my $width = $1 + 0; my $height = $2 + 0; if ($width > $size || $height > $size) { $self->pointed_hint('menu-icon-too-big', $pointer, $tag, "$name: ${width}x${height} > ${size}x${size}"); } close($fd); return; PARSE_ERROR: close($fd); $self->pointed_hint('menu-icon-cannot-be-parsed', $pointer, $tag, "$name: looking for $parse"); return; } # Syntax-checks a .desktop file. sub verify_desktop_file { my ($self, $item, $desktop_cmds) = @_; my ($saw_first, $warned_cr, %vals, @pending); open(my $fd, '<', $item->unpacked_path) or die encode_utf8('Cannot open ' . $item->unpacked_path); my $position = 1; while (my $line = <$fd>) { chomp $line; my $pointer = $item->pointer($position); next if $line =~ /^\s*\#/ || $line =~ /^\s*$/; if ($line =~ s/\r//) { $self->pointed_hint('desktop-entry-file-has-crs', $pointer) unless $warned_cr; $warned_cr = 1; } # Err on the side of caution for now. If the first non-comment line # is not the required [Desktop Entry] group, ignore this file. Also # ignore any keys in other groups. last if $saw_first && $line =~ /^\[(.*)\]\s*$/; unless ($saw_first) { return unless $line =~ /^\[(KDE )?Desktop Entry\]\s*$/; $saw_first = 1; $self->pointed_hint('desktop-contains-deprecated-key', $pointer) if $line =~ /^\[KDE Desktop Entry\]\s*$/; } # Tag = Value. For most errors, just add the error to pending rather # than warning on it immediately since we want to not warn on tag # errors if we didn't know the file type. # # TODO: We do not check for properly formatted localised values for # keys but might be worth checking if they are properly formatted (not # their value) if ($line =~ /^(.*?)\s*=\s*(.*)$/) { my ($tag, $value) = ($1, $2); my $basetag = $tag; $basetag =~ s/\[([^\]]+)\]$//; if (exists $vals{$tag}) { $self->pointed_hint('duplicate-key-in-desktop', $pointer,$tag); } elsif ($self->DEPRECATED_DESKTOP_KEYS->recognizes($basetag)) { if ($basetag eq 'Encoding') { push(@pending, ['desktop-entry-contains-encoding-key',$pointer, $tag] ); } else { push( @pending, [ 'desktop-entry-contains-deprecated-key', $pointer, $tag ] ); } } elsif (not $self->KNOWN_DESKTOP_KEYS->recognizes($basetag) and not $self->KDE_DESKTOP_KEYS->recognizes($basetag) and not $basetag =~ /^X-/) { push(@pending, ['desktop-entry-contains-unknown-key', $pointer, $tag]); } $vals{$tag} = $value; } } continue { ++$position; } close($fd); # Now validate the data in the desktop file, but only if it's a known type. # Warn if it's not. my $type = $vals{'Type'}; return unless defined $type; unless ($known_desktop_types{$type}) { $self->pointed_hint('desktop-entry-unknown-type', $item->pointer, $type); return; } $self->pointed_hint(@{$_}) for @pending; # Test for important keys. for my $tag (@req_desktop_keys) { unless (defined $vals{$tag}) { $self->pointed_hint('desktop-entry-missing-required-key', $item->pointer, $tag); } } # test if missing Keywords (only if NoDisplay is not set) if (!defined $vals{NoDisplay}) { $self->pointed_hint('desktop-entry-lacks-icon-entry', $item->pointer) unless defined $vals{Icon}; $self->pointed_hint('desktop-entry-lacks-keywords-entry', $item->pointer) if !defined $vals{Keywords} && $vals{'Type'} eq 'Application'; } # Only test whether the binary is in the package if the desktop file is # directly under /usr/share/applications. Too many applications use # desktop files for other purposes with custom paths. # # TODO: Should check quoting and the check special field # codes in Exec for desktop files. if ( $item->name =~ m{^usr/share/applications/} && $vals{'Exec'} && $vals{'Exec'} =~ /\S/) { my ($okay, $command) = $self->verify_cmd($item->pointer, $vals{'Exec'}); $self->pointed_hint('desktop-command-not-in-package', $item->pointer, $command) unless $okay || $command eq 'kcmshell'; $command =~ s{^(?:usr/)?s?bin/}{}; $desktop_cmds->{$command} = 1 unless $command =~ m/^(?:su-to-root|sux?|(?:gk|kde)su)$/; } # Check the Category tag. my $in_reserved; if (defined $vals{'Categories'}) { my $saw_main; my @categories = split(/;/, $vals{'Categories'}); for my $category (@categories) { next if $category =~ /^X-/; if ($reserved_categories{$category}) { $self->pointed_hint('desktop-entry-uses-reserved-category', $item->pointer,$category) unless $vals{'OnlyShowIn'}; $saw_main = 1; $in_reserved = 1; } elsif (!$self->ADD_CATEGORIES->recognizes($category) && !$main_categories{$category}) { $self->pointed_hint('desktop-entry-invalid-category', $item->pointer, $category); } elsif ($main_categories{$category}) { $saw_main = 1; } } $self->pointed_hint('desktop-entry-lacks-main-category',$item->pointer) unless $saw_main; } # Check the OnlyShowIn tag. If this is not an application in a reserved # category, warn about any desktop entry that specifies OnlyShowIn for # more than one environment. In that case, the application probably # should be using NotShowIn instead. if (defined $vals{OnlyShowIn} and not $in_reserved) { my @envs = split(/;/, $vals{OnlyShowIn}); if (@envs > 1) { $self->pointed_hint('desktop-entry-limited-to-environments', $item->pointer); } } # Check that the Exec tag specifies how to pass a filename if MimeType # tags are present. if ($item->name =~ m{^usr/share/applications/} && defined $vals{'MimeType'}) { $self->pointed_hint('desktop-mime-but-no-exec-code', $item->pointer) unless defined $vals{'Exec'} && $vals{'Exec'} =~ /(?:^|[^%])%[fFuU]/; } return; } # Verify whether a command is shipped as part of the package. Takes the full # path to the file being checked (for error reporting) and the binary. # Returns a list whose first member is true if the command is present and # false otherwise, and whose second member is the command (minus any leading # su-to-root wrapper). Shared between the desktop and menu code. sub verify_cmd { my ($self, $pointer, $exec) = @_; my $index = $self->processable->installed; # This routine handles su wrappers. The option parsing here is ugly and # dead-simple, but it's hopefully good enough for what will show up in # desktop files. su-to-root and sux require -c options, kdesu optionally # allows one, and gksu has the command at the end of its arguments. my @components = split($SPACE, $exec); my $cmd; $self->pointed_hint('su-to-root-with-usr-sbin', $pointer) if $components[0] && $components[0] eq '/usr/sbin/su-to-root'; if ( $components[0] && $components[0] =~ m{^(?:/usr/s?bin/)?(su-to-root|gksu|kdesu|sux)$}){ my $wrapper = $1; shift @components; while (@components) { unless ($components[0]) { shift @components; next; } if ($components[0] eq '-c') { $cmd = $components[1]; last; } elsif ( $components[0] =~ /^-[Dfmupi]|^--(user|description|message)/) { shift @components; shift @components; } elsif ($components[0] =~ /^-/) { shift @components; } else { last; } } if (!$cmd && $wrapper =~ /^(gk|kde)su$/) { if (@components) { $cmd = $components[0]; } else { $cmd = $wrapper; undef $wrapper; } } $self->pointed_hint('su-wrapper-without--c', $pointer, $wrapper) unless $cmd; $self->pointed_hint('su-wrapper-not-su-to-root', $pointer, $wrapper) if $wrapper && $wrapper !~ /su-to-root/ && $wrapper ne $self->processable->name; } else { $cmd = $components[0]; } my $cmd_file = $cmd; if ($cmd_file) { $cmd_file =~ s{^/}{}; } my $okay = $cmd && ( $cmd =~ /^[\'\"]/ || $index->lookup($cmd_file) || $cmd =~ m{^(/bin/)?sh} || $cmd =~ m{^(/usr/bin/)?sensible-(pager|editor|browser)} || any { $index->lookup($_ . $cmd) } @path); return ($okay, $cmd_file); } 1; # Local Variables: # indent-tabs-mode: nil # cperl-indent-level: 4 # End: # vim: syntax=perl sw=4 sts=4 sr et