diff --git a/.github/workflows/content.yml b/.github/workflows/content.yml new file mode 100644 index 0000000..9d755be --- /dev/null +++ b/.github/workflows/content.yml @@ -0,0 +1,88 @@ +name: content + +# Validate content metadata and keep catalog.json up to date. +# +# This repository only owns metadata and the generated catalog. The workflow +# never builds, publishes or verifies Docker images: it never logs in to a +# registry and needs no secrets. + +on: + pull_request: + branches: + - main + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: content-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + validate: + name: Validate + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Validate content metadata + run: python3 scripts/content.py validate + + # Catalog generation must succeed, but the committed catalog.json is + # refreshed by the catalog job on main only: pull requests are not + # required to keep it up to date. + - name: Check catalog can be generated + run: | + python3 scripts/content.py catalog --output /tmp/floatctf-catalog.json + test -s /tmp/floatctf-catalog.json + + - name: Run unit tests + run: python3 -m unittest discover -s scripts/tests -v + + catalog: + name: Update catalog.json + needs: validate + if: | + github.ref == 'refs/heads/main' + && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout main + uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Regenerate catalog.json + run: python3 scripts/content.py catalog + + - name: Commit catalog.json + run: | + set -euo pipefail + + if [[ -z "$(git status --porcelain -- catalog.json)" ]]; then + echo "catalog.json is already up to date" + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add catalog.json + git commit -m "chore: update catalog [skip ci]" + git push origin "HEAD:${GITHUB_REF_NAME}" diff --git a/README.md b/README.md index dd66ff0..a56c554 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ gameboxes/ meta.toml ``` +`meta.toml` 字段见 [Content Metadata](#content-metadata)。 + 内容发生变化后运行: ```bash @@ -75,8 +77,11 @@ meta.toml - 扫描 `challenges/` - 扫描 `gameboxes/` -- 更新 `events/.toml` +- 更新 `events/.toml` 的 `[content]` - 生成 `docs/.md` +- 最后校验 `meta.toml`(`scripts/content.py validate`) + +先同步再校验:删除或重命名内容时不会被 Event 中的旧引用卡住。 然后正常提交: @@ -110,3 +115,250 @@ floatctf-content:main > 比赛公开前,只向 `origin` 推送,不要向 `upstream` 推送。 +## Content Metadata + +`meta.toml` 是 Challenge / GameBox 的唯一元数据来源,用于生成 `catalog.json`。 + +```text +meta.toml + ↓ +scripts/content.py + ↓ +catalog.json + ↓ +FloatCTF Platform +``` + +本仓库只负责元数据校验与 Catalog 生成,**不负责构建、发布或验证镜像**。 + +三个概念要区分清楚: + +| 概念 | 来源 | 用途 | +|------|------|------| +| `id` | 目录名 | FloatCTF 内部稳定 ID,Event 引用、catalog 中的 `id` | +| `name` | `meta.toml` | UI 显示名称 | +| `safe_name` | `meta.toml`(可选) | Docker repository 名,用于 catalog 中的 image 引用 | + +`id` 不需要在 `meta.toml` 中声明,Challenge 与 GameBox 允许使用相同 `id` +与相同 `safe_name`,因为 image tag(`challenge-v*` / `gamebox-v*`)不同。 + +```toml +name = "comment" +version = "1.0.0" +author = "fb0sh@outlook.com" +category = "web" +difficulty = "easy" +tags = ["php", "web"] +description = "注释里面有什么?" +# 可选;缺省由目录名派生 +# safe_name = "comment" + +[flag] +type = "dynamic" + +[docker] +port = 80 + +[docker.recommended_resources] +cpu_millis = 500 +memory_bytes = 268435456 +pids_limit = 100 +``` + +必填字段: + +```text +name version author category difficulty tags description +``` + +字段说明: + +| 字段 | 说明 | +|------|------| +| `difficulty` | `unknown` / `beginner` / `easy` / `medium` / `hard` / `expert` | +| `tags` | 字符串数组,可以为空数组,每项必须是非空字符串 | +| `safe_name` | 可选字段;未填写时由目录名自动派生。所有 Challenge / GameBox 都必须最终得到合法的 `safe_name`;自动派生失败时必须显式填写 | + +- `version` 使用 `x.y.z`(SemVer),例如 `1.0.0`。 +- `category` 不限制取值,现有内容使用 `ai` / `crypto` / `misc` / `pwn` / `reverse` / `web`。 +- 旧内容已统一补充 `difficulty = "unknown"` 与 `tags = []`; + `unknown` 仅用于兼容,新内容请填写真实难度。 +- `events` 关系由 `events/*.toml` 自动反向生成,不要在 `meta.toml` 中手工维护。 + +### safe_name + +所有 Challenge / GameBox 都必须拥有有效的 `safe_name`,无论它是 container +还是 static / attachment-only 内容。 +`meta.toml` 中的 `safe_name` 字段本身可以省略,此时由目录名自动派生; +如果无法自动派生,则必须显式填写。 + +`safe_name` 是 Docker repository 名(必须匹配 +`^[a-z0-9]+(?:[._-][a-z0-9]+)*$`),而 `id` 可以包含空格、大写、撇号甚至中文。 +没有显式写 `safe_name` 时,由**目录名**自动派生: + +```text +comment → comment +Android_reverse → android_reverse +FloatCTF-qidong → floatctf-qidong +Cirno's perfect math class → cirnos-perfect-math-class +``` + +派生规则:小写 → Unicode NFKD 归一化 → 删除撇号(`'` 与 `’`)→ +非 `a-z0-9._-` 字符转成 `-` → 合并连续分隔符 → 去首尾 `. _ -`。 + +例如: + +```text +challenges/Cirno's perfect math class/meta.toml + +name = "Cirno's perfect math class" +safe_name = "cirnos-perfect-math-class" # 可省略,自动派生结果相同 +``` + +Catalog 中仍然是原始 `id`,只有 image 使用 `safe_name`: + +```json +{ + "id": "Cirno's perfect math class", + "image": "floatctf/cirnos-perfect-math-class:challenge-v1.0.0" +} +``` + +只有自动派生失败时才必须显式写 `safe_name`(例如全中文目录名): + +```toml +name = "题目" +safe_name = "challenge-001" +``` + +否则 `validate` 会报错: + +```text +error: challenges/题目/meta.toml: unable to derive Docker safe_name; set safe_name explicitly +``` + +同一类型下 `safe_name` 不允许冲突(`challenges/Foo` 与 `challenges/foo` +都会派生成 `foo`,必须改名或显式指定)。 + +校验整个仓库: + +```bash +python3 scripts/content.py validate +``` + +## Event Metadata + +`events/*.toml` 里除了由脚本生成的 `[content]`,还必须包含: + +```text +id title description started_at ended_at +``` + +```toml +schema_version = 1 + +id = "freshcup-2027" +title = "2027 FloatCTF 新生赛" +description = "2027 FloatCTF 新生赛题目仓库" +started_at = "2027-10-19 14:30" +ended_at = "2027-10-19 18:30" + +# BEGIN GENERATED CONTENT +[content] +challenges = [] +gameboxes = [] +# END GENERATED CONTENT +``` + +- `id` 必须等于文件名:`events/freshcup-2027.toml` → `id = "freshcup-2027"`。 +- `title` / `description` / `started_at` / `ended_at` 必须是 strip 后非空的字符串 + (暂不校验日期格式)。 +- `[content]` 由 `./scripts/sync-event.sh` 自动生成,不要手工维护。 + +`./scripts/sync-event.sh` 的执行顺序: + +```text +扫描 Challenge / GameBox + → 更新 Event 的 [content] generated block + → 生成 docs/.md + → 最后执行完整 metadata validation +``` + +先同步再校验,所以删除或重命名内容时不会被 Event 里的旧引用阻塞。 + +## Image Reference + +Catalog 中的 `image` 只是**规范化的引用**,由 `scripts/content.py` 生成, +供 FloatCTF 平台使用。本仓库不构建、不推送、不验证该镜像: + +```text +Challenge: floatctf/{safe_name}:challenge-v{version} +GameBox: floatctf/{safe_name}:gamebox-v{version} +``` + +例如: + +```text +floatctf/comment:challenge-v1.0.0 +floatctf/cirnos-perfect-math-class:challenge-v1.0.0 +``` + +`src/Dockerfile` 是否存在决定内容类型: + +```text +存在 → container content + Catalog 才可能包含 image 与 docker +不存在 → static / attachment content + 仍然进入 Catalog,但既没有 image 也没有 docker + (即使 meta.toml 中写了 [docker] 也不会输出) +``` + +不检查 Docker Hub 是否已有该镜像、本地是否能构建、tag 是否存在,也不做 +pull / push。 + +## Catalog + +`catalog.json` 是自动生成的官方题库索引,由 GitHub Actions 在 `main` +分支上重新生成并提交。平台可以直接读取: + +```text +https://raw.githubusercontent.com/FloatCTF/floatctf-content/main/catalog.json +``` + +本地重新生成与校验: + +```bash +python3 scripts/content.py catalog # 写入 ./catalog.json +python3 scripts/content.py catalog --output /tmp/c.json # 写到别处,不动工作树 +python3 scripts/content.py catalog --check # 只检查是否最新 +``` + +> **catalog.json is generated. Do not edit it manually.** + +Catalog 只包含元数据,不包含 flag 值;只有带 `src/Dockerfile` 的内容才有 +`image` 与 `docker` 字段。 + +提交到 `main` 的原因:Git 历史可追踪、`raw.githubusercontent.com` 直接访问、 +不需要 GitHub Pages、本地开发也能查看。 + +## Publishing Flow + +```text +Event private repo + └─ ./scripts/sync-event.sh # 扫描内容 → 更新 event manifest / docs → validate + └─ ./scripts/publish.sh # 推送到 upstream event/ 并创建 PR + └─ Pull Request # validate + catalog 生成测试 + unittest + └─ main # validate + unittest + 重新生成 catalog.json + └─ catalog.json # 有变化时由 github-actions[bot] 自动提交 + └─ FloatCTF 平台读取 raw catalog.json +``` + +- Pull Request:`validate` → `catalog --output`(不修改工作树)→ unittest。 + 不要求 `catalog.json` 已经是最新。 +- `main`(push 或 `workflow_dispatch`):`validate` + unittest 通过后重新生成 + `catalog.json`,有变化时用 `github-actions[bot]` 提交 + `chore: update catalog [skip ci]`。 +- 只有 `main` 会提交 catalog;PR 与其他分支不会。 +- Action 不登录任何 Registry、不构建镜像、不需要任何 Secret。 + + diff --git a/catalog.json b/catalog.json new file mode 100644 index 0000000..54dfca0 --- /dev/null +++ b/catalog.json @@ -0,0 +1,605 @@ +{ + "version": 1, + "challenges": [ + { + "id": "Android_reverse", + "name": "Android_reverse", + "version": "1.0.0", + "author": "ablklice@gmail.com", + "category": "reverse", + "difficulty": "unknown", + "tags": [], + "description": "简单的android逆向", + "flag": { + "type": "static" + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "Cirno's perfect math class", + "name": "Cirno's perfect math class", + "version": "1.0.0", + "author": "hakureiyukari01@gmail.com", + "category": "misc", + "difficulty": "unknown", + "tags": [], + "description": "琪露诺发现幻想乡巴士里有base编码", + "flag": { + "type": "static" + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "Flag_in_the_model", + "name": "Flag_in_the_model", + "version": "1.0.0", + "author": "myx2727461997@gmail.com", + "category": "misc", + "difficulty": "unknown", + "tags": [], + "description": ".pt?这是什么后缀名?", + "flag": { + "type": "static" + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "FloatCTF-qidong", + "name": "FloatCTF-qidong", + "version": "1.0.0", + "author": "myx2727461997@gmail.com", + "category": "web", + "difficulty": "unknown", + "tags": [], + "description": "大喊FloatCTF启动来获得flag吧", + "image": "floatctf/floatctf-qidong:challenge-v1.0.0", + "flag": { + "type": "dynamic" + }, + "docker": { + "port": 80, + "recommended_resources": { + "cpu_millis": 500, + "memory_bytes": 268435456, + "pids_limit": 100 + } + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "Hajimi", + "name": "Hajimi", + "version": "1.0.0", + "author": "myx2727461997@gmail.com", + "category": "pwn", + "difficulty": "unknown", + "tags": [], + "description": "我是耄耋,我还活着,拿nc拯救我吧,哈!!!", + "image": "floatctf/hajimi:challenge-v1.0.0", + "flag": { + "type": "dynamic" + }, + "docker": { + "port": 1337, + "recommended_resources": { + "cpu_millis": 500, + "memory_bytes": 268435456, + "pids_limit": 100 + } + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "Smali", + "name": "Smali", + "version": "1.0.0", + "author": "ablklice@gmail.com", + "category": "reverse", + "difficulty": "unknown", + "tags": [], + "description": "smali后缀文件,何意味", + "flag": { + "type": "static" + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "ai_shell", + "name": "ai_shell", + "version": "1.0.0", + "author": "fb0sh@outlook.com", + "category": "ai", + "difficulty": "unknown", + "tags": [], + "description": "这个执行命令小模型,会听你的话吗?", + "image": "floatctf/ai_shell:challenge-v1.0.0", + "flag": { + "type": "dynamic" + }, + "docker": { + "port": 80, + "recommended_resources": { + "cpu_millis": 500, + "memory_bytes": 268435456, + "pids_limit": 100 + } + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "backdoor", + "name": "backdoor", + "version": "1.0.0", + "author": "fb0sh@outlook.com", + "category": "web", + "difficulty": "unknown", + "tags": [], + "description": "php backdoor", + "image": "floatctf/backdoor:challenge-v1.0.0", + "flag": { + "type": "dynamic" + }, + "docker": { + "port": 80, + "recommended_resources": { + "cpu_millis": 500, + "memory_bytes": 268435456, + "pids_limit": 100 + } + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "base64", + "name": "base64", + "version": "1.0.0", + "author": "fb0sh@outlook.com", + "category": "crypto", + "difficulty": "unknown", + "tags": [], + "description": "ZmxhZ3tiMWQxZmNmNy01NzQyLTQ0OTctODYwOC1iZGU1NDlmMWQ1MmV9", + "flag": { + "type": "static" + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "caesar", + "name": "caesar", + "version": "1.0.0", + "author": "myx2727461997@gmail.com", + "category": "crypto", + "difficulty": "unknown", + "tags": [], + "description": "凯撒大帝的秘密信息!\n\n密文:iordwfwi{fdhvdu_flskhu_lv_hdvb}\n\n提示1:这是最古老的加密方法之一\n提示2:凯撒密码的位移量通常在 1-25 之间\n提示3:试试往回移动 3 位\n", + "flag": { + "type": "static" + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "comment", + "name": "comment", + "version": "1.0.0", + "author": "fb0sh@outlook.com", + "category": "web", + "difficulty": "easy", + "tags": [ + "php", + "web" + ], + "description": "注释里面有什么?", + "image": "floatctf/comment:challenge-v1.0.0", + "flag": { + "type": "dynamic" + }, + "docker": { + "port": 80, + "recommended_resources": { + "cpu_millis": 500, + "memory_bytes": 268435456, + "pids_limit": 100 + } + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "cookie", + "name": "cookie", + "version": "1.0.0", + "author": "myx2727461997@gmail.com", + "category": "web", + "difficulty": "unknown", + "tags": [], + "description": "想成为管理员吗?也许你需要一个特殊的饼干!", + "image": "floatctf/cookie:challenge-v1.0.0", + "flag": { + "type": "dynamic" + }, + "docker": { + "port": 80, + "recommended_resources": { + "cpu_millis": 500, + "memory_bytes": 268435456, + "pids_limit": 100 + } + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "ctf_start", + "name": "ctf_start", + "version": "1.0.0", + "author": "ablklice@gmail.com", + "category": "misc", + "difficulty": "unknown", + "tags": [], + "description": "这图片好像缺点什么", + "flag": { + "type": "static" + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "dir_enum", + "name": "dir_enum", + "version": "1.0.0", + "author": "fb0sh@outlook.com", + "category": "web", + "difficulty": "unknown", + "tags": [], + "description": "想必你一定知道什么是目录扫描吧", + "image": "floatctf/dir_enum:challenge-v1.0.0", + "flag": { + "type": "dynamic" + }, + "docker": { + "port": 80, + "recommended_resources": { + "cpu_millis": 500, + "memory_bytes": 268435456, + "pids_limit": 100 + } + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "easy_reverse", + "name": "easy_reverse", + "version": "1.0.0", + "author": "ablklice@gmail.com", + "category": "reverse", + "difficulty": "unknown", + "tags": [], + "description": "使用tea算法进行解密,答案使用flag{}进行包裹", + "flag": { + "type": "static" + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "frontend_bypass", + "name": "frontend_bypass", + "version": "1.0.0", + "author": "ablklice@gmail.com", + "category": "web", + "difficulty": "unknown", + "tags": [], + "description": "前端绕过", + "image": "floatctf/frontend_bypass:challenge-v1.0.0", + "flag": { + "type": "dynamic" + }, + "docker": { + "port": 80, + "recommended_resources": { + "cpu_millis": 500, + "memory_bytes": 268435456, + "pids_limit": 100 + } + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "komachi's book", + "name": "komachi's book", + "version": "1.0.0", + "author": "hakureiyukari01@gmail.com", + "category": "misc", + "difficulty": "unknown", + "tags": [], + "description": "小町摸鱼时会写小说,她可能会将小说变成zip包来存储", + "flag": { + "type": "static" + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "learn_http", + "name": "learn_http", + "version": "1.0.0", + "author": "fb0sh@outlook.com", + "category": "web", + "difficulty": "unknown", + "tags": [], + "description": "HTTP Protocol", + "image": "floatctf/learn_http:challenge-v1.0.0", + "flag": { + "type": "dynamic" + }, + "docker": { + "port": 80, + "recommended_resources": { + "cpu_millis": 500, + "memory_bytes": 268435456, + "pids_limit": 100 + } + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "matrix_rsa", + "name": "matrix_rsa", + "version": "1.0.0", + "author": "myx2727461997@gmail.com", + "category": "crypto", + "difficulty": "unknown", + "tags": [], + "description": "你告诉我什么叫矩阵RSA?", + "flag": { + "type": "static" + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "miku_flag", + "name": "miku_flag", + "version": "1.0.0", + "author": "ablklice@gmail.com", + "category": "misc", + "difficulty": "unknown", + "tags": [], + "description": "这图片好像缺点什么", + "flag": { + "type": "static" + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "orin's pack", + "name": "orin's pack", + "version": "1.0.0", + "author": "hakureiyukari01@gmail.com", + "category": "misc", + "difficulty": "unknown", + "tags": [], + "description": "阿燐在她的猫车中找到一个神秘压缩包", + "flag": { + "type": "static" + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "php_file", + "name": "php_file", + "version": "1.0.0", + "author": "ablklice@gmail.com", + "category": "web", + "difficulty": "unknown", + "tags": [], + "description": "file伪协议", + "image": "floatctf/php_file:challenge-v1.0.0", + "flag": { + "type": "dynamic" + }, + "docker": { + "port": 80, + "recommended_resources": { + "cpu_millis": 500, + "memory_bytes": 268435456, + "pids_limit": 100 + } + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "png", + "name": "png", + "version": "1.0.0", + "author": "fb0sh@outlook.com", + "category": "misc", + "difficulty": "unknown", + "tags": [], + "description": "图片里面有什么", + "flag": { + "type": "static" + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "ret2text", + "name": "ret2text", + "version": "1.0.0", + "author": "fb0sh@outlook.com", + "category": "pwn", + "difficulty": "unknown", + "tags": [], + "description": "return to text segment", + "image": "floatctf/ret2text:challenge-v1.0.0", + "flag": { + "type": "dynamic" + }, + "docker": { + "port": 1337, + "recommended_resources": { + "cpu_millis": 500, + "memory_bytes": 268435456, + "pids_limit": 100 + } + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "robots", + "name": "robots", + "version": "1.0.0", + "author": "myx2727461997@gmail.com", + "category": "web", + "difficulty": "unknown", + "tags": [], + "description": "听说搜索引擎的爬虫都会先看 robots.txt 文件?", + "image": "floatctf/robots:challenge-v1.0.0", + "flag": { + "type": "dynamic" + }, + "docker": { + "port": 80, + "recommended_resources": { + "cpu_millis": 500, + "memory_bytes": 268435456, + "pids_limit": 100 + } + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "strings_attached1", + "name": "strings_attached1", + "version": "1.0.0", + "author": "ablklice@gmail.com", + "category": "reverse", + "difficulty": "unknown", + "tags": [], + "description": "找到了一串字符串,要用什么算法解密呢", + "flag": { + "type": "static" + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "strings_attached2", + "name": "strings_attached2", + "version": "1.0.0", + "author": "ablklice@gmail.com", + "category": "reverse", + "difficulty": "unknown", + "tags": [], + "description": "和某一道题加密算法一样,但好像又有一些不同", + "flag": { + "type": "static" + }, + "events": [ + "challenges-202510-freshcup" + ] + }, + { + "id": "xzmu_anime_club", + "name": "xzmu_anime_club", + "version": "1.0.0", + "author": "hakureiyukari01@gmail.com", + "category": "crypto", + "difficulty": "unknown", + "tags": [], + "description": "这里是西藏民族大学动漫社,欢迎来玩", + "flag": { + "type": "static" + }, + "events": [ + "challenges-202510-freshcup" + ] + } + ], + "gameboxes": [], + "events": [ + { + "id": "challenges-202510-freshcup", + "title": "2025 FloatCTF 新生赛", + "description": "2025 FloatCTF 新生赛题目仓库", + "started_at": "2025-10-19 14:30", + "ended_at": "2025-10-19 18:30", + "challenges": [ + "Android_reverse", + "Cirno's perfect math class", + "Flag_in_the_model", + "FloatCTF-qidong", + "Hajimi", + "Smali", + "ai_shell", + "backdoor", + "base64", + "caesar", + "comment", + "cookie", + "ctf_start", + "dir_enum", + "easy_reverse", + "frontend_bypass", + "komachi's book", + "learn_http", + "matrix_rsa", + "miku_flag", + "orin's pack", + "php_file", + "png", + "ret2text", + "robots", + "strings_attached1", + "strings_attached2", + "xzmu_anime_club" + ], + "gameboxes": [] + } + ] +} diff --git a/challenges/Android_reverse/meta.toml b/challenges/Android_reverse/meta.toml index 680157c..204f656 100644 --- a/challenges/Android_reverse/meta.toml +++ b/challenges/Android_reverse/meta.toml @@ -2,6 +2,8 @@ name = "Android_reverse" version = "1.0.0" author = "ablklice@gmail.com" category = "reverse" +difficulty = "unknown" +tags = [] description = "简单的android逆向" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/Cirno's perfect math class/meta.toml b/challenges/Cirno's perfect math class/meta.toml index faa43c7..13c9e94 100644 --- a/challenges/Cirno's perfect math class/meta.toml +++ b/challenges/Cirno's perfect math class/meta.toml @@ -2,6 +2,8 @@ name = "Cirno's perfect math class" version = "1.0.0" author = "hakureiyukari01@gmail.com" category = "misc" +difficulty = "unknown" +tags = [] description = "琪露诺发现幻想乡巴士里有base编码" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/Flag_in_the_model/meta.toml b/challenges/Flag_in_the_model/meta.toml index 9b3ff27..73cb82d 100644 --- a/challenges/Flag_in_the_model/meta.toml +++ b/challenges/Flag_in_the_model/meta.toml @@ -2,6 +2,8 @@ name = "Flag_in_the_model" version = "1.0.0" author = "myx2727461997@gmail.com" category = "misc" +difficulty = "unknown" +tags = [] description = ".pt?这是什么后缀名?" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/FloatCTF-qidong/meta.toml b/challenges/FloatCTF-qidong/meta.toml index d5615a1..45ccccb 100644 --- a/challenges/FloatCTF-qidong/meta.toml +++ b/challenges/FloatCTF-qidong/meta.toml @@ -2,6 +2,8 @@ name = "FloatCTF-qidong" version = "1.0.0" author = "myx2727461997@gmail.com" category = "web" +difficulty = "unknown" +tags = [] description = "大喊FloatCTF启动来获得flag吧" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/Hajimi/meta.toml b/challenges/Hajimi/meta.toml index dbf4e58..486ce47 100644 --- a/challenges/Hajimi/meta.toml +++ b/challenges/Hajimi/meta.toml @@ -2,6 +2,8 @@ name = "Hajimi" version = "1.0.0" author = "myx2727461997@gmail.com" category = "pwn" +difficulty = "unknown" +tags = [] description = "我是耄耋,我还活着,拿nc拯救我吧,哈!!!" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/Smali/meta.toml b/challenges/Smali/meta.toml index a48aeab..4bafd1d 100644 --- a/challenges/Smali/meta.toml +++ b/challenges/Smali/meta.toml @@ -2,6 +2,8 @@ name = "Smali" version = "1.0.0" author = "ablklice@gmail.com" category = "reverse" +difficulty = "unknown" +tags = [] description = "smali后缀文件,何意味" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/ai_shell/meta.toml b/challenges/ai_shell/meta.toml index 4217c26..5a3c4bd 100644 --- a/challenges/ai_shell/meta.toml +++ b/challenges/ai_shell/meta.toml @@ -2,6 +2,8 @@ name = "ai_shell" version = "1.0.0" author = "fb0sh@outlook.com" category = "ai" +difficulty = "unknown" +tags = [] description = "这个执行命令小模型,会听你的话吗?" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/backdoor/meta.toml b/challenges/backdoor/meta.toml index a7f34ed..2be89d0 100644 --- a/challenges/backdoor/meta.toml +++ b/challenges/backdoor/meta.toml @@ -2,6 +2,8 @@ name = "backdoor" version = "1.0.0" author = "fb0sh@outlook.com" category = "web" +difficulty = "unknown" +tags = [] description = "php backdoor" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/base64/meta.toml b/challenges/base64/meta.toml index a86679c..19e3ed1 100644 --- a/challenges/base64/meta.toml +++ b/challenges/base64/meta.toml @@ -2,6 +2,8 @@ name = "base64" version = "1.0.0" author = "fb0sh@outlook.com" category = "crypto" +difficulty = "unknown" +tags = [] description = "ZmxhZ3tiMWQxZmNmNy01NzQyLTQ0OTctODYwOC1iZGU1NDlmMWQ1MmV9" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/caesar/meta.toml b/challenges/caesar/meta.toml index ca813cc..0553aef 100644 --- a/challenges/caesar/meta.toml +++ b/challenges/caesar/meta.toml @@ -2,6 +2,8 @@ name = "caesar" version = "1.0.0" author = "myx2727461997@gmail.com" category = "crypto" +difficulty = "unknown" +tags = [] description = """ 凯撒大帝的秘密信息! diff --git a/challenges/comment/meta.toml b/challenges/comment/meta.toml index 53fcf8e..5eb56c0 100644 --- a/challenges/comment/meta.toml +++ b/challenges/comment/meta.toml @@ -2,6 +2,8 @@ name = "comment" version = "1.0.0" author = "fb0sh@outlook.com" category = "web" +difficulty = "easy" +tags = ["php", "web"] description = "注释里面有什么?" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/cookie/meta.toml b/challenges/cookie/meta.toml index 0dbe3d7..7e529ae 100644 --- a/challenges/cookie/meta.toml +++ b/challenges/cookie/meta.toml @@ -2,6 +2,8 @@ name = "cookie" version = "1.0.0" author = "myx2727461997@gmail.com" category = "web" +difficulty = "unknown" +tags = [] description = "想成为管理员吗?也许你需要一个特殊的饼干!" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/ctf_start/meta.toml b/challenges/ctf_start/meta.toml index fca80d1..7df8c65 100644 --- a/challenges/ctf_start/meta.toml +++ b/challenges/ctf_start/meta.toml @@ -2,6 +2,8 @@ name = "ctf_start" version = "1.0.0" author = "ablklice@gmail.com" category = "misc" +difficulty = "unknown" +tags = [] description = "这图片好像缺点什么" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/dir_enum/meta.toml b/challenges/dir_enum/meta.toml index 570cf87..0944c84 100644 --- a/challenges/dir_enum/meta.toml +++ b/challenges/dir_enum/meta.toml @@ -2,6 +2,8 @@ name = "dir_enum" version = "1.0.0" author = "fb0sh@outlook.com" category = "web" +difficulty = "unknown" +tags = [] description = "想必你一定知道什么是目录扫描吧" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/easy_reverse/meta.toml b/challenges/easy_reverse/meta.toml index b9d583c..e759865 100644 --- a/challenges/easy_reverse/meta.toml +++ b/challenges/easy_reverse/meta.toml @@ -2,6 +2,8 @@ name = "easy_reverse" version = "1.0.0" author = "ablklice@gmail.com" category = "reverse" +difficulty = "unknown" +tags = [] description = "使用tea算法进行解密,答案使用flag{}进行包裹" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/frontend_bypass/meta.toml b/challenges/frontend_bypass/meta.toml index 8896b92..a8cd196 100644 --- a/challenges/frontend_bypass/meta.toml +++ b/challenges/frontend_bypass/meta.toml @@ -2,6 +2,8 @@ name = "frontend_bypass" version = "1.0.0" author = "ablklice@gmail.com" category = "web" +difficulty = "unknown" +tags = [] description = "前端绕过" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/komachi's book/meta.toml b/challenges/komachi's book/meta.toml index 033f2e5..044f8b9 100644 --- a/challenges/komachi's book/meta.toml +++ b/challenges/komachi's book/meta.toml @@ -2,6 +2,8 @@ name = "komachi's book" version = "1.0.0" author = "hakureiyukari01@gmail.com" category = "misc" +difficulty = "unknown" +tags = [] description = "小町摸鱼时会写小说,她可能会将小说变成zip包来存储" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/learn_http/meta.toml b/challenges/learn_http/meta.toml index 14c774e..a980a68 100644 --- a/challenges/learn_http/meta.toml +++ b/challenges/learn_http/meta.toml @@ -2,6 +2,8 @@ name = "learn_http" version = "1.0.0" author = "fb0sh@outlook.com" category = "web" +difficulty = "unknown" +tags = [] description = "HTTP Protocol" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/matrix_rsa/meta.toml b/challenges/matrix_rsa/meta.toml index a5701c1..7ba21d5 100644 --- a/challenges/matrix_rsa/meta.toml +++ b/challenges/matrix_rsa/meta.toml @@ -2,6 +2,8 @@ name = "matrix_rsa" version = "1.0.0" author = "myx2727461997@gmail.com" category = "crypto" +difficulty = "unknown" +tags = [] description = "你告诉我什么叫矩阵RSA?" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/miku_flag/meta.toml b/challenges/miku_flag/meta.toml index c1527b7..edca74a 100644 --- a/challenges/miku_flag/meta.toml +++ b/challenges/miku_flag/meta.toml @@ -2,6 +2,8 @@ name = "miku_flag" version = "1.0.0" author = "ablklice@gmail.com" category = "misc" +difficulty = "unknown" +tags = [] description = "这图片好像缺点什么" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/orin's pack/meta.toml b/challenges/orin's pack/meta.toml index 7d748aa..9619cef 100644 --- a/challenges/orin's pack/meta.toml +++ b/challenges/orin's pack/meta.toml @@ -2,6 +2,8 @@ name = "orin's pack" version = "1.0.0" author = "hakureiyukari01@gmail.com" category = "misc" +difficulty = "unknown" +tags = [] description = "阿燐在她的猫车中找到一个神秘压缩包" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/php_file/meta.toml b/challenges/php_file/meta.toml index 6791b3d..787866a 100644 --- a/challenges/php_file/meta.toml +++ b/challenges/php_file/meta.toml @@ -2,6 +2,8 @@ name = "php_file" version = "1.0.0" author = "ablklice@gmail.com" category = "web" +difficulty = "unknown" +tags = [] description = "file伪协议" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/png/meta.toml b/challenges/png/meta.toml index a5c682d..b0e359e 100644 --- a/challenges/png/meta.toml +++ b/challenges/png/meta.toml @@ -2,6 +2,8 @@ name = "png" version = "1.0.0" author = "fb0sh@outlook.com" category = "misc" +difficulty = "unknown" +tags = [] description = "图片里面有什么" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/ret2text/meta.toml b/challenges/ret2text/meta.toml index 3f74faf..b8f305e 100644 --- a/challenges/ret2text/meta.toml +++ b/challenges/ret2text/meta.toml @@ -2,6 +2,8 @@ name = "ret2text" version = "1.0.0" author = "fb0sh@outlook.com" category = "pwn" +difficulty = "unknown" +tags = [] description = "return to text segment" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/robots/meta.toml b/challenges/robots/meta.toml index 1afcb9c..8ebc793 100644 --- a/challenges/robots/meta.toml +++ b/challenges/robots/meta.toml @@ -2,6 +2,8 @@ name = "robots" version = "1.0.0" author = "myx2727461997@gmail.com" category = "web" +difficulty = "unknown" +tags = [] description = "听说搜索引擎的爬虫都会先看 robots.txt 文件?" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/strings_attached1/meta.toml b/challenges/strings_attached1/meta.toml index db1a76d..5dc6188 100644 --- a/challenges/strings_attached1/meta.toml +++ b/challenges/strings_attached1/meta.toml @@ -2,6 +2,8 @@ name = "strings_attached1" version = "1.0.0" author = "ablklice@gmail.com" category = "reverse" +difficulty = "unknown" +tags = [] description = "找到了一串字符串,要用什么算法解密呢" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/strings_attached2/meta.toml b/challenges/strings_attached2/meta.toml index 6d76cc5..97e9ceb 100644 --- a/challenges/strings_attached2/meta.toml +++ b/challenges/strings_attached2/meta.toml @@ -2,6 +2,8 @@ name = "strings_attached2" version = "1.0.0" author = "ablklice@gmail.com" category = "reverse" +difficulty = "unknown" +tags = [] description = "和某一道题加密算法一样,但好像又有一些不同" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/challenges/xzmu_anime_club/meta.toml b/challenges/xzmu_anime_club/meta.toml index f415604..aea1f93 100644 --- a/challenges/xzmu_anime_club/meta.toml +++ b/challenges/xzmu_anime_club/meta.toml @@ -2,6 +2,8 @@ name = "xzmu_anime_club" version = "1.0.0" author = "hakureiyukari01@gmail.com" category = "crypto" +difficulty = "unknown" +tags = [] description = "这里是西藏民族大学动漫社,欢迎来玩" # Optional: 显式 safe_name;缺省由 name 派生(派生失败时必须显式提供) diff --git a/gameboxes/.gitkeep b/gameboxes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/content.py b/scripts/content.py new file mode 100755 index 0000000..ce65597 --- /dev/null +++ b/scripts/content.py @@ -0,0 +1,911 @@ +#!/usr/bin/env python3 +"""FloatCTF content metadata and catalog helper. + +This module is the single source of truth for: + +* content ids, types, versions and Docker safe names (``meta.toml``) +* ``catalog.json`` + +It only validates metadata and generates the catalog. Building, publishing or +verifying container images is explicitly out of scope: the ``image`` field is +derived metadata for the FloatCTF platform to consume. + +Only the Python standard library (3.11+) is required. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import tomllib +import unicodedata +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, NoReturn, Sequence + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +CHALLENGES_DIR = "challenges" +GAMEBOXES_DIR = "gameboxes" +EVENTS_DIR = "events" + +CONTENT_CHALLENGE = "challenge" +CONTENT_GAMEBOX = "gamebox" + +CONTENT_DIRS: dict[str, str] = { + CONTENT_CHALLENGE: CHALLENGES_DIR, + CONTENT_GAMEBOX: GAMEBOXES_DIR, +} + +CATALOG_FILE = "catalog.json" +CATALOG_VERSION = 1 + +#: Docker Hub namespace used when building the catalog image reference. +IMAGE_NAMESPACE = "floatctf" + +#: Content is considered container-based when ``/src/Dockerfile`` +#: exists; only then does the catalog carry an ``image`` field. +SOURCE_SUBDIR = "src" +DOCKERFILE_NAME = "Dockerfile" + +VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+$") + +#: Valid Docker repository name used as ``floatctf/``. +SAFE_NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:[._-][a-z0-9]+)*$") + +DIFFICULTIES: tuple[str, ...] = ( + "unknown", + "beginner", + "easy", + "medium", + "hard", + "expert", +) + +REQUIRED_FIELDS: tuple[str, ...] = ( + "name", + "version", + "author", + "category", + "difficulty", + "tags", + "description", +) + +TEXT_FIELDS: tuple[str, ...] = ( + "name", + "author", + "category", + "description", +) + +#: Event fields copied straight into the catalog, so they must be present. +EVENT_REQUIRED_TEXT_FIELDS: tuple[str, ...] = ( + "title", + "description", + "started_at", + "ended_at", +) + +POSITIVE_RESOURCE_FIELDS: tuple[str, ...] = ( + "cpu_millis", + "memory_bytes", + "pids_limit", +) + +PORT_MIN = 1 +PORT_MAX = 65535 + + +class ContentError(Exception): + """A user facing content problem.""" + + +# --------------------------------------------------------------------------- +# Docker safe names +# --------------------------------------------------------------------------- + + +def derive_safe_name(content_id: str) -> str: + """Derive a Docker repository name from a content id. + + ``Cirno's perfect math class`` becomes ``cirnos-perfect-math-class``. + Returns an empty string when nothing usable is left, in which case the + content must set ``safe_name`` explicitly. + """ + + name = content_id.lower() + name = unicodedata.normalize("NFKD", name) + name = "".join(char for char in name if not unicodedata.combining(char)) + name = name.replace("'", "").replace("\u2019", "") + name = re.sub(r"[^a-z0-9._-]+", "-", name) + name = re.sub(r"[._-]{2,}", "-", name) + name = name.strip("._-") + + if not SAFE_NAME_PATTERN.match(name): + return "" + + return name + + +def explicit_safe_name(meta: dict[str, Any]) -> str | None: + """Return the explicit ``safe_name`` when it is a usable string.""" + + value = meta.get("safe_name") + + if isinstance(value, str) and value.strip(): + return value.strip() + + return None + + +def has_dockerfile(content: "Content", root: Path) -> bool: + """True when *content* is a container (``src/Dockerfile`` exists). + + Container content gets an ``image`` reference in the catalog; the + attachment-only content does not. + """ + + return (root / content.path / SOURCE_SUBDIR / DOCKERFILE_NAME).is_file() + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + + +@dataclass +class Content: + """One challenge or gamebox directory.""" + + id: str + type: str # CONTENT_CHALLENGE | CONTENT_GAMEBOX + path: Path # e.g. challenges/comment + meta: dict[str, Any] = field(default_factory=dict) + + @property + def version(self) -> str: + return str(self.meta.get("version", "")) + + @property + def safe_name(self) -> str: + """Docker repository name: explicit ``safe_name`` or derived from id.""" + + return explicit_safe_name(self.meta) or derive_safe_name(self.id) + + @property + def meta_path(self) -> Path: + return self.path / "meta.toml" + + +@dataclass +class Event: + """One event manifest (``events/.toml``).""" + + id: str + path: Path + meta: dict[str, Any] = field(default_factory=dict) + challenges: list[str] = field(default_factory=list) + gameboxes: list[str] = field(default_factory=list) + + +@dataclass +class ValidationResult: + """Outcome of :func:`validate`.""" + + challenges: list[Content] = field(default_factory=list) + gameboxes: list[Content] = field(default_factory=list) + events: list[Event] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + + @property + def ok(self) -> bool: + return not self.errors + + +# --------------------------------------------------------------------------- +# Small helpers +# --------------------------------------------------------------------------- + + +def _display(path: Path, root: Path) -> str: + """Render *path* relative to *root* when possible.""" + + try: + return path.relative_to(root).as_posix() + except ValueError: + return path.as_posix() + + +def _report(errors: list[str] | None, message: str) -> None: + """Collect *message* or raise it when no collector is provided.""" + + if errors is None: + raise ContentError(message) + errors.append(message) + + +def _is_positive_int(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value > 0 + + +def _is_plain_int(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) + + +# --------------------------------------------------------------------------- +# Loading +# --------------------------------------------------------------------------- + + +def load_meta(path: Path) -> dict[str, Any]: + """Parse a TOML file, raising :class:`ContentError` on failure.""" + + try: + with path.open("rb") as handle: + data = tomllib.load(handle) + except FileNotFoundError as exc: + raise ContentError(f"{path.as_posix()}: file not found") from exc + except IsADirectoryError as exc: + raise ContentError(f"{path.as_posix()}: expected a file") from exc + except OSError as exc: + raise ContentError(f"{path.as_posix()}: cannot read file: {exc}") from exc + except tomllib.TOMLDecodeError as exc: + raise ContentError(f"{path.as_posix()}: invalid TOML: {exc}") from exc + + if not isinstance(data, dict): + raise ContentError(f"{path.as_posix()}: expected a TOML table") + + return data + + +def _content_dirs(base: Path) -> list[Path]: + if not base.is_dir(): + return [] + + return sorted( + ( + entry + for entry in base.iterdir() + if entry.is_dir() and not entry.name.startswith(".") + ), + key=lambda entry: entry.name, + ) + + +def scan_contents( + root: Path, + content_type: str, + errors: list[str] | None = None, +) -> list[Content]: + """Scan ``challenges/`` or ``gameboxes/`` and return entries sorted by id.""" + + if content_type not in CONTENT_DIRS: + raise ValueError(f"unknown content type: {content_type}") + + base = root / CONTENT_DIRS[content_type] + contents: list[Content] = [] + seen: dict[str, Path] = {} + + for directory in _content_dirs(base): + meta_path = directory / "meta.toml" + + if not meta_path.is_file(): + _report(errors, f"{_display(meta_path, root)}: missing meta.toml") + continue + + try: + meta = load_meta(meta_path) + except ContentError as exc: + _report(errors, str(exc)) + continue + + if directory.name in seen: + _report( + errors, + f"{_display(meta_path, root)}: duplicate {content_type} id " + f"'{directory.name}' (already used by " + f"{_display(seen[directory.name], root)})", + ) + else: + seen[directory.name] = meta_path + + contents.append( + Content( + id=directory.name, + type=content_type, + # Kept relative to *root* so ``root / content.path`` is correct + # for both absolute and relative roots. + path=Path(CONTENT_DIRS[content_type]) / directory.name, + meta=meta, + ) + ) + + return sorted(contents, key=lambda content: content.id) + + +def _event_reference_array( + meta: dict[str, Any], + key: str, + display: str, + errors: list[str] | None, +) -> list[str]: + content = meta.get("content") + + if content is None: + return [] + + if not isinstance(content, dict): + _report(errors, f"{display}: [content] must be a table") + return [] + + values = content.get(key, []) + + if not isinstance(values, list) or any( + not isinstance(value, str) or not value.strip() for value in values + ): + _report( + errors, + f"{display}: [content].{key} must be an array of non-empty strings", + ) + return [] + + return [value.strip() for value in values] + + +def _validate_event_text_fields( + meta: dict[str, Any], + display: str, + errors: list[str] | None, +) -> None: + """Require the event fields the catalog exposes.""" + + for name in EVENT_REQUIRED_TEXT_FIELDS: + if name not in meta: + _report(errors, f"{display}: missing field '{name}'") + elif not isinstance(meta[name], str) or not meta[name].strip(): + _report( + errors, + f"{display}: field '{name}' must be a non-empty string", + ) + + +def load_events(root: Path, errors: list[str] | None = None) -> list[Event]: + """Load ``events/*.toml`` sorted by event id.""" + + base = root / EVENTS_DIR + + if not base.is_dir(): + return [] + + events: list[Event] = [] + + for path in sorted(base.glob("*.toml"), key=lambda item: item.name): + display = _display(path, root) + + try: + meta = load_meta(path) + except ContentError as exc: + _report(errors, str(exc)) + continue + + raw_id = meta.get("id") + + if not isinstance(raw_id, str) or not raw_id.strip(): + _report(errors, f"{display}: missing field 'id'") + continue + + event_id = raw_id.strip() + + if event_id != path.stem: + _report( + errors, + f"{display}: id must match file name '{path.stem}'", + ) + + _validate_event_text_fields(meta, display, errors) + + events.append( + Event( + id=event_id, + path=path, + meta=meta, + challenges=_event_reference_array( + meta, "challenges", display, errors + ), + gameboxes=_event_reference_array( + meta, "gameboxes", display, errors + ), + ) + ) + + return sorted(events, key=lambda event: event.id) + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + + +def validate_meta(content: Content, root: Path) -> list[str]: + """Return every metadata problem for a single content entry.""" + + meta = content.meta + display = _display(content.meta_path, root) + errors: list[str] = [] + + for name in REQUIRED_FIELDS: + if name not in meta: + errors.append(f"{display}: missing field '{name}'") + + for name in TEXT_FIELDS: + if name in meta and ( + not isinstance(meta[name], str) or not meta[name].strip() + ): + errors.append(f"{display}: field '{name}' must be a non-empty string") + + if "version" in meta: + version = meta["version"] + if not isinstance(version, str) or not VERSION_PATTERN.match(version): + errors.append( + f"{display}: invalid version {version!r} (expected x.y.z)" + ) + + if "difficulty" in meta: + difficulty = meta["difficulty"] + if not isinstance(difficulty, str) or difficulty not in DIFFICULTIES: + errors.append( + f"{display}: invalid difficulty {difficulty!r} " + f"(expected one of {', '.join(DIFFICULTIES)})" + ) + + if "tags" in meta: + tags = meta["tags"] + if not isinstance(tags, list): + errors.append( + f"{display}: field 'tags' must be an array of non-empty strings" + ) + elif any(not isinstance(tag, str) or not tag.strip() for tag in tags): + errors.append( + f"{display}: field 'tags' must be an array of non-empty strings" + ) + + if "safe_name" in meta: + safe_name = explicit_safe_name(meta) + + if safe_name is None or not SAFE_NAME_PATTERN.match(safe_name): + errors.append(f"{display}: invalid safe_name {meta['safe_name']!r}") + elif not derive_safe_name(content.id): + errors.append( + f"{display}: unable to derive Docker safe_name; " + f"set safe_name explicitly" + ) + + docker = meta.get("docker") + + if docker is not None: + if not isinstance(docker, dict): + errors.append(f"{display}: field 'docker' must be a table") + else: + port = docker.get("port") + + if port is not None and ( + not _is_plain_int(port) or not PORT_MIN <= port <= PORT_MAX + ): + errors.append( + f"{display}: invalid docker.port {port!r} " + f"(expected {PORT_MIN}..{PORT_MAX})" + ) + + resources = docker.get("recommended_resources") + + if resources is not None: + if not isinstance(resources, dict): + errors.append( + f"{display}: field 'docker.recommended_resources' " + f"must be a table" + ) + else: + for name in POSITIVE_RESOURCE_FIELDS: + value = resources.get(name) + if value is None: + continue + if not _is_positive_int(value): + errors.append( + f"{display}: invalid " + f"docker.recommended_resources.{name} " + f"{value!r} (expected a positive integer)" + ) + + return errors + + +def _duplicate_values(values: Sequence[str]) -> list[str]: + """Return the values that occur more than once, sorted.""" + + seen: set[str] = set() + duplicates: set[str] = set() + + for value in values: + if value in seen: + duplicates.add(value) + seen.add(value) + + return sorted(duplicates) + + +def _safe_name_errors(contents: Sequence[Content]) -> list[str]: + """Return duplicate Docker safe_name errors for one content type.""" + + grouped: dict[str, list[Content]] = {} + + for content in contents: + safe_name = content.safe_name + + # Invalid or underivable names are already reported by validate_meta. + if safe_name and SAFE_NAME_PATTERN.match(safe_name): + grouped.setdefault(safe_name, []).append(content) + + errors: list[str] = [] + + for safe_name, items in sorted(grouped.items()): + if len(items) < 2: + continue + + names = "' and '".join(sorted(item.id for item in items)) + errors.append( + f"duplicate {items[0].type} safe_name '{safe_name}': '{names}'" + ) + + return errors + + +def validate(root: Path) -> ValidationResult: + """Validate all content and events below *root*.""" + + errors: list[str] = [] + + challenges = scan_contents(root, CONTENT_CHALLENGE, errors) + gameboxes = scan_contents(root, CONTENT_GAMEBOX, errors) + + for content in (*challenges, *gameboxes): + errors.extend(validate_meta(content, root)) + + # Safe names must be unique per content type; a challenge and a gamebox may + # share one because their image tags differ. + errors.extend(_safe_name_errors(challenges)) + errors.extend(_safe_name_errors(gameboxes)) + + events = load_events(root, errors) + + challenge_ids = {content.id for content in challenges} + gamebox_ids = {content.id for content in gameboxes} + + for event in events: + display = _display(event.path, root) + + for reference in _duplicate_values(event.challenges): + errors.append(f"{display}: duplicate challenge '{reference}'") + + for reference in _duplicate_values(event.gameboxes): + errors.append(f"{display}: duplicate gamebox '{reference}'") + + for reference in event.challenges: + if reference not in challenge_ids: + errors.append(f"{display}: unknown challenge '{reference}'") + + for reference in event.gameboxes: + if reference not in gamebox_ids: + errors.append(f"{display}: unknown gamebox '{reference}'") + + return ValidationResult( + challenges=challenges, + gameboxes=gameboxes, + events=events, + errors=errors, + ) + + +# --------------------------------------------------------------------------- +# Images +# --------------------------------------------------------------------------- + + +def image_ref(content: Content) -> str: + """Return ``floatctf/{safe_name}:{type}-v{version}``.""" + + safe_name = content.safe_name + + if not safe_name: + raise ContentError( + f"{content.meta_path.as_posix()}: unable to derive Docker " + f"safe_name; set safe_name explicitly" + ) + + if not SAFE_NAME_PATTERN.match(safe_name): + raise ContentError( + f"{content.meta_path.as_posix()}: invalid safe_name {safe_name!r}" + ) + + return ( + f"{IMAGE_NAMESPACE}/{safe_name}" + f":{content.type}-v{content.version}" + ) + + +# --------------------------------------------------------------------------- +# Catalog +# --------------------------------------------------------------------------- + + +def _event_index(events: Sequence[Event], attribute: str) -> dict[str, list[str]]: + index: dict[str, list[str]] = {} + + for event in events: + for reference in getattr(event, attribute): + index.setdefault(reference, []).append(event.id) + + return { + key: sorted(set(values)) + for key, values in index.items() + } + + +def _flag_entry(meta: dict[str, Any]) -> dict[str, Any] | None: + """Expose the flag *type* only; values never belong in the catalog.""" + + flag = meta.get("flag") + + if not isinstance(flag, dict): + return None + + entry: dict[str, Any] = {} + + if isinstance(flag.get("type"), str): + entry["type"] = flag["type"] + + return entry or None + + +def _docker_entry(meta: dict[str, Any]) -> dict[str, Any] | None: + docker = meta.get("docker") + + if not isinstance(docker, dict): + return None + + entry: dict[str, Any] = {} + + if _is_plain_int(docker.get("port")): + entry["port"] = docker["port"] + + resources = docker.get("recommended_resources") + + if isinstance(resources, dict): + selected = { + name: resources[name] + for name in POSITIVE_RESOURCE_FIELDS + if _is_positive_int(resources.get(name)) + } + if selected: + entry["recommended_resources"] = selected + + return entry or None + + +def content_entry( + content: Content, + event_ids: Sequence[str], + root: Path, +) -> dict[str, Any]: + """Build the catalog entry for one challenge or gamebox.""" + + meta = content.meta + + entry: dict[str, Any] = { + "id": content.id, + "name": meta.get("name", content.id), + "version": content.version, + "author": meta.get("author", ""), + "category": meta.get("category", ""), + "difficulty": meta.get("difficulty", ""), + "tags": list(meta.get("tags", [])), + "description": meta.get("description", ""), + } + + # Only container content publishes an image and runtime configuration; + # static / attachment-only content carries neither, even when its + # meta.toml declares a [docker] table. + container = has_dockerfile(content, root) + + if container: + entry["image"] = image_ref(content) + + flag = _flag_entry(meta) + + if flag is not None: + entry["flag"] = flag + + if container: + docker = _docker_entry(meta) + + if docker is not None: + entry["docker"] = docker + + entry["events"] = sorted(set(event_ids)) + + return entry + + +def event_entry(event: Event) -> dict[str, Any]: + """Build the catalog entry for one event.""" + + meta = event.meta + + return { + "id": event.id, + "title": meta.get("title", ""), + "description": meta.get("description", ""), + "started_at": meta.get("started_at", ""), + "ended_at": meta.get("ended_at", ""), + "challenges": sorted(set(event.challenges)), + "gameboxes": sorted(set(event.gameboxes)), + } + + +def build_catalog(root: Path) -> dict[str, Any]: + """Build the catalog data structure (deterministic).""" + + challenges = scan_contents(root, CONTENT_CHALLENGE) + gameboxes = scan_contents(root, CONTENT_GAMEBOX) + events = load_events(root) + + challenge_events = _event_index(events, "challenges") + gamebox_events = _event_index(events, "gameboxes") + + return { + "version": CATALOG_VERSION, + "challenges": [ + content_entry(content, challenge_events.get(content.id, []), root) + for content in sorted(challenges, key=lambda item: item.id) + ], + "gameboxes": [ + content_entry(content, gamebox_events.get(content.id, []), root) + for content in sorted(gameboxes, key=lambda item: item.id) + ], + "events": [ + event_entry(event) + for event in sorted(events, key=lambda item: item.id) + ], + } + + +def render_catalog(catalog: dict[str, Any]) -> str: + """Serialize a catalog exactly the way it is committed.""" + + return json.dumps(catalog, indent=2, ensure_ascii=False) + "\n" + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + + +def fail(message: str) -> NoReturn: + print(f"error: {message}", file=sys.stderr) + raise SystemExit(1) + + +def cmd_validate(args: argparse.Namespace) -> int: + result = validate(Path(args.root)) + + if result.errors: + for message in result.errors: + print(f"error: {message}", file=sys.stderr) + return 1 + + print("Validated content:") + print(f" Challenges: {len(result.challenges)}") + print(f" GameBoxes: {len(result.gameboxes)}") + print(f" Events: {len(result.events)}") + + return 0 + + +def cmd_catalog(args: argparse.Namespace) -> int: + root = Path(args.root) + + # Never write a catalog derived from broken metadata. + result = validate(root) + + if result.errors: + for message in result.errors: + print(f"error: {message}", file=sys.stderr) + return 1 + + rendered = render_catalog(build_catalog(root)) + + if args.check: + catalog_path = root / CATALOG_FILE + current = ( + catalog_path.read_text(encoding="utf-8") + if catalog_path.is_file() + else None + ) + + if current != rendered: + print(f"error: {CATALOG_FILE} is out of date", file=sys.stderr) + print("run:", file=sys.stderr) + print(" python3 scripts/content.py catalog", file=sys.stderr) + return 1 + + print(f"{CATALOG_FILE} is up to date") + return 0 + + target = Path(args.output) if args.output else root / CATALOG_FILE + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(rendered, encoding="utf-8") + print(f"Wrote {target.as_posix()}") + + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="content.py", + description="FloatCTF content metadata and catalog helper.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + common = argparse.ArgumentParser(add_help=False) + common.add_argument( + "--root", + default=".", + help="repository root (default: current directory)", + ) + + validate_parser = subparsers.add_parser( + "validate", + parents=[common], + help="validate challenges, gameboxes and events", + ) + validate_parser.set_defaults(func=cmd_validate) + + catalog_parser = subparsers.add_parser( + "catalog", + parents=[common], + help="generate catalog.json", + ) + catalog_group = catalog_parser.add_mutually_exclusive_group() + catalog_group.add_argument( + "--check", + action="store_true", + help="fail when catalog.json is out of date instead of writing it", + ) + catalog_group.add_argument( + "--output", + metavar="FILE", + help="write the catalog to FILE (default: /catalog.json)", + ) + catalog_parser.set_defaults(func=cmd_catalog) + + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + try: + return int(args.func(args)) + except ContentError as exc: + fail(str(exc)) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sync-event.sh b/scripts/sync-event.sh index b24e92c..97383ad 100755 --- a/scripts/sync-event.sh +++ b/scripts/sync-event.sh @@ -23,6 +23,15 @@ DOC_FILE="docs/${EVENT}.md" command -v python3 >/dev/null 2>&1 || die "python3 is required" +# ----------------------------------------------------------------------------- +# Event manifest and documentation +# ----------------------------------------------------------------------------- +# +# The generated block is refreshed from the directories that actually exist +# *before* the full metadata validation runs. Otherwise removing or renaming a +# challenge would deadlock: the stale event reference would fail validation +# while this script is exactly what removes it. + python3 - "$EVENT" "$EVENT_FILE" "$DOC_FILE" <<'PY' from __future__ import annotations @@ -345,3 +354,18 @@ print(f" GameBoxes: {len(gameboxes)}") print(f" Manifest: {EVENT_FILE}") print(f" Document: {DOC_FILE}") PY + +# ----------------------------------------------------------------------------- +# Metadata validation +# ----------------------------------------------------------------------------- + +# scripts/content.py is the single source of truth for metadata validation and +# runs on the synced tree. Event repositories created from an older event/base +# branch may not have it yet; in that case only the checks inside the sync +# block above apply. +if [[ -f scripts/content.py ]]; then + echo "==> Validating content metadata" + python3 scripts/content.py validate +else + echo "warning: scripts/content.py not found; skipping metadata validation" >&2 +fi diff --git a/scripts/tests/fixtures/invalid/challenges/broken_difficulty/meta.toml b/scripts/tests/fixtures/invalid/challenges/broken_difficulty/meta.toml new file mode 100644 index 0000000..641e221 --- /dev/null +++ b/scripts/tests/fixtures/invalid/challenges/broken_difficulty/meta.toml @@ -0,0 +1,7 @@ +name = "broken" +version = "1.0.0" +author = "dev@floatctf.local" +category = "web" +difficulty = "impossible" +tags = [] +description = "invalid difficulty" diff --git a/scripts/tests/fixtures/invalid/challenges/broken_docker/meta.toml b/scripts/tests/fixtures/invalid/challenges/broken_docker/meta.toml new file mode 100644 index 0000000..9ff8431 --- /dev/null +++ b/scripts/tests/fixtures/invalid/challenges/broken_docker/meta.toml @@ -0,0 +1,14 @@ +name = "broken" +version = "1.0.0" +author = "dev@floatctf.local" +category = "web" +difficulty = "easy" +tags = "php" +description = "invalid tags" + +[docker] +port = 70000 + +[docker.recommended_resources] +cpu_millis = 0 +pids_limit = -1 diff --git a/scripts/tests/fixtures/invalid/challenges/broken_missing_difficulty/meta.toml b/scripts/tests/fixtures/invalid/challenges/broken_missing_difficulty/meta.toml new file mode 100644 index 0000000..07a3430 --- /dev/null +++ b/scripts/tests/fixtures/invalid/challenges/broken_missing_difficulty/meta.toml @@ -0,0 +1,6 @@ +name = "broken" +version = "1.0.0" +author = "dev@floatctf.local" +category = "web" +tags = [] +description = "missing difficulty" diff --git a/scripts/tests/fixtures/invalid/challenges/broken_no_meta/README.md b/scripts/tests/fixtures/invalid/challenges/broken_no_meta/README.md new file mode 100644 index 0000000..b1a0769 --- /dev/null +++ b/scripts/tests/fixtures/invalid/challenges/broken_no_meta/README.md @@ -0,0 +1 @@ +This directory intentionally has no meta.toml. diff --git a/scripts/tests/fixtures/invalid/challenges/broken_safe_name/meta.toml b/scripts/tests/fixtures/invalid/challenges/broken_safe_name/meta.toml new file mode 100644 index 0000000..fc9a0fe --- /dev/null +++ b/scripts/tests/fixtures/invalid/challenges/broken_safe_name/meta.toml @@ -0,0 +1,8 @@ +name = "broken_safe_name" +version = "1.0.0" +author = "dev@floatctf.local" +category = "web" +difficulty = "easy" +tags = [] +description = "upper case and a space are not a valid Docker repository name" +safe_name = "Foo Bar" diff --git a/scripts/tests/fixtures/invalid/challenges/broken_version/meta.toml b/scripts/tests/fixtures/invalid/challenges/broken_version/meta.toml new file mode 100644 index 0000000..9f61b51 --- /dev/null +++ b/scripts/tests/fixtures/invalid/challenges/broken_version/meta.toml @@ -0,0 +1,7 @@ +name = "broken" +version = "1.0" +author = "dev@floatctf.local" +category = "web" +difficulty = "easy" +tags = [] +description = "invalid version" diff --git "a/scripts/tests/fixtures/invalid/challenges/\351\242\230\347\233\256/meta.toml" "b/scripts/tests/fixtures/invalid/challenges/\351\242\230\347\233\256/meta.toml" new file mode 100644 index 0000000..5076c53 --- /dev/null +++ "b/scripts/tests/fixtures/invalid/challenges/\351\242\230\347\233\256/meta.toml" @@ -0,0 +1,7 @@ +name = "题目" +version = "1.0.0" +author = "dev@floatctf.local" +category = "web" +difficulty = "easy" +tags = [] +description = "Unicode id without an explicit safe_name cannot be derived" diff --git a/scripts/tests/fixtures/invalid/events/dup.toml b/scripts/tests/fixtures/invalid/events/dup.toml new file mode 100644 index 0000000..b67be8f --- /dev/null +++ b/scripts/tests/fixtures/invalid/events/dup.toml @@ -0,0 +1,18 @@ +schema_version = 1 + +id = "dup" +title = "duplicate references" +description = "the same content listed twice must not validate" +started_at = "2025-01-01 10:00" +ended_at = "2025-01-01 18:00" + +[content] +challenges = [ + "broken_version", + "broken_version", +] + +gameboxes = [ + "box", + "box", +] diff --git a/scripts/tests/fixtures/invalid/events/empty_title.toml b/scripts/tests/fixtures/invalid/events/empty_title.toml new file mode 100644 index 0000000..ccc811f --- /dev/null +++ b/scripts/tests/fixtures/invalid/events/empty_title.toml @@ -0,0 +1,11 @@ +schema_version = 1 + +id = "empty_title" +title = " " +description = "title is blank" +started_at = "2027-10-19 14:30" +ended_at = "2027-10-19 18:30" + +[content] +challenges = [] +gameboxes = [] diff --git a/scripts/tests/fixtures/invalid/events/freshcup.toml b/scripts/tests/fixtures/invalid/events/freshcup.toml new file mode 100644 index 0000000..d3c2c3b --- /dev/null +++ b/scripts/tests/fixtures/invalid/events/freshcup.toml @@ -0,0 +1,16 @@ +schema_version = 1 + +id = "freshcup" +title = "Broken references" +description = "events referencing content that does not exist" +started_at = "2025-01-01 10:00" +ended_at = "2025-01-01 18:00" + +[content] +challenges = [ + "ghost", +] + +gameboxes = [ + "ghostbox", +] diff --git a/scripts/tests/fixtures/invalid/events/missing_description.toml b/scripts/tests/fixtures/invalid/events/missing_description.toml new file mode 100644 index 0000000..a9a4f6e --- /dev/null +++ b/scripts/tests/fixtures/invalid/events/missing_description.toml @@ -0,0 +1,10 @@ +schema_version = 1 + +id = "missing_description" +title = "Missing description" +started_at = "2027-10-19 14:30" +ended_at = "2027-10-19 18:30" + +[content] +challenges = [] +gameboxes = [] diff --git a/scripts/tests/fixtures/invalid/events/missing_ended_at.toml b/scripts/tests/fixtures/invalid/events/missing_ended_at.toml new file mode 100644 index 0000000..40bcfb1 --- /dev/null +++ b/scripts/tests/fixtures/invalid/events/missing_ended_at.toml @@ -0,0 +1,10 @@ +schema_version = 1 + +id = "missing_ended_at" +title = "Missing ended_at" +description = "ended_at is absent" +started_at = "2027-10-19 14:30" + +[content] +challenges = [] +gameboxes = [] diff --git a/scripts/tests/fixtures/invalid/events/missing_started_at.toml b/scripts/tests/fixtures/invalid/events/missing_started_at.toml new file mode 100644 index 0000000..d91c256 --- /dev/null +++ b/scripts/tests/fixtures/invalid/events/missing_started_at.toml @@ -0,0 +1,10 @@ +schema_version = 1 + +id = "missing_started_at" +title = "Missing started_at" +description = "started_at is absent" +ended_at = "2027-10-19 18:30" + +[content] +challenges = [] +gameboxes = [] diff --git a/scripts/tests/fixtures/invalid/events/missing_title.toml b/scripts/tests/fixtures/invalid/events/missing_title.toml new file mode 100644 index 0000000..f39df2d --- /dev/null +++ b/scripts/tests/fixtures/invalid/events/missing_title.toml @@ -0,0 +1,10 @@ +schema_version = 1 + +id = "missing_title" +description = "title is absent" +started_at = "2027-10-19 14:30" +ended_at = "2027-10-19 18:30" + +[content] +challenges = [] +gameboxes = [] diff --git a/scripts/tests/fixtures/invalid/events/wrong_type.toml b/scripts/tests/fixtures/invalid/events/wrong_type.toml new file mode 100644 index 0000000..af61295 --- /dev/null +++ b/scripts/tests/fixtures/invalid/events/wrong_type.toml @@ -0,0 +1,11 @@ +schema_version = 1 + +id = "wrong_type" +title = 123 +description = "title is not a string" +started_at = 20271019 +ended_at = "2027-10-19 18:30" + +[content] +challenges = [] +gameboxes = [] diff --git a/scripts/tests/fixtures/invalid/gameboxes/box/meta.toml b/scripts/tests/fixtures/invalid/gameboxes/box/meta.toml new file mode 100644 index 0000000..02a0b75 --- /dev/null +++ b/scripts/tests/fixtures/invalid/gameboxes/box/meta.toml @@ -0,0 +1,7 @@ +name = "box" +version = "1.0.0" +author = "dev@floatctf.local" +category = "misc" +difficulty = "easy" +tags = [] +description = "referenced twice by events/dup.toml" diff --git a/scripts/tests/fixtures/safe-names/challenges/Android_reverse/meta.toml b/scripts/tests/fixtures/safe-names/challenges/Android_reverse/meta.toml new file mode 100644 index 0000000..a4364ca --- /dev/null +++ b/scripts/tests/fixtures/safe-names/challenges/Android_reverse/meta.toml @@ -0,0 +1,7 @@ +name = "Android_reverse" +version = "1.0.0" +author = "dev@floatctf.local" +category = "reverse" +difficulty = "easy" +tags = [] +description = "attachment-only fixture: no src/Dockerfile" diff --git a/scripts/tests/fixtures/safe-names/challenges/Cirno's perfect math class/meta.toml b/scripts/tests/fixtures/safe-names/challenges/Cirno's perfect math class/meta.toml new file mode 100644 index 0000000..acc66ba --- /dev/null +++ b/scripts/tests/fixtures/safe-names/challenges/Cirno's perfect math class/meta.toml @@ -0,0 +1,7 @@ +name = "Cirno's perfect math class" +version = "1.0.0" +author = "dev@floatctf.local" +category = "misc" +difficulty = "easy" +tags = [] +description = "attachment-only fixture: no src/Dockerfile" diff --git a/scripts/tests/fixtures/safe-names/challenges/FloatCTF-qidong/meta.toml b/scripts/tests/fixtures/safe-names/challenges/FloatCTF-qidong/meta.toml new file mode 100644 index 0000000..7e7803f --- /dev/null +++ b/scripts/tests/fixtures/safe-names/challenges/FloatCTF-qidong/meta.toml @@ -0,0 +1,13 @@ +name = "FloatCTF-qidong" +version = "1.0.0" +author = "dev@floatctf.local" +category = "web" +difficulty = "easy" +tags = ["web"] +description = "container fixture" + +[flag] +type = "dynamic" + +[docker] +port = 80 diff --git a/scripts/tests/fixtures/safe-names/challenges/FloatCTF-qidong/src/Dockerfile b/scripts/tests/fixtures/safe-names/challenges/FloatCTF-qidong/src/Dockerfile new file mode 100644 index 0000000..c35f1b5 --- /dev/null +++ b/scripts/tests/fixtures/safe-names/challenges/FloatCTF-qidong/src/Dockerfile @@ -0,0 +1 @@ +FROM scratch diff --git a/scripts/tests/fixtures/safe-names/challenges/static_with_docker/meta.toml b/scripts/tests/fixtures/safe-names/challenges/static_with_docker/meta.toml new file mode 100644 index 0000000..c42e3ce --- /dev/null +++ b/scripts/tests/fixtures/safe-names/challenges/static_with_docker/meta.toml @@ -0,0 +1,15 @@ +name = "static_with_docker" +version = "1.0.0" +author = "dev@floatctf.local" +category = "misc" +difficulty = "easy" +tags = [] +description = "static content that still declares a [docker] table" + +[docker] +port = 8080 + +[docker.recommended_resources] +cpu_millis = 100 +memory_bytes = 67108864 +pids_limit = 10 diff --git "a/scripts/tests/fixtures/safe-names/challenges/\351\242\230\347\233\256/meta.toml" "b/scripts/tests/fixtures/safe-names/challenges/\351\242\230\347\233\256/meta.toml" new file mode 100644 index 0000000..0544c13 --- /dev/null +++ "b/scripts/tests/fixtures/safe-names/challenges/\351\242\230\347\233\256/meta.toml" @@ -0,0 +1,12 @@ +name = "题目" +version = "2.0.0" +author = "dev@floatctf.local" +category = "misc" +difficulty = "medium" +tags = [] +description = "Unicode id needs an explicit safe_name" +safe_name = "challenge-001" + +[flag] +type = "static" +value = "flag{fixture-flag-must-not-leak}" diff --git "a/scripts/tests/fixtures/safe-names/challenges/\351\242\230\347\233\256/src/Dockerfile" "b/scripts/tests/fixtures/safe-names/challenges/\351\242\230\347\233\256/src/Dockerfile" new file mode 100644 index 0000000..c35f1b5 --- /dev/null +++ "b/scripts/tests/fixtures/safe-names/challenges/\351\242\230\347\233\256/src/Dockerfile" @@ -0,0 +1 @@ +FROM scratch diff --git a/scripts/tests/fixtures/safe-names/events/demo.toml b/scripts/tests/fixtures/safe-names/events/demo.toml new file mode 100644 index 0000000..e27d19d --- /dev/null +++ b/scripts/tests/fixtures/safe-names/events/demo.toml @@ -0,0 +1,19 @@ +schema_version = 1 + +id = "demo" +title = "safe_name demo" +description = "fixture event" +started_at = "2025-01-01 10:00" +ended_at = "2025-01-01 18:00" + +# BEGIN GENERATED CONTENT +[content] +challenges = [ + "FloatCTF-qidong", + "题目", +] + +gameboxes = [ + "Android_reverse", +] +# END GENERATED CONTENT diff --git a/scripts/tests/fixtures/safe-names/gameboxes/Android_reverse/meta.toml b/scripts/tests/fixtures/safe-names/gameboxes/Android_reverse/meta.toml new file mode 100644 index 0000000..8bfd1af --- /dev/null +++ b/scripts/tests/fixtures/safe-names/gameboxes/Android_reverse/meta.toml @@ -0,0 +1,10 @@ +name = "Android_reverse" +version = "1.0.0" +author = "dev@floatctf.local" +category = "reverse" +difficulty = "easy" +tags = [] +description = "gamebox sharing a safe_name with the challenge of the same id" + +[docker] +port = 9000 diff --git a/scripts/tests/fixtures/safe-names/gameboxes/Android_reverse/src/Dockerfile b/scripts/tests/fixtures/safe-names/gameboxes/Android_reverse/src/Dockerfile new file mode 100644 index 0000000..c35f1b5 --- /dev/null +++ b/scripts/tests/fixtures/safe-names/gameboxes/Android_reverse/src/Dockerfile @@ -0,0 +1 @@ +FROM scratch diff --git a/scripts/tests/fixtures/valid/challenges/comment/meta.toml b/scripts/tests/fixtures/valid/challenges/comment/meta.toml new file mode 100644 index 0000000..f3c81ec --- /dev/null +++ b/scripts/tests/fixtures/valid/challenges/comment/meta.toml @@ -0,0 +1,18 @@ +name = "comment" +version = "1.0.0" +author = "fb0sh@outlook.com" +category = "web" +difficulty = "easy" +tags = ["php", "web"] +description = "注释里面有什么?" + +[flag] +type = "dynamic" + +[docker] +port = 80 + +[docker.recommended_resources] +cpu_millis = 500 +memory_bytes = 268435456 +pids_limit = 100 diff --git a/scripts/tests/fixtures/valid/challenges/comment/src/Dockerfile b/scripts/tests/fixtures/valid/challenges/comment/src/Dockerfile new file mode 100644 index 0000000..c35f1b5 --- /dev/null +++ b/scripts/tests/fixtures/valid/challenges/comment/src/Dockerfile @@ -0,0 +1 @@ +FROM scratch diff --git a/scripts/tests/fixtures/valid/challenges/cookie/meta.toml b/scripts/tests/fixtures/valid/challenges/cookie/meta.toml new file mode 100644 index 0000000..fbaaf9b --- /dev/null +++ b/scripts/tests/fixtures/valid/challenges/cookie/meta.toml @@ -0,0 +1,11 @@ +name = "cookie" +version = "1.0.0" +author = "dev@floatctf.local" +category = "web" +difficulty = "unknown" +tags = [] +description = "想成为管理员吗?也许你需要一个特殊的饼干!" + +[flag] +type = "static" +value = "flag{fixture-flag-must-not-leak}" diff --git a/scripts/tests/fixtures/valid/events/freshcup.toml b/scripts/tests/fixtures/valid/events/freshcup.toml new file mode 100644 index 0000000..01bd49a --- /dev/null +++ b/scripts/tests/fixtures/valid/events/freshcup.toml @@ -0,0 +1,18 @@ +schema_version = 1 + +id = "freshcup" +title = "Freshcup Fixture" +description = "Fixture event" +started_at = "2025-01-01 10:00" +ended_at = "2025-01-01 18:00" + +# BEGIN GENERATED CONTENT +[content] +challenges = [ + "comment", +] + +gameboxes = [ + "comment", +] +# END GENERATED CONTENT diff --git a/scripts/tests/fixtures/valid/gameboxes/comment/meta.toml b/scripts/tests/fixtures/valid/gameboxes/comment/meta.toml new file mode 100644 index 0000000..662c390 --- /dev/null +++ b/scripts/tests/fixtures/valid/gameboxes/comment/meta.toml @@ -0,0 +1,10 @@ +name = "comment" +version = "1.0.0" +author = "dev@floatctf.local" +category = "misc" +difficulty = "medium" +tags = ["box"] +description = "GameBox fixture" + +[docker] +port = 8080 diff --git a/scripts/tests/fixtures/valid/gameboxes/comment/src/Dockerfile b/scripts/tests/fixtures/valid/gameboxes/comment/src/Dockerfile new file mode 100644 index 0000000..c35f1b5 --- /dev/null +++ b/scripts/tests/fixtures/valid/gameboxes/comment/src/Dockerfile @@ -0,0 +1 @@ +FROM scratch diff --git a/scripts/tests/test_content.py b/scripts/tests/test_content.py new file mode 100644 index 0000000..21d5e73 --- /dev/null +++ b/scripts/tests/test_content.py @@ -0,0 +1,1038 @@ +#!/usr/bin/env python3 +"""Unit tests for ``scripts/content.py``. + +Run with:: + + python3 -m unittest discover -s scripts/tests -v + +The tests only use fixtures below ``scripts/tests/fixtures``; production +content is never required. +""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import tomllib +import unittest +from pathlib import Path + +TESTS_DIR = Path(__file__).resolve().parent +SCRIPTS_DIR = TESTS_DIR.parent +FIXTURES_DIR = TESTS_DIR / "fixtures" +VALID_FIXTURE = FIXTURES_DIR / "valid" +INVALID_FIXTURE = FIXTURES_DIR / "invalid" +SAFE_NAMES_FIXTURE = FIXTURES_DIR / "safe-names" + +#: Keys that must never be exported into catalog.json. +FORBIDDEN_CATALOG_KEYS = { + "value", + "flag_value", + "template", + "secret", + "token", + "password", + "env", + "solution", +} + +CONTENT_META_TEMPLATE = """\ +name = "{name}" +version = "1.0.0" +author = "dev@floatctf.local" +category = "web" +difficulty = "easy" +tags = [] +description = "generated by tests" +{safe_name} +""" + + +def _load_content_module(): + spec = importlib.util.spec_from_file_location( + "floatctf_content", SCRIPTS_DIR / "content.py" + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules["floatctf_content"] = module + spec.loader.exec_module(module) + return module + + +content = _load_content_module() + + +def quiet(function, *args, **kwargs): + """Call *function* while swallowing stdout/stderr.""" + + with contextlib.redirect_stdout(io.StringIO()): + with contextlib.redirect_stderr(io.StringIO()): + return function(*args, **kwargs) + + +def copy_fixture(fixture: Path, destination: Path) -> Path: + shutil.copytree(fixture, destination, dirs_exist_ok=True) + return destination + + +def write_content( + root: Path, + kind: str, + content_id: str, + *, + safe_name: str | None = None, + dockerfile: bool = False, +) -> Path: + """Create a minimal content directory below *root* (kind: challenges/...).""" + + directory = root / kind / content_id + directory.mkdir(parents=True, exist_ok=True) + (directory / "meta.toml").write_text( + CONTENT_META_TEMPLATE.format( + name=content_id, + safe_name=f'safe_name = "{safe_name}"' if safe_name else "", + ), + encoding="utf-8", + ) + + if dockerfile: + source = directory / "src" + source.mkdir(parents=True, exist_ok=True) + (source / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8") + + return directory + + +def find_entry(entries: list[dict], content_id: str) -> dict: + for entry in entries: + if entry["id"] == content_id: + return entry + raise AssertionError(f"{content_id!r} missing") + + +def json_keys(node) -> set[str]: + """Collect every dict key used anywhere in *node*.""" + + keys: set[str] = set() + + if isinstance(node, dict): + for key, value in node.items(): + keys.add(key) + keys |= json_keys(value) + elif isinstance(node, list): + for item in node: + keys |= json_keys(item) + + return keys + + +# --------------------------------------------------------------------------- +# 1. image naming +# --------------------------------------------------------------------------- + + +class ImageRefTests(unittest.TestCase): + def _content(self, content_type: str, version: str = "1.0.0"): + return content.Content( + id="comment", + type=content_type, + path=Path("challenges/comment"), + meta={"version": version}, + ) + + def test_challenge_image_ref(self) -> None: + self.assertEqual( + content.image_ref(self._content(content.CONTENT_CHALLENGE)), + "floatctf/comment:challenge-v1.0.0", + ) + + def test_gamebox_image_ref(self) -> None: + self.assertEqual( + content.image_ref(self._content(content.CONTENT_GAMEBOX, "1.2.0")), + "floatctf/comment:gamebox-v1.2.0", + ) + + def test_image_ref_from_fixture(self) -> None: + challenges = content.scan_contents( + VALID_FIXTURE, content.CONTENT_CHALLENGE + ) + gameboxes = content.scan_contents(VALID_FIXTURE, content.CONTENT_GAMEBOX) + + self.assertEqual( + [content.image_ref(item) for item in challenges], + [ + "floatctf/comment:challenge-v1.0.0", + "floatctf/cookie:challenge-v1.0.0", + ], + ) + self.assertEqual( + [content.image_ref(item) for item in gameboxes], + ["floatctf/comment:gamebox-v1.0.0"], + ) + +# --------------------------------------------------------------------------- +# Docker safe_name: derivation, override and conflicts +# --------------------------------------------------------------------------- + + +class SafeNameTests(unittest.TestCase): + def fixture_challenges(self) -> dict[str, "content.Content"]: + return { + item.id: item + for item in content.scan_contents( + SAFE_NAMES_FIXTURE, content.CONTENT_CHALLENGE + ) + } + + def fixture_gameboxes(self) -> dict[str, "content.Content"]: + return { + item.id: item + for item in content.scan_contents( + SAFE_NAMES_FIXTURE, content.CONTENT_GAMEBOX + ) + } + + def test_derive_simple(self) -> None: + self.assertEqual(content.derive_safe_name("comment"), "comment") + + def test_derive_uppercase(self) -> None: + self.assertEqual( + content.derive_safe_name("Android_reverse"), "android_reverse" + ) + + def test_derive_uppercase_and_dash(self) -> None: + self.assertEqual( + content.derive_safe_name("FloatCTF-qidong"), "floatctf-qidong" + ) + + def test_derive_apostrophe(self) -> None: + self.assertEqual( + content.derive_safe_name("Cirno's perfect math class"), + "cirnos-perfect-math-class", + ) + self.assertEqual( + content.derive_safe_name("Cirno\u2019s book"), "cirnos-book" + ) + + def test_derive_whitespace(self) -> None: + self.assertEqual(content.derive_safe_name("foo bar"), "foo-bar") + self.assertEqual(content.derive_safe_name("foo bar"), "foo-bar") + + def test_derive_normalizes_separators(self) -> None: + self.assertEqual(content.derive_safe_name("foo__bar"), "foo-bar") + self.assertEqual(content.derive_safe_name("--Foo..Bar--"), "foo-bar") + self.assertEqual(content.derive_safe_name("foo.bar"), "foo.bar") + + def test_derive_returns_empty_for_unicode_only_ids(self) -> None: + self.assertEqual(content.derive_safe_name("题目"), "") + + def test_derive_matches_docker_repository_pattern(self) -> None: + for content_id in ( + "comment", + "Android_reverse", + "FloatCTF-qidong", + "Cirno's perfect math class", + "komachi's book", + "orin's pack", + "Flag_in_the_model", + ): + derived = content.derive_safe_name(content_id) + self.assertTrue( + content.SAFE_NAME_PATTERN.match(derived), + f"{content_id!r} derived to invalid {derived!r}", + ) + + def test_explicit_safe_name_wins(self) -> None: + item = content.Content( + id="Cirno's perfect math class", + type=content.CONTENT_CHALLENGE, + path=Path("challenges/Cirno's perfect math class"), + meta={"safe_name": "custom-name"}, + ) + + self.assertEqual(item.safe_name, "custom-name") + + def test_explicit_safe_name_for_unicode_id(self) -> None: + item = content.Content( + id="题目", + type=content.CONTENT_CHALLENGE, + path=Path("challenges/题目"), + meta={"safe_name": "challenge-001"}, + ) + + self.assertEqual(item.safe_name, "challenge-001") + + def test_fixture_safe_names(self) -> None: + challenges = self.fixture_challenges() + + self.assertEqual(challenges["Android_reverse"].safe_name, "android_reverse") + self.assertEqual( + challenges["FloatCTF-qidong"].safe_name, "floatctf-qidong" + ) + self.assertEqual( + challenges["Cirno's perfect math class"].safe_name, + "cirnos-perfect-math-class", + ) + self.assertEqual(challenges["题目"].safe_name, "challenge-001") + self.assertTrue(content.validate(SAFE_NAMES_FIXTURE).ok) + + def test_image_ref_uses_safe_name(self) -> None: + challenges = self.fixture_challenges() + + self.assertEqual( + content.image_ref(challenges["FloatCTF-qidong"]), + "floatctf/floatctf-qidong:challenge-v1.0.0", + ) + self.assertEqual( + content.image_ref(challenges["题目"]), + "floatctf/challenge-001:challenge-v2.0.0", + ) + + def test_challenge_and_gamebox_may_share_safe_name(self) -> None: + challenge = self.fixture_challenges()["Android_reverse"] + gamebox = self.fixture_gameboxes()["Android_reverse"] + + self.assertEqual(challenge.safe_name, gamebox.safe_name) + self.assertEqual(challenge.safe_name, "android_reverse") + self.assertNotEqual( + content.image_ref(challenge), content.image_ref(gamebox) + ) + + def test_image_ref_rejects_underivable_safe_name(self) -> None: + item = content.Content( + id="题目", + type=content.CONTENT_CHALLENGE, + path=Path("challenges/题目"), + meta={"version": "1.0.0"}, + ) + + with self.assertRaises(content.ContentError) as raised: + content.image_ref(item) + + self.assertIn("unable to derive Docker safe_name", str(raised.exception)) + + def test_image_ref_rejects_invalid_safe_name(self) -> None: + item = content.Content( + id="comment", + type=content.CONTENT_CHALLENGE, + path=Path("challenges/comment"), + meta={"version": "1.0.0", "safe_name": "Foo Bar"}, + ) + + with self.assertRaises(content.ContentError) as raised: + content.image_ref(item) + + self.assertIn("invalid safe_name", str(raised.exception)) + + +# --------------------------------------------------------------------------- +# 2 - 5. validation +# --------------------------------------------------------------------------- + + +class ValidateTests(unittest.TestCase): + def errors(self, root: Path) -> list[str]: + return content.validate(root).errors + + def test_valid_fixture(self) -> None: + result = content.validate(VALID_FIXTURE) + + self.assertTrue(result.ok, result.errors) + self.assertEqual(len(result.challenges), 2) + self.assertEqual(len(result.gameboxes), 1) + self.assertEqual(len(result.events), 1) + + def test_missing_field(self) -> None: + errors = self.errors(INVALID_FIXTURE) + + self.assertIn( + "challenges/broken_missing_difficulty/meta.toml: " + "missing field 'difficulty'", + errors, + ) + + def test_invalid_version(self) -> None: + errors = self.errors(INVALID_FIXTURE) + + self.assertIn( + "challenges/broken_version/meta.toml: " + "invalid version '1.0' (expected x.y.z)", + errors, + ) + + def test_invalid_difficulty(self) -> None: + errors = self.errors(INVALID_FIXTURE) + + self.assertIn( + "challenges/broken_difficulty/meta.toml: " + "invalid difficulty 'impossible' " + "(expected one of unknown, beginner, easy, medium, hard, expert)", + errors, + ) + + def test_invalid_port(self) -> None: + errors = self.errors(INVALID_FIXTURE) + + self.assertIn( + "challenges/broken_docker/meta.toml: " + "invalid docker.port 70000 (expected 1..65535)", + errors, + ) + + def test_invalid_tags(self) -> None: + errors = self.errors(INVALID_FIXTURE) + + self.assertIn( + "challenges/broken_docker/meta.toml: " + "field 'tags' must be an array of non-empty strings", + errors, + ) + + def test_invalid_recommended_resources(self) -> None: + errors = self.errors(INVALID_FIXTURE) + + self.assertIn( + "challenges/broken_docker/meta.toml: " + "invalid docker.recommended_resources.cpu_millis 0 " + "(expected a positive integer)", + errors, + ) + self.assertIn( + "challenges/broken_docker/meta.toml: " + "invalid docker.recommended_resources.pids_limit -1 " + "(expected a positive integer)", + errors, + ) + + def test_missing_meta_toml(self) -> None: + errors = self.errors(INVALID_FIXTURE) + + self.assertIn( + "challenges/broken_no_meta/meta.toml: missing meta.toml", + errors, + ) + + def test_unknown_event_references(self) -> None: + errors = self.errors(INVALID_FIXTURE) + + self.assertIn( + "events/freshcup.toml: unknown challenge 'ghost'", + errors, + ) + self.assertIn( + "events/freshcup.toml: unknown gamebox 'ghostbox'", + errors, + ) + + def test_all_difficulties_are_accepted(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = copy_fixture(VALID_FIXTURE, Path(tmp) / "content") + meta_path = root / "challenges" / "comment" / "meta.toml" + original = meta_path.read_text(encoding="utf-8") + + for difficulty in content.DIFFICULTIES: + meta_path.write_text( + original.replace( + 'difficulty = "easy"', f'difficulty = "{difficulty}"' + ), + encoding="utf-8", + ) + self.assertTrue( + content.validate(root).ok, + f"difficulty '{difficulty}' should be accepted", + ) + + def test_empty_accounts_for_missing_directories(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + result = content.validate(Path(tmp)) + + self.assertTrue(result.ok, result.errors) + self.assertEqual( + (len(result.challenges), len(result.gameboxes), len(result.events)), + (0, 0, 0), + ) + + def test_invalid_explicit_safe_name(self) -> None: + errors = self.errors(INVALID_FIXTURE) + + self.assertIn( + "challenges/broken_safe_name/meta.toml: invalid safe_name 'Foo Bar'", + errors, + ) + + def test_underivable_safe_name_requires_explicit_value(self) -> None: + errors = self.errors(INVALID_FIXTURE) + + self.assertIn( + "challenges/题目/meta.toml: unable to derive Docker safe_name; " + "set safe_name explicitly", + errors, + ) + + def test_explicit_safe_name_makes_unicode_id_valid(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = copy_fixture(SAFE_NAMES_FIXTURE, Path(tmp) / "content") + + self.assertTrue(content.validate(root).ok, content.validate(root).errors) + + meta_path = root / "challenges" / "题目" / "meta.toml" + meta_path.write_text( + meta_path.read_text(encoding="utf-8").replace( + 'safe_name = "challenge-001"\n', "" + ), + encoding="utf-8", + ) + + self.assertIn( + "challenges/题目/meta.toml: unable to derive Docker safe_name; " + "set safe_name explicitly", + content.validate(root).errors, + ) + + def test_duplicate_safe_name_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "content" + write_content(root, "challenges", "Foo") + write_content(root, "challenges", "foo") + + self.assertIn( + "duplicate challenge safe_name 'foo': 'Foo' and 'foo'", + content.validate(root).errors, + ) + + def test_duplicate_safe_name_across_types_is_allowed(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "content" + write_content(root, "challenges", "foo") + write_content(root, "gameboxes", "foo") + + result = content.validate(root) + + self.assertTrue(result.ok, result.errors) + + def test_duplicate_event_references(self) -> None: + errors = self.errors(INVALID_FIXTURE) + + self.assertIn("events/dup.toml: duplicate challenge 'broken_version'", errors) + self.assertIn("events/dup.toml: duplicate gamebox 'box'", errors) + + def test_event_missing_title(self) -> None: + self.assertIn( + "events/missing_title.toml: missing field 'title'", + self.errors(INVALID_FIXTURE), + ) + + def test_event_empty_title(self) -> None: + self.assertIn( + "events/empty_title.toml: field 'title' must be a non-empty string", + self.errors(INVALID_FIXTURE), + ) + + def test_event_missing_description(self) -> None: + self.assertIn( + "events/missing_description.toml: missing field 'description'", + self.errors(INVALID_FIXTURE), + ) + + def test_event_missing_started_at(self) -> None: + self.assertIn( + "events/missing_started_at.toml: missing field 'started_at'", + self.errors(INVALID_FIXTURE), + ) + + def test_event_missing_ended_at(self) -> None: + self.assertIn( + "events/missing_ended_at.toml: missing field 'ended_at'", + self.errors(INVALID_FIXTURE), + ) + + def test_event_fields_must_be_strings(self) -> None: + errors = self.errors(INVALID_FIXTURE) + + self.assertIn( + "events/wrong_type.toml: field 'title' must be a non-empty string", + errors, + ) + self.assertIn( + "events/wrong_type.toml: field 'started_at' must be a non-empty string", + errors, + ) + + def test_valid_event(self) -> None: + result = content.validate(VALID_FIXTURE) + event = result.events[0] + + self.assertTrue(result.ok, result.errors) + self.assertEqual(event.id, "freshcup") + for name in content.EVENT_REQUIRED_TEXT_FIELDS: + self.assertTrue(str(event.meta[name]).strip(), name) + + +# --------------------------------------------------------------------------- +# 6 - 9. catalog +# --------------------------------------------------------------------------- + + +class CatalogTests(unittest.TestCase): + def catalog(self) -> dict: + return content.build_catalog(VALID_FIXTURE) + + def entry(self, collection: str, content_id: str) -> dict: + for item in self.catalog()[collection]: + if item["id"] == content_id: + return item + raise AssertionError(f"{collection}/{content_id} missing from catalog") + + def test_catalog_root_structure(self) -> None: + catalog = self.catalog() + + self.assertEqual( + list(catalog.keys()), + ["version", "challenges", "gameboxes", "events"], + ) + self.assertEqual(catalog["version"], 1) + + def test_catalog_challenge_generation(self) -> None: + self.assertEqual( + self.entry("challenges", "comment"), + { + "id": "comment", + "name": "comment", + "version": "1.0.0", + "author": "fb0sh@outlook.com", + "category": "web", + "difficulty": "easy", + "tags": ["php", "web"], + "description": "注释里面有什么?", + "image": "floatctf/comment:challenge-v1.0.0", + "flag": {"type": "dynamic"}, + "docker": { + "port": 80, + "recommended_resources": { + "cpu_millis": 500, + "memory_bytes": 268435456, + "pids_limit": 100, + }, + }, + "events": ["freshcup"], + }, + ) + + def test_catalog_gamebox_generation(self) -> None: + self.assertEqual( + self.entry("gameboxes", "comment"), + { + "id": "comment", + "name": "comment", + "version": "1.0.0", + "author": "dev@floatctf.local", + "category": "misc", + "difficulty": "medium", + "tags": ["box"], + "description": "GameBox fixture", + "image": "floatctf/comment:gamebox-v1.0.0", + "docker": {"port": 8080}, + "events": ["freshcup"], + }, + ) + + def test_catalog_challenge_without_events_or_docker(self) -> None: + # No src/Dockerfile means attachment-only content: no image field. + self.assertEqual( + self.entry("challenges", "cookie"), + { + "id": "cookie", + "name": "cookie", + "version": "1.0.0", + "author": "dev@floatctf.local", + "category": "web", + "difficulty": "unknown", + "tags": [], + "description": "想成为管理员吗?也许你需要一个特殊的饼干!", + "flag": {"type": "static"}, + "events": [], + }, + ) + + def test_catalog_event_entry(self) -> None: + self.assertEqual( + self.catalog()["events"], + [ + { + "id": "freshcup", + "title": "Freshcup Fixture", + "description": "Fixture event", + "started_at": "2025-01-01 10:00", + "ended_at": "2025-01-01 18:00", + "challenges": ["comment"], + "gameboxes": ["comment"], + } + ], + ) + + def test_event_to_challenge_reverse_relation(self) -> None: + catalog = self.catalog() + + comment_challenge = self.entry("challenges", "comment") + comment_gamebox = self.entry("gameboxes", "comment") + + self.assertEqual(comment_challenge["events"], ["freshcup"]) + self.assertEqual(comment_gamebox["events"], ["freshcup"]) + + # An event only lists what it references. + self.assertEqual(catalog["events"][0]["challenges"], ["comment"]) + self.assertEqual(catalog["events"][0]["gameboxes"], ["comment"]) + + def test_catalog_never_exposes_flag_values(self) -> None: + catalog = self.catalog() + rendered = content.render_catalog(catalog) + + for entry in [*catalog["challenges"], *catalog["gameboxes"]]: + if "flag" in entry: + self.assertEqual(set(entry["flag"]), {"type"}) + + self.assertFalse(FORBIDDEN_CATALOG_KEYS & json_keys(catalog)) + self.assertNotIn("fixture-flag-must-not-leak", rendered) + + def test_container_challenge_has_image_in_catalog(self) -> None: + catalog = content.build_catalog(SAFE_NAMES_FIXTURE) + + self.assertEqual( + find_entry(catalog["challenges"], "FloatCTF-qidong")["image"], + "floatctf/floatctf-qidong:challenge-v1.0.0", + ) + self.assertEqual( + find_entry(catalog["challenges"], "题目")["image"], + "floatctf/challenge-001:challenge-v2.0.0", + ) + + def test_static_challenge_has_no_image_in_catalog(self) -> None: + catalog = content.build_catalog(SAFE_NAMES_FIXTURE) + entry = find_entry(catalog["challenges"], "Android_reverse") + + self.assertNotIn("image", entry) + self.assertIsNone(entry.get("image")) + self.assertNotIn('"image": null', content.render_catalog(catalog)) + self.assertEqual(entry["id"], "Android_reverse") + + def test_catalog_image_matches_dockerfile_and_pattern(self) -> None: + catalog = content.build_catalog(SAFE_NAMES_FIXTURE) + image_pattern = re.compile( + r"^floatctf/[a-z0-9]+(?:[._-][a-z0-9]+)*" + r":(?:challenge|gamebox)-v\d+\.\d+\.\d+$" + ) + + for kind, entries in ( + ("challenges", catalog["challenges"]), + ("gameboxes", catalog["gameboxes"]), + ): + for entry in entries: + dockerfile = ( + SAFE_NAMES_FIXTURE + / kind + / entry["id"] + / "src" + / "Dockerfile" + ) + + if "image" in entry: + self.assertTrue(dockerfile.is_file(), entry["id"]) + self.assertRegex(entry["image"], image_pattern) + else: + self.assertFalse(dockerfile.is_file(), entry["id"]) + + self.assertFalse( + "docker" in entry and "image" not in entry, + f"{entry['id']} exposes docker without image", + ) + + def test_catalog_keeps_raw_id_and_unicode_description(self) -> None: + catalog = content.build_catalog(SAFE_NAMES_FIXTURE) + rendered = content.render_catalog(catalog) + + self.assertEqual( + [item["id"] for item in catalog["challenges"]], + sorted(item["id"] for item in catalog["challenges"]), + ) + self.assertIn("题目", rendered) + self.assertIn("Unicode id needs an explicit safe_name", rendered) + + def test_static_content_omits_image_and_docker(self) -> None: + catalog = content.build_catalog(SAFE_NAMES_FIXTURE) + entry = find_entry(catalog["challenges"], "static_with_docker") + + # The fixture really declares a [docker] table ... + declared = { + item.id: item + for item in content.scan_contents( + SAFE_NAMES_FIXTURE, content.CONTENT_CHALLENGE + ) + }["static_with_docker"] + self.assertIn("docker", declared.meta) + self.assertFalse( + content.has_dockerfile(declared, SAFE_NAMES_FIXTURE) + ) + + # ... but static content exposes neither image nor docker. + self.assertNotIn("image", entry) + self.assertNotIn("docker", entry) + + def test_container_content_keeps_image_and_docker(self) -> None: + entry = find_entry( + content.build_catalog(SAFE_NAMES_FIXTURE)["challenges"], + "FloatCTF-qidong", + ) + + self.assertEqual( + entry["image"], "floatctf/floatctf-qidong:challenge-v1.0.0" + ) + self.assertEqual(entry["docker"]["port"], 80) + + def test_catalog_is_deterministic(self) -> None: + first = content.render_catalog(content.build_catalog(VALID_FIXTURE)) + second = content.render_catalog(content.build_catalog(VALID_FIXTURE)) + + self.assertEqual(first, second) + self.assertTrue(first.endswith("\n")) + self.assertNotIn("generated_at", first) + + catalog = self.catalog() + self.assertEqual( + [item["id"] for item in catalog["challenges"]], + sorted(item["id"] for item in catalog["challenges"]), + ) + self.assertEqual( + [item["id"] for item in catalog["gameboxes"]], + sorted(item["id"] for item in catalog["gameboxes"]), + ) + self.assertEqual( + [item["id"] for item in catalog["events"]], + sorted(item["id"] for item in catalog["events"]), + ) + + def test_catalog_check_detects_missing_and_stale_file(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = copy_fixture(VALID_FIXTURE, Path(tmp) / "content") + + self.assertEqual(quiet(content.main, ["catalog", "--check", "--root", str(root)]), 1) + + self.assertEqual(quiet(content.main, ["catalog", "--root", str(root)]), 0) + self.assertEqual(quiet(content.main, ["catalog", "--check", "--root", str(root)]), 0) + + (root / "catalog.json").write_text("{}\n", encoding="utf-8") + self.assertEqual(quiet(content.main, ["catalog", "--check", "--root", str(root)]), 1) + + def test_catalog_round_trips_through_json(self) -> None: + rendered = content.render_catalog(content.build_catalog(VALID_FIXTURE)) + + self.assertEqual( + json.loads(rendered), + content.build_catalog(VALID_FIXTURE), + ) + self.assertIn("注释里面有什么?", rendered) + + def test_catalog_output_writes_to_another_path(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = copy_fixture(VALID_FIXTURE, Path(tmp) / "content") + target = Path(tmp) / "out" / "catalog.json" + + result = quiet( + content.main, + ["catalog", "--output", str(target), "--root", str(root)], + ) + + self.assertEqual(result, 0) + self.assertTrue(target.is_file()) + self.assertFalse((root / "catalog.json").exists()) + self.assertEqual( + target.read_text(encoding="utf-8"), + content.render_catalog(content.build_catalog(root)), + ) + + def test_catalog_check_and_output_are_mutually_exclusive(self) -> None: + with self.assertRaises(SystemExit) as raised: + quiet( + content.main, + [ + "catalog", + "--check", + "--output", + "/tmp/never-written.json", + "--root", + str(VALID_FIXTURE), + ], + ) + + self.assertEqual(raised.exception.code, 2) + + def test_catalog_refuses_invalid_content(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = copy_fixture(INVALID_FIXTURE, Path(tmp) / "content") + + self.assertEqual( + quiet(content.main, ["catalog", "--root", str(root)]), 1 + ) + self.assertFalse((root / "catalog.json").exists()) + + +# --------------------------------------------------------------------------- +# ./scripts/sync-event.sh integration +# --------------------------------------------------------------------------- + + +@unittest.skipUnless(shutil.which("git"), "git is required") +class SyncEventScriptTests(unittest.TestCase): + """The event sync must not deadlock on stale event references. + + ``sync-event.sh`` refreshes the generated ``[content]`` block from the + directories that exist and only validates afterwards, so deleting or + renaming a challenge/gamebox must succeed. + """ + + EVENT_ID = "freshcup-2027" + + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) / self.EVENT_ID + self.root.mkdir(parents=True) + + scripts = self.root / "scripts" + scripts.mkdir() + for name in ("sync-event.sh", "content.py"): + shutil.copy2(SCRIPTS_DIR / name, scripts / name) + + self.write_event(["a", "b"], ["box_a", "box_b"]) + write_content(self.root, "challenges", "a") + write_content(self.root, "challenges", "b") + write_content(self.root, "gameboxes", "box_a") + write_content(self.root, "gameboxes", "box_b") + + self.git("init", "-q") + self.commit() + + def tearDown(self) -> None: + self._tmp.cleanup() + + # -- helpers ---------------------------------------------------------- + + def git(self, *args: str) -> str: + result = subprocess.run( + ["git", "-C", str(self.root), *args], + check=True, + capture_output=True, + text=True, + env={ + **os.environ, + "GIT_AUTHOR_NAME": "test", + "GIT_AUTHOR_EMAIL": "test@example.com", + "GIT_COMMITTER_NAME": "test", + "GIT_COMMITTER_EMAIL": "test@example.com", + }, + ) + return result.stdout.strip() + + def commit(self, message: str = "sync") -> None: + self.git("add", "-A") + self.git("commit", "-q", "-m", message) + + def write_event(self, challenges: list[str], gameboxes: list[str]) -> None: + def array(name: str, values: list[str]) -> str: + lines = [f"{name} = ["] + lines += [f' "{value}",' for value in values] + lines.append("]") + return "\n".join(lines) + + events = self.root / "events" + events.mkdir(exist_ok=True) + (events / f"{self.EVENT_ID}.toml").write_text( + "schema_version = 1\n" + "\n" + f'id = "{self.EVENT_ID}"\n' + 'title = "Freshcup 2027"\n' + 'description = "integration fixture"\n' + 'started_at = "2027-10-19 14:30"\n' + 'ended_at = "2027-10-19 18:30"\n' + "\n" + "# BEGIN GENERATED CONTENT\n" + "[content]\n" + f"{array('challenges', challenges)}\n" + "\n" + f"{array('gameboxes', gameboxes)}\n" + "# END GENERATED CONTENT\n", + encoding="utf-8", + ) + + def sync(self) -> subprocess.CompletedProcess: + return subprocess.run( + ["bash", "scripts/sync-event.sh"], + cwd=self.root, + capture_output=True, + text=True, + ) + + def event_content(self) -> tuple[list[str], list[str]]: + with (self.root / "events" / f"{self.EVENT_ID}.toml").open("rb") as handle: + data = tomllib.load(handle) + + return data["content"]["challenges"], data["content"]["gameboxes"] + + # -- tests ------------------------------------------------------------ + + def test_delete_content_updates_event_then_validates(self) -> None: + self.assertEqual(self.sync().returncode, 0) + self.assertEqual( + self.event_content(), (["a", "b"], ["box_a", "box_b"]) + ) + + shutil.rmtree(self.root / "challenges" / "b") + shutil.rmtree(self.root / "gameboxes" / "box_b") + self.commit("delete b") + + result = self.sync() + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Validated content", result.stdout) + self.assertEqual(self.event_content(), (["a"], ["box_a"])) + + def test_rename_content_updates_event_then_validates(self) -> None: + self.assertEqual(self.sync().returncode, 0) + + (self.root / "challenges" / "b").rename( + self.root / "challenges" / "renamed" + ) + (self.root / "gameboxes" / "box_b").rename( + self.root / "gameboxes" / "box_renamed" + ) + self.commit("rename b") + + result = self.sync() + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + self.event_content(), (["a", "renamed"], ["box_a", "box_renamed"]) + ) + + def test_event_is_synced_before_validation_fails(self) -> None: + write_content(self.root, "challenges", "c") + meta = self.root / "challenges" / "c" / "meta.toml" + meta.write_text( + meta.read_text(encoding="utf-8").replace( + 'difficulty = "easy"', 'difficulty = "impossible"' + ), + encoding="utf-8", + ) + + result = self.sync() + + self.assertEqual(result.returncode, 1) + self.assertIn("invalid difficulty", result.stderr) + # the event was refreshed before the validation failure + self.assertEqual(self.event_content()[0], ["a", "b", "c"])