panic="abort" 让进程级 panic 无法恢复,裸 unwrap 与之冲突。新增第 16 条
明确:
- 默认走 ? / Result,经 AppError 或 Option 映射失败
- .expect("reason") 仅用于真正的不变量,消息必须解释为何不可能失败
- LazyLock 编译期常量、WASM 浏览器上下文、#[cfg(not(server))] stub、
bin 工具与 #[cfg(test)] 为豁免集
- 预留 clippy unwrap_used/expect_used lint 的 allow 范围
22 KiB
Repository Guidelines
Yggdrasil is a fullstack blog/CMS built with Dioxus 0.7. A single Rust crate (yggdrasil, Cargo edition 2021) compiles to two targets from one codebase: a WASM frontend (feature web) and a native Axum server (feature server). Stack: PostgreSQL via tokio-postgres + deadpool, Tailwind CSS v4, Argon2 passwords, moka cache, syntect code highlighting, katex-rs math, mimalloc allocator. The blog also runs user-submitted code in isolated Docker containers.
Architecture & Data Flow
The server feature gate is the central organizing principle. Nearly every module gates real DB/IO/Axum logic under #[cfg(feature = "server")] and provides a compiling stub under #[cfg(not(feature = "server"))] for the WASM build. default = ["web", "server"] (fullstack); override to build one target only (see Development Commands).
- Two endpoint kinds: (1) Dioxus server functions
#[server(Name, "/api")](auth, posts, comments, settings, database, code_runner); (2) manual Axum routers merged into the app router insrc/main.rs(upload, image serving, health, SSE stream). - Boot (
src/main.rs, server branch): dotenvy → tracing → build_info → hard-checkDATABASE_URL→validate_database_url()→ CSRF warn → a throwaway multi-thread tokio runtime runsensure_database()+ migrations + port pre-probe, then is dropped →dioxus::server::servereturns the Axum router with background tasks (session cleanup, post purge, image-cache cleanup, IP purge, sysinfo sampler). mimalloc is the global allocator undercfg(all(feature="server", not(wasm32))). - Read flow (e.g.
GET /post/:slug): middleware stack[ssr_generation → add_cache_control → csrf → optional compression → 30s Timeout → admin_guard]→ Dioxus IncrementalRenderer checks persistedstatic/<route>/index/<hash>.html(TTLSSR_CACHE_SECS, default 3600s). HIT → serve cached HTML. MISS → SSR rendersPostDetail→use_server_future(get_post_by_slug(slug))→ Dioxus deserializes to a server fn call →cache::get_post_by_slug(moka, 600s TTL) → miss →get_conn()from deadpool pool → query → cache set → return. - Write flow (e.g. create post): admin client → POST
/api/CreatePost→ CSRF validates Origin →get_current_admin_user()→ validate →spawn_blocking(render_markdown_enhanced)→ BEGIN TXN → INSERT post +sync_tags→ COMMIT → invalidate matching moka caches andssr_cache::invalidate_ssr_*(physical dir deletion) → return. - Auth: cookie-session (HttpOnly, SameSite=Lax, optional Secure), Argon2 hashing in
spawn_blocking, moka-cached sessions re-checked againstusers.session_generationon every hit. First registered user becomes admin (atomicINSERT ... ON CONFLICT). - Code execution:
```lang runnable ```blocks and/admin/runnerrun code in Docker containers (bollard,src/infra/docker.rs) — read-only rootfs + tmpfs/code, UID 1000, resource/cap-limited,ContainerGuardcleanup. SSE streams output atGET /api/exec/stream.
Key Directories
src/— Rust source (single crate).main.rs(entry),router.rs(Dioxus routes),middleware.rs(axum layers: compression, cache-control, admin_guard),cache.rs(moka, many domain caches with distinct TTLs),ssr_cache.rs(physical SSR cache invalidation),theme.rs(light/dark/system),highlight.rs(syntect),context.rs(UserContextglobal login state).src/api/— endpoints:auth.rs,posts/(create/update/delete/trash/list/read/search/stats/tags/rebuild/helpers/types),comments/,settings/,database/(admin: console/export/backup),code_runner/,upload.rs,image.rs,health.rs,sse. Cross-cutting:error.rs(AppError),csrf.rs,rate_limit.rs,sanitizer.rs,slug.rs,markdown.rs(render_markdown_enhanced),katex.rs.src/db/—pool.rs(DB_POOL: LazyLock<deadpool>,get_conn()runtime fast-fail,get_conn_for_startup()retry),migrate.rs(MIGRATIONSarray + runner),retry.rs,mod.rs(format_with_sources,DummyPoolstub).src/models/—post.rs,user.rs,comment.rs,settings.rs(serde DTOs shared across SSR/cache/API).src/auth/—password.rs(Argon2),session.rs(UUID token, SHA-256hash_token, cookie build/parse).src/components/— Dioxus components (layouts, header/nav/footer, post/comments/code_runner/skeletons/forms/ui atoms).src/pages/— route components.post_detail.rsheader docs are the canonical guide foruse_server_future+ route-subscription gotchas — read before editing pages.src/tasks/— server-only background loops spawned inserve().src/infra/—docker.rs(bollard),runner_config.rs.src/hooks/,src/utils/— query/event hooks; text/time/html helpers.src/bin/generate_highlight_css.rs— build-tool binary (regeneratespublic/highlight.cssfrom syntect themes;required-features = ["server"]).src/*_bridge.rs— wasm-bindgen bridges for JS editors/terminal (tiptap_bridge,codemirror_bridge,xterm_bridge).
libs/— pnpm JS workspace, packages named@yggdrasil/*. Each builds to a self-contained IIFE bundle written directly intopublic/<dir>/and consumed by the Rust side via window globals (js_sys::Reflect::geton object-literal modules, or global__init*functions via a typedinvoke_optional_globalhelper).tiptap-editor→public/tiptap/(rich-text Markdown editor),codemirror-editor→public/codemirror/(code-runner source editor),lightbox→public/lightbox/,xterm-terminal→public/xterm/,yggdrasil-core→public/yggdrasil-core/,mermaid-renderer(dynamically script-injected by yggdrasil-core on viewport visibility),shared(cross-lib constants:ThemeName,THEME_CHANGE_EVENT— inlined into each IIFE, not bundled).
migrations/— 14 numbered SQL files (NNN_desc.sql); each must also be registered in theMIGRATIONSarray insrc/db/migrate.rs(enforced by a compile-test).syntaxes/—.sublime-syntaxdefinitions (JSX/Kotlin/Swift/TSX/TypeScript/Vue/Zig); embedded viainclude_str!at compile time.themes/— Catppuccin Latte (light) / Mocha (dark).tmThemefor syntect.docker/—Dockerfile(app),build-runners.sh+runner-base/+runner-{python,node,go,rust,bun}/(sandbox images).docs/—DEPLOYMENT.md,test-markdown.md(rendering test fixture).DEVELOPMENT.md(perf benchmarking + highlighting guide).CHANGELOG.md(Keep a Changelog v1.1.0, SemVer; current0.5.0).scripts/—migrate.sh(manual DB migration runner; companion to the built-in startup migrator),xun.fish(full-deploy pipeline to thexunserver — build all images, scp, rolling-restartapponly, verify).static/— Dioxus IncrementalRenderer persists SSR HTML here at runtime (gitignored output, not source).
Development Commands
Prerequisites: Rust 1.95+, wasm32-unknown-unknown target, dx CLI (v0.7.9), tailwindcss CLI v4, PostgreSQL, Node 20+ / pnpm.
# Dev server (builds libs + highlight.css + katex.css first, assumes node_modules present)
make dev
# Full release build (client WASM + native server)
make build
# Linux cross-build (musl static binary)
make build-linux
# CSS
make css # input.css -> public/style.css (one-shot)
make css-watch # with --watch
# Lint (JS Biome + Rust clippy, no writes)
make lint
# Auto-fix (Biome -> cargo fix -> cargo fmt -> dx fmt)
make fix
# Tests
make test # == cargo test --features server
cargo test --features server highlight_code_swift -- --nocapture # single highlight test w/ output
# Docs (rustdoc, --no-deps --document-private-items, ayu theme) -> public/doc/
make doc
make doc-open
# Build JS libs
make build-libs
make build-editor # pnpm --filter @yggdrasil/tiptap-editor run build (also codemirror/lightbox/core/xterm/mermaid)
# Regenerate highlight.css (only when adding new syntect scope types)
cargo run --features server --bin generate_highlight_css
# WASM-only build (e.g. to check the web target)
cargo build --no-default-features --features web
# Server-only build (as the Dockerfile does)
cargo build --no-default-features --features server
# Database
DATABASE_URL=postgres://postgres:postgres@localhost:5432/yggdrasil ./scripts/migrate.sh # manual; also auto-runs on server startup
# Docker
make docker # native arch, load into local daemon
make docker-amd64 # x86_64 via QEMU (Apple Silicon)
make docker-apple # x86_64 via Apple Container CLI (macOS 26+)
make docker-multiarch IMAGE=ghcr.io/owner/yggdrasil:latest # amd64+arm64, push to registry
Important Configuration
Feature model (Cargo.toml): default = ["web", "server"]. web = dioxus/web (WASM); server = all native deps (tokio, axum, tokio-postgres, deadpool, argon2, moka, syntect, katex-rs, mimalloc, governor, bollard, …). Most deps are optional = true and gated behind the server feature list. Release profile: opt-level=3, lto="thin", codegen-units=1, strip=symbols, panic="abort" (shed WASM unwind metadata; server relies on systemd/k8s restart — design around Result + ?, never panic-driven control flow).
.cargo/config.toml: sets --cfg getrandom_backend="wasm_js" for wasm32-unknown-unknown only (workaround for a Dioxus 0.7.9 cfg leak into the server build). musl/freebsd linker blocks are commented templates.
build.rs: injects YGG_BUILD_GIT_DESCRIBE/HASH/COMMIT_DATE + rustc version + build time via cargo:rustc-env (read by src/build_info.rs through env!). 3-tier fallback: env var → local git → "unknown". rerun-if-changed=.git/HEAD + .git/index. std-only (no build-deps).
Self-contained binary: migrations (src/db/migrate.rs include_str!), custom syntaxes (src/highlight.rs include_str!), and public/highlight.css (pre-generated at build time) are embedded — the runtime scratch image needs only the binary + public/ + uploads/.
Key env vars (see .env.example for the full ~45-var reference; no mailer, no LISTEN_ADDR — uses IP/PORT, no UPLOAD dir env, no session-lifetime/ADMIN env):
| Category | Var | Purpose (defaults) |
|---|---|---|
| Database | DATABASE_URL |
PostgreSQL connection string (required) |
| Database | DB_POOL_SIZE |
deadpool pool size (20) |
| Database | STATEMENT_TIMEOUT_SECS |
per-query SQL timeout (30) |
| Database | MIGRATE_STARTUP_TIMEOUT_SECS |
startup DB-connect retry window (30) |
| Server | RUST_LOG |
tracing filter (info) |
| Server | IP / PORT |
bind address (set in Dockerfile 0.0.0.0:3000) |
| Server | DIOXUS_PUBLIC_PATH |
public assets path (Dockerfile /app/public) |
| Perf | SSR_CACHE_SECS |
SSR page cache TTL (3600) |
| Perf | COMPRESSION_ALGORITHMS |
response compression — gzip/brotli/deflate/zstd/all/off (off) |
| Perf | TOKIO_WORKER_THREADS |
tokio workers (read by runtime, not app code) |
| Perf | SYSINFO_SAMPLE_SECS |
/admin/system metric interval (0.5) |
| Security | APP_BASE_URL |
CSRF trusted origin (prod strongly recommended; else Host-header fallback) |
| Security | COOKIE_SECURE |
add Secure to session cookie (false) |
| Security | TRUSTED_PROXY_COUNT |
reverse-proxy hop count for real-IP from XFF (0) |
| Security | EXPOSE_VERSION_HEADERS |
attach Server/X-Yggdrasil-Version/Git headers (true) |
| Security | MAX_SESSIONS_PER_USER |
concurrent-session cap w/ LRU evict (5) |
| Images | WEBP_QUALITY / WEBP_METHOD |
WebP encode quality (85) / method (2) |
| Images | MAX_IMAGE_DIMENSION / MAX_IMAGE_PIXELS |
max edge px (8192) / total pixels (50M) |
| Images | IMAGE_DISK_CACHE_MAX_MB / _MAX_AGE_HOURS |
uploads/.cache cap (1024) / retention (168 = 7d) |
| Rate limit | RATE_LIMIT_{STRICT,UPLOAD,IMAGE,COMMENT,CODE_EXEC,UNKNOWN}_PER_SEC/_BURST |
governor buckets keyed by client IP |
| Runners | CODE_RUNNER_ALLOW_NETWORK / _MAX_CONCURRENT / _MAX_CPU_CORES / _MAX_MEMORY_MB / _MAX_TIMEOUT_SECS / _MAX_OUTPUT_BYTES / _MAX_SOURCE_BYTES |
sandbox limits |
| Runners | CODE_RUNNER_LANGUAGES |
optional allow-list (default: all registered) |
| Runners | DOCKER_SOCKET_PATH |
docker.sock for bollard (/var/run/docker.sock) |
Production deployment (see docs/DEPLOYMENT.md): the app does NOT do TLS — a reverse proxy (nginx/Caddy) is mandatory. MUST set APP_BASE_URL, COOKIE_SECURE=true, TRUSTED_PROXY_COUNT (exact proxy hop count — a wrong value lets attackers spoof XFF to bypass rate limits or makes all users share one proxy-IP bucket). nginx: client_max_body_size 12m (app hard-limits 10 MiB), proxy_read/send_timeout 360s (image transcoding up to 300s). Bind 127.0.0.1:3000:3000, not 0.0.0.0. Health: /healthz (liveness), /readyz (readiness, SELECT 1).
Code Conventions & Common Patterns
- Dual-target gating. Any code touching DB/IO/Axum must gate impl under
#[cfg(feature = "server")]and provide a compiling#[cfg(not(feature = "server"))]stub. Never put server-only deps in code reachable by the web build. TheDummyPoolstub insrc/db/mod.rsexists for this — do not delete it. - Server functions.
#[server(FnName, "/api")] pub async fn name(args...) -> Result<T, ServerFnError>. Args/return serde-serializable. Re-export from the module'smod.rs(pub use create::create_post;). - Error handling. Never
?a raw DB error intoServerFnError. UseAppErrorconstructors (db_conn/query/tx) which log the full chain viadb::format_with_sourcesbut expose a generic message — they never leak SQL. Map domain failures viaAppError::Unauthorized/Forbidden/NotFound/BadRequest/Internalthen.into(). Validation/business rejections returnOk(Response{success:false,...}), notErr. - Component purity (Dioxus 0.7).
#[component]bodies andrsx!must be pure — nosignal.set,spawn, DOM calls, or side effects in the render body. Derive data inline or via signals; do effects inuse_effect. Don't store derivable data inuse_signal. (See thedioxus-render-purityskill.) - Async data in pages. Use
use_server_future(move || { ... })?. To re-run on route-param change you MUST read the router state inside the closure viarouter().current::<Route>()(it subscribes viaReactiveContext) — a movedStringprop is a frozen snapshot that won't re-trigger. To force a child remount on identity change (e.g. slug), wrap it in a single-elementfor x in std::iter::once(...) { Comp { key: "{x}" } }— a barekeyon a non-list element is ignored by Dioxus's diff. Seesrc/pages/post_detail.rsheader docs. - Auth guard. Every admin server fn starts with
let user = get_current_admin_user().await?;. The SSRadmin_guardmiddleware is a fast-path 302 (fail-OPEN on DB error); the clientAdminLayoutis the backstop. Don't rely solely on the middleware for security decisions in server fns. - CPU-bound work (Argon2, syntect/markdown render) MUST go in
tokio::task::spawn_blocking— never on the async worker. - Caching. Read-through on reads (
cache::get→ miss → db →cache::set); on writes call the matchingcache::invalidate_*andssr_cache::invalidate_ssr_*before returning. Use theCacheKeyenum; don't hand-roll keys. Note:ssr_cache::GLOBAL_GENERATION/X-SSR-Generationis observability only — real SSR freshness is physical dir deletion +SSR_CACHE_SECSTTL. - Migrations. Create
migrations/NNN_desc.sqland append("NNN", include_str!("../../migrations/NNN_desc.sql"))to theMIGRATIONSarray insrc/db/migrate.rs(a compile-test guards file/array parity). Each migration runs in its own transaction; write them idempotent-safe. - DB connections. Runtime path =
get_conn()(fast-fail — do NOT retry pool-fullTimeout, to avoid avalanche); startup path =get_conn_for_startup().statement_timeoutis injected globally via libpq options — don't add per-query timeouts. - Markdown/HTML rendering (
render_markdown_enhanced): pulldown-cmark + syntect classed highlighting + TOC + heading anchors, CPU-bound →spawn_blocking. Article HTML is rendered once at save time and stored inposts.content_html— modifying syntaxes does not auto-refresh existing posts; rebuild via the/admin/posts"rebuild all" button (rebuild_content_html, batch size 500). - Code highlighting (
src/highlight.rs): syntectClassedHTMLGeneratoremits CSS classes paired withpublic/highlight.css. To add/fix a language: editsyntaxes/<Lang>.sublime-syntax(theexpressioncontext'sincludeorder matters — multi-token rules before single-token ones), validate the YAML, add a test asserting CSS classes, runcargo test --features server highlight_code_<lang> -- --nocapture, and regeneratepublic/highlight.cssonly if a new scope type was added (cargo run --features server --bin generate_highlight_css). SeeDEVELOPMENT.mdfor the full guide. - WebP: the
imagecrate's"webp"feature is intentionally excluded — all WebP encode/decode goes through zenwebp (src/webp.rs). Do NOT add it. - JS libs (
libs/): pnpm workspace, TypeScript strict (target ES2020,verbatimModuleSyntax⟹ useimport type), Biome formatter (2-space, single quotes, semicolons,trailingCommas: all, line width 100), Vite 8 IIFE bundles written into../../public/<dir>/.@yggdrasil/sharedis inlined into each IIFE — IIFEs cannot import each other at runtime. Usemake build-libsormake build-<name>(pnpm --filter). - Heavy
//!module docs explain WHY. Read a module's top doc comment before editing it. User-facing strings are predominantly Chinese. - No
unwrap()in non-test code.panic = "abort"(see Profile config) means any panic kills the whole process — there is no unwind, no recovery, just an immediate crash. This is incompatible withunwrap()'s "I'll deal with it later" semantics. Rules:- Default to
?/Resultfor fallible operations (DB, IO, parsing, header construction, regex compilation at call sites). Map failures throughAppError(server) or returnOption(WASM). .expect("reason")is permitted only for true invariants — cases where aNone/Errwould indicate a code bug, not a runtime condition. The message MUST explain why it cannot fail (e.g."val.max(1) 保证非零","etag 仅含 ASCII hex","静态 302 响应必然构造成功"). A bare.expect("TODO")or.expect("unreachable")is equivalent tounwrapand is not acceptable.LazyLock/OnceLockinitialization of compile-time constants (staticRegex,NonZeroU32::new(val.max(1)), syntect's built-inPlain Textsyntax) may use.expect()with an explanatory message — these run at most once and a failure means the source constant itself is wrong, which should surface immediately at startup, not silently degrade.- WASM browser-context calls (
web_sys::window(),Reflect::geton a known global) may use.expect()only inside#[cfg(target_arch = "wasm32")]/#[component]/use_effectscopes where a missingwindowproves the code is running outside a browser — a deployment bug, not a runtime input. unreachable!()is permitted ONLY in#[cfg(not(feature = "server"))]stubs of server functions — these branches are compiled out of the real server build and exist solely to satisfy the WASM target's type checker.- Build-tool binaries (
src/bin/*) and#[cfg(test)]modules are exempt —unwrap/expect/panicare idiomatic in tests and one-shot codegen tools. - If clippy's
unwrap_used/expect_usedlints are later wired in ([lints]table inCargo.toml), the exemptions above are the intendedallowset; do not relax them further without a documented invariant.
- Default to
Workflow
- 每完成一个功能点立即提交。Agent 自主判断提交时机——当一个逻辑完整的改动通过验证(编译通过 / 测试通过)后,无需等待用户指令,直接
git add+git commit。 - 提交粒度按"功能点"而非"文件":相关联的多文件改动合并为一个提交,不相关的改动拆成多个提交。
- 提交信息遵循现有风格:
type(scope): 简述,正文(可选)说明动机与关键改动。常见 type:feat/fix/docs/refactor/chore/perf。 - 只在用户明确要求时才
git push。提交到本地即可,不主动推送。
Testing & QA
- Layout: mostly inline
#[cfg(test)] mod testsunit tests acrosssrc/(~40 modules) plus exactly one integration filetests/post_detail_slug_rerun.rs(a source-string guard asserting a Dioxus render-purity antipattern is absent). - Philosophy: pure-function unit tests deliberately decoupled from DB/FS/cache. Inject dependencies (closures, temp dirs) instead of touching live state. No test connects to live Postgres — there is no test DB harness.
- Feature gating: server-touching tests use
#[cfg(all(test, feature = "server"))]; pure-logic tests use plain#[cfg(test)]. serial_testserializes tests that mutate process-global state (moka cache singletons incache.rs, env vars incsrf/rate_limit, in-process task maps inprogress.rs, Docker ininfra/docker.rs) — never DB rows.- Docker tests auto-skip when the daemon is unavailable (
require_docker().await→None→eprintln!("skip: Docker daemon 不可用")). - Run:
make test(==cargo test --features server; default features already enableserver). For a single highlight test with visible output:cargo test --features server highlight_code_<lang> -- --nocapture. - Highlighting tests (
src/highlight.rs, ~30 tests) are the canonical example of the test philosophy; include compile-consistency tests (custom_syntax_list_matches_directory,migrations_match_files_on_disk) that keep embedded arrays in sync with their on-disk directories. - Coverage: no formal coverage target; tests defend invariants and guard footguns (migration/syntax array parity, render-purity anti-patterns).
CI
Gitea Actions (.gitea/workflows/ci.yaml): a check job (lint/typecheck) and a build job. Docker images are not pushed by CI — deployment is manual via scripts/xun.fish (build → scp → rolling-restart app only → verify /healthz + /readyz). CI dashboard: https://git.rua.plus/api/v1/repos/xfy/yggdrasil/actions/tasks.