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