3 -- SPDX-License-Identifier: BSD-2-Clause-FreeBSD
5 -- Copyright(c) 2020 The FreeBSD Foundation.
7 -- Redistribution and use in source and binary forms, with or without
8 -- modification, are permitted provided that the following conditions
10 -- 1. Redistributions of source code must retain the above copyright
11 -- notice, this list of conditions and the following disclaimer.
12 -- 2. Redistributions in binary form must reproduce the above copyright
13 -- notice, this list of conditions and the following disclaimer in the
14 -- documentation and/or other materials provided with the distribution.
16 -- THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
17 -- ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18 -- IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
19 -- ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
20 -- FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21 -- DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
22 -- OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
23 -- HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
24 -- LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
25 -- OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
31 if #args == 0 then usage() end
33 local printall, checkonly, pkgonly =
34 #args == 1, false, false
35 local dcount, dsize, fuid, fgid, fid =
36 false, false, false, false, false
38 local w_notagdirs = false
42 if args[i] == '-h' then
44 elseif args[i] == '-a' then
46 elseif args[i] == '-c' then
49 elseif args[i] == '-p' then
54 if args[i] == '-count' then
56 elseif args[i] == '-size' then
58 elseif args[i] == '-fsetuid' then
60 elseif args[i] == '-fsetgid' then
62 elseif args[i] == '-fsetid' then
69 elseif args[i] == '-v' then
71 elseif args[i] == '-Wcheck-notagdir' then
73 elseif args[i]:match('^%-') then
74 io.stderr:write('Unknown argument '..args[i]..'.\n')
82 if filename == nil then
83 io.stderr:write('Missing filename.\n')
87 local sess = Analysis_session(filename, verbose, w_notagdirs)
90 io.write('--- PACKAGE REPORTS ---\n')
91 io.write(sess.pkg_report_full())
92 io.write('--- LINTING REPORTS ---\n')
97 io.write(sess.pkg_report_simple(dcount, dsize, {
98 fuid and sess.pkg_issetuid or nil,
99 fgid and sess.pkg_issetgid or nil,
100 fid and sess.pkg_issetid or nil
103 io.stderr:write('This text should not be displayed.')
108 --- @param man boolean
110 local sn = 'Usage: '..arg[0].. ' [-h] [-a | -c | -p [-count] [-size] [-f...]] [-W...] metalog-path \n'
117 The script reads METALOG file created by pkgbase (make packages) and generates
118 reports about the installed system and issues. It accepts an mtree file in a
119 format that's returned by `mtree -c | mtree -C`
122 -a prints all scan results. this is the default option if no option
124 -c lints the file and gives warnings/errors, including duplication
125 and conflicting metadata
126 -Wcheck-notagdir entries with dir type and no tags will be also
127 included the first time they appear
128 -p list all package names found in the file as exactly specified by
130 -count display the number of files of the package
131 -size display the size of the package
132 -fsetgid only include packages with setgid files
133 -fsetuid only include packages with setuid files
134 -fsetid only include packages with setgid or setuid files
146 --- @param sess Analysis_session
147 function print_lints(sess)
148 local dupwarn, duperr = sess.dup_report()
151 local inodewarn, inodeerr = sess.inode_report()
157 function sortedPairs(t)
159 for k in next, t do sortedk[#sortedk+1] = k end
164 return sortedk[i], t[sortedk[i]]
168 --- @param t table <T, U>
169 --- @param f function <U -> U>
170 function table_map(t, f)
172 for k, v in pairs(t) do res[k] = f(v) end
176 --- @class MetalogRow
177 -- a table contaning file's info, from a line content from METALOG file
178 -- all fields in the table are strings
181 -- filename = ./usr/share/man/man3/inet6_rthdr_segments.3.gz
190 -- tags = 'package=clibs,debug'
193 --- @param line string
194 function MetalogRow(line, lineno)
195 local res, attrs = {}, {}
196 local filename, rest = line:match('^(%S+) (.+)$')
197 -- mtree file has space escaped as '\\040', not affecting splitting
199 for attrpair in rest:gmatch('[^ ]+') do
200 local k, v = attrpair:match('^(.-)=(.+)')
203 res.filename = filename
209 -- check if an array of MetalogRows are equivalent. if not, the first field
210 -- that's different is returned secondly
211 --- @param rows MetalogRow[]
212 --- @param ignore_name boolean
213 --- @param ignore_tags boolean
214 function metalogrows_all_equal(rows, ignore_name, ignore_tags)
215 local __eq = function(l, o)
216 if not ignore_name and l.filename ~= o.filename then
217 return false, 'filename'
219 -- ignoring linenum in METALOG file as it's not relavant
220 for k in pairs(l.attrs) do
221 if ignore_tags and k == 'tags' then goto continue end
222 if l.attrs[k] ~= o.attrs[k] and o.attrs[k] ~= nil then
229 for _, v in ipairs(rows) do
230 local bol, offby = __eq(v, rows[1])
231 if not bol then return false, offby end
236 --- @param tagstr string
237 function pkgname_from_tag(tagstr)
238 local ext, pkgname, pkgend = '', '', ''
239 for seg in tagstr:gmatch('[^,]+') do
240 if seg:match('package=') then
242 elseif seg == 'development' or seg == 'profile'
243 or seg == 'debug' or seg == 'docs' then
246 ext = ext == '' and seg or ext..'-'..seg
250 ..(ext == '' and '' or '-'..ext)
251 ..(pkgend == '' and '' or '-'..pkgend)
255 --- @class Analysis_session
256 --- @param metalog string
257 --- @param verbose boolean
258 --- @param w_notagdirs boolean turn on to also check directories
259 function Analysis_session(metalog, verbose, w_notagdirs)
260 local files = {} -- map<string, MetalogRow[]>
261 -- set is map<elem, bool>. if bool is true then elem exists
262 local pkgs = {} -- map<string, set<string>>
263 ----- used to keep track of files not belonging to a pkg. not used so
264 ----- it is commented with -----
265 -----local nopkg = {} -- set<string>
271 -- returns number of files in package and size of package
272 -- nil is returned upon errors
273 --- @param pkgname string
274 local function pkg_size(pkgname)
275 local filecount, sz = 0, 0
276 for filename in pairs(pkgs[pkgname]) do
277 local rows = files[filename]
278 -- normally, there should be only one row per filename
279 -- if these rows are equal, there should be warning, but it
280 -- does not affect size counting. if not, it is an error
281 if #rows > 1 and not metalogrows_all_equal(rows) then
285 if row.attrs.type == 'file' then
286 sz = sz + tonumber(row.attrs.size)
288 filecount = filecount + 1
293 --- @param pkgname string
294 --- @param mode number
295 local function pkg_ismode(pkgname, mode)
296 for filename in pairs(pkgs[pkgname]) do
297 for _, row in ipairs(files[filename]) do
298 if tonumber(row.attrs.mode, 8) & mode ~= 0 then
306 --- @param pkgname string
308 local function pkg_issetuid(pkgname)
309 return pkg_ismode(pkgname, 2048)
312 --- @param pkgname string
314 local function pkg_issetgid(pkgname)
315 return pkg_ismode(pkgname, 1024)
318 --- @param pkgname string
320 local function pkg_issetid(pkgname)
321 return pkg_issetuid(pkgname) or pkg_issetgid(pkgname)
325 -- { [*string]: { count=1, size=2, issetuid=true, issetgid=true } }
326 local function pkg_report_helper_table()
328 for pkgname in pairs(pkgs) do
331 res[pkgname].size = pkg_size(pkgname)
332 res[pkgname].issetuid = pkg_issetuid(pkgname)
333 res[pkgname].issetgid = pkg_issetgid(pkgname)
338 -- returns a string describing package scan report
340 local function pkg_report_full()
342 for pkgname, v in sortedPairs(pkg_report_helper_table()) do
343 sb[#sb+1] = 'Package '..pkgname..':'
344 if v.issetuid or v.issetgid then
345 sb[#sb+1] = ''..table.concat({
346 v.issetuid and ' setuid' or '',
347 v.issetgid and ' setgid' or '' }, '')
349 sb[#sb+1] = '\n number of files: '..(v.count or '?')
350 ..'\n total size: '..(v.size or '?')
353 return table.concat(sb, '')
356 --- @param have_count boolean
357 --- @param have_size boolean
358 --- @param filters function[]
360 -- returns a string describing package size report.
361 -- sample: "mypackage 2 2048"* if both booleans are true
362 local function pkg_report_simple(have_count, have_size, filters)
363 filters = filters or {}
365 for pkgname, v in sortedPairs(pkg_report_helper_table()) do
367 -- doing a foldl to all the function results with (and)
368 for _, f in pairs(filters) do pred = pred and f(pkgname) end
370 sb[#sb+1] = pkgname..table.concat({
371 have_count and (' '..(v.count or '?')) or '',
372 have_size and (' '..(v.size or '?')) or ''}, '')
376 return table.concat(sb, '')
379 -- returns a string describing duplicate file warnings,
380 -- returns a string describing duplicate file errors
382 local function dup_report()
383 local warn, errs = {}, {}
384 for filename, rows in sortedPairs(files) do
385 if #rows == 1 then goto continue end
386 local iseq, offby = metalogrows_all_equal(rows)
387 if iseq then -- repeated line, just a warning
388 warn[#warn+1] = 'warning: '..filename
389 ..' repeated with same meta: line '
391 table_map(rows, function(e) return e.linenum end), ',')
393 elseif not metalogrows_all_equal(rows, false, true) then
394 -- same filename (possibly different tags), different metadata, an error
395 errs[#errs+1] = 'error: '..filename
396 ..' exists in multiple locations and with different meta: line '
398 table_map(rows, function(e) return e.linenum end), ',')
399 ..'. off by "'..offby..'"'
404 return table.concat(warn, ''), table.concat(errs, '')
407 -- returns a string describing warnings of found hard links
408 -- returns a string describing errors of found hard links
410 local function inode_report()
411 -- obtain inodes of filenames
412 local attributes = require('lfs').attributes
413 local inm = {} -- map<number, string[]>
414 local unstatables = {} -- string[]
415 for filename in pairs(files) do
416 -- i only took the first row of a filename,
417 -- and skip links and folders
418 if files[filename][1].attrs.type ~= 'file' then
421 -- make ./xxx become /xxx so that we can stat
422 filename = filename:sub(2)
423 local fs = attributes(filename)
425 unstatables[#unstatables+1] = filename
429 inm[inode] = inm[inode] or {}
430 -- add back the dot prefix
431 table.insert(inm[inode], '.'..filename)
435 local warn, errs = {}, {}
436 for _, filenames in pairs(inm) do
437 if #filenames == 1 then goto continue end
438 -- i only took the first row of a filename
439 local rows = table_map(filenames, function(e)
442 local iseq, offby = metalogrows_all_equal(rows, true, true)
444 errs[#errs+1] = 'error: '
445 ..'entries point to the same inode but have different meta: '
446 ..table.concat(filenames, ',')..' in line '
448 table_map(rows, function(e) return e.linenum end), ',')
449 ..'. off by "'..offby..'"'
455 if #unstatables > 0 then
456 warn[#warn+1] = verbose and
457 'note: skipped checking inodes: '..table.concat(unstatables, ',')..'\n'
459 'note: skipped checking inodes for '..#unstatables..' entries\n'
462 return table.concat(warn, ''), table.concat(errs, '')
466 local fp, errmsg, errcode = io.open(metalog, 'r')
468 io.stderr:write('cannot open '..metalog..': '..errmsg..': '..errcode..'\n')
472 -- scan all lines and put file data into the dictionaries
473 local firsttimes = {} -- set<string>
475 for line in fp:lines() do
476 -----local isinpkg = false
478 -- skip lines begining with #
479 if line:match('^%s*#') then goto continue end
481 if line:match('^%s*$') then goto continue end
483 local data = MetalogRow(line, lineno)
484 -- entries with dir and no tags... ignore for the first time
485 if not w_notagdirs and
486 data.attrs.tags == nil and data.attrs.type == 'dir'
487 and not firsttimes[data.filename] then
488 firsttimes[data.filename] = true
492 files[data.filename] = files[data.filename] or {}
493 table.insert(files[data.filename], data)
495 if data.attrs.tags ~= nil then
496 pkgname = pkgname_from_tag(data.attrs.tags)
497 pkgs[pkgname] = pkgs[pkgname] or {}
498 pkgs[pkgname][data.filename] = true
501 -----if not isinpkg then nopkg[data.filename] = true end
511 pkg_issetuid = pkg_issetuid,
512 pkg_issetgid = pkg_issetgid,
513 pkg_issetid = pkg_issetid,
514 pkg_report_full = pkg_report_full,
515 pkg_report_simple = pkg_report_simple,
516 dup_report = dup_report,
517 inode_report = inode_report