_binRules.mjs

//本檔為內部使用, 不由index匯出: 外部執行檔清單(fsEnsureBins)之名稱與相對路徑白名單, 供fsEnsureBinsCore檢核清單與fsGetBinManifestEntry產生清單項共用(單一出處)


//檔名: 英數、底線開頭, 其後英數與._+-, 不以.結尾(Windows會去除結尾之點)
let reName = /^[A-Za-z0-9_](?:[A-Za-z0-9._+-]{0,198}[A-Za-z0-9_+-])?$/

//相對路徑之一段(repo內路徑、壓縮檔內之成員): 同檔名, 惟中間可有空白
let reSeg = /^[A-Za-z0-9_](?:[A-Za-z0-9._+ -]{0,198}[A-Za-z0-9_+-])?$/

//Windows保留裝置名(可帶副檔名)
let reDevice = /^(con|prn|aux|nul|com\d|lpt\d)(\.|$)/i


/**
 * 是否為清單可用之檔名(原始字串, 不接受包裝物件)
 *
 * @param {*} v 輸入任意值
 * @returns {Boolean} 回傳布林值
 */
function isBinName(v) {
    return typeof v === 'string' && reName.test(v) && !reDevice.test(v)
}


/**
 * 是否為清單可用之相對路徑: 以/分隔, 每段合檔名規則(中間可有空白)且非保留裝置名, 無空段、.、..、絕對路徑、反斜線(原始字串, 不接受包裝物件)
 *
 * @param {*} v 輸入任意值
 * @returns {Boolean} 回傳布林值
 */
function isBinRel(v) {
    if (typeof v !== 'string' || v === '' || v.length > 1024) {
        return false
    }
    return v.split('/').every((s) => {
        return reSeg.test(s) && !reDevice.test(s)
    })
}


//提交雜湊(40碼小寫十六進位)
let reCommit = /^[0-9a-f]{40}$/


/**
 * 清單之網址(廠商檔、分割檔、壓縮檔之url)之檢核(規格M2、F15之網址釘選判定): 只接受兩種已釘選之形式, 回傳錯誤說明或''
 *
 * ①GitHub版本標籤之資產網址 https://github.com/<owner>/<repo>/releases/download/<tag>/<檔>(恰此6段; tag可含編碼之斜線); ②GitHub原始檔網址且ref為40碼小寫提交: https://raw.githubusercontent.com/<owner>/<repo>/<commit>/<path>或https://github.com/<owner>/<repo>/raw/<commit>/<path>。
 * 須為https、不得帶帳密、不得有查詢字串、片段或埠號(以原始字串判定, 含空之?、#與預設埠:443)、反斜線與空白; 各段不得為空, 各段解碼後owner、repo、檔名與原始檔路徑之各段不得含斜線或反斜線; 其他主機、releases/latest、分支或標籤之原始檔網址(含master、main、HEAD、refs/heads/…與其他分支名)、blob頁面皆拒收(分支名無法自網址與標籤區分, 故原始檔一律須為提交)
 *
 * @param {*} v 輸入任意值
 * @returns {String} 回傳錯誤說明, 合格為''
 */
function binUrlProblem(v) {
    if (typeof v !== 'string') {
        return 'must be a primitive string'
    }
    //形式以原始字串判定(URL解析會補斜線、把反斜線當成斜線、把空之?與#與預設埠:443正規化掉): 須以https://開頭(scheme不分大小寫), 不得有查詢字串、片段、反斜線、空白、帳密、埠號
    if (!/^https:\/\//i.test(v)) {
        return 'must be an https url (starting with https://)'
    }
    if (/[?#]/.test(v)) {
        return 'must not have a query or fragment'
    }
    if (/[\\\s]/.test(v)) {
        return 'must not have backslashes or whitespace'
    }
    let auth = v.slice('https://'.length).split('/')[0]
    if (auth.includes('@')) {
        return 'must not contain credentials'
    }
    if (auth.includes(':')) {
        return 'must not have a port'
    }
    let u = null
    try {
        u = new URL(v)
    }
    catch (err) {
        return 'is not a valid url'
    }
    let segs = u.pathname.split('/').slice(1)
    let dec = []
    for (let s of segs) {
        try {
            dec.push(decodeURIComponent(s))
        }
        catch (err) {
            return 'has an invalid percent-encoding in its path'
        }
    }
    let plain = (i) => {
        //owner、repo、檔名等段: 非空且解碼後不含斜線、反斜線
        return dec[i] !== undefined && dec[i] !== '' && !/[/\\]/.test(dec[i])
    }
    let restOk = (i) => {
        //原始檔之路徑(第i段起): 至少一段且各段非空、解碼後不含斜線與反斜線(檢查之段即請求之段)
        for (let k = i; k < dec.length; k++) {
            if (!plain(k)) {
                return false
            }
        }
        return dec.length > i
    }
    let need = 'must be a GitHub release asset url (https://github.com/<owner>/<repo>/releases/download/<tag>/<file>) or a GitHub raw file url at a 40-character commit (https://raw.githubusercontent.com/<owner>/<repo>/<commit>/<path>), so that it does not change; releases/latest, branches and other hosts are not accepted'
    let host = u.hostname
    if (host === 'github.com') {
        if (segs.length === 6 && segs[2] === 'releases' && segs[3] === 'download' && plain(0) && plain(1) && dec[4] !== '' && plain(5)) {
            return ''
        }
        if (plain(0) && plain(1) && segs[2] === 'raw' && reCommit.test(segs[3] || '') && restOk(4)) {
            return ''
        }
        return need
    }
    if (host === 'raw.githubusercontent.com') {
        if (plain(0) && plain(1) && reCommit.test(segs[2] || '') && restOk(3)) {
            return ''
        }
        return need
    }
    return need
}


export { isBinName, isBinRel, binUrlProblem }