From 96165f50ab1c177cb2fe77287d36d4b7f0edac4a Mon Sep 17 00:00:00 2001 From: fb0sh Date: Wed, 16 Sep 2026 12:20:30 +0800 Subject: [PATCH 1/6] feat(content): add catalog generation and Docker Hub publishing Add scripts/content.py as the single implementation for content ids, image references, OCI/FloatCTF labels, catalog.json and changed-content detection. - validate challenges/*, gameboxes/* and events/* metadata - generate a deterministic catalog.json for the platform - build OCI labels + GitHub Actions outputs for CI - migrate existing meta.toml files with difficulty and tags - add content.yml: PR validation, main build/push, catalog refresh - add 39 unittest cases with fixtures - document metadata, images, catalog and publishing flow --- .github/workflows/content.yml | 201 ++++ README.md | 130 ++ catalog.json | 621 ++++++++++ challenges/Android_reverse/meta.toml | 2 + .../Cirno's perfect math class/meta.toml | 2 + challenges/Flag_in_the_model/meta.toml | 2 + challenges/FloatCTF-qidong/meta.toml | 2 + challenges/Hajimi/meta.toml | 2 + challenges/Smali/meta.toml | 2 + challenges/ai_shell/meta.toml | 2 + challenges/backdoor/meta.toml | 2 + challenges/base64/meta.toml | 2 + challenges/caesar/meta.toml | 2 + challenges/comment/meta.toml | 2 + challenges/cookie/meta.toml | 2 + challenges/ctf_start/meta.toml | 2 + challenges/dir_enum/meta.toml | 2 + challenges/easy_reverse/meta.toml | 2 + challenges/frontend_bypass/meta.toml | 2 + challenges/komachi's book/meta.toml | 2 + challenges/learn_http/meta.toml | 2 + challenges/matrix_rsa/meta.toml | 2 + challenges/miku_flag/meta.toml | 2 + challenges/orin's pack/meta.toml | 2 + challenges/php_file/meta.toml | 2 + challenges/png/meta.toml | 2 + challenges/ret2text/meta.toml | 2 + challenges/robots/meta.toml | 2 + challenges/strings_attached1/meta.toml | 2 + challenges/strings_attached2/meta.toml | 2 + challenges/xzmu_anime_club/meta.toml | 2 + gameboxes/.gitkeep | 0 scripts/content.py | 1057 +++++++++++++++++ scripts/sync-event.sh | 18 + .../challenges/broken_difficulty/meta.toml | 7 + .../challenges/broken_docker/meta.toml | 14 + .../broken_missing_difficulty/meta.toml | 6 + .../challenges/broken_no_meta/README.md | 1 + .../challenges/broken_version/meta.toml | 7 + .../fixtures/invalid/events/freshcup.toml | 16 + .../valid/challenges/comment/meta.toml | 18 + .../valid/challenges/comment/src/Dockerfile | 1 + .../valid/challenges/cookie/meta.toml | 11 + .../tests/fixtures/valid/events/freshcup.toml | 18 + .../valid/gameboxes/comment/meta.toml | 10 + .../valid/gameboxes/comment/src/Dockerfile | 1 + scripts/tests/test_content.py | 681 +++++++++++ 47 files changed, 2874 insertions(+) create mode 100644 .github/workflows/content.yml create mode 100644 catalog.json create mode 100644 gameboxes/.gitkeep create mode 100755 scripts/content.py create mode 100644 scripts/tests/fixtures/invalid/challenges/broken_difficulty/meta.toml create mode 100644 scripts/tests/fixtures/invalid/challenges/broken_docker/meta.toml create mode 100644 scripts/tests/fixtures/invalid/challenges/broken_missing_difficulty/meta.toml create mode 100644 scripts/tests/fixtures/invalid/challenges/broken_no_meta/README.md create mode 100644 scripts/tests/fixtures/invalid/challenges/broken_version/meta.toml create mode 100644 scripts/tests/fixtures/invalid/events/freshcup.toml create mode 100644 scripts/tests/fixtures/valid/challenges/comment/meta.toml create mode 100644 scripts/tests/fixtures/valid/challenges/comment/src/Dockerfile create mode 100644 scripts/tests/fixtures/valid/challenges/cookie/meta.toml create mode 100644 scripts/tests/fixtures/valid/events/freshcup.toml create mode 100644 scripts/tests/fixtures/valid/gameboxes/comment/meta.toml create mode 100644 scripts/tests/fixtures/valid/gameboxes/comment/src/Dockerfile create mode 100644 scripts/tests/test_content.py diff --git a/.github/workflows/content.yml b/.github/workflows/content.yml new file mode 100644 index 0000000..a247615 --- /dev/null +++ b/.github/workflows/content.yml @@ -0,0 +1,201 @@ +name: content + +# Validate content metadata and catalog on every pull request, build and +# publish Docker images from main, then refresh catalog.json. +# +# Required repository secrets (main branch only): +# DOCKERHUB_USERNAME +# DOCKERHUB_TOKEN +# +# The workflow never uses pull_request_target and never exposes Docker Hub +# credentials to pull requests: PR runs only build images, they never log in +# and never push. + +on: + pull_request: + branches: + - main + push: + branches: + - main + workflow_dispatch: + inputs: + build_all: + description: Build and publish every image with a Dockerfile + type: boolean + default: false + +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 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Validate content metadata + run: python3 scripts/content.py validate + + - name: Check catalog.json is up to date + run: python3 scripts/content.py catalog --check + + - name: Run unit tests + run: python3 -m unittest discover -s scripts/tests -v + + detect: + name: Detect changed content + needs: validate + runs-on: ubuntu-latest + outputs: + paths: ${{ steps.changed.outputs.paths }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Detect changed challenges and gameboxes + id: changed + env: + EVENT_NAME: ${{ github.event_name }} + BUILD_ALL: ${{ inputs.build_all }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PUSH_BEFORE_SHA: ${{ github.event.before }} + HEAD_SHA: ${{ github.sha }} + run: | + set -euo pipefail + + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + if [[ "$BUILD_ALL" == "true" ]]; then + python3 scripts/content.py changed \ + --all --dockerfile-only --github-output "$GITHUB_OUTPUT" + else + # Manual runs without build_all only validate and refresh catalog. + echo 'paths=[]' >> "$GITHUB_OUTPUT" + fi + exit 0 + fi + + if [[ "$EVENT_NAME" == "pull_request" ]]; then + BASE="$PR_BASE_SHA" + else + BASE="$PUSH_BEFORE_SHA" + fi + + python3 scripts/content.py changed \ + --base "$BASE" \ + --head "$HEAD_SHA" \ + --dockerfile-only \ + --github-output "$GITHUB_OUTPUT" + + build: + name: Build ${{ matrix.path }} + needs: detect + if: needs.detect.outputs.paths != '[]' && needs.detect.outputs.paths != '' + runs-on: ubuntu-latest + env: + # Pull requests only build; push and manual runs publish. + PUBLISH: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} + strategy: + fail-fast: false + matrix: + path: ${{ fromJSON(needs.detect.outputs.paths) }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + if: env.PUBLISH == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Resolve image metadata + id: image + run: | + python3 scripts/content.py image-meta "${{ matrix.path }}" \ + --github-output "$GITHUB_OUTPUT" + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: ${{ steps.image.outputs.context }} + file: ${{ steps.image.outputs.dockerfile }} + push: ${{ env.PUBLISH == 'true' }} + tags: ${{ steps.image.outputs.image }} + labels: ${{ steps.image.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + catalog: + name: Update catalog.json + needs: + - validate + - detect + - build + if: | + always() + && github.event_name != 'pull_request' + && needs.validate.result == 'success' + && needs.detect.result == 'success' + && (needs.build.result == 'success' || needs.build.result == 'skipped') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout main + uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 + + - 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..429dc5f 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ gameboxes/ meta.toml ``` +`meta.toml` 字段见 [Content Metadata](#content-metadata)。 + 内容发生变化后运行: ```bash @@ -73,6 +75,7 @@ meta.toml 脚本会自动: +- 校验 `meta.toml`(`scripts/content.py validate`) - 扫描 `challenges/` - 扫描 `gameboxes/` - 更新 `events/.toml` @@ -110,3 +113,130 @@ floatctf-content:main > 比赛公开前,只向 `origin` 推送,不要向 `upstream` 推送。 +## Content Metadata + +`meta.toml` 是 Challenge / GameBox 的唯一元数据来源,同时用于: + +- `catalog.json` +- Docker Image 的 OCI / FloatCTF Labels + +目录名即内容 ID(例如 `challenges/comment/` → `comment`), +不需要在 `meta.toml` 中额外声明 `id`。 +Challenge 与 GameBox 允许使用相同 ID,因为镜像 tag 不同。 + +```toml +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 +``` + +必填字段: + +```text +name version author category difficulty tags description +``` + +新增字段: + +| 字段 | 说明 | +|------|------| +| `difficulty` | `unknown` / `beginner` / `easy` / `medium` / `hard` / `expert` | +| `tags` | 字符串数组,可以为空数组,每项必须是非空字符串 | + +- `version` 使用 `x.y.z`(SemVer),例如 `1.0.0`。 +- `category` 不限制取值,现有内容使用 `ai` / `crypto` / `misc` / `pwn` / `reverse` / `web`。 +- 旧内容已统一补充 `difficulty = "unknown"` 与 `tags = []`; + `unknown` 仅用于兼容,新内容请填写真实难度。 +- `events` 关系由 `events/*.toml` 自动反向生成,不要在 `meta.toml` 中手工维护。 + +校验整个仓库: + +```bash +python3 scripts/content.py validate +``` + +## Official Images + +镜像名规则只在 `scripts/content.py` 中实现,不要在别处重新拼接: + +```text +Challenge: floatctf/{id}:challenge-v{version} +GameBox: floatctf/{id}:gamebox-v{version} +``` + +例如: + +```text +floatctf/comment:challenge-v1.0.0 +``` + +Challenge 的构建上下文固定为 `challenges/{id}/src`,Dockerfile 为 +`challenges/{id}/src/Dockerfile`;GameBox 同理使用 `gameboxes/{id}/src`。 + +本地查看某个内容的镜像信息(镜像名、构建上下文、Labels): + +```bash +python3 scripts/content.py image-meta challenges/comment +``` + +## Catalog + +`catalog.json` 是自动生成的官方题库索引,由 GitHub Actions 在 `main` +分支上重新生成并提交。平台可以直接读取: + +```text +https://raw.githubusercontent.com/FloatCTF/floatctf-content/main/catalog.json +``` + +本地重新生成与校验: + +```bash +python3 scripts/content.py catalog +python3 scripts/content.py catalog --check +``` + +> **catalog.json is generated. Do not edit it manually.** + +提交到 `main` 的原因:Git 历史可追踪、`raw.githubusercontent.com` 直接访问、 +不需要 GitHub Pages、本地开发也能查看。 + +## Publishing Flow + +```text +Event private repo + └─ ./scripts/sync-event.sh # validate + 更新 event manifest / docs + └─ ./scripts/publish.sh # 推送到 upstream event/ 并创建 PR + └─ Pull Request # validate + catalog --check + docker build(不 push) + └─ main # 构建并推送变化的镜像到 Docker Hub + └─ catalog.json # 自动重新生成并提交 + └─ FloatCTF 平台读取 raw catalog.json +``` + +GitHub Actions 需要配置的 Secrets(仅 `main` 使用,PR 不会接触): + +```text +DOCKERHUB_USERNAME +DOCKERHUB_TOKEN +``` + +手动触发(`workflow_dispatch`): + +- `build_all = false`:只执行 validate 与 catalog。 +- `build_all = true`:构建并推送所有带 Dockerfile 的 Challenge / GameBox, + 然后重新生成 `catalog.json`。 + diff --git a/catalog.json b/catalog.json new file mode 100644 index 0000000..7c72c3c --- /dev/null +++ b/catalog.json @@ -0,0 +1,621 @@ +{ + "version": 1, + "challenges": [ + { + "id": "Android_reverse", + "name": "Android_reverse", + "version": "1.0.0", + "author": "ablklice@gmail.com", + "category": "reverse", + "difficulty": "unknown", + "tags": [], + "description": "简单的android逆向", + "image": "floatctf/Android_reverse:challenge-v1.0.0", + "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编码", + "image": "floatctf/Cirno's perfect math class:challenge-v1.0.0", + "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?这是什么后缀名?", + "image": "floatctf/Flag_in_the_model:challenge-v1.0.0", + "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后缀文件,何意味", + "image": "floatctf/Smali:challenge-v1.0.0", + "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", + "image": "floatctf/base64:challenge-v1.0.0", + "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", + "image": "floatctf/caesar:challenge-v1.0.0", + "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": "这图片好像缺点什么", + "image": "floatctf/ctf_start:challenge-v1.0.0", + "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{}进行包裹", + "image": "floatctf/easy_reverse:challenge-v1.0.0", + "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包来存储", + "image": "floatctf/komachi's book:challenge-v1.0.0", + "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?", + "image": "floatctf/matrix_rsa:challenge-v1.0.0", + "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": "这图片好像缺点什么", + "image": "floatctf/miku_flag:challenge-v1.0.0", + "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": "阿燐在她的猫车中找到一个神秘压缩包", + "image": "floatctf/orin's pack:challenge-v1.0.0", + "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": "图片里面有什么", + "image": "floatctf/png:challenge-v1.0.0", + "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": "找到了一串字符串,要用什么算法解密呢", + "image": "floatctf/strings_attached1:challenge-v1.0.0", + "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": "和某一道题加密算法一样,但好像又有一些不同", + "image": "floatctf/strings_attached2:challenge-v1.0.0", + "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": "这里是西藏民族大学动漫社,欢迎来玩", + "image": "floatctf/xzmu_anime_club:challenge-v1.0.0", + "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..93836d5 --- /dev/null +++ b/scripts/content.py @@ -0,0 +1,1057 @@ +#!/usr/bin/env python3 +"""FloatCTF content metadata, catalog and container image helper. + +This module is the single source of truth for: + +* content ids, types and versions (``meta.toml``) +* Docker image references and OCI / FloatCTF labels +* ``catalog.json`` +* changed content detection for CI + +Only the Python standard library (3.11+) is required. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import tomllib +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterable, 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 + +IMAGE_NAMESPACE = "floatctf" +IMAGE_VENDOR = "FloatCTF" +IMAGE_SOURCE = "https://github.com/FloatCTF/floatctf-content" + +SOURCE_SUBDIR = "src" +DOCKERFILE_NAME = "Dockerfile" + +VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+$") + +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", +) + +POSITIVE_RESOURCE_FIELDS: tuple[str, ...] = ( + "cpu_millis", + "memory_bytes", + "pids_limit", +) + +PORT_MIN = 1 +PORT_MAX = 65535 + +#: GitHub Actions output name used by ``changed --github-output``. +GITHUB_PATHS_OUTPUT = "paths" + +#: Delimiter used for multi-line GitHub Actions outputs. +GITHUB_OUTPUT_DELIMITER = "EOF" + + +class ContentError(Exception): + """A user facing content problem.""" + + +# --------------------------------------------------------------------------- +# 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 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 _label_text(value: Any) -> str: + """Collapse whitespace so a value is safe for a Docker label.""" + + return " ".join(str(value).split()) + + +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, + path=directory, + 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 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}'", + ) + + 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" + ) + + 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 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)) + + 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 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/{id}:{type}-v{version}``.""" + + return ( + f"{IMAGE_NAMESPACE}/{content.id}" + f":{content.type}-v{content.version}" + ) + + +def image_context(content: Content) -> tuple[str, str]: + """Return the Docker build context and Dockerfile paths, relative to root.""" + + context = content.path / SOURCE_SUBDIR + dockerfile = context / DOCKERFILE_NAME + + return context.as_posix(), dockerfile.as_posix() + + +def ensure_dockerfile(content: Content, root: Path) -> str: + """Return the Dockerfile path or raise :class:`ContentError`.""" + + _, dockerfile = image_context(content) + + if not (root / dockerfile).is_file(): + raise ContentError( + f"{content.path.as_posix()}: Dockerfile not found: {dockerfile}" + ) + + return dockerfile + + +def image_labels(content: Content, revision: str | None = None) -> dict[str, str]: + """Return the OCI / FloatCTF labels for *content*.""" + + meta = content.meta + tags = meta.get("tags") + + if not isinstance(tags, list): + tags = [] + + labels = { + "org.opencontainers.image.title": _label_text( + meta.get("name", content.id) + ), + "org.opencontainers.image.description": _label_text( + meta.get("description", "") + ), + "org.opencontainers.image.version": _label_text(content.version), + "org.opencontainers.image.vendor": IMAGE_VENDOR, + "org.opencontainers.image.source": IMAGE_SOURCE, + "io.floatctf.type": content.type, + "io.floatctf.id": content.id, + "io.floatctf.category": _label_text(meta.get("category", "")), + "io.floatctf.difficulty": _label_text(meta.get("difficulty", "")), + "io.floatctf.version": _label_text(content.version), + "io.floatctf.tags": ",".join(_label_text(tag) for tag in tags), + } + + revision = revision or os.environ.get("GITHUB_SHA") or "" + + if revision.strip(): + labels["org.opencontainers.image.revision"] = revision.strip() + + return labels + + +def load_content(root: Path, path: str) -> Content: + """Load one ``challenges/`` or ``gameboxes/`` directory.""" + + candidate = Path(path.strip().rstrip("/")) + + if candidate.is_absolute(): + try: + candidate = candidate.relative_to(root) + except ValueError: + raise ContentError( + f"{path}: must live inside the repository root" + ) from None + + parts = candidate.parts + + if len(parts) != 2 or parts[0] not in CONTENT_DIRS.values(): + raise ContentError( + f"{path}: expected challenges/ or gameboxes/" + ) + + content_type = ( + CONTENT_CHALLENGE if parts[0] == CHALLENGES_DIR else CONTENT_GAMEBOX + ) + directory = root / parts[0] / parts[1] + meta_path = directory / "meta.toml" + + if not directory.is_dir(): + raise ContentError(f"{path}: directory not found") + + if not meta_path.is_file(): + raise ContentError(f"{_display(meta_path, root)}: missing meta.toml") + + content = Content( + id=parts[1], + type=content_type, + path=Path(parts[0]) / parts[1], + meta=load_meta(meta_path), + ) + + errors = validate_meta(content, root) + + if errors: + raise ContentError(errors[0]) + + ensure_dockerfile(content, root) + + return content + + +# --------------------------------------------------------------------------- +# 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]) -> 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", ""), + "image": image_ref(content), + } + + flag = _flag_entry(meta) + + if flag is not None: + entry["flag"] = flag + + 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, [])) + for content in sorted(challenges, key=lambda item: item.id) + ], + "gameboxes": [ + content_entry(content, gamebox_events.get(content.id, [])) + 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" + + +# --------------------------------------------------------------------------- +# Changed content detection +# --------------------------------------------------------------------------- + + +def content_paths(names: Iterable[str]) -> list[str]: + """Map changed file paths to content directories (unique, sorted).""" + + found: set[str] = set() + + for name in names: + parts = Path(name).parts + + if len(parts) < 2: + continue + + if parts[0] in CONTENT_DIRS.values() and parts[1]: + found.add(f"{parts[0]}/{parts[1]}") + + return sorted(found) + + +def _git_diff_names(root: Path, base: str, head: str) -> list[str]: + result = subprocess.run( + ["git", "-C", str(root), "diff", "--name-only", "-z", base, head], + capture_output=True, + check=False, + ) + + if result.returncode != 0: + message = result.stderr.decode("utf-8", errors="replace").strip() + raise ContentError( + f"git diff {base} {head} failed" + + (f": {message}" if message else "") + ) + + output = result.stdout.decode("utf-8", errors="replace") + + return [name for name in output.split("\0") if name] + + +def _all_content_paths(root: Path) -> list[str]: + paths: list[str] = [] + + for content_type in CONTENT_DIRS: + for content in scan_contents(root, content_type): + paths.append(f"{CONTENT_DIRS[content_type]}/{content.id}") + + return sorted(set(paths)) + + +def _is_zero_sha(value: str) -> bool: + return bool(value) and set(value) <= {"0"} + + +def find_changed( + root: Path, + base: str | None = None, + head: str | None = None, + *, + all_content: bool = False, + dockerfile_only: bool = False, +) -> list[str]: + """Return content directories touched between *base* and *head*.""" + + if all_content: + paths = _all_content_paths(root) + else: + if not base or not head: + raise ContentError("changed requires --base and --head, or --all") + + if _is_zero_sha(base): + # First push of a branch: everything is new. + paths = _all_content_paths(root) + else: + paths = [ + path + for path in content_paths(_git_diff_names(root, base, head)) + if (root / path).is_dir() + ] + + if dockerfile_only: + paths = [ + path + for path in paths + if (root / path / SOURCE_SUBDIR / DOCKERFILE_NAME).is_file() + ] + + return sorted(set(paths)) + + +# --------------------------------------------------------------------------- +# GitHub Actions output +# --------------------------------------------------------------------------- + + +def write_github_output(path: str, values: dict[str, str]) -> None: + """Append step outputs to a ``$GITHUB_OUTPUT`` file. + + Multi-line values use the documented heredoc form so that e.g. the + ``labels`` output of ``image-meta`` can be fed to + ``docker/build-push-action`` unchanged. + """ + + with open(path, "a", encoding="utf-8") as handle: + for key, value in values.items(): + if "\n" in value or "\r" in value: + handle.write( + f"{key}<<{GITHUB_OUTPUT_DELIMITER}\n" + f"{value}\n" + f"{GITHUB_OUTPUT_DELIMITER}\n" + ) + else: + handle.write(f"{key}={value}\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) + 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 + + (root / CATALOG_FILE).write_text(rendered, encoding="utf-8") + print(f"Wrote {CATALOG_FILE}") + + return 0 + + +def cmd_image_meta(args: argparse.Namespace) -> int: + root = Path(args.root) + content = load_content(root, args.path) + context, dockerfile = image_context(content) + + meta = { + "id": content.id, + "type": content.type, + "version": content.version, + "image": image_ref(content), + "context": context, + "dockerfile": dockerfile, + "labels": image_labels(content, args.revision), + } + + if args.github_output: + labels = "\n".join( + f"{key}={value}" for key, value in meta["labels"].items() + ) + write_github_output( + args.github_output, + { + "id": meta["id"], + "type": meta["type"], + "version": meta["version"], + "image": meta["image"], + "context": meta["context"], + "dockerfile": meta["dockerfile"], + "labels": labels, + }, + ) + + print(json.dumps(meta, indent=2, ensure_ascii=False)) + + return 0 + + +def cmd_changed(args: argparse.Namespace) -> int: + root = Path(args.root) + paths = find_changed( + root, + args.base, + args.head, + all_content=args.all, + dockerfile_only=args.dockerfile_only, + ) + + if args.github_output: + write_github_output( + args.github_output, + { + GITHUB_PATHS_OUTPUT: json.dumps( + paths, ensure_ascii=False, separators=(",", ":") + ) + }, + ) + + print(json.dumps(paths, indent=2, ensure_ascii=False)) + + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="content.py", + description="FloatCTF content metadata, catalog and image 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_parser.add_argument( + "--check", + action="store_true", + help="fail when catalog.json is out of date instead of writing it", + ) + catalog_parser.set_defaults(func=cmd_catalog) + + image_parser = subparsers.add_parser( + "image-meta", + parents=[common], + help="print image metadata for one content directory", + ) + image_parser.add_argument("path", help="challenges/ or gameboxes/") + image_parser.add_argument( + "--github-output", + metavar="FILE", + help="also append step outputs to a GitHub Actions output file", + ) + image_parser.add_argument( + "--revision", + help="value for org.opencontainers.image.revision " + "(default: $GITHUB_SHA)", + ) + image_parser.set_defaults(func=cmd_image_meta) + + changed_parser = subparsers.add_parser( + "changed", + parents=[common], + help="list content directories changed between two revisions", + ) + changed_parser.add_argument("--base", help="base git revision") + changed_parser.add_argument("--head", help="head git revision") + changed_parser.add_argument( + "--all", + action="store_true", + help="list every content directory instead of diffing", + ) + changed_parser.add_argument( + "--dockerfile-only", + action="store_true", + help="only list content that has a Dockerfile", + ) + changed_parser.add_argument( + "--github-output", + metavar="FILE", + help="also append step outputs to a GitHub Actions output file", + ) + changed_parser.set_defaults(func=cmd_changed) + + 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..d37c447 100755 --- a/scripts/sync-event.sh +++ b/scripts/sync-event.sh @@ -23,6 +23,24 @@ DOC_FILE="docs/${EVENT}.md" command -v python3 >/dev/null 2>&1 || die "python3 is required" +# ----------------------------------------------------------------------------- +# Metadata validation +# ----------------------------------------------------------------------------- + +# scripts/content.py is the single source of truth for metadata validation. +# Event repositories created from an older event/base branch may not have it +# yet; in that case fall back to the checks further down. +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 + +# ----------------------------------------------------------------------------- +# Event manifest and documentation +# ----------------------------------------------------------------------------- + python3 - "$EVENT" "$EVENT_FILE" "$DOC_FILE" <<'PY' from __future__ import annotations 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_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/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/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..801d275 --- /dev/null +++ b/scripts/tests/test_content.py @@ -0,0 +1,681 @@ +#!/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 shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +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" + + +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 + + +# --------------------------------------------------------------------------- +# 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"], + ) + + def test_image_metadata_paths(self) -> None: + challenge = content.load_content(VALID_FIXTURE, "challenges/comment") + gamebox = content.load_content(VALID_FIXTURE, "gameboxes/comment") + + self.assertEqual( + content.image_context(challenge), + ("challenges/comment/src", "challenges/comment/src/Dockerfile"), + ) + self.assertEqual( + content.image_context(gamebox), + ("gameboxes/comment/src", "gameboxes/comment/src/Dockerfile"), + ) + + def test_image_meta_requires_dockerfile(self) -> None: + with self.assertRaises(SystemExit) as raised: + quiet( + content.main, + ["image-meta", "challenges/cookie", "--root", str(VALID_FIXTURE)], + ) + + self.assertEqual(raised.exception.code, 1) + + +# --------------------------------------------------------------------------- +# 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), + ) + + +# --------------------------------------------------------------------------- +# 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: + self.assertEqual( + self.entry("challenges", "cookie"), + { + "id": "cookie", + "name": "cookie", + "version": "1.0.0", + "author": "dev@floatctf.local", + "category": "web", + "difficulty": "unknown", + "tags": [], + "description": "想成为管理员吗?也许你需要一个特殊的饼干!", + "image": "floatctf/cookie:challenge-v1.0.0", + "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: + entry = self.entry("challenges", "cookie") + rendered = content.render_catalog(self.catalog()) + + self.assertEqual(entry["flag"], {"type": "static"}) + self.assertNotIn("fixture-flag-must-not-leak", rendered) + + 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) + + +# --------------------------------------------------------------------------- +# labels / github output +# --------------------------------------------------------------------------- + + +class LabelTests(unittest.TestCase): + def test_image_labels(self) -> None: + challenge = content.load_content(VALID_FIXTURE, "challenges/comment") + + with mock.patch.dict(os.environ, {}, clear=True): + labels = content.image_labels(challenge) + + self.assertEqual( + labels, + { + "org.opencontainers.image.title": "comment", + "org.opencontainers.image.description": "注释里面有什么?", + "org.opencontainers.image.version": "1.0.0", + "org.opencontainers.image.vendor": "FloatCTF", + "org.opencontainers.image.source": ( + "https://github.com/FloatCTF/floatctf-content" + ), + "io.floatctf.type": "challenge", + "io.floatctf.id": "comment", + "io.floatctf.category": "web", + "io.floatctf.difficulty": "easy", + "io.floatctf.version": "1.0.0", + "io.floatctf.tags": "php,web", + }, + ) + + def test_image_labels_revision(self) -> None: + challenge = content.load_content(VALID_FIXTURE, "challenges/comment") + + labels = content.image_labels(challenge, "deadbeef") + self.assertEqual(labels["org.opencontainers.image.revision"], "deadbeef") + + with mock.patch.dict(os.environ, {"GITHUB_SHA": "cafebabe"}, clear=True): + labels = content.image_labels(challenge) + self.assertEqual(labels["org.opencontainers.image.revision"], "cafebabe") + + def test_image_labels_are_single_line(self) -> None: + challenge = content.Content( + id="multi", + type=content.CONTENT_CHALLENGE, + path=Path("challenges/multi"), + meta={ + "name": "multi", + "version": "1.0.0", + "category": "web", + "difficulty": "hard", + "tags": ["a", "b"], + "description": "line one\n\nline two", + }, + ) + + labels = content.image_labels(challenge) + + self.assertEqual( + labels["org.opencontainers.image.description"], "line one line two" + ) + for value in labels.values(): + self.assertNotIn("\n", value) + + def test_image_meta_github_output(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / "github_output" + + result = quiet( + content.main, + [ + "image-meta", + "challenges/comment", + "--root", + str(VALID_FIXTURE), + "--github-output", + str(output), + "--revision", + "deadbeef", + ], + ) + + self.assertEqual(result, 0) + + text = output.read_text(encoding="utf-8") + self.assertIn("image=floatctf/comment:challenge-v1.0.0\n", text) + self.assertIn("context=challenges/comment/src\n", text) + self.assertIn("labels< None: + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / "github_output" + + result = quiet( + content.main, + [ + "changed", + "--all", + "--dockerfile-only", + "--root", + str(VALID_FIXTURE), + "--github-output", + str(output), + ], + ) + + self.assertEqual(result, 0) + self.assertEqual( + output.read_text(encoding="utf-8"), + 'paths=["challenges/comment","gameboxes/comment"]\n', + ) + + +# --------------------------------------------------------------------------- +# 10. changed path detection +# --------------------------------------------------------------------------- + + +class ChangedTests(unittest.TestCase): + def test_content_paths_filters_sorts_and_dedupes(self) -> None: + names = [ + "challenges/comment/src/index.php", + "challenges/comment/meta.toml", + "challenges/comment/README.md", + "gameboxes/foo/meta.toml", + "events/freshcup.toml", + "scripts/content.py", + "scripts/tests/fixtures/valid/challenges/comment/meta.toml", + "docs/freshcup.md", + "README.md", + "catalog.json", + ] + + self.assertEqual( + content.content_paths(names), + ["challenges/comment", "gameboxes/foo"], + ) + + def test_content_paths_handles_spaces_and_quotes(self) -> None: + names = [ + "challenges/Cirno's perfect math class/src/Dockerfile", + "challenges/Cirno's perfect math class/meta.toml", + ] + + self.assertEqual( + content.content_paths(names), + ["challenges/Cirno's perfect math class"], + ) + + def test_content_paths_ignores_non_content(self) -> None: + self.assertEqual( + content.content_paths( + ["events/a.toml", "challenges", "challenges/", "gameboxes/x"] + ), + ["gameboxes/x"], + ) + + def test_find_changed_requires_revisions(self) -> None: + with self.assertRaises(content.ContentError): + content.find_changed(VALID_FIXTURE) + + def test_find_changed_dockerfile_filter(self) -> None: + self.assertEqual( + content.find_changed(VALID_FIXTURE, all_content=True), + ["challenges/comment", "challenges/cookie", "gameboxes/comment"], + ) + self.assertEqual( + content.find_changed( + VALID_FIXTURE, all_content=True, dockerfile_only=True + ), + ["challenges/comment", "gameboxes/comment"], + ) + + +@unittest.skipUnless(shutil.which("git"), "git is required") +class ChangedGitTests(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + self._git("init", "-q") + + self.write("challenges/comment/meta.toml", 'name = "comment"\n') + self.write("challenges/comment/src/Dockerfile", "FROM scratch\n") + self.write("gameboxes/box/meta.toml", 'name = "box"\n') + self.write("events/freshcup.toml", 'id = "freshcup"\n') + self.write("README.md", "root\n") + + self.commit("one") + self.base = self.rev("HEAD") + + self.write("challenges/comment/src/Dockerfile", "FROM busybox\n") + self.write("gameboxes/box/README.md", "docs only\n") + self.write("events/freshcup.toml", 'id = "freshcup"\n# changed\n') + self.commit("two") + self.head = self.rev("HEAD") + + def tearDown(self) -> None: + self._tmp.cleanup() + + 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 rev(self, reference: str) -> str: + return self._git("rev-parse", reference) + + def write(self, relative: str, text: str) -> None: + path = self.root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + def commit(self, message: str) -> None: + self._git("add", "-A") + self._git("commit", "-q", "-m", message) + + def test_diff_detects_src_changes_only(self) -> None: + self.assertEqual( + content.find_changed(self.root, self.base, self.head), + ["challenges/comment", "gameboxes/box"], + ) + self.assertEqual( + content.find_changed( + self.root, self.base, self.head, dockerfile_only=True + ), + ["challenges/comment"], + ) + + def test_events_and_scripts_do_not_trigger_builds(self) -> None: + self.write("scripts/content.py", "# changed\n") + self.commit("scripts") + scripts_head = self.rev("HEAD") + + self.assertEqual( + content.find_changed(self.root, self.head, scripts_head, dockerfile_only=True), + [], + ) + + def test_zero_base_builds_everything(self) -> None: + self.assertEqual( + content.find_changed(self.root, "0" * 40, self.head), + ["challenges/comment", "gameboxes/box"], + ) + + +if __name__ == "__main__": + unittest.main() From 5fa71e8208fb5cbc36b6814923104ef9b574163c Mon Sep 17 00:00:00 2001 From: fb0sh Date: Wed, 16 Sep 2026 12:40:14 +0800 Subject: [PATCH 2/6] fix(content): derive Docker safe_name and gate publishing to main - add safe_name (explicit or derived from the directory name) and use it for floatctf/:-v; ids such as "Cirno's perfect math class" no longer yield illegal repository names - validate explicit safe_name, report underivable ids, and detect duplicate safe_name conflicts per content type - omit "image" from catalog entries without src/Dockerfile - reject duplicated content references inside events - add "catalog --output"; PR CI no longer requires a fresh catalog.json - publish only from refs/heads/main and refuse to overwrite an existing image tag via docker buildx imagetools inspect - document safe_name, static content and the version bump policy - 30 new unittest cases (69 total) --- .github/workflows/content.yml | 34 +- README.md | 122 ++++- catalog.json | 20 +- scripts/content.py | 178 ++++++- .../challenges/broken_safe_name/meta.toml | 8 + .../\351\242\230\347\233\256/meta.toml" | 7 + .../tests/fixtures/invalid/events/dup.toml | 18 + .../fixtures/invalid/gameboxes/box/meta.toml | 7 + .../challenges/Android_reverse/meta.toml | 7 + .../Cirno's perfect math class/meta.toml | 7 + .../challenges/FloatCTF-qidong/meta.toml | 13 + .../challenges/FloatCTF-qidong/src/Dockerfile | 1 + .../\351\242\230\347\233\256/meta.toml" | 12 + .../\351\242\230\347\233\256/src/Dockerfile" | 1 + .../fixtures/safe-names/events/demo.toml | 19 + .../gameboxes/Android_reverse/meta.toml | 10 + .../gameboxes/Android_reverse/src/Dockerfile | 1 + scripts/tests/test_content.py | 445 +++++++++++++++++- 18 files changed, 859 insertions(+), 51 deletions(-) create mode 100644 scripts/tests/fixtures/invalid/challenges/broken_safe_name/meta.toml create mode 100644 "scripts/tests/fixtures/invalid/challenges/\351\242\230\347\233\256/meta.toml" create mode 100644 scripts/tests/fixtures/invalid/events/dup.toml create mode 100644 scripts/tests/fixtures/invalid/gameboxes/box/meta.toml create mode 100644 scripts/tests/fixtures/safe-names/challenges/Android_reverse/meta.toml create mode 100644 scripts/tests/fixtures/safe-names/challenges/Cirno's perfect math class/meta.toml create mode 100644 scripts/tests/fixtures/safe-names/challenges/FloatCTF-qidong/meta.toml create mode 100644 scripts/tests/fixtures/safe-names/challenges/FloatCTF-qidong/src/Dockerfile create mode 100644 "scripts/tests/fixtures/safe-names/challenges/\351\242\230\347\233\256/meta.toml" create mode 100644 "scripts/tests/fixtures/safe-names/challenges/\351\242\230\347\233\256/src/Dockerfile" create mode 100644 scripts/tests/fixtures/safe-names/events/demo.toml create mode 100644 scripts/tests/fixtures/safe-names/gameboxes/Android_reverse/meta.toml create mode 100644 scripts/tests/fixtures/safe-names/gameboxes/Android_reverse/src/Dockerfile diff --git a/.github/workflows/content.yml b/.github/workflows/content.yml index a247615..ebdb3f8 100644 --- a/.github/workflows/content.yml +++ b/.github/workflows/content.yml @@ -50,8 +50,13 @@ jobs: - name: Validate content metadata run: python3 scripts/content.py validate - - name: Check catalog.json is up to date - run: python3 scripts/content.py catalog --check + # 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 @@ -113,8 +118,9 @@ jobs: if: needs.detect.outputs.paths != '[]' && needs.detect.outputs.paths != '' runs-on: ubuntu-latest env: - # Pull requests only build; push and manual runs publish. - PUBLISH: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} + # Only main publishes to Docker Hub. Pull requests and manual runs on + # any other branch build but never log in, push or commit. + PUBLISH: ${{ github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} strategy: fail-fast: false matrix: @@ -144,6 +150,23 @@ jobs: python3 scripts/content.py image-meta "${{ matrix.path }}" \ --github-output "$GITHUB_OUTPUT" + # Released tags are immutable: an existing tag means the version must be + # bumped in meta.toml instead of overwriting a published image. + - name: Check image tag is not published yet + if: env.PUBLISH == 'true' + env: + IMAGE: ${{ steps.image.outputs.image }} + run: | + set -euo pipefail + + if docker buildx imagetools inspect "$IMAGE" >/dev/null 2>&1; then + echo "error: image already exists: $IMAGE" >&2 + echo "bump version in meta.toml before publishing" >&2 + exit 1 + fi + + echo "tag is free: $IMAGE" + - name: Build and push image uses: docker/build-push-action@v6 with: @@ -163,7 +186,8 @@ jobs: - build if: | always() - && github.event_name != 'pull_request' + && github.ref == 'refs/heads/main' + && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && needs.validate.result == 'success' && needs.detect.result == 'success' && (needs.build.result == 'success' || needs.build.result == 'skipped') diff --git a/README.md b/README.md index 429dc5f..d99d7df 100644 --- a/README.md +++ b/README.md @@ -120,9 +120,16 @@ floatctf-content:main - `catalog.json` - Docker Image 的 OCI / FloatCTF Labels -目录名即内容 ID(例如 `challenges/comment/` → `comment`), -不需要在 `meta.toml` 中额外声明 `id`。 -Challenge 与 GameBox 允许使用相同 ID,因为镜像 tag 不同。 +三个概念要区分清楚: + +| 概念 | 来源 | 用途 | +|------|------|------| +| `id` | 目录名 | FloatCTF 内部稳定 ID,Event 引用、catalog 中的 `id` | +| `name` | `meta.toml` | UI 显示名称 | +| `safe_name` | `meta.toml`(可选) | Docker repository 名(`floatctf/{safe_name}`) | + +`id` 不需要在 `meta.toml` 中声明,Challenge 与 GameBox 允许使用相同 `id` +与相同 `safe_name`,因为镜像 tag(`challenge-v*` / `gamebox-v*`)不同。 ```toml name = "comment" @@ -132,6 +139,8 @@ category = "web" difficulty = "easy" tags = ["php", "web"] description = "注释里面有什么?" +# 可选;缺省由目录名派生 +# safe_name = "comment" [flag] type = "dynamic" @@ -151,12 +160,13 @@ pids_limit = 100 name version author category difficulty tags description ``` -新增字段: +字段说明: | 字段 | 说明 | |------|------| | `difficulty` | `unknown` / `beginner` / `easy` / `medium` / `hard` / `expert` | | `tags` | 字符串数组,可以为空数组,每项必须是非空字符串 | +| `safe_name` | 可选;Docker repository 名,必须匹配 `^[a-z0-9]+(?:[._-][a-z0-9]+)*$` | - `version` 使用 `x.y.z`(SemVer),例如 `1.0.0`。 - `category` 不限制取值,现有内容使用 `ai` / `crypto` / `misc` / `pwn` / `reverse` / `web`。 @@ -164,6 +174,57 @@ name version author category difficulty tags description `unknown` 仅用于兼容,新内容请填写真实难度。 - `events` 关系由 `events/*.toml` 自动反向生成,不要在 `meta.toml` 中手工维护。 +### safe_name + +`safe_name` 是 Docker repository 名,`id` 可以包含空格、大写、撇号甚至中文, +`safe_name` 必须始终是合法的 Docker repository 名。 + +没有显式写 `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 @@ -175,18 +236,26 @@ python3 scripts/content.py validate 镜像名规则只在 `scripts/content.py` 中实现,不要在别处重新拼接: ```text -Challenge: floatctf/{id}:challenge-v{version} -GameBox: floatctf/{id}:gamebox-v{version} +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 ``` -Challenge 的构建上下文固定为 `challenges/{id}/src`,Dockerfile 为 -`challenges/{id}/src/Dockerfile`;GameBox 同理使用 `gameboxes/{id}/src`。 +构建上下文固定为 `challenges/{id}/src`(GameBox 为 `gameboxes/{id}/src`), +Dockerfile 固定为 `{context}/Dockerfile`,不支持自定义 context。 + +**只有存在 `src/Dockerfile` 的内容才有镜像**;附件题(static / attachment-only) +不会构建镜像,Catalog 中也不会出现 `image` 字段。 + +已发布的 tag 是**不可覆盖**的:`main` 发布前会检查 +`floatctf/{safe_name}:{type}-v{version}` 是否已存在,已存在则直接失败并 +要求先 bump `version`。 本地查看某个内容的镜像信息(镜像名、构建上下文、Labels): @@ -206,12 +275,16 @@ https://raw.githubusercontent.com/FloatCTF/floatctf-content/main/catalog.json 本地重新生成与校验: ```bash -python3 scripts/content.py catalog -python3 scripts/content.py catalog --check +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` 字段。 + 提交到 `main` 的原因:Git 历史可追踪、`raw.githubusercontent.com` 直接访问、 不需要 GitHub Pages、本地开发也能查看。 @@ -221,12 +294,29 @@ python3 scripts/content.py catalog --check Event private repo └─ ./scripts/sync-event.sh # validate + 更新 event manifest / docs └─ ./scripts/publish.sh # 推送到 upstream event/ 并创建 PR - └─ Pull Request # validate + catalog --check + docker build(不 push) - └─ main # 构建并推送变化的镜像到 Docker Hub + └─ Pull Request # validate + unittest + catalog 生成测试 + docker build(不 push) + └─ main # 检查 tag 未占用 → 构建并推送镜像到 Docker Hub └─ catalog.json # 自动重新生成并提交 └─ FloatCTF 平台读取 raw catalog.json ``` +- 只有 `refs/heads/main` 上的 `push` / `workflow_dispatch` 会 push 镜像与提交 + `catalog.json`。 +- Pull Request 与非 `main` 分支的手动触发只做 validate / 测试 / `docker build`, + 不登录 Docker Hub、不 push、不提交。 + +### 版本策略 + +只要需要重新发布镜像(修改了 `src/**`,或需要刷新镜像 metadata), +就必须 bump `version`: + +```text +1.0.0 → 1.0.1 +``` + +已存在的 `floatctf/comment:challenge-v1.0.0` 不会被覆盖, +Action 会失败并提示 bump version。 + GitHub Actions 需要配置的 Secrets(仅 `main` 使用,PR 不会接触): ```text @@ -236,7 +326,9 @@ DOCKERHUB_TOKEN 手动触发(`workflow_dispatch`): -- `build_all = false`:只执行 validate 与 catalog。 -- `build_all = true`:构建并推送所有带 Dockerfile 的 Challenge / GameBox, - 然后重新生成 `catalog.json`。 +- `build_all = false`:只执行 validate、unittest 与 catalog 生成测试; + 在 `main` 上还会刷新 `catalog.json`。 +- `build_all = true`:构建所有带 Dockerfile 的 Challenge / GameBox; + 在 `main` 上会检查 tag 并 push,在其他分支只 build。 + diff --git a/catalog.json b/catalog.json index 7c72c3c..54dfca0 100644 --- a/catalog.json +++ b/catalog.json @@ -10,7 +10,6 @@ "difficulty": "unknown", "tags": [], "description": "简单的android逆向", - "image": "floatctf/Android_reverse:challenge-v1.0.0", "flag": { "type": "static" }, @@ -27,7 +26,6 @@ "difficulty": "unknown", "tags": [], "description": "琪露诺发现幻想乡巴士里有base编码", - "image": "floatctf/Cirno's perfect math class:challenge-v1.0.0", "flag": { "type": "static" }, @@ -44,7 +42,6 @@ "difficulty": "unknown", "tags": [], "description": ".pt?这是什么后缀名?", - "image": "floatctf/Flag_in_the_model:challenge-v1.0.0", "flag": { "type": "static" }, @@ -61,7 +58,7 @@ "difficulty": "unknown", "tags": [], "description": "大喊FloatCTF启动来获得flag吧", - "image": "floatctf/FloatCTF-qidong:challenge-v1.0.0", + "image": "floatctf/floatctf-qidong:challenge-v1.0.0", "flag": { "type": "dynamic" }, @@ -86,7 +83,7 @@ "difficulty": "unknown", "tags": [], "description": "我是耄耋,我还活着,拿nc拯救我吧,哈!!!", - "image": "floatctf/Hajimi:challenge-v1.0.0", + "image": "floatctf/hajimi:challenge-v1.0.0", "flag": { "type": "dynamic" }, @@ -111,7 +108,6 @@ "difficulty": "unknown", "tags": [], "description": "smali后缀文件,何意味", - "image": "floatctf/Smali:challenge-v1.0.0", "flag": { "type": "static" }, @@ -178,7 +174,6 @@ "difficulty": "unknown", "tags": [], "description": "ZmxhZ3tiMWQxZmNmNy01NzQyLTQ0OTctODYwOC1iZGU1NDlmMWQ1MmV9", - "image": "floatctf/base64:challenge-v1.0.0", "flag": { "type": "static" }, @@ -195,7 +190,6 @@ "difficulty": "unknown", "tags": [], "description": "凯撒大帝的秘密信息!\n\n密文:iordwfwi{fdhvdu_flskhu_lv_hdvb}\n\n提示1:这是最古老的加密方法之一\n提示2:凯撒密码的位移量通常在 1-25 之间\n提示3:试试往回移动 3 位\n", - "image": "floatctf/caesar:challenge-v1.0.0", "flag": { "type": "static" }, @@ -265,7 +259,6 @@ "difficulty": "unknown", "tags": [], "description": "这图片好像缺点什么", - "image": "floatctf/ctf_start:challenge-v1.0.0", "flag": { "type": "static" }, @@ -307,7 +300,6 @@ "difficulty": "unknown", "tags": [], "description": "使用tea算法进行解密,答案使用flag{}进行包裹", - "image": "floatctf/easy_reverse:challenge-v1.0.0", "flag": { "type": "static" }, @@ -349,7 +341,6 @@ "difficulty": "unknown", "tags": [], "description": "小町摸鱼时会写小说,她可能会将小说变成zip包来存储", - "image": "floatctf/komachi's book:challenge-v1.0.0", "flag": { "type": "static" }, @@ -391,7 +382,6 @@ "difficulty": "unknown", "tags": [], "description": "你告诉我什么叫矩阵RSA?", - "image": "floatctf/matrix_rsa:challenge-v1.0.0", "flag": { "type": "static" }, @@ -408,7 +398,6 @@ "difficulty": "unknown", "tags": [], "description": "这图片好像缺点什么", - "image": "floatctf/miku_flag:challenge-v1.0.0", "flag": { "type": "static" }, @@ -425,7 +414,6 @@ "difficulty": "unknown", "tags": [], "description": "阿燐在她的猫车中找到一个神秘压缩包", - "image": "floatctf/orin's pack:challenge-v1.0.0", "flag": { "type": "static" }, @@ -467,7 +455,6 @@ "difficulty": "unknown", "tags": [], "description": "图片里面有什么", - "image": "floatctf/png:challenge-v1.0.0", "flag": { "type": "static" }, @@ -534,7 +521,6 @@ "difficulty": "unknown", "tags": [], "description": "找到了一串字符串,要用什么算法解密呢", - "image": "floatctf/strings_attached1:challenge-v1.0.0", "flag": { "type": "static" }, @@ -551,7 +537,6 @@ "difficulty": "unknown", "tags": [], "description": "和某一道题加密算法一样,但好像又有一些不同", - "image": "floatctf/strings_attached2:challenge-v1.0.0", "flag": { "type": "static" }, @@ -568,7 +553,6 @@ "difficulty": "unknown", "tags": [], "description": "这里是西藏民族大学动漫社,欢迎来玩", - "image": "floatctf/xzmu_anime_club:challenge-v1.0.0", "flag": { "type": "static" }, diff --git a/scripts/content.py b/scripts/content.py index 93836d5..0429db5 100755 --- a/scripts/content.py +++ b/scripts/content.py @@ -20,6 +20,7 @@ import subprocess import sys import tomllib +import unicodedata from dataclasses import dataclass, field from pathlib import Path from typing import Any, Iterable, NoReturn, Sequence @@ -52,6 +53,9 @@ 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", @@ -98,6 +102,56 @@ 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 dockerfile_path(content: "Content", root: Path) -> Path: + """Return the Dockerfile path of *content* below *root*.""" + + return root / content.path / SOURCE_SUBDIR / DOCKERFILE_NAME + + +def has_dockerfile(content: "Content", root: Path) -> bool: + """True when *content* is a container (``src/Dockerfile`` exists).""" + + return dockerfile_path(content, root).is_file() + + # --------------------------------------------------------------------------- # Models # --------------------------------------------------------------------------- @@ -116,6 +170,12 @@ class Content: 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" @@ -398,6 +458,17 @@ def validate_meta(content: Content, root: Path) -> list[str]: 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: @@ -437,6 +508,46 @@ def validate_meta(content: Content, root: Path) -> list[str]: 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*.""" @@ -448,6 +559,11 @@ def validate(root: Path) -> ValidationResult: 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} @@ -456,6 +572,12 @@ def validate(root: Path) -> ValidationResult: 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}'") @@ -478,10 +600,23 @@ def validate(root: Path) -> ValidationResult: def image_ref(content: Content) -> str: - """Return ``floatctf/{id}:{type}-v{version}``.""" + """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}/{content.id}" + f"{IMAGE_NAMESPACE}/{safe_name}" f":{content.type}-v{content.version}" ) @@ -651,7 +786,11 @@ def _docker_entry(meta: dict[str, Any]) -> dict[str, Any] | None: return entry or None -def content_entry(content: Content, event_ids: Sequence[str]) -> dict[str, Any]: +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 @@ -665,9 +804,13 @@ def content_entry(content: Content, event_ids: Sequence[str]) -> dict[str, Any]: "difficulty": meta.get("difficulty", ""), "tags": list(meta.get("tags", [])), "description": meta.get("description", ""), - "image": image_ref(content), } + # Only container content publishes an image; attachment-only content is + # simply served without one. + if has_dockerfile(content, root): + entry["image"] = image_ref(content) + flag = _flag_entry(meta) if flag is not None: @@ -712,11 +855,11 @@ def build_catalog(root: Path) -> dict[str, Any]: return { "version": CATALOG_VERSION, "challenges": [ - content_entry(content, challenge_events.get(content.id, [])) + 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, [])) + content_entry(content, gamebox_events.get(content.id, []), root) for content in sorted(gameboxes, key=lambda item: item.id) ], "events": [ @@ -876,6 +1019,15 @@ def cmd_validate(args: argparse.Namespace) -> int: 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: @@ -895,8 +1047,10 @@ def cmd_catalog(args: argparse.Namespace) -> int: print(f"{CATALOG_FILE} is up to date") return 0 - (root / CATALOG_FILE).write_text(rendered, encoding="utf-8") - print(f"Wrote {CATALOG_FILE}") + 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 @@ -989,11 +1143,17 @@ def build_parser() -> argparse.ArgumentParser: parents=[common], help="generate catalog.json", ) - catalog_parser.add_argument( + 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) image_parser = subparsers.add_parser( 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/\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/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/\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/test_content.py b/scripts/tests/test_content.py index 801d275..272450b 100644 --- a/scripts/tests/test_content.py +++ b/scripts/tests/test_content.py @@ -16,6 +16,7 @@ import io import json import os +import re import shutil import subprocess import sys @@ -29,6 +30,30 @@ 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(): @@ -58,6 +83,57 @@ def copy_fixture(fixture: Path, destination: Path) -> Path: 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 # --------------------------------------------------------------------------- @@ -125,6 +201,161 @@ def test_image_meta_requires_dockerfile(self) -> None: self.assertEqual(raised.exception.code, 1) +# --------------------------------------------------------------------------- +# 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 # --------------------------------------------------------------------------- @@ -252,6 +483,70 @@ def test_empty_accounts_for_missing_directories(self) -> None: (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) + # --------------------------------------------------------------------------- # 6 - 9. catalog @@ -322,6 +617,7 @@ def test_catalog_gamebox_generation(self) -> None: ) 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"), { @@ -333,7 +629,6 @@ def test_catalog_challenge_without_events_or_docker(self) -> None: "difficulty": "unknown", "tags": [], "description": "想成为管理员吗?也许你需要一个特殊的饼干!", - "image": "floatctf/cookie:challenge-v1.0.0", "flag": {"type": "static"}, "events": [], }, @@ -369,12 +664,74 @@ def test_event_to_challenge_reverse_relation(self) -> None: self.assertEqual(catalog["events"][0]["gameboxes"], ["comment"]) def test_catalog_never_exposes_flag_values(self) -> None: - entry = self.entry("challenges", "cookie") - rendered = content.render_catalog(self.catalog()) + catalog = self.catalog() + rendered = content.render_catalog(catalog) - self.assertEqual(entry["flag"], {"type": "static"}) + 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"]) + + 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_catalog_is_deterministic(self) -> None: first = content.render_catalog(content.build_catalog(VALID_FIXTURE)) second = content.render_catalog(content.build_catalog(VALID_FIXTURE)) @@ -418,6 +775,49 @@ def test_catalog_round_trips_through_json(self) -> None: ) 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()) + # --------------------------------------------------------------------------- # labels / github output @@ -483,6 +883,33 @@ def test_image_labels_are_single_line(self) -> None: for value in labels.values(): self.assertNotIn("\n", value) + def test_image_labels_keep_raw_content_id(self) -> None: + item = content.Content( + id="Cirno's perfect math class", + type=content.CONTENT_CHALLENGE, + path=Path("challenges/Cirno's perfect math class"), + meta={ + "name": "Cirno's perfect math class", + "version": "1.0.0", + "category": "misc", + "difficulty": "easy", + "tags": ["misc"], + "description": "fixture", + }, + ) + + labels = content.image_labels(item) + + # Labels keep the FloatCTF id; only the image repository is sanitized. + self.assertEqual(labels["io.floatctf.id"], "Cirno's perfect math class") + self.assertEqual( + labels["org.opencontainers.image.title"], "Cirno's perfect math class" + ) + self.assertEqual( + content.image_ref(item), + "floatctf/cirnos-perfect-math-class:challenge-v1.0.0", + ) + def test_image_meta_github_output(self) -> None: with tempfile.TemporaryDirectory() as tmp: output = Path(tmp) / "github_output" @@ -676,6 +1103,16 @@ def test_zero_base_builds_everything(self) -> None: ["challenges/comment", "gameboxes/box"], ) + def test_deleted_content_is_not_scheduled_for_build(self) -> None: + shutil.rmtree(self.root / "challenges" / "comment") + self.commit("delete comment") + head = self.rev("HEAD") + + self.assertEqual( + content.find_changed(self.root, self.head, head, dockerfile_only=True), + [], + ) + if __name__ == "__main__": unittest.main() From 700342c1c85bcf3b82b97e3472b7619a1e32c3cc Mon Sep 17 00:00:00 2001 From: fb0sh Date: Wed, 16 Sep 2026 14:28:17 +0800 Subject: [PATCH 3/6] fix(ci): allow republishing image tags and narrow build triggers - drop the Docker Hub tag existence check (no imagetools inspect): main builds and pushes directly, an existing tag is simply overwritten - remove the immutable-tag / bump-version rules from the README - only meta.toml and src/** changes below a content directory schedule an image build; README.md, attachment/**, solution/**, docs/** and events/** no longer do - rename content_paths to changed_image_paths and update the tests --- .github/workflows/content.yml | 17 ---------- README.md | 33 +++++++++---------- scripts/content.py | 29 +++++++++++++---- scripts/tests/test_content.py | 61 +++++++++++++++++++++++++++-------- 4 files changed, 86 insertions(+), 54 deletions(-) diff --git a/.github/workflows/content.yml b/.github/workflows/content.yml index ebdb3f8..652ad21 100644 --- a/.github/workflows/content.yml +++ b/.github/workflows/content.yml @@ -150,23 +150,6 @@ jobs: python3 scripts/content.py image-meta "${{ matrix.path }}" \ --github-output "$GITHUB_OUTPUT" - # Released tags are immutable: an existing tag means the version must be - # bumped in meta.toml instead of overwriting a published image. - - name: Check image tag is not published yet - if: env.PUBLISH == 'true' - env: - IMAGE: ${{ steps.image.outputs.image }} - run: | - set -euo pipefail - - if docker buildx imagetools inspect "$IMAGE" >/dev/null 2>&1; then - echo "error: image already exists: $IMAGE" >&2 - echo "bump version in meta.toml before publishing" >&2 - exit 1 - fi - - echo "tag is free: $IMAGE" - - name: Build and push image uses: docker/build-push-action@v6 with: diff --git a/README.md b/README.md index d99d7df..e93571c 100644 --- a/README.md +++ b/README.md @@ -253,9 +253,20 @@ Dockerfile 固定为 `{context}/Dockerfile`,不支持自定义 context。 **只有存在 `src/Dockerfile` 的内容才有镜像**;附件题(static / attachment-only) 不会构建镜像,Catalog 中也不会出现 `image` 字段。 -已发布的 tag 是**不可覆盖**的:`main` 发布前会检查 -`floatctf/{safe_name}:{type}-v{version}` 是否已存在,已存在则直接失败并 -要求先 bump `version`。 +`version` 参与 tag 命名。同一个 tag 可以被重新构建并覆盖,例如 +`floatctf/comment:challenge-v1.0.0` 再次发布会用新构建的镜像替换它。 + +只有以下变化会触发镜像构建: + +```text +challenges//meta.toml +challenges//src/** +gameboxes//meta.toml +gameboxes//src/** +``` + +`README.md`、`attachment/**`、`solution/**`、`docs/**`、`events/**` 等变化 +不会触发镜像构建。 本地查看某个内容的镜像信息(镜像名、构建上下文、Labels): @@ -295,7 +306,7 @@ Event private repo └─ ./scripts/sync-event.sh # validate + 更新 event manifest / docs └─ ./scripts/publish.sh # 推送到 upstream event/ 并创建 PR └─ Pull Request # validate + unittest + catalog 生成测试 + docker build(不 push) - └─ main # 检查 tag 未占用 → 构建并推送镜像到 Docker Hub + └─ main # 构建并推送镜像到 Docker Hub └─ catalog.json # 自动重新生成并提交 └─ FloatCTF 平台读取 raw catalog.json ``` @@ -305,18 +316,6 @@ Event private repo - Pull Request 与非 `main` 分支的手动触发只做 validate / 测试 / `docker build`, 不登录 Docker Hub、不 push、不提交。 -### 版本策略 - -只要需要重新发布镜像(修改了 `src/**`,或需要刷新镜像 metadata), -就必须 bump `version`: - -```text -1.0.0 → 1.0.1 -``` - -已存在的 `floatctf/comment:challenge-v1.0.0` 不会被覆盖, -Action 会失败并提示 bump version。 - GitHub Actions 需要配置的 Secrets(仅 `main` 使用,PR 不会接触): ```text @@ -329,6 +328,6 @@ DOCKERHUB_TOKEN - `build_all = false`:只执行 validate、unittest 与 catalog 生成测试; 在 `main` 上还会刷新 `catalog.json`。 - `build_all = true`:构建所有带 Dockerfile 的 Challenge / GameBox; - 在 `main` 上会检查 tag 并 push,在其他分支只 build。 + 在 `main` 上会 push,在其他分支只 build。 diff --git a/scripts/content.py b/scripts/content.py index 0429db5..1143dfe 100755 --- a/scripts/content.py +++ b/scripts/content.py @@ -880,19 +880,30 @@ def render_catalog(catalog: dict[str, Any]) -> str: # --------------------------------------------------------------------------- -def content_paths(names: Iterable[str]) -> list[str]: - """Map changed file paths to content directories (unique, sorted).""" +def changed_image_paths(names: Iterable[str]) -> list[str]: + """Map changed file paths to the content directories that need a build. + + Only ``//meta.toml`` and ``//src/**`` count. + README.md, ``attachment/**``, ``solution/**``, ``docs/**``, ``events/**`` + and everything else never trigger an image build. + """ found: set[str] = set() for name in names: parts = Path(name).parts - if len(parts) < 2: + if len(parts) < 3 or parts[0] not in CONTENT_DIRS.values(): continue - if parts[0] in CONTENT_DIRS.values() and parts[1]: - found.add(f"{parts[0]}/{parts[1]}") + kind, content_id = parts[0], parts[1] + rest = parts[2:] + + if not content_id: + continue + + if rest == ("meta.toml",) or rest[0] == SOURCE_SUBDIR: + found.add(f"{kind}/{content_id}") return sorted(found) @@ -938,7 +949,11 @@ def find_changed( all_content: bool = False, dockerfile_only: bool = False, ) -> list[str]: - """Return content directories touched between *base* and *head*.""" + """Return content directories needing a build between *base* and *head*. + + ``--all`` lists every content directory; otherwise only ``meta.toml`` and + ``src/**`` changes count (see :func:`changed_image_paths`). + """ if all_content: paths = _all_content_paths(root) @@ -952,7 +967,7 @@ def find_changed( else: paths = [ path - for path in content_paths(_git_diff_names(root, base, head)) + for path in changed_image_paths(_git_diff_names(root, base, head)) if (root / path).is_dir() ] diff --git a/scripts/tests/test_content.py b/scripts/tests/test_content.py index 272450b..7f6ec3e 100644 --- a/scripts/tests/test_content.py +++ b/scripts/tests/test_content.py @@ -967,42 +967,50 @@ def test_changed_github_output_is_single_line(self) -> None: class ChangedTests(unittest.TestCase): - def test_content_paths_filters_sorts_and_dedupes(self) -> None: - names = [ - "challenges/comment/src/index.php", + def test_changed_image_paths_only_meta_and_src(self) -> None: + triggering = [ "challenges/comment/meta.toml", - "challenges/comment/README.md", + "challenges/comment/src/index.php", + "challenges/comment/src/Dockerfile", "gameboxes/foo/meta.toml", + "gameboxes/foo/src/entrypoint.sh", + ] + ignored = [ + "challenges/comment/README.md", + "challenges/comment/attachment/payload.zip", + "challenges/comment/solution/writeup.md", + "challenges/comment/exp.py", + "docs/comment.md", "events/freshcup.toml", "scripts/content.py", "scripts/tests/fixtures/valid/challenges/comment/meta.toml", - "docs/freshcup.md", "README.md", "catalog.json", ] self.assertEqual( - content.content_paths(names), + content.changed_image_paths(triggering + ignored), ["challenges/comment", "gameboxes/foo"], ) + self.assertEqual(content.changed_image_paths(ignored), []) - def test_content_paths_handles_spaces_and_quotes(self) -> None: + def test_changed_image_paths_handles_spaces_and_quotes(self) -> None: names = [ "challenges/Cirno's perfect math class/src/Dockerfile", "challenges/Cirno's perfect math class/meta.toml", ] self.assertEqual( - content.content_paths(names), + content.changed_image_paths(names), ["challenges/Cirno's perfect math class"], ) - def test_content_paths_ignores_non_content(self) -> None: + def test_changed_image_paths_ignores_non_content(self) -> None: self.assertEqual( - content.content_paths( + content.changed_image_paths( ["events/a.toml", "challenges", "challenges/", "gameboxes/x"] ), - ["gameboxes/x"], + [], ) def test_find_changed_requires_revisions(self) -> None: @@ -1075,10 +1083,12 @@ def commit(self, message: str) -> None: self._git("add", "-A") self._git("commit", "-q", "-m", message) - def test_diff_detects_src_changes_only(self) -> None: + def test_diff_detects_src_and_meta_changes_only(self) -> None: + # The second commit also touched gameboxes/box/README.md and an event: + # neither may schedule an image build. self.assertEqual( content.find_changed(self.root, self.base, self.head), - ["challenges/comment", "gameboxes/box"], + ["challenges/comment"], ) self.assertEqual( content.find_changed( @@ -1087,6 +1097,31 @@ def test_diff_detects_src_changes_only(self) -> None: ["challenges/comment"], ) + def test_meta_only_change_triggers_build(self) -> None: + self.write("gameboxes/box/meta.toml", 'name = "box"\nversion = "1.0.1"\n') + self.commit("meta") + head = self.rev("HEAD") + + self.assertEqual( + content.find_changed(self.root, self.head, head), + ["gameboxes/box"], + ) + + def test_readme_attachment_and_solution_do_not_trigger_builds(self) -> None: + self.write("challenges/comment/README.md", "docs\n") + self.write("challenges/comment/attachment/payload.zip", "binary\n") + self.write("challenges/comment/solution/writeup.md", "solution\n") + self.commit("docs") + head = self.rev("HEAD") + + self.assertEqual(content.find_changed(self.root, self.head, head), []) + self.assertEqual( + content.find_changed( + self.root, self.head, head, dockerfile_only=True + ), + [], + ) + def test_events_and_scripts_do_not_trigger_builds(self) -> None: self.write("scripts/content.py", "# changed\n") self.commit("scripts") From 5bf6d76a1a3d8255761b0372f28f4b76da2b87e7 Mon Sep 17 00:00:00 2001 From: fb0sh Date: Wed, 16 Sep 2026 14:39:27 +0800 Subject: [PATCH 4/6] refactor: keep only metadata validation and catalog generation This repository owns content metadata and catalog.json, nothing else. - remove every Docker step from the workflow: no setup-buildx, login, build-push, build matrix, PUBLISH flag, secrets or image existence check - drop the workflow_dispatch build_all input - workflow now only runs validate + unittest + catalog; the catalog bot commit stays restricted to main - remove the CI-only CLI surface from content.py: image-meta, changed, changed_image_paths, git diff detection, GitHub Actions outputs and the OCI image labels (image_ref and has_dockerfile stay for the catalog) - drop the matching tests and unused imports (52 tests remain) - rewrite the README around meta.toml -> scripts/content.py -> catalog.json -> FloatCTF Platform; document that image is metadata only --- .github/workflows/content.yml | 132 +----------- README.md | 82 +++---- scripts/content.py | 388 ++-------------------------------- scripts/tests/test_content.py | 358 ------------------------------- 4 files changed, 55 insertions(+), 905 deletions(-) diff --git a/.github/workflows/content.yml b/.github/workflows/content.yml index 652ad21..9d755be 100644 --- a/.github/workflows/content.yml +++ b/.github/workflows/content.yml @@ -1,15 +1,10 @@ name: content -# Validate content metadata and catalog on every pull request, build and -# publish Docker images from main, then refresh catalog.json. +# Validate content metadata and keep catalog.json up to date. # -# Required repository secrets (main branch only): -# DOCKERHUB_USERNAME -# DOCKERHUB_TOKEN -# -# The workflow never uses pull_request_target and never exposes Docker Hub -# credentials to pull requests: PR runs only build images, they never log in -# and never push. +# 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: @@ -19,11 +14,6 @@ on: branches: - main workflow_dispatch: - inputs: - build_all: - description: Build and publish every image with a Dockerfile - type: boolean - default: false permissions: contents: read @@ -39,8 +29,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 @@ -61,119 +49,12 @@ jobs: - name: Run unit tests run: python3 -m unittest discover -s scripts/tests -v - detect: - name: Detect changed content - needs: validate - runs-on: ubuntu-latest - outputs: - paths: ${{ steps.changed.outputs.paths }} - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - - name: Detect changed challenges and gameboxes - id: changed - env: - EVENT_NAME: ${{ github.event_name }} - BUILD_ALL: ${{ inputs.build_all }} - PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} - PUSH_BEFORE_SHA: ${{ github.event.before }} - HEAD_SHA: ${{ github.sha }} - run: | - set -euo pipefail - - if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then - if [[ "$BUILD_ALL" == "true" ]]; then - python3 scripts/content.py changed \ - --all --dockerfile-only --github-output "$GITHUB_OUTPUT" - else - # Manual runs without build_all only validate and refresh catalog. - echo 'paths=[]' >> "$GITHUB_OUTPUT" - fi - exit 0 - fi - - if [[ "$EVENT_NAME" == "pull_request" ]]; then - BASE="$PR_BASE_SHA" - else - BASE="$PUSH_BEFORE_SHA" - fi - - python3 scripts/content.py changed \ - --base "$BASE" \ - --head "$HEAD_SHA" \ - --dockerfile-only \ - --github-output "$GITHUB_OUTPUT" - - build: - name: Build ${{ matrix.path }} - needs: detect - if: needs.detect.outputs.paths != '[]' && needs.detect.outputs.paths != '' - runs-on: ubuntu-latest - env: - # Only main publishes to Docker Hub. Pull requests and manual runs on - # any other branch build but never log in, push or commit. - PUBLISH: ${{ github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} - strategy: - fail-fast: false - matrix: - path: ${{ fromJSON(needs.detect.outputs.paths) }} - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Docker Hub - if: env.PUBLISH == 'true' - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Resolve image metadata - id: image - run: | - python3 scripts/content.py image-meta "${{ matrix.path }}" \ - --github-output "$GITHUB_OUTPUT" - - - name: Build and push image - uses: docker/build-push-action@v6 - with: - context: ${{ steps.image.outputs.context }} - file: ${{ steps.image.outputs.dockerfile }} - push: ${{ env.PUBLISH == 'true' }} - tags: ${{ steps.image.outputs.image }} - labels: ${{ steps.image.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - catalog: name: Update catalog.json - needs: - - validate - - detect - - build + needs: validate if: | - always() - && github.ref == 'refs/heads/main' + github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') - && needs.validate.result == 'success' - && needs.detect.result == 'success' - && (needs.build.result == 'success' || needs.build.result == 'skipped') runs-on: ubuntu-latest permissions: contents: write @@ -182,7 +63,6 @@ jobs: uses: actions/checkout@v4 with: ref: ${{ github.ref_name }} - fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 diff --git a/README.md b/README.md index e93571c..3772885 100644 --- a/README.md +++ b/README.md @@ -115,10 +115,19 @@ floatctf-content:main ## Content Metadata -`meta.toml` 是 Challenge / GameBox 的唯一元数据来源,同时用于: +`meta.toml` 是 Challenge / GameBox 的唯一元数据来源,用于生成 `catalog.json`。 -- `catalog.json` -- Docker Image 的 OCI / FloatCTF Labels +```text +meta.toml + ↓ +scripts/content.py + ↓ +catalog.json + ↓ +FloatCTF Platform +``` + +本仓库只负责元数据校验与 Catalog 生成,**不负责构建、发布或验证镜像**。 三个概念要区分清楚: @@ -126,10 +135,10 @@ floatctf-content:main |------|------|------| | `id` | 目录名 | FloatCTF 内部稳定 ID,Event 引用、catalog 中的 `id` | | `name` | `meta.toml` | UI 显示名称 | -| `safe_name` | `meta.toml`(可选) | Docker repository 名(`floatctf/{safe_name}`) | +| `safe_name` | `meta.toml`(可选) | Docker repository 名,用于 catalog 中的 image 引用 | `id` 不需要在 `meta.toml` 中声明,Challenge 与 GameBox 允许使用相同 `id` -与相同 `safe_name`,因为镜像 tag(`challenge-v*` / `gamebox-v*`)不同。 +与相同 `safe_name`,因为 image tag(`challenge-v*` / `gamebox-v*`)不同。 ```toml name = "comment" @@ -231,9 +240,10 @@ error: challenges/题目/meta.toml: unable to derive Docker safe_name; set safe_ python3 scripts/content.py validate ``` -## Official Images +## Image Reference -镜像名规则只在 `scripts/content.py` 中实现,不要在别处重新拼接: +Catalog 中的 `image` 只是**规范化的引用**,由 `scripts/content.py` 生成, +供 FloatCTF 平台使用。本仓库不构建、不推送、不验证该镜像: ```text Challenge: floatctf/{safe_name}:challenge-v{version} @@ -247,32 +257,15 @@ floatctf/comment:challenge-v1.0.0 floatctf/cirnos-perfect-math-class:challenge-v1.0.0 ``` -构建上下文固定为 `challenges/{id}/src`(GameBox 为 `gameboxes/{id}/src`), -Dockerfile 固定为 `{context}/Dockerfile`,不支持自定义 context。 - -**只有存在 `src/Dockerfile` 的内容才有镜像**;附件题(static / attachment-only) -不会构建镜像,Catalog 中也不会出现 `image` 字段。 - -`version` 参与 tag 命名。同一个 tag 可以被重新构建并覆盖,例如 -`floatctf/comment:challenge-v1.0.0` 再次发布会用新构建的镜像替换它。 - -只有以下变化会触发镜像构建: +**只有存在 `src/Dockerfile` 的内容才有 `image`**: ```text -challenges//meta.toml -challenges//src/** -gameboxes//meta.toml -gameboxes//src/** +challenges//src/Dockerfile 存在 → Catalog 包含 image +不存在(附件题 static / attachment) → 仍然进入 Catalog,只是没有 image ``` -`README.md`、`attachment/**`、`solution/**`、`docs/**`、`events/**` 等变化 -不会触发镜像构建。 - -本地查看某个内容的镜像信息(镜像名、构建上下文、Labels): - -```bash -python3 scripts/content.py image-meta challenges/comment -``` +不检查 Docker Hub 是否已有该镜像、本地是否能构建、tag 是否存在,也不做 +pull / push。 ## Catalog @@ -305,29 +298,18 @@ Catalog 只包含元数据,不包含 flag 值;只有带 `src/Dockerfile` 的 Event private repo └─ ./scripts/sync-event.sh # validate + 更新 event manifest / docs └─ ./scripts/publish.sh # 推送到 upstream event/ 并创建 PR - └─ Pull Request # validate + unittest + catalog 生成测试 + docker build(不 push) - └─ main # 构建并推送镜像到 Docker Hub - └─ catalog.json # 自动重新生成并提交 + └─ Pull Request # validate + catalog 生成测试 + unittest + └─ main # validate + unittest + 重新生成 catalog.json + └─ catalog.json # 有变化时由 github-actions[bot] 自动提交 └─ FloatCTF 平台读取 raw catalog.json ``` -- 只有 `refs/heads/main` 上的 `push` / `workflow_dispatch` 会 push 镜像与提交 - `catalog.json`。 -- Pull Request 与非 `main` 分支的手动触发只做 validate / 测试 / `docker build`, - 不登录 Docker Hub、不 push、不提交。 - -GitHub Actions 需要配置的 Secrets(仅 `main` 使用,PR 不会接触): - -```text -DOCKERHUB_USERNAME -DOCKERHUB_TOKEN -``` - -手动触发(`workflow_dispatch`): - -- `build_all = false`:只执行 validate、unittest 与 catalog 生成测试; - 在 `main` 上还会刷新 `catalog.json`。 -- `build_all = true`:构建所有带 Dockerfile 的 Challenge / GameBox; - 在 `main` 上会 push,在其他分支只 build。 +- 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/scripts/content.py b/scripts/content.py index 1143dfe..0108fd7 100755 --- a/scripts/content.py +++ b/scripts/content.py @@ -1,12 +1,14 @@ #!/usr/bin/env python3 -"""FloatCTF content metadata, catalog and container image helper. +"""FloatCTF content metadata and catalog helper. This module is the single source of truth for: -* content ids, types and versions (``meta.toml``) -* Docker image references and OCI / FloatCTF labels +* content ids, types, versions and Docker safe names (``meta.toml``) * ``catalog.json`` -* changed content detection for CI + +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. """ @@ -15,15 +17,13 @@ import argparse import json -import os import re -import subprocess import sys import tomllib import unicodedata from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Iterable, NoReturn, Sequence +from typing import Any, NoReturn, Sequence # --------------------------------------------------------------------------- # Constants @@ -44,10 +44,11 @@ CATALOG_FILE = "catalog.json" CATALOG_VERSION = 1 +#: Docker Hub namespace used when building the catalog image reference. IMAGE_NAMESPACE = "floatctf" -IMAGE_VENDOR = "FloatCTF" -IMAGE_SOURCE = "https://github.com/FloatCTF/floatctf-content" +#: Content is considered container-based when ``/src/Dockerfile`` +#: exists; only then does the catalog carry an ``image`` field. SOURCE_SUBDIR = "src" DOCKERFILE_NAME = "Dockerfile" @@ -91,12 +92,6 @@ PORT_MIN = 1 PORT_MAX = 65535 -#: GitHub Actions output name used by ``changed --github-output``. -GITHUB_PATHS_OUTPUT = "paths" - -#: Delimiter used for multi-line GitHub Actions outputs. -GITHUB_OUTPUT_DELIMITER = "EOF" - class ContentError(Exception): """A user facing content problem.""" @@ -140,16 +135,14 @@ def explicit_safe_name(meta: dict[str, Any]) -> str | None: return None -def dockerfile_path(content: "Content", root: Path) -> Path: - """Return the Dockerfile path of *content* below *root*.""" - - return root / content.path / SOURCE_SUBDIR / DOCKERFILE_NAME - - def has_dockerfile(content: "Content", root: Path) -> bool: - """True when *content* is a container (``src/Dockerfile`` exists).""" + """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 dockerfile_path(content, root).is_file() + return (root / content.path / SOURCE_SUBDIR / DOCKERFILE_NAME).is_file() # --------------------------------------------------------------------------- @@ -228,12 +221,6 @@ def _report(errors: list[str] | None, message: str) -> None: errors.append(message) -def _label_text(value: Any) -> str: - """Collapse whitespace so a value is safe for a Docker label.""" - - return " ".join(str(value).split()) - - def _is_positive_int(value: Any) -> bool: return isinstance(value, int) and not isinstance(value, bool) and value > 0 @@ -621,112 +608,6 @@ def image_ref(content: Content) -> str: ) -def image_context(content: Content) -> tuple[str, str]: - """Return the Docker build context and Dockerfile paths, relative to root.""" - - context = content.path / SOURCE_SUBDIR - dockerfile = context / DOCKERFILE_NAME - - return context.as_posix(), dockerfile.as_posix() - - -def ensure_dockerfile(content: Content, root: Path) -> str: - """Return the Dockerfile path or raise :class:`ContentError`.""" - - _, dockerfile = image_context(content) - - if not (root / dockerfile).is_file(): - raise ContentError( - f"{content.path.as_posix()}: Dockerfile not found: {dockerfile}" - ) - - return dockerfile - - -def image_labels(content: Content, revision: str | None = None) -> dict[str, str]: - """Return the OCI / FloatCTF labels for *content*.""" - - meta = content.meta - tags = meta.get("tags") - - if not isinstance(tags, list): - tags = [] - - labels = { - "org.opencontainers.image.title": _label_text( - meta.get("name", content.id) - ), - "org.opencontainers.image.description": _label_text( - meta.get("description", "") - ), - "org.opencontainers.image.version": _label_text(content.version), - "org.opencontainers.image.vendor": IMAGE_VENDOR, - "org.opencontainers.image.source": IMAGE_SOURCE, - "io.floatctf.type": content.type, - "io.floatctf.id": content.id, - "io.floatctf.category": _label_text(meta.get("category", "")), - "io.floatctf.difficulty": _label_text(meta.get("difficulty", "")), - "io.floatctf.version": _label_text(content.version), - "io.floatctf.tags": ",".join(_label_text(tag) for tag in tags), - } - - revision = revision or os.environ.get("GITHUB_SHA") or "" - - if revision.strip(): - labels["org.opencontainers.image.revision"] = revision.strip() - - return labels - - -def load_content(root: Path, path: str) -> Content: - """Load one ``challenges/`` or ``gameboxes/`` directory.""" - - candidate = Path(path.strip().rstrip("/")) - - if candidate.is_absolute(): - try: - candidate = candidate.relative_to(root) - except ValueError: - raise ContentError( - f"{path}: must live inside the repository root" - ) from None - - parts = candidate.parts - - if len(parts) != 2 or parts[0] not in CONTENT_DIRS.values(): - raise ContentError( - f"{path}: expected challenges/ or gameboxes/" - ) - - content_type = ( - CONTENT_CHALLENGE if parts[0] == CHALLENGES_DIR else CONTENT_GAMEBOX - ) - directory = root / parts[0] / parts[1] - meta_path = directory / "meta.toml" - - if not directory.is_dir(): - raise ContentError(f"{path}: directory not found") - - if not meta_path.is_file(): - raise ContentError(f"{_display(meta_path, root)}: missing meta.toml") - - content = Content( - id=parts[1], - type=content_type, - path=Path(parts[0]) / parts[1], - meta=load_meta(meta_path), - ) - - errors = validate_meta(content, root) - - if errors: - raise ContentError(errors[0]) - - ensure_dockerfile(content, root) - - return content - - # --------------------------------------------------------------------------- # Catalog # --------------------------------------------------------------------------- @@ -875,137 +756,6 @@ def render_catalog(catalog: dict[str, Any]) -> str: return json.dumps(catalog, indent=2, ensure_ascii=False) + "\n" -# --------------------------------------------------------------------------- -# Changed content detection -# --------------------------------------------------------------------------- - - -def changed_image_paths(names: Iterable[str]) -> list[str]: - """Map changed file paths to the content directories that need a build. - - Only ``//meta.toml`` and ``//src/**`` count. - README.md, ``attachment/**``, ``solution/**``, ``docs/**``, ``events/**`` - and everything else never trigger an image build. - """ - - found: set[str] = set() - - for name in names: - parts = Path(name).parts - - if len(parts) < 3 or parts[0] not in CONTENT_DIRS.values(): - continue - - kind, content_id = parts[0], parts[1] - rest = parts[2:] - - if not content_id: - continue - - if rest == ("meta.toml",) or rest[0] == SOURCE_SUBDIR: - found.add(f"{kind}/{content_id}") - - return sorted(found) - - -def _git_diff_names(root: Path, base: str, head: str) -> list[str]: - result = subprocess.run( - ["git", "-C", str(root), "diff", "--name-only", "-z", base, head], - capture_output=True, - check=False, - ) - - if result.returncode != 0: - message = result.stderr.decode("utf-8", errors="replace").strip() - raise ContentError( - f"git diff {base} {head} failed" - + (f": {message}" if message else "") - ) - - output = result.stdout.decode("utf-8", errors="replace") - - return [name for name in output.split("\0") if name] - - -def _all_content_paths(root: Path) -> list[str]: - paths: list[str] = [] - - for content_type in CONTENT_DIRS: - for content in scan_contents(root, content_type): - paths.append(f"{CONTENT_DIRS[content_type]}/{content.id}") - - return sorted(set(paths)) - - -def _is_zero_sha(value: str) -> bool: - return bool(value) and set(value) <= {"0"} - - -def find_changed( - root: Path, - base: str | None = None, - head: str | None = None, - *, - all_content: bool = False, - dockerfile_only: bool = False, -) -> list[str]: - """Return content directories needing a build between *base* and *head*. - - ``--all`` lists every content directory; otherwise only ``meta.toml`` and - ``src/**`` changes count (see :func:`changed_image_paths`). - """ - - if all_content: - paths = _all_content_paths(root) - else: - if not base or not head: - raise ContentError("changed requires --base and --head, or --all") - - if _is_zero_sha(base): - # First push of a branch: everything is new. - paths = _all_content_paths(root) - else: - paths = [ - path - for path in changed_image_paths(_git_diff_names(root, base, head)) - if (root / path).is_dir() - ] - - if dockerfile_only: - paths = [ - path - for path in paths - if (root / path / SOURCE_SUBDIR / DOCKERFILE_NAME).is_file() - ] - - return sorted(set(paths)) - - -# --------------------------------------------------------------------------- -# GitHub Actions output -# --------------------------------------------------------------------------- - - -def write_github_output(path: str, values: dict[str, str]) -> None: - """Append step outputs to a ``$GITHUB_OUTPUT`` file. - - Multi-line values use the documented heredoc form so that e.g. the - ``labels`` output of ``image-meta`` can be fed to - ``docker/build-push-action`` unchanged. - """ - - with open(path, "a", encoding="utf-8") as handle: - for key, value in values.items(): - if "\n" in value or "\r" in value: - handle.write( - f"{key}<<{GITHUB_OUTPUT_DELIMITER}\n" - f"{value}\n" - f"{GITHUB_OUTPUT_DELIMITER}\n" - ) - else: - handle.write(f"{key}={value}\n") - - # --------------------------------------------------------------------------- # Commands # --------------------------------------------------------------------------- @@ -1070,72 +820,10 @@ def cmd_catalog(args: argparse.Namespace) -> int: return 0 -def cmd_image_meta(args: argparse.Namespace) -> int: - root = Path(args.root) - content = load_content(root, args.path) - context, dockerfile = image_context(content) - - meta = { - "id": content.id, - "type": content.type, - "version": content.version, - "image": image_ref(content), - "context": context, - "dockerfile": dockerfile, - "labels": image_labels(content, args.revision), - } - - if args.github_output: - labels = "\n".join( - f"{key}={value}" for key, value in meta["labels"].items() - ) - write_github_output( - args.github_output, - { - "id": meta["id"], - "type": meta["type"], - "version": meta["version"], - "image": meta["image"], - "context": meta["context"], - "dockerfile": meta["dockerfile"], - "labels": labels, - }, - ) - - print(json.dumps(meta, indent=2, ensure_ascii=False)) - - return 0 - - -def cmd_changed(args: argparse.Namespace) -> int: - root = Path(args.root) - paths = find_changed( - root, - args.base, - args.head, - all_content=args.all, - dockerfile_only=args.dockerfile_only, - ) - - if args.github_output: - write_github_output( - args.github_output, - { - GITHUB_PATHS_OUTPUT: json.dumps( - paths, ensure_ascii=False, separators=(",", ":") - ) - }, - ) - - print(json.dumps(paths, indent=2, ensure_ascii=False)) - - return 0 - - def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="content.py", - description="FloatCTF content metadata, catalog and image helper.", + description="FloatCTF content metadata and catalog helper.", ) subparsers = parser.add_subparsers(dest="command", required=True) @@ -1171,48 +859,6 @@ def build_parser() -> argparse.ArgumentParser: ) catalog_parser.set_defaults(func=cmd_catalog) - image_parser = subparsers.add_parser( - "image-meta", - parents=[common], - help="print image metadata for one content directory", - ) - image_parser.add_argument("path", help="challenges/ or gameboxes/") - image_parser.add_argument( - "--github-output", - metavar="FILE", - help="also append step outputs to a GitHub Actions output file", - ) - image_parser.add_argument( - "--revision", - help="value for org.opencontainers.image.revision " - "(default: $GITHUB_SHA)", - ) - image_parser.set_defaults(func=cmd_image_meta) - - changed_parser = subparsers.add_parser( - "changed", - parents=[common], - help="list content directories changed between two revisions", - ) - changed_parser.add_argument("--base", help="base git revision") - changed_parser.add_argument("--head", help="head git revision") - changed_parser.add_argument( - "--all", - action="store_true", - help="list every content directory instead of diffing", - ) - changed_parser.add_argument( - "--dockerfile-only", - action="store_true", - help="only list content that has a Dockerfile", - ) - changed_parser.add_argument( - "--github-output", - metavar="FILE", - help="also append step outputs to a GitHub Actions output file", - ) - changed_parser.set_defaults(func=cmd_changed) - return parser diff --git a/scripts/tests/test_content.py b/scripts/tests/test_content.py index 7f6ec3e..0e2d894 100644 --- a/scripts/tests/test_content.py +++ b/scripts/tests/test_content.py @@ -15,15 +15,12 @@ import importlib.util import io import json -import os import re import shutil -import subprocess import sys import tempfile import unittest from pathlib import Path -from unittest import mock TESTS_DIR = Path(__file__).resolve().parent SCRIPTS_DIR = TESTS_DIR.parent @@ -178,29 +175,6 @@ def test_image_ref_from_fixture(self) -> None: ["floatctf/comment:gamebox-v1.0.0"], ) - def test_image_metadata_paths(self) -> None: - challenge = content.load_content(VALID_FIXTURE, "challenges/comment") - gamebox = content.load_content(VALID_FIXTURE, "gameboxes/comment") - - self.assertEqual( - content.image_context(challenge), - ("challenges/comment/src", "challenges/comment/src/Dockerfile"), - ) - self.assertEqual( - content.image_context(gamebox), - ("gameboxes/comment/src", "gameboxes/comment/src/Dockerfile"), - ) - - def test_image_meta_requires_dockerfile(self) -> None: - with self.assertRaises(SystemExit) as raised: - quiet( - content.main, - ["image-meta", "challenges/cookie", "--root", str(VALID_FIXTURE)], - ) - - self.assertEqual(raised.exception.code, 1) - - # --------------------------------------------------------------------------- # Docker safe_name: derivation, override and conflicts # --------------------------------------------------------------------------- @@ -819,335 +793,3 @@ def test_catalog_refuses_invalid_content(self) -> None: self.assertFalse((root / "catalog.json").exists()) -# --------------------------------------------------------------------------- -# labels / github output -# --------------------------------------------------------------------------- - - -class LabelTests(unittest.TestCase): - def test_image_labels(self) -> None: - challenge = content.load_content(VALID_FIXTURE, "challenges/comment") - - with mock.patch.dict(os.environ, {}, clear=True): - labels = content.image_labels(challenge) - - self.assertEqual( - labels, - { - "org.opencontainers.image.title": "comment", - "org.opencontainers.image.description": "注释里面有什么?", - "org.opencontainers.image.version": "1.0.0", - "org.opencontainers.image.vendor": "FloatCTF", - "org.opencontainers.image.source": ( - "https://github.com/FloatCTF/floatctf-content" - ), - "io.floatctf.type": "challenge", - "io.floatctf.id": "comment", - "io.floatctf.category": "web", - "io.floatctf.difficulty": "easy", - "io.floatctf.version": "1.0.0", - "io.floatctf.tags": "php,web", - }, - ) - - def test_image_labels_revision(self) -> None: - challenge = content.load_content(VALID_FIXTURE, "challenges/comment") - - labels = content.image_labels(challenge, "deadbeef") - self.assertEqual(labels["org.opencontainers.image.revision"], "deadbeef") - - with mock.patch.dict(os.environ, {"GITHUB_SHA": "cafebabe"}, clear=True): - labels = content.image_labels(challenge) - self.assertEqual(labels["org.opencontainers.image.revision"], "cafebabe") - - def test_image_labels_are_single_line(self) -> None: - challenge = content.Content( - id="multi", - type=content.CONTENT_CHALLENGE, - path=Path("challenges/multi"), - meta={ - "name": "multi", - "version": "1.0.0", - "category": "web", - "difficulty": "hard", - "tags": ["a", "b"], - "description": "line one\n\nline two", - }, - ) - - labels = content.image_labels(challenge) - - self.assertEqual( - labels["org.opencontainers.image.description"], "line one line two" - ) - for value in labels.values(): - self.assertNotIn("\n", value) - - def test_image_labels_keep_raw_content_id(self) -> None: - item = content.Content( - id="Cirno's perfect math class", - type=content.CONTENT_CHALLENGE, - path=Path("challenges/Cirno's perfect math class"), - meta={ - "name": "Cirno's perfect math class", - "version": "1.0.0", - "category": "misc", - "difficulty": "easy", - "tags": ["misc"], - "description": "fixture", - }, - ) - - labels = content.image_labels(item) - - # Labels keep the FloatCTF id; only the image repository is sanitized. - self.assertEqual(labels["io.floatctf.id"], "Cirno's perfect math class") - self.assertEqual( - labels["org.opencontainers.image.title"], "Cirno's perfect math class" - ) - self.assertEqual( - content.image_ref(item), - "floatctf/cirnos-perfect-math-class:challenge-v1.0.0", - ) - - def test_image_meta_github_output(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - output = Path(tmp) / "github_output" - - result = quiet( - content.main, - [ - "image-meta", - "challenges/comment", - "--root", - str(VALID_FIXTURE), - "--github-output", - str(output), - "--revision", - "deadbeef", - ], - ) - - self.assertEqual(result, 0) - - text = output.read_text(encoding="utf-8") - self.assertIn("image=floatctf/comment:challenge-v1.0.0\n", text) - self.assertIn("context=challenges/comment/src\n", text) - self.assertIn("labels< None: - with tempfile.TemporaryDirectory() as tmp: - output = Path(tmp) / "github_output" - - result = quiet( - content.main, - [ - "changed", - "--all", - "--dockerfile-only", - "--root", - str(VALID_FIXTURE), - "--github-output", - str(output), - ], - ) - - self.assertEqual(result, 0) - self.assertEqual( - output.read_text(encoding="utf-8"), - 'paths=["challenges/comment","gameboxes/comment"]\n', - ) - - -# --------------------------------------------------------------------------- -# 10. changed path detection -# --------------------------------------------------------------------------- - - -class ChangedTests(unittest.TestCase): - def test_changed_image_paths_only_meta_and_src(self) -> None: - triggering = [ - "challenges/comment/meta.toml", - "challenges/comment/src/index.php", - "challenges/comment/src/Dockerfile", - "gameboxes/foo/meta.toml", - "gameboxes/foo/src/entrypoint.sh", - ] - ignored = [ - "challenges/comment/README.md", - "challenges/comment/attachment/payload.zip", - "challenges/comment/solution/writeup.md", - "challenges/comment/exp.py", - "docs/comment.md", - "events/freshcup.toml", - "scripts/content.py", - "scripts/tests/fixtures/valid/challenges/comment/meta.toml", - "README.md", - "catalog.json", - ] - - self.assertEqual( - content.changed_image_paths(triggering + ignored), - ["challenges/comment", "gameboxes/foo"], - ) - self.assertEqual(content.changed_image_paths(ignored), []) - - def test_changed_image_paths_handles_spaces_and_quotes(self) -> None: - names = [ - "challenges/Cirno's perfect math class/src/Dockerfile", - "challenges/Cirno's perfect math class/meta.toml", - ] - - self.assertEqual( - content.changed_image_paths(names), - ["challenges/Cirno's perfect math class"], - ) - - def test_changed_image_paths_ignores_non_content(self) -> None: - self.assertEqual( - content.changed_image_paths( - ["events/a.toml", "challenges", "challenges/", "gameboxes/x"] - ), - [], - ) - - def test_find_changed_requires_revisions(self) -> None: - with self.assertRaises(content.ContentError): - content.find_changed(VALID_FIXTURE) - - def test_find_changed_dockerfile_filter(self) -> None: - self.assertEqual( - content.find_changed(VALID_FIXTURE, all_content=True), - ["challenges/comment", "challenges/cookie", "gameboxes/comment"], - ) - self.assertEqual( - content.find_changed( - VALID_FIXTURE, all_content=True, dockerfile_only=True - ), - ["challenges/comment", "gameboxes/comment"], - ) - - -@unittest.skipUnless(shutil.which("git"), "git is required") -class ChangedGitTests(unittest.TestCase): - def setUp(self) -> None: - self._tmp = tempfile.TemporaryDirectory() - self.root = Path(self._tmp.name) - self._git("init", "-q") - - self.write("challenges/comment/meta.toml", 'name = "comment"\n') - self.write("challenges/comment/src/Dockerfile", "FROM scratch\n") - self.write("gameboxes/box/meta.toml", 'name = "box"\n') - self.write("events/freshcup.toml", 'id = "freshcup"\n') - self.write("README.md", "root\n") - - self.commit("one") - self.base = self.rev("HEAD") - - self.write("challenges/comment/src/Dockerfile", "FROM busybox\n") - self.write("gameboxes/box/README.md", "docs only\n") - self.write("events/freshcup.toml", 'id = "freshcup"\n# changed\n') - self.commit("two") - self.head = self.rev("HEAD") - - def tearDown(self) -> None: - self._tmp.cleanup() - - 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 rev(self, reference: str) -> str: - return self._git("rev-parse", reference) - - def write(self, relative: str, text: str) -> None: - path = self.root / relative - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") - - def commit(self, message: str) -> None: - self._git("add", "-A") - self._git("commit", "-q", "-m", message) - - def test_diff_detects_src_and_meta_changes_only(self) -> None: - # The second commit also touched gameboxes/box/README.md and an event: - # neither may schedule an image build. - self.assertEqual( - content.find_changed(self.root, self.base, self.head), - ["challenges/comment"], - ) - self.assertEqual( - content.find_changed( - self.root, self.base, self.head, dockerfile_only=True - ), - ["challenges/comment"], - ) - - def test_meta_only_change_triggers_build(self) -> None: - self.write("gameboxes/box/meta.toml", 'name = "box"\nversion = "1.0.1"\n') - self.commit("meta") - head = self.rev("HEAD") - - self.assertEqual( - content.find_changed(self.root, self.head, head), - ["gameboxes/box"], - ) - - def test_readme_attachment_and_solution_do_not_trigger_builds(self) -> None: - self.write("challenges/comment/README.md", "docs\n") - self.write("challenges/comment/attachment/payload.zip", "binary\n") - self.write("challenges/comment/solution/writeup.md", "solution\n") - self.commit("docs") - head = self.rev("HEAD") - - self.assertEqual(content.find_changed(self.root, self.head, head), []) - self.assertEqual( - content.find_changed( - self.root, self.head, head, dockerfile_only=True - ), - [], - ) - - def test_events_and_scripts_do_not_trigger_builds(self) -> None: - self.write("scripts/content.py", "# changed\n") - self.commit("scripts") - scripts_head = self.rev("HEAD") - - self.assertEqual( - content.find_changed(self.root, self.head, scripts_head, dockerfile_only=True), - [], - ) - - def test_zero_base_builds_everything(self) -> None: - self.assertEqual( - content.find_changed(self.root, "0" * 40, self.head), - ["challenges/comment", "gameboxes/box"], - ) - - def test_deleted_content_is_not_scheduled_for_build(self) -> None: - shutil.rmtree(self.root / "challenges" / "comment") - self.commit("delete comment") - head = self.rev("HEAD") - - self.assertEqual( - content.find_changed(self.root, self.head, head, dockerfile_only=True), - [], - ) - - -if __name__ == "__main__": - unittest.main() From 1ee08caf1d13b5788ad7d8a74a523c3f6e08c638 Mon Sep 17 00:00:00 2001 From: fb0sh Date: Wed, 16 Sep 2026 14:50:41 +0800 Subject: [PATCH 5/6] fix(content): sync events before validating and tighten catalog rules - sync-event.sh now scans, refreshes the event [content] block and the docs and only then runs the full metadata validation, so deleting or renaming a challenge/gamebox no longer deadlocks on the stale event reference - validate the event fields the catalog exposes: title, description, started_at and ended_at must be non-empty strings - static content (no src/Dockerfile) no longer emits "docker" in the catalog even when meta.toml declares a [docker] table; container content keeps both image and docker - scan_contents stores content paths relative to root so --root works for relative paths other than "." - 12 new tests, including a sync-event integration test that reproduces the old deadlock --- README.md | 59 ++++- scripts/content.py | 47 +++- scripts/sync-event.sh | 34 ++- .../fixtures/invalid/events/empty_title.toml | 11 + .../invalid/events/missing_description.toml | 10 + .../invalid/events/missing_ended_at.toml | 10 + .../invalid/events/missing_started_at.toml | 10 + .../invalid/events/missing_title.toml | 10 + .../fixtures/invalid/events/wrong_type.toml | 11 + .../challenges/static_with_docker/meta.toml | 15 ++ scripts/tests/test_content.py | 243 ++++++++++++++++++ 11 files changed, 432 insertions(+), 28 deletions(-) create mode 100644 scripts/tests/fixtures/invalid/events/empty_title.toml create mode 100644 scripts/tests/fixtures/invalid/events/missing_description.toml create mode 100644 scripts/tests/fixtures/invalid/events/missing_ended_at.toml create mode 100644 scripts/tests/fixtures/invalid/events/missing_started_at.toml create mode 100644 scripts/tests/fixtures/invalid/events/missing_title.toml create mode 100644 scripts/tests/fixtures/invalid/events/wrong_type.toml create mode 100644 scripts/tests/fixtures/safe-names/challenges/static_with_docker/meta.toml diff --git a/README.md b/README.md index 3772885..1dc3ac8 100644 --- a/README.md +++ b/README.md @@ -75,11 +75,13 @@ meta.toml 脚本会自动: -- 校验 `meta.toml`(`scripts/content.py validate`) - 扫描 `challenges/` - 扫描 `gameboxes/` -- 更新 `events/.toml` +- 更新 `events/.toml` 的 `[content]` - 生成 `docs/.md` +- 最后校验 `meta.toml`(`scripts/content.py validate`) + +先同步再校验:删除或重命名内容时不会被 Event 中的旧引用卡住。 然后正常提交: @@ -240,6 +242,46 @@ error: challenges/题目/meta.toml: unable to derive Docker safe_name; set safe_ 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` 生成, @@ -257,11 +299,14 @@ floatctf/comment:challenge-v1.0.0 floatctf/cirnos-perfect-math-class:challenge-v1.0.0 ``` -**只有存在 `src/Dockerfile` 的内容才有 `image`**: +`src/Dockerfile` 是否存在决定内容类型: ```text -challenges//src/Dockerfile 存在 → Catalog 包含 image -不存在(附件题 static / attachment) → 仍然进入 Catalog,只是没有 image +存在 → container content + Catalog 才可能包含 image 与 docker +不存在 → static / attachment content + 仍然进入 Catalog,但既没有 image 也没有 docker + (即使 meta.toml 中写了 [docker] 也不会输出) ``` 不检查 Docker Hub 是否已有该镜像、本地是否能构建、tag 是否存在,也不做 @@ -287,7 +332,7 @@ python3 scripts/content.py catalog --check # 只检查是否最新 > **catalog.json is generated. Do not edit it manually.** Catalog 只包含元数据,不包含 flag 值;只有带 `src/Dockerfile` 的内容才有 -`image` 字段。 +`image` 与 `docker` 字段。 提交到 `main` 的原因:Git 历史可追踪、`raw.githubusercontent.com` 直接访问、 不需要 GitHub Pages、本地开发也能查看。 @@ -296,7 +341,7 @@ Catalog 只包含元数据,不包含 flag 值;只有带 `src/Dockerfile` 的 ```text Event private repo - └─ ./scripts/sync-event.sh # validate + 更新 event manifest / docs + └─ ./scripts/sync-event.sh # 扫描内容 → 更新 event manifest / docs → validate └─ ./scripts/publish.sh # 推送到 upstream event/ 并创建 PR └─ Pull Request # validate + catalog 生成测试 + unittest └─ main # validate + unittest + 重新生成 catalog.json diff --git a/scripts/content.py b/scripts/content.py index 0108fd7..ce65597 100755 --- a/scripts/content.py +++ b/scripts/content.py @@ -83,6 +83,14 @@ "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", @@ -310,7 +318,9 @@ def scan_contents( Content( id=directory.name, type=content_type, - path=directory, + # 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, ) ) @@ -347,6 +357,23 @@ def _event_reference_array( 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.""" @@ -380,6 +407,8 @@ def load_events(root: Path, errors: list[str] | None = None) -> list[Event]: f"{display}: id must match file name '{path.stem}'", ) + _validate_event_text_fields(meta, display, errors) + events.append( Event( id=event_id, @@ -687,9 +716,12 @@ def content_entry( "description": meta.get("description", ""), } - # Only container content publishes an image; attachment-only content is - # simply served without one. - if has_dockerfile(content, root): + # 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) @@ -697,10 +729,11 @@ def content_entry( if flag is not None: entry["flag"] = flag - docker = _docker_entry(meta) + if container: + docker = _docker_entry(meta) - if docker is not None: - entry["docker"] = docker + if docker is not None: + entry["docker"] = docker entry["events"] = sorted(set(event_ids)) diff --git a/scripts/sync-event.sh b/scripts/sync-event.sh index d37c447..97383ad 100755 --- a/scripts/sync-event.sh +++ b/scripts/sync-event.sh @@ -23,23 +23,14 @@ DOC_FILE="docs/${EVENT}.md" command -v python3 >/dev/null 2>&1 || die "python3 is required" -# ----------------------------------------------------------------------------- -# Metadata validation -# ----------------------------------------------------------------------------- - -# scripts/content.py is the single source of truth for metadata validation. -# Event repositories created from an older event/base branch may not have it -# yet; in that case fall back to the checks further down. -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 - # ----------------------------------------------------------------------------- # 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 @@ -363,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/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/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/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/test_content.py b/scripts/tests/test_content.py index 0e2d894..21d5e73 100644 --- a/scripts/tests/test_content.py +++ b/scripts/tests/test_content.py @@ -15,10 +15,13 @@ 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 @@ -521,6 +524,57 @@ def test_duplicate_event_references(self) -> None: 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 @@ -695,6 +749,11 @@ def test_catalog_image_matches_dockerfile_and_pattern(self) -> None: 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) @@ -706,6 +765,37 @@ def test_catalog_keeps_raw_id_and_unicode_description(self) -> None: 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)) @@ -793,3 +883,156 @@ def test_catalog_refuses_invalid_content(self) -> None: 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"]) From 4a6965f80a320d7da374c2058c7994db389ec59a Mon Sep 17 00:00:00 2001 From: fb0sh Date: Wed, 16 Sep 2026 14:55:36 +0800 Subject: [PATCH 6/6] docs: clarify safe_name requirement --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1dc3ac8..a56c554 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ name version author category difficulty tags description |------|------| | `difficulty` | `unknown` / `beginner` / `easy` / `medium` / `hard` / `expert` | | `tags` | 字符串数组,可以为空数组,每项必须是非空字符串 | -| `safe_name` | 可选;Docker repository 名,必须匹配 `^[a-z0-9]+(?:[._-][a-z0-9]+)*$` | +| `safe_name` | 可选字段;未填写时由目录名自动派生。所有 Challenge / GameBox 都必须最终得到合法的 `safe_name`;自动派生失败时必须显式填写 | - `version` 使用 `x.y.z`(SemVer),例如 `1.0.0`。 - `category` 不限制取值,现有内容使用 `ai` / `crypto` / `misc` / `pwn` / `reverse` / `web`。 @@ -187,9 +187,13 @@ name version author category difficulty tags description ### safe_name -`safe_name` 是 Docker repository 名,`id` 可以包含空格、大写、撇号甚至中文, -`safe_name` 必须始终是合法的 Docker repository 名。 +所有 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