🍕 Sync 2026-10-01 13:33:56

This commit is contained in:
github-actions[bot] committed 2026-10-01 13:33:56 +08:00
1 parent 9057fa94b1
commit 1f3a4e8fb4
24 files changed
+731 -83

No files matched your search

@@ -90,10 +90,15 @@ local function read_cron_fields()
return cron_enable, cron_time
end
-- read subscription level rules from form, return rules table
-- read subscription level rules from form, return rules table, err
--
-- 第二个返回值是**用户可见**的校验错误。目前只有重命名规则的正则需要校验:
-- 非法 pattern 会被 node.rename_with_rules 里的 pcall 静默吞掉,用户看到的是
-- 「规则写了但没生效」。在这里拦下来,调用方用 back_to_list(err) 回显。
local function read_rules_fields()
local http = require("luci.http")
local core = require("substore.core")
local node = require("substore.node")
local proto_list = {}
-- 协议清单来自 core.RULE_PROTOS,与表单模板共用一份(见该常量的说明)
for _, p in ipairs(core.RULE_PROTOS) do
@@ -101,13 +106,16 @@ local function read_rules_fields()
end
local rules_enable = fv(http, "rules_enable") or "0"
if rules_enable ~= "1" then rules_enable = "0" end
local rename_map = fv(http, "rename_map") or ""
local vok, verr = node.validate_rename_map(rename_map)
if not vok then return nil, verr end
return {
rules_enable = rules_enable,
proto_filter = table.concat(proto_list, ","),
keyword_include = fv(http, "keyword_include") or "",
keyword_exclude = fv(http, "keyword_exclude") or "",
dedup = fv(http, "dedup") or "0",
rename_map = fv(http, "rename_map") or "",
rename_map = rename_map,
}
end
@@ -124,7 +132,9 @@ function action_create()
return back_to_list("名称和 URL 不能为空")
end
local cron_enable, cron_time = read_cron_fields()
local rules = read_rules_fields()
-- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」
local rules, rules_err = read_rules_fields()
if not rules then return back_to_list(rules_err or "规则无效") end
local id, err = core.add(name, url, {
proxy_enable = proxy_enable, proxy = proxy,
cron_enable = cron_enable, cron_time = cron_time,
@@ -153,7 +163,9 @@ function action_save()
return back_to_list("名称和 URL 不能为空")
end
local cron_enable, cron_time = read_cron_fields()
local rules = read_rules_fields()
-- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」
local rules, rules_err = read_rules_fields()
if not rules then return back_to_list(rules_err or "规则无效") end
local ok, err = core.save_meta(id, {
name = name, url = url, proxy_enable = proxy_enable, proxy = proxy,
cron_enable = cron_enable, cron_time = cron_time,
@@ -174,7 +186,9 @@ function action_local_create()
local name = (fv(http, "name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local content = fv(http, "content") or ""
local local_mode = fv(http, "local_mode") or "text"
local rules = read_rules_fields()
-- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」
local rules, rules_err = read_rules_fields()
if not rules then return back_to_list(rules_err or "规则无效") end
-- §19:空名称 / 空内容必须明确报错,不能无声创建无效订阅
if name == "" then return back_to_list("名称不能为空") end
if content == "" then return back_to_list("订阅内容不能为空") end
@@ -198,7 +212,9 @@ function action_local_save()
local name = (fv(http, "name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
local content = fv(http, "content") or ""
local local_mode = fv(http, "local_mode") or "text"
local rules = read_rules_fields()
-- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」
local rules, rules_err = read_rules_fields()
if not rules then return back_to_list(rules_err or "规则无效") end
if id == "" then return back_to_list("缺少订阅 ID") end
if name == "" then return back_to_list("名称不能为空") end
if content == "" then return back_to_list("订阅内容不能为空") end
@@ -370,7 +386,9 @@ function action_combo_save()
-- §18:此前名称留空、或一个来源都没勾选时,这里整段跳过、直接跳回列表 ——
-- 用户填了表单却什么都没发生,页面上也没有任何提示。
if name == "" then return back_to_list("名称不能为空") end
local rules = read_rules_fields()
-- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」
local rules, rules_err = read_rules_fields()
if not rules then return back_to_list(rules_err or "规则无效") end
local o = {
rules_enable = rules.rules_enable, proto_filter = rules.proto_filter,
keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude,
@@ -18,17 +18,18 @@
local rm = (it and it.rename_map) or ""
local local_mode = (it and it.local_mode) or "text"
local content = (it and it.raw_content) or ""
-- 注入 JS 的初始内容:JSON 字符串字面量,转义 "</" 防止提前闭合 script
-- 注入 JS 的初始内容:JSON 字符串字面量,把全部 "<" 转成 <,
-- 同时挡住 "</script" 提前闭合与 "<!--" 吞掉其后整页(详见 nodes.htm 同名注释)
local util = require("substore.util")
local js_content = (util.json_encode(content) or '""'):gsub("</", "<\\/")
local js_content = (util.json_encode(content) or '""'):gsub("<", "\\u003c")
-- local_mode 是用户可提交的字段,直接拼进 JS 字符串会被闭合引号注入;
-- 归一为白名单取值后用 json_encode 输出,保证是合法的 JS 字符串字面量
if local_mode ~= "form" then local_mode = "text" end
local js_mode = util.json_encode(local_mode) or '"text"'
-- 协议与字段清单:与 core.merge_form_node 共用 substore/node.lua 的同一份数据
local node_mod = require("substore.node")
local js_protos = (util.json_encode(node_mod.PROTOS) or "[]"):gsub("</", "<\\/")
local js_proto_fields = (util.json_encode(node_mod.PROTO_FIELDS) or "{}"):gsub("</", "<\\/")
local js_protos = (util.json_encode(node_mod.PROTOS) or "[]"):gsub("<", "\\u003c")
local js_proto_fields = (util.json_encode(node_mod.PROTO_FIELDS) or "{}"):gsub("<", "\\u003c")
%>
<h2><%=is_edit and luci.i18n.translate("Edit local subscription") or luci.i18n.translate("Add local subscription")%></h2>
@@ -8,12 +8,13 @@
local meta = id ~= "" and core.get(id) or nil
local nodes = id ~= "" and core.read_nodes(id) or {}
local n = nodes[idx]
-- 注入 JS 的节点对象:转义 "</" 防止提前闭合 script
local js_node = n and ((util.json_encode(n) or "null"):gsub("</", "<\\/")) or "null"
-- 注入 JS 的节点对象:把全部 "<" 转成 <,同时挡住 "</script" 提前闭合
-- 与 "<!--" 让脚本块吞掉其后整页(详见 nodes.htm 同名注释)
local js_node = n and ((util.json_encode(n) or "null"):gsub("<", "\\u003c")) or "null"
-- 协议与字段清单:与 core.merge_form_node 共用 substore/node.lua 的同一份数据
local node_mod = require("substore.node")
local js_protos = (util.json_encode(node_mod.PROTOS) or "[]"):gsub("</", "<\\/")
local js_proto_fields = (util.json_encode(node_mod.PROTO_FIELDS) or "{}"):gsub("</", "<\\/")
local js_protos = (util.json_encode(node_mod.PROTOS) or "[]"):gsub("<", "\\u003c")
local js_proto_fields = (util.json_encode(node_mod.PROTO_FIELDS) or "{}"):gsub("<", "\\u003c")
local back_url = luci.dispatcher.build_url('admin/services/substore/nodes') .. "?id=" .. luci.util.urlencode(id)
%>
<h2><%:Edit node%> — <%=luci.util.pcdata(meta and meta.name or "")%></h2>
@@ -18,8 +18,11 @@
-- 但只有 JSON 编码还不够:json_encode 不转义 "<",id 直接来自查询串,
-- 形如 "</script><script>alert(1)</script>" 的值会在 HTML 词法阶段就闭合
-- script 元素(与 JS 字符串上下文无关),剩下的部分被当作 HTML 执行。
-- 转义 "</" 即可:在 JS 字符串里 "\/" 就是 "/",而 HTML 不再看到 "</script"。
local js_id = (util.json_encode(id) or '""'):gsub("</", "<\\/")
-- 把**全部** "<" 转成 <:在 JS/JSON 字符串里这就是 "<",而 HTML 词法
-- 阶段再也看不到 "<",于是 "</script"(提前闭合)与 "<!--"(进入 script
-- data escaped 状态,后面的 </script> 不再结束脚本块,整页被吞进脚本)两个
-- 坑一并堵上。只转 "</" 挡不住 "<!--"。
local js_id = (util.json_encode(id) or '""'):gsub("<", "\\u003c")
-- 标注原始数组下标(filter 保留表引用、sort 原地排序,下标可穿透筛选排序)
for i, nn in ipairs(nodes) do nn.__idx = i end
@@ -47,7 +50,7 @@
if flash_err and flash_err ~= "" then
%>
<div class="cbi-section" style="border-left:4px solid #cc0000;padding:0.5em 0.75em;background:#fdf0f0;color:#cc0000">
<strong><%:操作失败%>:</strong> <%=luci.util.pcdata(flash_err)%>
<strong><%:Operation failed%>:</strong> <%=luci.util.pcdata(flash_err)%>
</div>
<% end %>
<h2><%:Nodes%> — <%=luci.util.pcdata(meta and meta.name or "")%></h2>
@@ -8,7 +8,7 @@
if flash_err and flash_err ~= "" then
%>
<div class="cbi-section" style="border-left:4px solid #cc0000;padding:0.5em 0.75em;background:#fdf0f0;color:#cc0000">
<strong><%:操作失败%>:</strong> <%=luci.util.pcdata(flash_err)%>
<strong><%:Operation failed%>:</strong> <%=luci.util.pcdata(flash_err)%>
</div>
<% end %>
@@ -103,7 +103,7 @@
onclick="var e=document.getElementById('link_<%=it.id%>');e.select();document.execCommand('copy');"><%:Copy%></button>
</div>
</div>
<div class="cbi-value-description"><%:选择格式后生成订阅链接,格式可随时切换%></div>
<div class="cbi-value-description"><%:Pick a format to generate the subscription link; you can switch formats at any time%></div>
</td>
<td>
<% if it["local"] then %>
@@ -125,10 +125,12 @@ function M.convert_protocol(node, target_proto)
new_node.port = node.port
end
-- trojan -> vmess/vless 生成新 uuid,避免直接复用密码
-- trojan -> vmess/vless 生成新 uuid,避免直接复用密码。
-- 必须是真的 UUID:客户端的 uuid 字段会按 16 字节解析,格式不对时多数客户端
-- 直接拒绝该节点。原来写的是 base64(seed) 截前 36 位 —— base64 只产出 24 个
-- 字符(`sub(1,36)` 是空操作),里面还可能带 `+` `/` `=`,不是合法 UUID。
if src == "trojan" and (tgt == "vmess" or tgt == "vless") then
local seed = tostring(os.time()) .. tostring(math.random(100000, 999999))
new_node.uuid = util.base64_encode(seed):sub(1, 36)
new_node.uuid = util.uuid()
end
-- 丢弃源协议专属字段(不带入转换后的协议)
@@ -251,11 +253,14 @@ function M.apply_template(nodes, template)
local cp = {}
for k, v in pairs(n) do cp[k] = v end
if cp.name and template ~= "" then
-- 替换值一律按**字面**处理:gsub 的替换串里 `%` 有语义
-- (`%1` 是捕获引用,`%%` 才是字面百分号,裸 `%` 会被吞掉或注入 NUL)。
-- 节点名来自订阅内容,必须原样落地。
local new_name = template
new_name = new_name:gsub("{{name}}", cp.name or "")
new_name = new_name:gsub("{{proto}}", cp.proto or "")
new_name = new_name:gsub("{{server}}", cp.server or "")
new_name = new_name:gsub("{{port}}", tostring(cp.port or ""))
new_name = new_name:gsub("{{name}}", util.gsub_literal(cp.name))
new_name = new_name:gsub("{{proto}}", util.gsub_literal(cp.proto))
new_name = new_name:gsub("{{server}}", util.gsub_literal(cp.server))
new_name = new_name:gsub("{{port}}", util.gsub_literal(cp.port))
cp.name = new_name
end
out[#out + 1] = cp
@@ -7,7 +7,7 @@ local parser = require("substore.parser")
local M = {}
M.version = "2.6.8"
M.version = "2.6.9"
M.DATA_DIR = "/etc/substore"
M.LIST_FILE = M.DATA_DIR .. "/subscriptions.json"
M.NODES_DIR = M.DATA_DIR .. "/nodes"
@@ -28,9 +28,20 @@ local function id_is_valid(id)
return type(id) == "string" and id ~= "" and id:match("^[A-Za-z0-9_%-]+$") ~= nil
end
-- 目录权限只收紧一次(每进程)。load() 每次读列表都会调用 ensure_dirs(),
-- 无条件 chmod 会让每次读取都多 fork 两个子进程。
local dirs_secured = false
function M.ensure_dirs()
util.ensure_dir(M.DATA_DIR)
util.ensure_dir(M.NODES_DIR)
util.ensure_dir(M.DATA_DIR, "700")
util.ensure_dir(M.NODES_DIR, "700")
if not dirs_secured then
dirs_secured = true
-- mkdir -m 只对**本次新建**的目录生效;从旧版本升级上来的机器上目录已存在,
-- 权限仍是当初按 umask 建的(通常 0755),所以这里显式再 chmod 一次。
-- 目录里是订阅 URL、公开下载 token 与节点凭据,0700 只留 root。
util.chmod(M.DATA_DIR, "700")
util.chmod(M.NODES_DIR, "700")
end
end
-- 读取订阅列表,返回 seq, items, err。
@@ -73,7 +84,8 @@ end
local function save(seq, items)
M.ensure_dirs()
return util.atomic_write(M.LIST_FILE, util.json_encode({ _seq = seq, items = items }))
-- 0600:列表里有订阅 URL 与公开下载 token
return util.atomic_write(M.LIST_FILE, util.json_encode({ _seq = seq, items = items }), "600")
end
-- 返回 arr, err。err 非空表示列表文件已损坏。
@@ -248,7 +260,8 @@ end
function M.write_nodes(id, nodes)
M.ensure_dirs()
return util.atomic_write(M.nodes_file(id), util.json_encode(nodes))
-- 0600:节点文件里有 uuid / 密码 / 私钥等全部凭据
return util.atomic_write(M.nodes_file(id), util.json_encode(nodes), "600")
end
function M.read_nodes(id)
@@ -1,6 +1,9 @@
-- node.lua — 统一节点模型(纯 Lua)
-- luci-app-substore
-- util 是叶子模块(自身不 require 任何 substore.*),所以这里不会形成循环依赖。
local util = require("substore.util")
local M = {}
M.PROTOS = {
@@ -366,11 +369,13 @@ function M.apply_rules(nodes, rules)
if rules.template_apply == true or rules.template_apply == "1" then
for _,n in ipairs(nodes) do
if n.template and n.template ~= "" and not n.url then
-- 替换值按字面处理:gsub 替换串里的 `%` 有语义,裸 `%` 会被吞掉、
-- 结尾的 `%` 会注入 NUL 字节(与 build_replacement 同一类问题)。
local url = n.template
url = url:gsub("{server}", n.server or "")
url = url:gsub("{port}", tostring(n.port or ""))
url = url:gsub("{uuid}", n.uuid or n.password or "")
url = url:gsub("{name}", n.name or "")
url = url:gsub("{server}", util.gsub_literal(n.server))
url = url:gsub("{port}", util.gsub_literal(n.port))
url = url:gsub("{uuid}", util.gsub_literal(n.uuid or n.password))
url = url:gsub("{name}", util.gsub_literal(n.name))
n.url = url
end
end
@@ -485,21 +490,24 @@ end
-- "旧名称=新名称"(精确匹配,type="exact")
-- "pattern -> replacement"(正则替换,type="regex")
-- "{server}_{port}_{proto}"(含 {var} 占位符,type="template")
-- line_no 记录规则在原始文本里的行号(从 1 起),仅用于报错时定位到用户写的那一行
function M.parse_rename_rules(rule_str)
rule_str = rule_str or ""
local rules = {}
local no = 0
for line in rule_str:gmatch("[^\r\n]+") do
no = no + 1
line = line:match("^%s*(.-)%s*$")
if line ~= "" and not line:match("^#") then
local pat, repl = line:match("^(.-)%s*%-%>%s*(.+)$")
if pat then
rules[#rules + 1] = { type = "regex", pattern = pat, replacement = repl }
rules[#rules + 1] = { type = "regex", pattern = pat, replacement = repl, line_no = no }
elseif line:find("{", 1, true) then
rules[#rules + 1] = { type = "template", template = line }
rules[#rules + 1] = { type = "template", template = line, line_no = no }
else
local k, v = line:match("^([^=]+)=(.*)$")
if k and v then
rules[#rules + 1] = { type = "exact", old = k:match("^%s*(.-)%s*$"), new = v:match("^%s*(.-)%s*$") }
rules[#rules + 1] = { type = "exact", old = k:match("^%s*(.-)%s*$"), new = v:match("^%s*(.-)%s*$"), line_no = no }
end
end
end
@@ -513,7 +521,7 @@ end
-- 在结果里产生 NUL 字节并一路写进节点名、写盘、下发到各订阅文件。
-- 所以替换前必须先把值里的 % 转义成 %%。
local function expand_template(template, n)
local function esc(v) return (tostring(v == nil and "" or v):gsub("%%", "%%%%")) end
local esc = util.gsub_literal
local out = template
out = out:gsub("{server}", esc(n.server))
out = out:gsub("{port}", esc(n.port))
@@ -639,6 +647,32 @@ local function build_replacement(rep)
return table.concat(out)
end
-- 校验重命名规则串里的正则是否可用。返回 ok, err。
--
-- 用户写的 pattern 经 regex_to_lua 转换后交给 string.gsub;非法 pattern(未闭合的
-- `[`、结尾的 `%` 等)会让 gsub 抛错。rename_with_rules 里那个 pcall 会把它吞掉,
-- 结果是「规则明明写了却完全不生效,页面上没有任何提示」—— 用户只会觉得功能坏了。
-- 在**保存时**校验并回显,用户才知道错在哪一行。
function M.validate_rename_map(rule_str)
for _, r in ipairs(M.parse_rename_rules(rule_str)) do
if r.type == "regex" then
local pat = regex_to_lua(r.pattern or "")
local repl = build_replacement(r.replacement)
local ok = pcall(function()
-- 空串足以让 gsub 编译 pattern 与替换串;抛错即说明写法非法
for _, alt in ipairs(split_alternatives(pat)) do
(""):gsub(alt, repl)
end
end)
if not ok then
return false, string.format("重命名规则第 %d 行:正则表达式无效(%s)",
r.line_no or 0, r.pattern or "")
end
end
end
return true
end
function M.rename_with_rules(nodes, rules)
rules = rules or {}
for _, n in ipairs(nodes) do
@@ -129,8 +129,13 @@ M.FORMAT_OPTIONS = {
-- 统一分发:nodes → 目标格式字符串
function M.generate(nodes, format, options)
format = (format or DEFAULT_FORMAT) or DEFAULT_FORMAT
if type(format) ~= "string" then format = DEFAULT_FORMAT end
-- 空串 / 纯空白等同于「未指定」。
-- `?target=` 会传进来 "",而 "" 在 Lua 里是**真值**,所以 `format or DEFAULT_FORMAT`
-- 兜不住它,会一路落到 "unsupported format: "(冒号后面什么都没有)——
-- 用户拿到的是一个说不出原因的错误页。控制器只在 nil 时兜底,覆盖不到空串。
if type(format) ~= "string" then format = "" end
format = format:match("^%s*(.-)%s*$") or ""
if format == "" then format = DEFAULT_FORMAT end
local norm = M.FORMAT_ALIASES[format:lower():gsub("[-_%s]", "")]
if not norm then norm = M.FORMAT_ALIASES[format:lower()] end
if not norm then return nil, "unsupported format: " .. tostring(format) end
@@ -8,6 +8,11 @@ local M = {}
M.TIMEOUT = 2 -- 每个目标的超时(秒)
-- 批量探测时同时存在的子进程上限。
-- 每个探测占 1 个进程 + 1 个管道 fd,节点数由订阅内容决定(可上千),
-- 不限并发会把路由器的 fd/进程额度打满,之后 io.popen 静默失败。
M.MAX_PARALLEL = 16
-- shell 引号:用单引号转义(util.shq),配合 safe_host 校验,双重防命令注入。
-- 不能用 string.format("%q"):它生成双引号字符串,sh 在双引号内仍会做 $() / ``
-- 命令替换,实测可注入。
@@ -145,26 +150,39 @@ end
-- 返回结果数组 { name=, server=, port=, latency=number|nil }
function M.probe(nodes, mode)
local jobs = {}
-- 分批:一批最多 MAX_PARALLEL 个进程,读完并关闭这一批再起下一批。
--
-- 原来是「先把全部节点的 io.popen 起完,再统一读取」。并行度确实最高
-- (总耗时≈单节点超时),但代价是无界的:每个节点占 1 个进程 + 1 个管道 fd,
-- 而节点数由订阅内容决定(上千个很常见)。路由器上 fd 与进程数都是硬上限,
-- 打满之后 io.popen 直接失败 —— 表现是一大片节点探测不出来,
-- 而不是报错。分批后并发度封顶,总耗时仍是「批数 × 单节点超时」。
local out = {}
local batch = {}
local function drain()
for _, j in ipairs(batch) do
local latency
if j.f then
local raw = j.f:read("*a") or ""
j.f:close()
latency = j.parse and j.parse(raw) or nil
end
out[#out + 1] = {
name = j.name, server = j.server, port = j.port, latency = latency,
}
end
batch = {}
end
for _, n in ipairs(nodes or {}) do
local cmd, parse = build_job(mode, n.server, n.port)
local f = cmd and io.popen(cmd) or nil
jobs[#jobs + 1] = {
batch[#batch + 1] = {
f = f, parse = parse,
name = n.name or "", server = n.server or "", port = n.port or "",
}
if #batch >= M.MAX_PARALLEL then drain() end
end
local out = {}
for _, j in ipairs(jobs) do
local latency
if j.f then
local raw = j.f:read("*a") or ""
j.f:close()
latency = j.parse and j.parse(raw) or nil
end
out[#out + 1] = {
name = j.name, server = j.server, port = j.port, latency = latency,
}
end
drain()
return out
end
@@ -124,6 +124,33 @@ function M.rnd_hex(len)
return table.concat(out)
end
-- RFC 4122 version 4 UUID(8-4-4-4-12 十六进制)。
--
-- 客户端的 uuid 字段要的是**合法 UUID**,不是「一串随机字符」:
-- vmess/vless 的 uuid 会被解析成 16 字节,格式不对时多数客户端直接拒绝该节点。
-- 所以这里按规范置版本位(第 13 位十六进制 = 4)与变体位(第 17 位 ∈ 8/9/a/b),
-- 而不是随便取 36 个字符。随机源复用 rnd_hex(优先 /dev/urandom)。
-- 32 个十六进制字符按 8-4-4-4-12 切分;第 13 位固定 '4'(版本),
-- 第 17 位取 8/9/a/b(变体,即高两位为二进制的 10)。
function M.uuid()
local h = M.rnd_hex(32)
if #h < 32 then return nil end
local variant = tonumber(h:sub(17, 17), 16) % 4 + 8 -- 8..11 → 8/9/a/b
return string.format("%s-%s-4%s-%x%s-%s",
h:sub(1, 8), h:sub(9, 12), h:sub(14, 16),
variant, h:sub(18, 20),
h:sub(21, 32))
end
-- 把任意字符串转成 string.gsub 的**字面**替换串。
--
-- gsub 的替换串有自己的语义:`%` 后接数字是捕获引用,`%%` 才是字面百分号,
-- 而 `%` 后接其它字符会被吞掉、结尾的 `%` 会注入 NUL 字节。
-- 用户提供的替换值(节点名、模板变量)必须原样落地,所以先转义。
function M.gsub_literal(s)
return (tostring(s or ""):gsub("%%", "%%%%"))
end
-- ---------- URL ----------
function M.url_decode(s)
if type(s) ~= "string" then return "" end
@@ -411,6 +438,12 @@ function M.file_size(path)
return #data
end
-- 修改文件权限。Lua 5.1 标准库**没有** os.chmod(那是 nixio/posix 才有的),
-- 所以走 busybox 的 chmod,路径经 shq 引用。mode 传八进制字符串(如 "600")。
function M.chmod(path, mode)
return os.execute("chmod " .. mode .. " " .. M.shq(path) .. " >/dev/null 2>&1") == 0
end
-- 原子写:先写临时文件再重命名,避免写一半损坏。
--
-- 两处必须做对,否则「原子」只是名义上的:
@@ -419,7 +452,10 @@ end
-- 2) write / close 的返回值必须检查:磁盘写满或写入被截断时 f:write 会失败,
-- 但 os.rename 依然成功 —— 于是原子地换上一个残缺文件,调用方却以为成功,
-- 下一次读取才发现数据没了。
function M.atomic_write(path, content)
--
-- mode 可选:写盘后把文件权限收紧到该值。数据文件里是订阅 URL、公开下载 token、
-- 节点凭据;io.open 按 umask 创建,默认通常是 0644 —— 同机任何用户都能读到。
function M.atomic_write(path, content, mode)
local tmp = string.format("%s.tmp.%s", path, M.rnd_hex(8))
local f, e = io.open(tmp, "wb")
if not f then return false, e end
@@ -439,11 +475,21 @@ function M.atomic_write(path, content)
os.remove(tmp)
return false, rerr
end
-- rename 保留临时文件自身的权限,所以收紧必须在 rename **之后**做:
-- 先 chmod 再 rename 的话,临时文件名是公开可猜的,中间窗口里别人仍能读到。
if mode then M.chmod(path, mode) end
return true
end
function M.ensure_dir(path)
os.execute("mkdir -p " .. M.shq(path))
-- 建目录。mode 可选,仅在目录**由本次调用创建**时生效 ——
-- 对已存在的目录 busybox mkdir 不会改权限(要改得显式 chmod),
-- 调用方若需要「无论新建与否都收紧」,用 M.chmod 单独补一次。
function M.ensure_dir(path, mode)
if mode then
os.execute("mkdir -p -m " .. mode .. " " .. M.shq(path) .. " >/dev/null 2>&1")
else
os.execute("mkdir -p " .. M.shq(path))
end
end
-- ---------- 人性化格式 ----------