-----------------------------------------------------------------------
--         FILE:  luaotfload-diagnostics.lua
--  DESCRIPTION:  part of luaotfload-tool / functionality accessible by the --diagnose option
-- REQUIREMENTS:  luaotfload-tool.lua
--       AUTHOR:  Philipp Gesang <phg@phi-gamma.net>
-----------------------------------------------------------------------

assert(luaotfload_module, "This is a part of luaotfload and should not be loaded independently") { 
    name          = "luaotfload-diagnostics",
    version       = "3.29",       --TAGVERSION
    date          = "2024-12-03", --TAGDATE
    description   = "luaotfload-tool submodule / diagnostics",
    license       = "GPL v2.0"
}

local names                    = fonts.names

local kpse                     = require "kpse"
local kpseexpand_path          = kpse.expand_path
local kpseexpand_var           = kpse.expand_var
local kpsefind_file            = kpse.find_file

local lfs                      = require "lfs"
local lfsattributes            = lfs.attributes
local lfsisfile                = lfs.isfile
local lfsreadlink              = lfs.readlink

local md5                      = require "md5"
local md5sumhexa               = md5.sumhexa

local ioopen                   = io.open

local osgetenv                 = os.getenv
local osname                   = os.name
local osremove                 = os.remove
local ostype                   = os.type
local stringformat             = string.format
local stringlower              = string.lower
local stringsub                = string.sub

local fileisreadable           = file.isreadable
local fileiswritable           = file.iswritable
local filesplitpath            = file.splitpath
local filesuffix               = file.suffix
local ioloaddata               = io.loaddata
local lua_of_json              = utilities.json.tolua
local tableconcat              = table.concat
local tablesortedkeys          = table.sortedkeys
local tabletohash              = table.tohash

local lpeg                     = require "lpeg"
local C, Cg, Ct                = lpeg.C, lpeg.Cg, lpeg.Ct
local lpegmatch                = lpeg.match

local report                   = luaotfload.log.report
local function out (...)
    report (false, 0, "diagnose", ...)
end

local parsers                  = luaotfload.parsers
local stripslashes             = parsers.stripslashes
local splitcomma               = parsers.splitcomma

local function check_index (errcnt)

    out "================= font names =================="
    local namedata = names.data()

    if not namedata then
        namedata = names.load ()
    end

    local mappings = namedata.mappings

    if not namedata and namedata.formats and namedata.version then
        out "Database corrupt."
        return errcnt + 1
    end

    out ("Database version: %.3f.", namedata.meta.version)
    out ("Font formats indexed: %s.",
         tableconcat (namedata.meta.formats, ", "))
    out ("%d font files indexed.", #mappings)

    local by_format = { }
    for i = 1, #mappings do
        local record = mappings[i]
        local format = stringlower (filesuffix (record.filename))
        local count  = by_format[format]
        if count then
            by_format[format] = count + 1
        else
            by_format[format] = 1
        end
    end

    local formats = tablesortedkeys (by_format)
    for i = 1, #formats do
        local format = formats[i]
        out ("%20s: %5d", format, by_format[format])
    end
    return errcnt
end

local function verify_files (errcnt)
    out "================ verify files ================="
    local status = config.luaotfload.status
    local hashes = status.hashes
    local notes  = status.notes
    if not hashes or #hashes == 0 then
        out ("FAILED: cannot read checksums from %s.", status_file)
        return 1/0
    elseif not notes then
        out ("FAILED: cannot read commit metadata from %s.",
             status_file)
        return 1/0
    end

    out ("Luaotfload revision %s.", notes.revision)
    out ("Committed by %s.",        notes.committer)
    out ("Timestamp %s.",           notes.timestamp)

    local nhashes = #hashes
    out ("Testing %d files for integrity.", nhashes)
    for i = 1, nhashes do
        local fname, canonicalsum = unpack (hashes[i])
        local location = kpsefind_file (fname)
                      or kpsefind_file (fname, "texmfscripts")
        if not location then
            errcnt = errcnt + 1
            out ("FAILED: file %s missing.", fname)
        else
            out ("File: %s.", location)
            local raw = ioloaddata (location)
            if not raw then
                errcnt = errcnt + 1
                out ("FAILED: file %d not readable.", fname)
            else
                local sum = md5sumhexa (raw)
                if sum ~= canonicalsum then
                    errcnt = errcnt + 1
                    out ("FAILED: checksum mismatch for file %s.",
                         fname)
                    out ("Expected %s.", canonicalsum)
                    out ("Got      %s.", sum)
                else
                    out ("Ok, %s passed.", fname)
                end
            end
        end
    end
    return errcnt
end

local function get_tentative_attributes (file)
    if not lfsisfile (file) then
        local chan = ioopen (file, "w")
        if chan then
            chan:close ()
            local attributes = lfsattributes (file)
            os.remove (file)
            return attributes
        end
    end
end

local p_permissions = Ct(Cg(Ct(C(1) * C(1) * C(1)), "u")
                       * Cg(Ct(C(1) * C(1) * C(1)), "g")
                       * Cg(Ct(C(1) * C(1) * C(1)), "o"))

local function analyze_permissions (raw)
    return lpegmatch (p_permissions, raw)
end

local function get_permissions (t, location)
    if stringsub (location, #location) == "/" then
        --- strip trailing slashes (lfs idiosyncrasy on Win)
        location = lpegmatch (stripslashes, location)
    end
    local attributes = lfsattributes (location)
    if not attributes then
        print""
        print("attr", location, attributes)
        os.exit()
    end

    if not attributes and t == "f" then
        attributes = get_tentative_attributes (location)
        if not attributes then
            return false
        end
    end

    local permissions

    if fileisreadable (location) then
        --- link handling appears to be unnecessary because
        --- lfs.attributes() will return the information on
        --- the link target.
        if mode == "link" then --follow and repeat
            location = lfsreadlink (location)
            attributes = lfsattributes (location)
        end
    end

    permissions = analyze_permissions (attributes.permissions)

    return {
        location    = location,
        mode        = attributes.mode,
        owner       = attributes.uid, --- useless on windows
        permissions = permissions,
        attributes  = attributes,
    }
end

local function check_conformance (spec, permissions, errcnt)
    local uid = permissions.attributes.uid
    local gid = permissions.attributes.gid
    local raw = permissions.attributes.permissions

    out ("Owner: %d, group %d, permissions %s.", uid, gid, raw)
    if ostype == "unix" then
        if uid == 0 or gid == 0 then
            out "Owned by the superuser, permission conflict likely."
            errcnt = errcnt + 1
        end
    end

    local user = permissions.permissions.u
    if spec.r == true then
        if user[1] == "r" then
            out "Readable: ok."
        else
            out "Not readable: permissions need fixing."
            errcnt = errcnt + 1
        end
    end

    if spec.w == true then
        if user[2] == "w"
        or  fileiswritable (permissions.location) then
            out "Writable: ok."
        else
            out "Not writable: permissions need fixing."
            errcnt = errcnt + 1
        end
    end

    return errcnt
end

local function init_desired_permissions ()
    local paths = config.luaotfload.paths
    local compress_db = config.luaotfload.db.compress
    return {
        { "d", {"r","w"}, function () return caches.getwritablepath ("", "") end },
        { "d", {"r","w"}, paths.prefix },
        { "f", {"r","w"}, paths.index_path_lua .. (compress_db and ".gz" or "") },
        { "f", {"r","w"}, paths.index_path_luc .. (compress_db and ".gz" or "") },
        { "f", {"r","w"}, paths.lookup_path_lua },
        { "f", {"r","w"}, paths.lookup_path_luc },
    }
end

local function check_permissions (errcnt)
    out [[=============== file permissions ==============]]
    local desired_permissions = init_desired_permissions ()
    for i = 1, #desired_permissions do
        local t, spec, path = unpack (desired_permissions[i])
        if type (path) == "function" then
            path = path ()
        end

        spec = tabletohash (spec)

        out ("Checking permissions of %s.", path)

        local permissions = get_permissions (t, path)
        if permissions then
            --inspect (permissions)
            errcnt = check_conformance (spec, permissions, errcnt)
        else
            errcnt = errcnt + 1
        end
    end
    return errcnt
end

local check_upstream

if kpsefind_file ("https.lua", "lua") == nil then
    function check_upstream (errcnt)
        out       [[============= upstream repository =============
                    WARNING: Cannot retrieve repository data.
                    Github API access requires the luasec library.
                    Grab it from <https://github.com/brunoos/luasec>
                    and retry.]]
        return errcnt
    end
else
--- github api stuff begin
    local https = require "ssl.https"

    local gh_api_root     = [[https://api.github.com]]
    local release_url     = [[https://github.com/latex3/luaotfload/releases]]
    local luaotfload_repo = [[latex3/luaotfload]]
    local user_agent      = [[latex3/luaotfload integrity check]]
    local shortbytes = 8

    local function gh_shortrevision (rev)
        return stringsub (rev, 1, shortbytes)
    end

    local function gh_encode_parameters (parameters)
        local acc = {}
        for field, value in next, parameters do
            --- unsafe, non-urlencoded coz it’s all ascii chars
            acc[#acc+1] = field .. "=" .. value
        end
        return "?" .. tableconcat (acc, "&")
    end

    local function gh_make_url (components, parameters)
        local url = tableconcat ({ gh_api_root,
                                   unpack (components) },
                                 "/")
        if parameters then
            url = url .. gh_encode_parameters (parameters)
        end
        return url
    end

    local alright = [[HTTP/1.1 200 OK]]

    local function gh_api_request (...)
        local args    = {...}
        local nargs   = #args
        local final   = args[nargs]
        local request = {
            url     = "",
            headers = { ["user-agent"] = user_agent },
        }
        if type (final) == "table" then
            args[nargs] = nil
            request = gh_make_url (args, final)
        else
            request = gh_make_url (args)
        end

        out ("Requesting <%s>.", request)
        local response, code, headers, status = https.request (request)
        if status ~= alright then
            out "Request failed!"
            return false
        end
        return response
    end

    local function gh_api_checklimit (headers)
        local rawlimit  = gh_api_request "rate_limit"
        local limitdata = lua_of_json (rawlimit)
        if not limitdata and limitdata.rate then
            out "Cannot parse API rate limit."
            return false
        end
        limitdata = limitdata.rate

        local limit = tonumber (limitdata.limit)
        local left  = tonumber (limitdata.remaining)
        local reset = tonumber (limitdata.reset)

        out ("%d of %d Github API requests left.", left, limit)
        if left == 0 then
            out ("Cannot make any more API requests.")
            if ostype == "unix" then
                out ("Try again later at %s.", osdate ("%F %T", reset))
            else --- windows doesn’t C99
                out ("Try again later at %s.",
                     osdate ("%Y-%m-%d %H:%M:%S", reset))
            end
        end
        return true
    end

    local function gh_tags ()
        out "Fetching tags from repository, please stand by."
        local rawtags = gh_api_request ("repos",
                                        luaotfload_repo,
                                        "tags")
        local taglist = lua_of_json (rawtags)
        if not taglist or #taglist == 0 then
            out "Cannot parse response."
            return false
        end

        local ntags = #taglist
        out ("Repository contains %d tags.", ntags)
        local _idx, latest = next (taglist)
        out ("The most recent release is %s (revision %s).",
             latest.name,
        gh_shortrevision (latest.commit.sha))
        return latest
    end

    local function gh_compare (head, base)
        if base == nil then
            base = "HEAD"
        end
        out ("Fetching comparison between %s and %s, \z
              please stand by.",
             gh_shortrevision (head),
             gh_shortrevision (base))
        local comparison = base .. "..." .. head
        local rawstatus = gh_api_request ("repos",
                                          luaotfload_repo,
                                          "compare",
                                          comparison)
        local status = lua_of_json (rawstatus)
        if not status then
            out "Cannot parse response for status request."
            return false
        end
        return status
    end

    local function gh_news (since)
        local compared  = gh_compare (since)
        if not compared then
            return false
        end
        local behind_by = compared.behind_by
        local ahead_by  = compared.ahead_by
        local status    = compared.status
        out ("Comparison state: %s.", status)
        if behind_by > 0 then
            out ("Your Luaotfload is %d \z
                  revisions behind upstream.",
                 behind_by)
            return behind_by
        elseif status == "ahead" then
            out "Since you are obviously from the future \z
                 I assume you already know the repository state."
        else
            out "Everything up to date. \z
                 Luaotfload is in sync with upstream."
        end
        return false
    end

    local function gh_catchup (current, latest)
        local compared = gh_compare (latest, current)
        local ahead_by = tonumber (compared.ahead_by)
        if ahead_by > 0 then
            local permalink_url = compared.permalink_url
            out ("Your Luaotfload is %d revisions \z
                  behind the most recent release.",
                 ahead_by)
            out ("To view the commit log, visit <%s>.",
                 permalink_url)
            out ("You can grab an up to date tarball at <%s>.",
                 release_url)
            return true
        else
            out "There weren't any new releases in the meantime."
            out "Luaotfload is up to date."
        end
        return false
    end

    function check_upstream (current)
        out "============= upstream repository ============="
        local _succ  = gh_api_checklimit ()
        local behind = gh_news (current)
        if behind then
            local latest  = gh_tags ()
            local _behind = gh_catchup (current,
                                        latest.commit.sha,
                                        latest.name)
        end
    end

    --- trivium: diff since the first revision as pushed by Élie
    --- in 2009
    --- local firstrevision = "c3ccb3ee07e0a67171c24960966ae974e0dd8e98"
    --- check_upstream (firstrevision)
end
--- github api stuff end

local function print_envvar (var)
    local val = osgetenv (var)
    if val then
        out ("%20s: %q", stringformat ("$%s", var), val)
        return val
    else
        out ("%20s: <unset>", stringformat ("$%s", var))
    end
end

local function print_path (var)
    local val = osgetenv (var)
    if val then
        local paths = filesplitpath (val)
        if paths then
            local npaths = #paths
            if npaths == 1 then
                out ("%20s: %q", stringformat ("$%s", var), val)
            elseif npaths > 1 then
                out ("%20s: <%d items>", stringformat ("$%s", var), npaths)
                for i = 1, npaths do
                    out ("                   +: %q", paths[i])
                end
            else
                out ("%20s: <empty>")
            end
        end
    else
        out ("%20s: <unset>", stringformat ("$%s", var))
    end
end

local function print_kpsevar (var)
    var = "$" .. var
    local val = kpseexpand_var (var)
    if val and val ~= var then
        out ("%20s: %q", var, val)
        return val
    else
        out ("%20s: <empty or unset>", var)
    end
end

local function print_kpsepath (var)
    var = "$" .. var
    local val = kpseexpand_path (var)
    if val and val ~= "" then
        local paths = filesplitpath (val)
        if paths then
            local npaths = #paths
            if npaths == 1 then
                out ("%20s: %q", var, paths[1])
            elseif npaths > 1 then
                out ("%20s: <%d items>", var, npaths)
                for i = 1, npaths do
                    out ("                   +: %q", paths[i])
                end
            else
                out ("%20s: <empty>")
            end
        end
    else
        out ("%20s: <empty or unset>", var)
    end
end

--- this test first if a variable is set and then expands the
--- paths; this is necessitated by the fact that expand-path will
--- return the empty string both if the variable is unset and if
--- the directory does not exist

local function print_kpsepathvar (var)
    local vvar = "$" .. var
    local val = kpseexpand_var (vvar)
    if val and val ~= vvar then
        out ("%20s: %q", vvar, val)
        print_kpsepath (var)
    else
        out ("%20s: <empty or unset>", var)
    end
end

local function check_environment (errcnt)
    out "============ environment settings ============="
    out ("system: %s/%s", ostype, osname)
    if ostype == "unix" and io.popen then
        local chan = io.popen ("uname -a", "r")
        if chan then
            out ("info: %s", chan:read "*all")
            chan:close ()
        end
    end

    out "1) *shell environment*"
    print_envvar "SHELL"
    print_path "PATH"
    print_path "OSFONTDIR"
    print_envvar "USER"
    if ostype == "windows" then
        print_envvar "WINDIR"
        print_envvar "CD"
        print_path "TEMP"
    elseif ostype == "unix" then
        print_envvar "HOME"
        print_envvar "PWD"
        print_path "TMPDIR"
    end

    out "2) *kpathsea*"
    print_kpsepathvar "OPENTYPEFONTS"
    print_kpsepathvar "TTFONTS"

    print_kpsepathvar "TEXMFCACHE"
    print_kpsepathvar "TEXMFVAR"

    --- the expansion of these can be quite large; as they aren’t
    --- usually essential to luaotfload, we won’t dump every single
    --- path
    print_kpsevar "LUAINPUTS"
    print_kpsevar "CLUAINPUTS"

    return errcnt
end

local anamneses   = {
    "environment",
    "files",
    "index",
    "repository",
    "permissions"
}

local function diagnose (job)
    local errcnt = 0
    local asked  = job.asked_diagnostics
    if asked == "all" or asked == "thorough" then
        asked = tabletohash (anamneses, true)
    else
        asked = lpegmatch (splitcomma, asked)
        asked = tabletohash (asked, true)
    end

    if asked.index == true then
        errcnt = check_index (errcnt)
        asked.index = nil
    end

    if asked.environment == true then
        errcnt = check_environment (errcnt)
        asked.environment = nil
    end

    if asked.files == true then
        errcnt = verify_files (errcnt)
        asked.files = nil
    end

    if asked.permissions == true then
        errcnt = check_permissions (errcnt)
        asked.permissions = nil
    end

    if asked.repository == true then
        local status = config.luaotfload.status
        check_upstream (status.notes.revision)
        asked.repository = nil
    end

    local rest = next (asked)
    if rest ~= nil then --> something unknown
        out ("Unknown diagnostic %q.", rest)
    end
    if errcnt == 0 then --> success
        out ("Everything appears to be in order, \z
              you may sleep well.")
        return true, false
    end
    out (             [[===============================================
                                            WARNING
                        ===============================================

                        The diagnostic detected %d errors.

                        This version of luaotfload may have been
                        tampered with. Modified versions of the
                        luaotfload source are unsupported. Read the log
                        carefully and get a clean version from CTAN or
                        github:

                            × http://www.ctan.org/pkg/luaotfload 
                            × https://github.com/latex3/luaotfload/releases

                        If you are uncertain as to how to proceed, then
                        ask on the lualatex mailing list:

                            http://www.tug.org/mailman/listinfo/lualatex-dev

                        ===============================================
]],          errcnt)
    return true, false
end

return diagnose

-- vim:tw=71:sw=4:ts=4:expandtab
