1#!/usr/libexec/flua 2 3-- SPDX-License-Identifier: BSD-2-Clause 4-- 5-- Copyright(c) 2020 The FreeBSD Foundation. 6-- 7-- Redistribution and use in source and binary forms, with or without 8-- modification, are permitted provided that the following conditions 9-- are met: 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. 15-- 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 26-- SUCH DAMAGE. 27 28 29function main(args) 30 if #args == 0 then usage() end 31 local filename 32 local printall, checkonly, pkgonly = 33 #args == 1, false, false 34 local dcount, dsize, fuid, fgid, fid = 35 false, false, false, false, false 36 local verbose = false 37 local w_notagdirs = false 38 39 local i = 1 40 while i <= #args do 41 if args[i] == '-h' then 42 usage(true) 43 elseif args[i] == '-a' then 44 printall = true 45 elseif args[i] == '-c' then 46 printall = false 47 checkonly = true 48 elseif args[i] == '-p' then 49 printall = false 50 pkgonly = true 51 while i < #args do 52 i = i+1 53 if args[i] == '-count' then 54 dcount = true 55 elseif args[i] == '-size' then 56 dsize = true 57 elseif args[i] == '-fsetuid' then 58 fuid = true 59 elseif args[i] == '-fsetgid' then 60 fgid = true 61 elseif args[i] == '-fsetid' then 62 fid = true 63 else 64 i = i-1 65 break 66 end 67 end 68 elseif args[i] == '-v' then 69 verbose = true 70 elseif args[i] == '-Wcheck-notagdir' then 71 w_notagdirs = true 72 elseif args[i]:match('^%-') then 73 io.stderr:write('Unknown argument '..args[i]..'.\n') 74 usage() 75 else 76 filename = args[i] 77 end 78 i = i+1 79 end 80 81 if filename == nil then 82 io.stderr:write('Missing filename.\n') 83 usage() 84 end 85 86 local sess = Analysis_session(filename, verbose, w_notagdirs) 87 88 local errors 89 if printall then 90 io.write('--- PACKAGE REPORTS ---\n') 91 io.write(sess.pkg_report_full()) 92 io.write('--- LINTING REPORTS ---\n') 93 errors = print_lints(sess) 94 elseif checkonly then 95 errors = print_lints(sess) 96 elseif pkgonly then 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 101 })) 102 else 103 io.stderr:write('This text should not be displayed.') 104 usage() 105 end 106 107 if errors then 108 return 1 109 end 110end 111 112--- @param man boolean 113function usage(man) 114 local sn = 'Usage: '..arg[0].. ' [-h] [-a | -c | -p [-count] [-size] [-f...]] [-W...] metalog-path \n' 115 if man then 116 io.write('\n') 117 io.write(sn) 118 io.write( 119[[ 120 121The script reads METALOG file created by pkgbase (make packages) and generates 122reports about the installed system and issues. It accepts an mtree file in a 123format that's returned by `mtree -c | mtree -C` 124 125 Options: 126 -a prints all scan results. this is the default option if no option 127 is provided. 128 -c lints the file and gives warnings/errors, including duplication 129 and conflicting metadata 130 -Wcheck-notagdir entries with dir type and no tags will be also 131 included the first time they appear 132 -p list all package names found in the file as exactly specified by 133 `tags=package=...` 134 -count display the number of files of the package 135 -size display the size of the package 136 -fsetgid only include packages with setgid files 137 -fsetuid only include packages with setuid files 138 -fsetid only include packages with setgid or setuid files 139 -v verbose mode 140 -h help page 141 142]]) 143 os.exit() 144 else 145 io.stderr:write(sn) 146 os.exit(1) 147 end 148end 149 150--- @param sess Analysis_session 151function print_lints(sess) 152 local dupwarn, duperr = sess.dup_report() 153 io.write(dupwarn) 154 io.write(duperr) 155 local inodewarn, inodeerr = sess.inode_report() 156 io.write(inodewarn) 157 io.write(inodeerr) 158 return #duperr > 0 or #inodeerr > 0 159end 160 161--- @param t table 162function sortedPairs(t) 163 local sortedk = {} 164 for k in next, t do sortedk[#sortedk+1] = k end 165 table.sort(sortedk) 166 local i = 0 167 return function() 168 i = i + 1 169 return sortedk[i], t[sortedk[i]] 170 end 171end 172 173--- @param t table <T, U> 174--- @param f function <U -> U> 175function table_map(t, f) 176 local res = {} 177 for k, v in pairs(t) do res[k] = f(v) end 178 return res 179end 180 181--- @class MetalogRow 182-- a table contaning file's info, from a line content from METALOG file 183-- all fields in the table are strings 184-- sample output: 185-- { 186-- filename = ./usr/share/man/man3/inet6_rthdr_segments.3.gz 187-- lineno = 5 188-- attrs = { 189-- gname = 'wheel' 190-- uname = 'root' 191-- mode = '0444' 192-- size = '1166' 193-- time = nil 194-- type = 'file' 195-- tags = 'package=clibs,debug' 196-- } 197-- } 198--- @param line string 199function MetalogRow(line, lineno) 200 local res, attrs = {}, {} 201 local filename, rest = line:match('^(%S+) (.+)$') 202 -- mtree file has space escaped as '\\040', not affecting splitting 203 -- string by space 204 for attrpair in rest:gmatch('[^ ]+') do 205 local k, v = attrpair:match('^(.-)=(.+)') 206 attrs[k] = v 207 end 208 res.filename = filename 209 res.linenum = lineno 210 res.attrs = attrs 211 return res 212end 213 214-- check if an array of MetalogRows are equivalent. if not, the first field 215-- that's different is returned secondly 216--- @param rows MetalogRow[] 217--- @param ignore_name boolean 218--- @param ignore_tags boolean 219function metalogrows_all_equal(rows, ignore_name, ignore_tags) 220 local __eq = function(l, o) 221 if not ignore_name and l.filename ~= o.filename then 222 return false, 'filename' 223 end 224 -- ignoring linenum in METALOG file as it's not relavant 225 for k in pairs(l.attrs) do 226 if ignore_tags and k == 'tags' then goto continue end 227 if l.attrs[k] ~= o.attrs[k] and o.attrs[k] ~= nil then 228 return false, k 229 end 230 ::continue:: 231 end 232 return true 233 end 234 for _, v in ipairs(rows) do 235 local bol, offby = __eq(v, rows[1]) 236 if not bol then return false, offby end 237 end 238 return true 239end 240 241--- @param tagstr string 242function pkgname_from_tag(tagstr) 243 local ext, pkgname, pkgend = '', '', '' 244 for seg in tagstr:gmatch('[^,]+') do 245 if seg:match('package=') then 246 pkgname = seg:sub(9) 247 elseif seg == 'development' or seg == 'profile' 248 or seg == 'debug' or seg == 'docs' then 249 pkgend = seg 250 else 251 ext = ext == '' and seg or ext..'-'..seg 252 end 253 end 254 pkgname = pkgname 255 ..(ext == '' and '' or '-'..ext) 256 ..(pkgend == '' and '' or '-'..pkgend) 257 return pkgname 258end 259 260--- @class Analysis_session 261--- @param metalog string 262--- @param verbose boolean 263--- @param w_notagdirs boolean turn on to also check directories 264function Analysis_session(metalog, verbose, w_notagdirs) 265 local stage_root = {} 266 local files = {} -- map<string, MetalogRow[]> 267 -- set is map<elem, bool>. if bool is true then elem exists 268 local pkgs = {} -- map<string, set<string>> 269 ----- used to keep track of files not belonging to a pkg. not used so 270 ----- it is commented with ----- 271 -----local nopkg = {} -- set<string> 272 --- @public 273 local swarn = {} 274 --- @public 275 local serrs = {} 276 277 -- returns number of files in package and size of package 278 -- nil is returned upon errors 279 --- @param pkgname string 280 local function pkg_size(pkgname) 281 local filecount, sz = 0, 0 282 for filename in pairs(pkgs[pkgname]) do 283 local rows = files[filename] 284 -- normally, there should be only one row per filename 285 -- if these rows are equal, there should be warning, but it 286 -- does not affect size counting. if not, it is an error 287 if #rows > 1 and not metalogrows_all_equal(rows) then 288 return nil 289 end 290 local row = rows[1] 291 if row.attrs.type == 'file' then 292 sz = sz + tonumber(row.attrs.size) 293 end 294 filecount = filecount + 1 295 end 296 return filecount, sz 297 end 298 299 --- @param pkgname string 300 --- @param mode number 301 local function pkg_ismode(pkgname, mode) 302 for filename in pairs(pkgs[pkgname]) do 303 for _, row in ipairs(files[filename]) do 304 if tonumber(row.attrs.mode, 8) & mode ~= 0 then 305 return true 306 end 307 end 308 end 309 return false 310 end 311 312 --- @param pkgname string 313 --- @public 314 local function pkg_issetuid(pkgname) 315 return pkg_ismode(pkgname, 2048) 316 end 317 318 --- @param pkgname string 319 --- @public 320 local function pkg_issetgid(pkgname) 321 return pkg_ismode(pkgname, 1024) 322 end 323 324 --- @param pkgname string 325 --- @public 326 local function pkg_issetid(pkgname) 327 return pkg_issetuid(pkgname) or pkg_issetgid(pkgname) 328 end 329 330 -- sample return: 331 -- { [*string]: { count=1, size=2, issetuid=true, issetgid=true } } 332 local function pkg_report_helper_table() 333 local res = {} 334 for pkgname in pairs(pkgs) do 335 res[pkgname] = {} 336 res[pkgname].count, 337 res[pkgname].size = pkg_size(pkgname) 338 res[pkgname].issetuid = pkg_issetuid(pkgname) 339 res[pkgname].issetgid = pkg_issetgid(pkgname) 340 end 341 return res 342 end 343 344 -- returns a string describing package scan report 345 --- @public 346 local function pkg_report_full() 347 local sb = {} 348 for pkgname, v in sortedPairs(pkg_report_helper_table()) do 349 sb[#sb+1] = 'Package '..pkgname..':' 350 if v.issetuid or v.issetgid then 351 sb[#sb+1] = ''..table.concat({ 352 v.issetuid and ' setuid' or '', 353 v.issetgid and ' setgid' or '' }, '') 354 end 355 sb[#sb+1] = '\n number of files: '..(v.count or '?') 356 ..'\n total size: '..(v.size or '?') 357 sb[#sb+1] = '\n' 358 end 359 return table.concat(sb, '') 360 end 361 362 --- @param have_count boolean 363 --- @param have_size boolean 364 --- @param filters function[] 365 --- @public 366 -- returns a string describing package size report. 367 -- sample: "mypackage 2 2048"* if both booleans are true 368 local function pkg_report_simple(have_count, have_size, filters) 369 filters = filters or {} 370 local sb = {} 371 for pkgname, v in sortedPairs(pkg_report_helper_table()) do 372 local pred = true 373 -- doing a foldl to all the function results with (and) 374 for _, f in pairs(filters) do pred = pred and f(pkgname) end 375 if pred then 376 sb[#sb+1] = pkgname..table.concat({ 377 have_count and (' '..(v.count or '?')) or '', 378 have_size and (' '..(v.size or '?')) or ''}, '') 379 ..'\n' 380 end 381 end 382 return table.concat(sb, '') 383 end 384 385 -- returns a string describing duplicate file warnings, 386 -- returns a string describing duplicate file errors 387 --- @public 388 local function dup_report() 389 local warn, errs = {}, {} 390 for filename, rows in sortedPairs(files) do 391 if #rows == 1 then goto continue end 392 local iseq, offby = metalogrows_all_equal(rows) 393 if iseq then -- repeated line, just a warning 394 local dupmsg = filename .. ' ' .. 395 rows[1].attrs.type .. 396 ' repeated with same meta: line ' .. 397 table.concat(table_map(rows, function(e) return e.linenum end), ',') 398 if rows[1].attrs.type == "dir" then 399 if verbose then 400 warn[#warn+1] = 'warning: ' .. dupmsg .. '\n' 401 end 402 else 403 errs[#errs+1] = 'error: ' .. dupmsg .. '\n' 404 end 405 elseif not metalogrows_all_equal(rows, false, true) then 406 -- same filename (possibly different tags), different metadata, an error 407 errs[#errs+1] = 'error: '..filename 408 ..' exists in multiple locations and with different meta: line ' 409 ..table.concat( 410 table_map(rows, function(e) return e.linenum end), ',') 411 ..'. off by "'..offby..'"' 412 errs[#errs+1] = '\n' 413 end 414 ::continue:: 415 end 416 return table.concat(warn, ''), table.concat(errs, '') 417 end 418 419 -- returns a string describing warnings of found hard links 420 -- returns a string describing errors of found hard links 421 --- @public 422 local function inode_report() 423 -- obtain inodes of filenames 424 local attributes = require('lfs').attributes 425 local inm = {} -- map<number, string[]> 426 local unstatables = {} -- string[] 427 for filename in pairs(files) do 428 -- i only took the first row of a filename, 429 -- and skip links and folders 430 if files[filename][1].attrs.type ~= 'file' then 431 goto continue 432 end 433 local fs = attributes(stage_root .. filename) 434 if fs == nil then 435 unstatables[#unstatables+1] = filename 436 goto continue 437 end 438 local inode = fs.ino 439 inm[inode] = inm[inode] or {} 440 table.insert(inm[inode], filename) 441 ::continue:: 442 end 443 444 local warn, errs = {}, {} 445 for _, filenames in pairs(inm) do 446 if #filenames == 1 then goto continue end 447 -- i only took the first row of a filename 448 local rows = table_map(filenames, function(e) 449 return files[e][1] 450 end) 451 local iseq, offby = metalogrows_all_equal(rows, true, true) 452 if not iseq then 453 errs[#errs+1] = 'error: ' 454 ..'entries point to the same inode but have different meta: ' 455 ..table.concat(filenames, ',')..' in line ' 456 ..table.concat( 457 table_map(rows, function(e) return e.linenum end), ',') 458 ..'. off by "'..offby..'"' 459 errs[#errs+1] = '\n' 460 end 461 ::continue:: 462 end 463 464 if #unstatables > 0 then 465 warn[#warn+1] = verbose and 466 'note: skipped checking inodes: '..table.concat(unstatables, ',')..'\n' 467 or 468 'note: skipped checking inodes for '..#unstatables..' entries\n' 469 end 470 471 return table.concat(warn, ''), table.concat(errs, '') 472 end 473 474 -- The METALOG file is assumed to be at the top of the stage directory. 475 stage_root = string.gsub(metalog, '/[^/]*$', '/') 476 477 do 478 local fp, errmsg, errcode = io.open(metalog, 'r') 479 if fp == nil then 480 io.stderr:write('cannot open '..metalog..': '..errmsg..': '..errcode..'\n') 481 os.exit(1) 482 end 483 484 -- scan all lines and put file data into the dictionaries 485 local firsttimes = {} -- set<string> 486 local lineno = 0 487 for line in fp:lines() do 488 -----local isinpkg = false 489 lineno = lineno + 1 490 -- skip lines beginning with # 491 if line:match('^%s*#') then goto continue end 492 -- skip blank lines 493 if line:match('^%s*$') then goto continue end 494 495 local data = MetalogRow(line, lineno) 496 -- entries with dir and no tags... ignore for the first time 497 if not w_notagdirs and 498 data.attrs.tags == nil and data.attrs.type == 'dir' 499 and not firsttimes[data.filename] then 500 firsttimes[data.filename] = true 501 goto continue 502 end 503 504 files[data.filename] = files[data.filename] or {} 505 table.insert(files[data.filename], data) 506 507 if data.attrs.tags ~= nil then 508 pkgname = pkgname_from_tag(data.attrs.tags) 509 pkgs[pkgname] = pkgs[pkgname] or {} 510 pkgs[pkgname][data.filename] = true 511 ------isinpkg = true 512 end 513 -----if not isinpkg then nopkg[data.filename] = true end 514 ::continue:: 515 end 516 517 fp:close() 518 end 519 520 return { 521 warn = swarn, 522 errs = serrs, 523 pkg_issetuid = pkg_issetuid, 524 pkg_issetgid = pkg_issetgid, 525 pkg_issetid = pkg_issetid, 526 pkg_report_full = pkg_report_full, 527 pkg_report_simple = pkg_report_simple, 528 dup_report = dup_report, 529 inode_report = inode_report 530 } 531end 532 533os.exit(main(arg)) 534