#!/usr/libexec/flua
function main(args)
if #args == 0 then usage() end
local filename
local printall, checkonly, pkgonly =
#args == 1, false, false
local dcount, dsize, fuid, fgid, fid =
false, false, false, false, false
local verbose = false
local w_notagdirs = false
local i = 1
while i <= #args do
if args[i] == '-h' then
usage(true)
elseif args[i] == '-a' then
printall = true
elseif args[i] == '-c' then
printall = false
checkonly = true
elseif args[i] == '-p' then
printall = false
pkgonly = true
while i < #args do
i = i+1
if args[i] == '-count' then
dcount = true
elseif args[i] == '-size' then
dsize = true
elseif args[i] == '-fsetuid' then
fuid = true
elseif args[i] == '-fsetgid' then
fgid = true
elseif args[i] == '-fsetid' then
fid = true
else
i = i-1
break
end
end
elseif args[i] == '-v' then
verbose = true
elseif args[i] == '-Wcheck-notagdir' then
w_notagdirs = true
elseif args[i]:match('^%-') then
io.stderr:write('Unknown argument '..args[i]..'.\n')
usage()
else
filename = args[i]
end
i = i+1
end
if filename == nil then
io.stderr:write('Missing filename.\n')
usage()
end
local sess = Analysis_session(filename, verbose, w_notagdirs)
local errors
if printall then
io.write('--- PACKAGE REPORTS ---\n')
io.write(sess.pkg_report_full())
io.write('--- LINTING REPORTS ---\n')
errors = print_lints(sess)
elseif checkonly then
errors = print_lints(sess)
elseif pkgonly then
io.write(sess.pkg_report_simple(dcount, dsize, {
fuid and sess.pkg_issetuid or nil,
fgid and sess.pkg_issetgid or nil,
fid and sess.pkg_issetid or nil
}))
else
io.stderr:write('This text should not be displayed.')
usage()
end
if errors then
return 1
end
end
function usage(man)
local sn = 'Usage: '..arg[0].. ' [-h] [-a | -c | -p [-count] [-size] [-f...]] [-W...] metalog-path \n'
if man then
io.write('\n')
io.write(sn)
io.write(
[[
The script reads METALOG file created by pkgbase (make packages) and generates
reports about the installed system and issues. It accepts an mtree file in a
format that's returned by `mtree -c | mtree -C`
Options:
-a prints all scan results. this is the default option if no option
is provided.
-c lints the file and gives warnings/errors, including duplication
and conflicting metadata
-Wcheck-notagdir entries with dir type and no tags will be also
included the first time they appear
-p list all package names found in the file as exactly specified by
`tags=package=...`
-count display the number of files of the package
-size display the size of the package
-fsetgid only include packages with setgid files
-fsetuid only include packages with setuid files
-fsetid only include packages with setgid or setuid files
-v verbose mode
-h help page
]])
os.exit()
else
io.stderr:write(sn)
os.exit(1)
end
end
function print_lints(sess)
local dupwarn, duperr = sess.dup_report()
io.write(dupwarn)
io.write(duperr)
local inodewarn, inodeerr = sess.inode_report()
io.write(inodewarn)
io.write(inodeerr)
return #duperr > 0 or #inodeerr > 0
end
function sortedPairs(t)
local sortedk = {}
for k in next, t do sortedk[#sortedk+1] = k end
table.sort(sortedk)
local i = 0
return function()
i = i + 1
return sortedk[i], t[sortedk[i]]
end
end
function table_map(t, f)
local res = {}
for k, v in pairs(t) do res[k] = f(v) end
return res
end
function MetalogRow(line, lineno)
local res, attrs = {}, {}
local filename, rest = line:match('^(%S+) (.+)$')
for attrpair in rest:gmatch('[^ ]+') do
local k, v = attrpair:match('^(.-)=(.+)')
attrs[k] = v
end
res.filename = filename
res.linenum = lineno
res.attrs = attrs
return res
end
function metalogrows_all_equal(rows, ignore_name, ignore_tags)
local __eq = function(l, o)
if not ignore_name and l.filename ~= o.filename then
return false, 'filename'
end
for k in pairs(l.attrs) do
if ignore_tags and k == 'tags' then goto continue end
if l.attrs[k] ~= o.attrs[k] and o.attrs[k] ~= nil then
return false, k
end
::continue::
end
return true
end
for _, v in ipairs(rows) do
local bol, offby = __eq(v, rows[1])
if not bol then return false, offby end
end
return true
end
function pkgname_from_tag(tagstr)
local ext, pkgname, pkgend = '', '', ''
for seg in tagstr:gmatch('[^,]+') do
if seg:match('package=') then
pkgname = seg:sub(9)
elseif seg == 'development' or seg == 'profile'
or seg == 'debug' or seg == 'docs' then
pkgend = seg
else
ext = ext == '' and seg or ext..'-'..seg
end
end
pkgname = pkgname
..(ext == '' and '' or '-'..ext)
..(pkgend == '' and '' or '-'..pkgend)
return pkgname
end
function Analysis_session(metalog, verbose, w_notagdirs)
local stage_root = {}
local files = {}
local pkgs = {}
local swarn = {}
local serrs = {}
local function pkg_size(pkgname)
local filecount, sz = 0, 0
for filename in pairs(pkgs[pkgname]) do
local rows = files[filename]
if #rows > 1 and not metalogrows_all_equal(rows) then
return nil
end
local row = rows[1]
if row.attrs.type == 'file' then
sz = sz + tonumber(row.attrs.size)
end
filecount = filecount + 1
end
return filecount, sz
end
local function pkg_ismode(pkgname, mode)
for filename in pairs(pkgs[pkgname]) do
for _, row in ipairs(files[filename]) do
if tonumber(row.attrs.mode, 8) & mode ~= 0 then
return true
end
end
end
return false
end
local function pkg_issetuid(pkgname)
return pkg_ismode(pkgname, 2048)
end
local function pkg_issetgid(pkgname)
return pkg_ismode(pkgname, 1024)
end
local function pkg_issetid(pkgname)
return pkg_issetuid(pkgname) or pkg_issetgid(pkgname)
end
local function pkg_report_helper_table()
local res = {}
for pkgname in pairs(pkgs) do
res[pkgname] = {}
res[pkgname].count,
res[pkgname].size = pkg_size(pkgname)
res[pkgname].issetuid = pkg_issetuid(pkgname)
res[pkgname].issetgid = pkg_issetgid(pkgname)
end
return res
end
local function pkg_report_full()
local sb = {}
for pkgname, v in sortedPairs(pkg_report_helper_table()) do
sb[#sb+1] = 'Package '..pkgname..':'
if v.issetuid or v.issetgid then
sb[#sb+1] = ''..table.concat({
v.issetuid and ' setuid' or '',
v.issetgid and ' setgid' or '' }, '')
end
sb[#sb+1] = '\n number of files: '..(v.count or '?')
..'\n total size: '..(v.size or '?')
sb[#sb+1] = '\n'
end
return table.concat(sb, '')
end
local function pkg_report_simple(have_count, have_size, filters)
filters = filters or {}
local sb = {}
for pkgname, v in sortedPairs(pkg_report_helper_table()) do
local pred = true
for _, f in pairs(filters) do pred = pred and f(pkgname) end
if pred then
sb[#sb+1] = pkgname..table.concat({
have_count and (' '..(v.count or '?')) or '',
have_size and (' '..(v.size or '?')) or ''}, '')
..'\n'
end
end
return table.concat(sb, '')
end
local function dup_report()
local warn, errs = {}, {}
for filename, rows in sortedPairs(files) do
if #rows == 1 then goto continue end
local iseq, offby = metalogrows_all_equal(rows)
if iseq then
local dupmsg = filename .. ' ' ..
rows[1].attrs.type ..
' repeated with same meta: line ' ..
table.concat(table_map(rows, function(e) return e.linenum end), ',')
if rows[1].attrs.type == "dir" then
if verbose then
warn[#warn+1] = 'warning: ' .. dupmsg .. '\n'
end
else
errs[#errs+1] = 'error: ' .. dupmsg .. '\n'
end
elseif not metalogrows_all_equal(rows, false, true) then
errs[#errs+1] = 'error: '..filename
..' exists in multiple locations and with different meta: line '
..table.concat(
table_map(rows, function(e) return e.linenum end), ',')
..'. off by "'..offby..'"'
errs[#errs+1] = '\n'
end
::continue::
end
return table.concat(warn, ''), table.concat(errs, '')
end
local function inode_report()
local attributes = require('lfs').attributes
local inm = {}
local unstatables = {}
for filename in pairs(files) do
if files[filename][1].attrs.type ~= 'file' then
goto continue
end
local fs = attributes(stage_root .. filename)
if fs == nil then
unstatables[#unstatables+1] = filename
goto continue
end
local inode = fs.ino
inm[inode] = inm[inode] or {}
table.insert(inm[inode], filename)
::continue::
end
local warn, errs = {}, {}
for _, filenames in pairs(inm) do
if #filenames == 1 then goto continue end
local rows = table_map(filenames, function(e)
return files[e][1]
end)
local iseq, offby = metalogrows_all_equal(rows, true, true)
if not iseq then
errs[#errs+1] = 'error: '
..'entries point to the same inode but have different meta: '
..table.concat(filenames, ',')..' in line '
..table.concat(
table_map(rows, function(e) return e.linenum end), ',')
..'. off by "'..offby..'"'
errs[#errs+1] = '\n'
end
::continue::
end
if #unstatables > 0 then
warn[#warn+1] = verbose and
'note: skipped checking inodes: '..table.concat(unstatables, ',')..'\n'
or
'note: skipped checking inodes for '..#unstatables..' entries\n'
end
return table.concat(warn, ''), table.concat(errs, '')
end
stage_root = string.gsub(metalog, '/[^/]*$', '/')
do
local fp, errmsg, errcode = io.open(metalog, 'r')
if fp == nil then
io.stderr:write('cannot open '..metalog..': '..errmsg..': '..errcode..'\n')
os.exit(1)
end
local firsttimes = {}
local lineno = 0
for line in fp:lines() do
lineno = lineno + 1
if line:match('^%s*#') then goto continue end
if line:match('^%s*$') then goto continue end
local data = MetalogRow(line, lineno)
if not w_notagdirs and
data.attrs.tags == nil and data.attrs.type == 'dir'
and not firsttimes[data.filename] then
firsttimes[data.filename] = true
goto continue
end
files[data.filename] = files[data.filename] or {}
table.insert(files[data.filename], data)
if data.attrs.tags ~= nil then
pkgname = pkgname_from_tag(data.attrs.tags)
pkgs[pkgname] = pkgs[pkgname] or {}
pkgs[pkgname][data.filename] = true
end
::continue::
end
fp:close()
end
return {
warn = swarn,
errs = serrs,
pkg_issetuid = pkg_issetuid,
pkg_issetgid = pkg_issetgid,
pkg_issetid = pkg_issetid,
pkg_report_full = pkg_report_full,
pkg_report_simple = pkg_report_simple,
dup_report = dup_report,
inode_report = inode_report
}
end
os.exit(main(arg))