Skip to content

Commit 4a239ed

Browse files
committed
fix(deploy): 平台范围显式化 + 权限收紧不再静默失效 + 启动前 doctor
- #46 平台范围:新增 src/platform_support.py,仅支持 Linux / macOS; run() 与 build_app() 两处入口都给出可读中文后 SystemExit(后者覆盖 `uvicorn src.main:build_app --factory` 这条不经过 run 的生产路径)。 instance_lock 改为「导入可用、取锁显式拒绝」,Windows 上不再以 ModuleNotFoundError 打断在 import 阶段,也绝不静默 no-op。 新增 windows-latest CI job,用真机守住这两条。 - #45 权限:新增 src/security/private_files.py 收口 POSIX chmod 与 Windows ACL(icacls /inheritance:r + 读回校验),conn / backup / chat 全部接入;data/ 校验失败阻断启动,备份与 dump 只告警。 - #47 doctor:新增 src/doctor.py(只读纯函数)与 scripts/doctor.py, 7 项自检(Python/依赖/密钥/目录权限/DB 版本/端口/前端产物/上游连通) 输出 PASS/WARN/FAIL 并以退出码汇总。 - 文档:README「部署注意」补平台范围、doctor 用法、文件权限两级口径; SECURITY / TECHNICAL 同步。 测试:4971 passed,覆盖率 100%(含 Windows 分支 monkeypatch 全覆盖)。
1 parent 8b9379e commit 4a239ed

22 files changed

Lines changed: 2103 additions & 16 deletions

‎.github/workflows/ci.yml‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,37 @@ jobs:
3535
- name: 测试与覆盖率(门槛 100%)
3636
run: uv run pytest -q --cov=src --cov-report=term --cov-fail-under=100
3737

38+
# 平台门禁必须有真机验证:其余 job 全在 ubuntu-latest,正是 issue #46 的
39+
# 成因——Windows 上的 `ModuleNotFoundError: No module named 'fcntl'` 在
40+
# Linux CI 里永远看不见。这个 job 不装项目依赖:两个断言都只用标准库,
41+
# 跑得快,且正好验证「导入期不炸、入口给可读中文」这两条。
42+
platform:
43+
runs-on: windows-latest
44+
timeout-minutes: 5
45+
steps:
46+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
47+
- uses: actions/setup-python@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
48+
with:
49+
python-version: "3.12"
50+
- name: 无 fcntl 时模块仍可导入(否则 build_app / scripts 全线起不来)
51+
shell: bash
52+
run: |
53+
PYTHONPATH=. python -c "import src.instance_lock as m; assert m.LOCK_SUPPORTED is False"
54+
- name: 入口必须给一句可读中文而不是 ModuleNotFoundError
55+
shell: bash
56+
run: |
57+
set +e
58+
output=$(PYTHONPATH=. python -c "from src.platform_support import require_supported_platform; require_supported_platform()" 2>&1)
59+
code=$?
60+
set -e
61+
echo "$output"
62+
test "$code" -ne 0
63+
echo "$output" | grep -q "仅 Linux / macOS"
64+
if echo "$output" | grep -q "ModuleNotFoundError"; then
65+
echo "::error::Windows 上仍出现 ModuleNotFoundError(issue #46 回归)"
66+
exit 1
67+
fi
68+
3869
security:
3970
runs-on: ubuntu-latest
4071
timeout-minutes: 15

‎README.md‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353

5454
## 快速开始
5555

56-
前置:Python 3.12+、[uv](https://docs.astral.sh/uv/)、Node.js 24+、pnpm 10+(仅前端构建)。
56+
前置:**Linux 或 macOS**(Windows 请用 WSL2 / Docker 部署,见「部署注意」)、Python 3.12+、[uv](https://docs.astral.sh/uv/)、Node.js 24+、pnpm 10+(仅前端构建)。
5757

5858
```bash
5959
uv sync
@@ -826,6 +826,7 @@ python3 scripts/restore_db.py data/backups/coding2api-20261014-030000.sqlite3 --
826826

827827
## 部署注意
828828

829+
- **平台范围:仅 Linux / macOS**。单实例锁(`src/instance_lock.py`)依赖 `flock`,Windows 上没有可靠等价物;直接跑会以一句可读中文退出而不是启动。Windows 请用 WSL2 或 Docker(容器内是 Linux)。同理,`data/` 的 `0700` 信任边界按 POSIX 权限表述——Windows 下由 `src/security/private_files.py` 改用 `icacls` 收紧 ACL,见「[文件权限](#文件权限posix-与-windows)`。
829830
- **挂载属主**:容器内 uid 1001(`appuser`)→ `./data` 与 `./secrets` 必须可写 / 可读:
830831

831832
```bash
@@ -837,6 +838,38 @@ python3 scripts/restore_db.py data/backups/coding2api-20261014-030000.sqlite3 --
837838
- **端口**:compose 里 `PORT` 同时决定容器内监听端口与宿主映射端口。
838839
- **改代码后重建镜像**:`docker compose up -d` 不带 `--build` 用本地已有镜像跑旧代码。改 `src/` 或前端后必须 `docker compose up -d --build`。
839840

841+
### 启动前自检(doctor)
842+
843+
`/healthz` 只能回答「起没起来」,而起不来的常见原因(端口被占、`APP_SECRET` 缺失、`web/dist` 落后一个 release、库版本落后)都在服务启动之前就注定了。部署前先跑一条只读自检:
844+
845+
```bash
846+
python3 scripts/doctor.py # 全量检查,PASS/WARN/FAIL + 退出码
847+
python3 scripts/doctor.py --skip-upstream # 离线 / 内网部署:跳过上游连通性
848+
python3 scripts/doctor.py --json # 机读输出(CI / 部署脚本当门禁)
849+
```
850+
851+
逐项覆盖:Python 版本与关键依赖可 import、`APP_SECRET` 强度(与服务启动同一套弱密钥判定)、`DATA_DIR` 可写与权限口径、`PUBLIC_BASE_URL` 与 `ALLOWED_HOSTS` 是否一致、SQLite `integrity_check` + 待执行迁移条数、端口能否 `bind`、`web/dist/index.html` 引用的 js/css 是否真在磁盘上、各渠道端点连通性(只报可达 / TLS 失败 / DNS 失败 / 4xx,不带凭证)。
852+
853+
- **只读**:不迁移、不写 `runtime_settings`、不落凭证明文——否则 doctor 自己会变成第二个故障源。
854+
- 退出码 `0` = 没有 FAIL(WARN 不拦),`1` = 有 FAIL。
855+
- 上游探测不带凭证,因此 `401/403` 判为**通过**(链路是通的,鉴权由服务承担)。
856+
857+
### 文件权限(POSIX 与 Windows)
858+
859+
同一套收口实现(`src/security/private_files.py`)被 `data/`、`backups/`、`dumps/` 共用:
860+
861+
- **Linux / macOS**:`chmod` 后**读回校验** `mode & 0o077 == 0`;
862+
- **Windows**:`icacls /inheritance:r` 把 DACL 替换成「仅当前用户 + SYSTEM」,再读回校验没有继承 ACE、没有多余主体。
863+
864+
**为什么不能只靠 `os.chmod`**:它在 Windows 上只影响只读位、不改 ACL,而 `data/` 与 `.env` 的 ACE 通常是从上级目录继承来的 `Everyone:(F)`——密文凭证与 `APP_SECRET` 一起可读,等价于明文。
865+
866+
收紧结果分两级:
867+
868+
- **`data/`(信任边界)**:校验不通过则**启动失败**,并在报错里给出「怎么手工修」。这是运维能修的确定问题,不是需要降级的不确定状态。
869+
- ** backups / dumps 等旁路产物**:失败只 `logger.warning`(不拿诊断/备份的失败去拖垮主流程)。
870+
871+
`scripts/doctor.py` 会在启动前只读复核:`data/` 权限不达标报 FAIL(与服务同口径),其余产物不参与判定。
872+
840873
### 轮换 APP_SECRET(仅在怀疑密钥外泄时)
841874

842875
`APP_SECRET` 是 Fernet 密钥的唯一来源(无盐 SHA-256),**直接换值会让已存凭证全部不可解**。要换必须重加密:

‎SECURITY.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,16 @@
1919

2020
## 部署方须知(本项目的信任边界)
2121

22+
**平台范围:仅 Linux / macOS。** 单实例锁依赖 `flock`(POSIX 专有),Windows 上没有可靠的等价物;直接启动会以一句可读中文退出,不提供服务(用 WSL2 或 Docker 部署)。Windows 上的文件权限见下条。
23+
2224
服务把上游账号凭证加密存本地,并暴露管理台。部署须确保:
2325

2426
- `APP_SECRET` 随机且保密:会话签名密钥 + 凭证加密密钥派生源(见 `src/db/crypto.py`)。泄露等同凭证泄露。
25-
- 管理台不要裸暴露公网。必须暴露时前置带鉴权/限流的反向代理,并确保 `PUBLIC_BASE_URL` 与 `ALLOWED_HOSTS` 正确(见 README「配置」)。
27+
- 管理台不要裸暴露公网。必须暴露时前置带鉴权/限流的反向代理,并确保 `PUBLIC_BASE_URL` 与 `ALLOWED_HOSTS` 正确(见 README「配置」;`python3 scripts/doctor.py` 会在启动前校验两者一致)。
2628
- `data/`(SQLite,含凭证密文与审计)、`secrets/users.txt`(口令哈希)不得进备份之外任何地方;服务启动把 `data/` 权限收到 `0700`。
29+
- **POSIX(Linux / macOS)**:`chmod` 后读回校验 `mode & 0o077 == 0`。
30+
- **Windows**:`os.chmod` 对 ACL 无效,改用 `icacls` 把 DACL 替换成「仅当前用户 + SYSTEM」并切断继承(`src/security/private_files.py`),同样读回校验。
31+
- 校验不通过时**启动失败**(报错给出手工修复口径);备份 / dump 这类旁路产物只告警,不拖垮主流程。`scripts/doctor.py` 可在启动前只读复核。
2732
- 告警 webhook 地址由 admin 配置:写入期与**投递前**都拒绝内网/回环/链路本地(云元数据)地址,解析失败按阻断处理。
2833
- 安全扫描在 CI 执行:bandit(中危+)、pip-audit、`pnpm audit --prod`。
2934

‎TECHNICAL.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ PROPOSAL.md 定方向,本文定实现。只写**当前代码现状**。
4242
| `src/compat/` | `openai/{request,response,errors,dsml}.py` `responses/{request,response}.py` `anthropic/{request,response}.py` | Chat 校验·聚合·error shape·DSML 泄漏回读/Responses 出口/Anthropic Messages 出口 |
4343
| `src/tasks/` | `pacer.py` `quota_probe.py` `checkin.py` `growth.py` `activity.py` `refresh.py` `retention.py` `backup.py` `alerting.py` `upstream_status.py` `model_probe.py` `status.py` `runner.py` | 全局节流器/额度探测/签到(`CHECKIN_TIMES` 空则每 10 分钟)/成长(仅 CB)/活跃上报/预刷新/小时汇总 + 90 天清理/备份/告警(九类规则 + webhook)/上游状态聚合/模型探活/`TASK_SPECS` + 运行态/调度接入 lifespan |
4444
| `src/stats/` | `backfill.py` `collector.py` `live.py` `query.py` `csv.py` | 历史回填/明细写入 + 小时双写 + 提交后广播/进程内实时广播/各类统计查询/CSV(公式注入防护) |
45-
| `src/` 顶层 | `sorting.py` `benchmarks.py` `pricing.py` `model_match.py` `model_alias.py` `key_quota.py` `tags.py` `request_context.py` `upstream_status.py` `error_text.py` `instance_lock.py` | `sort`/`order` 白名单/OpenRouter 目录/成本估算(USD→CNY)/模型名匹配/`MODEL_ALIASES` 解析/Key 级配额(六项)/凭证标签纯函数/请求 ID + 凭证 id contextvar/状态页聚合/异常 → 带类名的文本(出口过脱敏)/`DATA_DIR` 单实例 `flock` |
45+
| `src/` 顶层 | `sorting.py` `benchmarks.py` `pricing.py` `model_match.py` `model_alias.py` `key_quota.py` `tags.py` `request_context.py` `upstream_status.py` `error_text.py` `instance_lock.py` `platform_support.py` `doctor.py` | `sort`/`order` 白名单/OpenRouter 目录/成本估算(USD→CNY)/模型名匹配/`MODEL_ALIASES` 解析/Key 级配额(六项)/凭证标签纯函数/请求 ID + 凭证 id contextvar/状态页聚合/异常 → 带类名的文本(出口过脱敏)/`DATA_DIR` 单实例 `flock`/平台范围门禁(仅 Linux / macOS,非支持平台给出可读退出)/启动前只读自检(Python·依赖·密钥·目录权限·DB 版本·端口·前端产物·上游连通性) |
46+
| `src/security/` | `private_files.py` | 私有目录/文件权限收口:POSIX `chmod` + 读回校验、Windows `icacls` 替换 DACL 并切断继承;`data/` 校验失败阻断启动,旁路产物只告警(issue #45) |
4647
| `src/api/` | `deps.py` `endpoints.py` `chat.py` `responses.py` `messages.py` `context.py` `models.py` `model_merge.py` `model_catalog.py` `balance.py` `authorize.py` `usage.py` `credential_login.py` `admin_credentials.py` `admin_keys.py` `admin_settings.py` `admin_users.py` `admin_audit.py` `admin_alerts.py` `admin_badges.py` `admin_upstream.py` `admin_model_catalog.py` `admin_model_availability.py` `admin_backups.py` `admin_billing.py` `logs.py` `activate.py` `admin_stats.py` `admin_auth.py` `playground.py` `streaming.py` `metrics.py` | Services 容器 + 鉴权依赖/`dispatch_compat` 骨架/三个 /v1 出口/压缩接线/`GET /v1/models`(SWR + 白名单)/归一·合并·别名/目录快照/余额/TRAE 回调/`/v1/usage`/OAuth 落库合并/凭证运维/Key CRUD/配置与任务/用户/审计/告警/角标/上游状态/模型目录 + 探活/备份/月度账单/日志页/激活/统计/登录/Playground/SSE 包装/`/metrics` |
4748
| `src/audit/actions.py` | — | 审计动作常量 + 中文标签 |
4849
| `web/` | — | React 前端。**`.npmrc` 的 `auto-install-peers=false` 与 lock 的 `settings.autoInstallPeers` 强耦合**(避免拉 `@lobehub/ui`/`antd` 子树);`Dockerfile` 必须 `COPY web/.npmrc`,否则 `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`。`shadcn` CLI **不是依赖**(组件源码已入库):样式层 `src/styles/shadcn-tailwind.css` 是 `shadcn@4.22.1/dist/tailwind.css` 的逐字节副本,加组件用 `pnpm dlx shadcn@latest add <name>`——把 CLI 留在 devDependencies 会拖进 ts-morph/minimatch/deepmerge 整棵依赖树,其中两条有 Dependabot 告警且上游无修复路径 |

‎scripts/doctor.py‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
#!/usr/bin/env python3
2+
"""启动前自检(只读):部署卡死时先跑这一条。
3+
4+
与 `create_user.py` / `backup_db.py` 同目录同风格。**绝不写库、不迁移、不启服务、
5+
不落凭证明文**——doctor 自己变成故障源就失去意义了。
6+
7+
用法:
8+
python3 scripts/doctor.py # 全量检查
9+
python3 scripts/doctor.py --skip-upstream # 离线部署:跳过上游连通性
10+
python3 scripts/doctor.py --json # 机读输出(CI 用)
11+
python3 scripts/doctor.py --port 8000 # 指定要探测的端口
12+
13+
退出码:0 = 没有 FAIL(WARN 不拦);1 = 有 FAIL。
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import argparse
19+
import json
20+
import sys
21+
from pathlib import Path
22+
23+
from pydantic import ValidationError
24+
25+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
26+
27+
from src import doctor # noqa: E402
28+
from src.config import Settings, load_settings # noqa: E402
29+
from src.webapp.static import frontend_dist # noqa: E402
30+
31+
#: 需要探测连通性的渠道端点。只取配置里的**当前**端点,不枚举白名单:
32+
#: 白名单里可能有用户永远不用的站点,逐条探测只会制造噪声与慢启动。
33+
_UPSTREAM_FIELDS = (
34+
("codebuddy", "codebuddy_api_endpoint"),
35+
("codebuddy_intl", "codebuddy_intl_api_endpoint"),
36+
("zen", "zen_api_endpoint"),
37+
("kilo", "kilo_api_endpoint"),
38+
("qoder", "qoder_api_endpoint"),
39+
("codearts", "codearts_api_endpoint"),
40+
("officeace", "officeace_api_endpoint"),
41+
("raccoon", "raccoon_api_endpoint"),
42+
("lingxi", "lingxi_api_endpoint"),
43+
)
44+
45+
46+
def upstream_endpoints(settings: Settings) -> list[tuple[str, str]]:
47+
"""配置里当前生效的渠道端点(去重,保持声明顺序)。"""
48+
seen: set[str] = set()
49+
endpoints: list[tuple[str, str]] = []
50+
for name, field in _UPSTREAM_FIELDS:
51+
url = getattr(settings, field, "").strip()
52+
if not url or url in seen:
53+
continue
54+
seen.add(url)
55+
endpoints.append((name, url))
56+
return endpoints
57+
58+
59+
def collect(settings: Settings, *, port: int | None = None,
60+
skip_upstream: bool = False) -> list[doctor.Check]:
61+
"""跑完全部检查;任一项内部异常都折算成 FAIL 而不是让脚本崩掉。"""
62+
checks = [
63+
doctor.check_python(sys.version_info[:3]),
64+
doctor.check_dependencies(),
65+
doctor.check_app_secret(settings),
66+
doctor.check_data_dir(settings),
67+
doctor.check_data_permissions(settings),
68+
doctor.check_host_config(settings),
69+
doctor.check_database(settings),
70+
doctor.check_port(settings.host, settings.port if port is None else port),
71+
doctor.check_frontend(frontend_dist()),
72+
]
73+
if not skip_upstream:
74+
checks.extend(doctor.check_upstreams(upstream_endpoints(settings)))
75+
return checks
76+
77+
78+
def render(checks: list[doctor.Check], *, as_json: bool = False) -> str:
79+
"""排版:JSON 给 CI,纯文本给人。"""
80+
if as_json:
81+
return json.dumps(
82+
[{"name": c.name, "status": c.status, "detail": c.detail} for c in checks],
83+
ensure_ascii=False, indent=2)
84+
width = max(len(c.name) for c in checks) if checks else 0
85+
lines = [f"[{c.status}] {c.name.ljust(width)} {c.detail}".rstrip() for c in checks]
86+
return "\n".join(lines)
87+
88+
89+
def summarize(checks: list[doctor.Check]) -> str:
90+
""""N 项通过 / M 项警告 / K 项失败"。"""
91+
passed = sum(1 for c in checks if c.status == doctor.PASS)
92+
warned = sum(1 for c in checks if c.status == doctor.WARN)
93+
failed = sum(1 for c in checks if c.status == doctor.FAIL)
94+
return f"{passed} 项通过,{warned} 项警告,{failed} 项失败"
95+
96+
97+
def load_settings_tolerant() -> tuple[Settings, str | None]:
98+
"""构造 Settings;`APP_SECRET` 缺失时降级为占位值并返回原因。
99+
100+
doctor 的用途之一就是**诊断 `APP_SECRET` 没配**——若这里直接让
101+
pydantic 的 `ValidationError` 打出来,其余六项检查全被挡住,等于在最需要
102+
自检的场景下自检不可用。降级后用 `CredentialCipher` 判定为 FAIL 并继续跑。
103+
"""
104+
try:
105+
return load_settings(), None
106+
except ValidationError:
107+
# `.env` 里其它键在这次降级里读不到:APP_SECRET 是唯一必填项,
108+
# 缺它基本等价于「还没建 .env」,其余项按默认值检查即可。
109+
return Settings(_env_file=None, APP_SECRET=""), "APP_SECRET 未配置"
110+
111+
112+
def main(argv: list[str] | None = None) -> int:
113+
parser = argparse.ArgumentParser(description="启动前自检(只读,不启服务)")
114+
parser.add_argument("--port", type=int, help="要探测的端口(默认取 PORT)")
115+
parser.add_argument("--skip-upstream", action="store_true",
116+
help="跳过上游连通性探测(离线部署)")
117+
parser.add_argument("--json", action="store_true", help="以 JSON 输出")
118+
args = parser.parse_args(argv)
119+
120+
settings, problem = load_settings_tolerant()
121+
if problem is not None:
122+
print(f"配置读取降级:{problem};其余检查按默认值继续", file=sys.stderr)
123+
checks = collect(settings, port=args.port, skip_upstream=args.skip_upstream)
124+
print(render(checks, as_json=args.json))
125+
if not args.json:
126+
print(summarize(checks))
127+
return 0 if all(c.ok for c in checks) else 1
128+
129+
130+
if __name__ == "__main__":
131+
raise SystemExit(main())

‎src/api/chat.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from fastapi import APIRouter, Depends, Request
1212

1313
from ..compat.openai.request import parse_chat_request
14+
from ..security.private_files import secure_dir
1415
from .deps import ApiKeyPrincipal, Services, api_key_user
1516
from .endpoints import dispatch_compat
1617

@@ -41,10 +42,11 @@ def dump_request_body(data_dir: Path, body: object) -> None:
4142
dump_dir.mkdir(parents=True, exist_ok=True, mode=DUMP_DIR_MODE)
4243
# mkdir 的 mode 只影响**新建**目录,且会被 umask 削;已存在的目录
4344
# (老版本建的、或数据目录搬迁带过来的)必须显式收紧。
44-
dump_dir.chmod(DUMP_DIR_MODE)
45+
secure_dir(dump_dir, mode=DUMP_DIR_MODE)
4546
path = dump_dir / f"{int(time.time() * 1000)}.json"
4647
# 用 os.open 建文件而不是 write_text:要先落权限再写内容,避免
47-
# 「先写后 chmod」窗口内同机其它用户能读到对话正文。
48+
# 「先写后 chmod」窗口内同机其它用户能读到对话正文。Windows 上
49+
# 新文件从已收紧的 `dumps/` 继承受保护 ACL(`(OI)(CI)`),同样没有窗口。
4850
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, DUMP_FILE_MODE)
4951
with os.fdopen(fd, "w", encoding="utf-8") as handle:
5052
handle.write(json.dumps(body, ensure_ascii=False, indent=1))

‎src/db/backup.py‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
from datetime import UTC, datetime
2929
from pathlib import Path
3030

31+
from ..security.private_files import secure_dir, secure_file
32+
3133
logger = logging.getLogger(__name__)
3234

3335
# 默认保留份数(后台任务与 CLI 的默认值;可经 BACKUP_KEEP 覆盖)。
@@ -89,9 +91,17 @@ def _unique_target(directory: Path, stem: str, stamp: str) -> Path:
8991

9092

9193
def _chmod(path: Path, mode: int) -> None:
92-
"""chmod;失败(共享目录、他人属主)不阻断——与 Database.__init__ 同口径。"""
93-
with suppress(OSError):
94-
os.chmod(path, mode)
94+
"""收紧权限;失败(共享目录、他人属主、平台不支持)只告警不阻断。
95+
96+
实现统一走 `src.security.private_files`:POSIX 是 chmod + 读回校验,Windows
97+
是 `icacls` 替换 DACL 并切断继承。**告警不能省**——Windows 上 `os.chmod`
98+
对 ACL 无效,静默吞异常会让 `SECURITY.md` 的 `0600` 承诺在 Windows 上
99+
完全不成立且无人知晓(issue #45)。
100+
"""
101+
if mode == 0o700:
102+
secure_dir(path)
103+
else:
104+
secure_file(path)
95105

96106

97107
def _prune(directory: Path, stem: str, keep: int) -> int:

0 commit comments

Comments
 (0)